Compare commits

...

40 Commits

Author SHA1 Message Date
m038 6d20e0fedc test: add S1–S6 Playwright tests for story mode (listing, shortcodes, back nav, cross-trip) 2026-06-20 10:13:56 +02:00
m038 832e135e3a fix: correct stale G1-G4 comment to G1-G5 in gpx-journey spec 2026-06-20 00:54:06 +02:00
m038 0b49f90206 test: add M5–M6 integration tests for GPX connector logic 2026-06-20 00:50:19 +02:00
m038 5a52b8ff18 test: add Playwright tests G1-G5 for buildJourneySegments algorithm
Tests load italy-2025 map page to get MapUtils in scope, then exercise the
GPX proximity algorithm with synthetic data via page.evaluate.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WPJztrVGbwic2xTG7G9fjM
2026-06-20 00:39:45 +02:00
m038 2efdfbebb7 docs: add GPX connector logic implementation plan 2026-06-20 00:24:32 +02:00
m038 dfdb4d5ac3 docs: add GPX connector logic design spec 2026-06-20 00:15:10 +02:00
m038 50b64fbcb3 build: add Italy 2025 stories folder to demo-load target
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vgmzx8VTTTmCskSpQtsLTr
2026-06-19 23:44:18 +02:00
m038 640016c54f docs: add Tuscany demo stories implementation plan 2026-06-19 23:34:38 +02:00
m038 64dbcefd9b docs: add Tuscany demo stories design spec (3 story composition showcases) 2026-06-19 23:30:59 +02:00
m038 3fbba7672d test: fix M2 timing — wait for first marker before counting
Markers are added in map.on('load') which fires after the canvas
becomes visible; the old check was racy. Add an explicit waitFor
so M2 reliably passes with demo data loaded.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vgmzx8VTTTmCskSpQtsLTr
2026-06-19 23:17:05 +02:00
m038 6c378d77ca build: add story folder to demo-load and demo-reset targets 2026-06-19 23:04:14 +02:00
m038 7602b135f8 docs: add stats redesign spec
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RB86BaJBG3eGiMdfhmHRrQ
2026-06-19 22:31:33 +02:00
m038 46c33837ba docs: add dev server port and trip page filter bar notes to CLAUDE.md 2026-06-19 21:56:03 +02:00
m038 b1ec642d60 test: add MapLibre canvas tests (M1–M4), skip N5 (map nav link disabled)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G7CzY4z2Qm5sYE2nySRWuH
2026-06-19 21:52:38 +02:00
m038 28dc6c1f6c test: add F1–F7 Playwright tests for trip page filter bar and stats toggle 2026-06-19 21:51:13 +02:00
m038 3c35176b90 test: update N4 — filter bar replaces trip nav link to dailies 2026-06-19 21:50:41 +02:00
m038 5e864b0c03 docs: add trip page filter bar implementation plan 2026-06-19 21:24:19 +02:00
m038 c9ce336b18 docs: add trip page filter bar design spec 2026-06-19 21:20:55 +02:00
m038 e329cd4ad2 docs: update trip-switching checklist — home.alias is now permanent /home
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RB86BaJBG3eGiMdfhmHRrQ
2026-06-19 15:52:46 +02:00
m038 abd953e1f6 docs: add home and trip pages implementation plan 2026-06-19 15:26:03 +02:00
m038 dc8b7f58d2 docs: add home page & content flow design spec 2026-06-19 15:07:10 +02:00
m038 6d54092413 docs: rewrite production todo — fresh install, correct Admin2 gap, ordered steps 2026-06-19 13:26:05 +02:00
m038 ab92f3b469 docs: add production todo list 2026-06-19 13:24:13 +02:00
m038 ae17483ca4 docs: update dark mode plan — CartoDB tiles replace Stadia (no key required) 2026-06-19 13:18:47 +02:00
m038 e032292c97 docs: update CLAUDE.md, bugs log, and posting pipeline for Grav 2.0 + trip entity
- CLAUDE.md: add Grav 2.0 upgrade method, Admin2 setup, trip entity architecture, updated paths
- bugs-and-fixes.md: fix stale paths, add BUG-004 (Admin2 empty dashboard) and BUG-005 (PHP session path)
- posting-pipeline.md: update paths to trips/dailies structure

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 13:11:53 +02:00
m038 e6eb93cd2c feat: trip entity restructure + Grav 2.0 upgrade
- Switch to getgrav/grav Docker image with GRAV_CHANNEL=beta (Grav 1.7.53)
- Apache runs as host UID 1000; fix-perms target handles container setup
- Rename tracker → dailies throughout (URL slug, templates, tests)
- Trip entity: /trips/<slug>/{dailies,map,stats,stories} hierarchy
- Nav driven by active_trip in site.yaml
- GPX route rendering on map via leaflet-gpx CDN
- Italy 2025 demo trip with real Tuscany GPS routes
- Admin blueprint for trip pages (date range, cover image, album URL)
- Dark mode + visual polish plan ready at docs/superpowers/plans/2026-06-19-dark-mode.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 09:09:19 +02:00
m038 2835d876cc docs: update fix-perms instructions for getgrav/grav image
Replace stale linuxserver.io paths (abc:users, /app/www/public/*)
with current approach: run make fix-perms after plugin install or
container recreation.
2026-06-19 02:01:16 +02:00
m038 2ff31f311b docs: add dark mode implementation plan 2026-06-19 01:59:17 +02:00
m038 0cb109b2a3 docs: add dark mode + visual polish design spec 2026-06-19 01:56:34 +02:00
m038 5e954d8adf fix: update paths for trips/japan-korea-2026/dailies restructure
- Update post form parent, Makefile demo targets, and test scripts to use
  new trip-scoped paths (01.trips/japan-korea-2026/01.dailies)
- Rename tracker.spec.js → dailies.spec.js and update all /tracker URLs
  to /trips/japan-korea-2026/dailies across nav.spec.js, post.spec.js,
  helpers.js, and dailies.spec.js
- Add Italy 2025 demo trip to Makefile demo-load/demo-reset targets

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 01:49:38 +02:00
m038 6926b4084a docs: add trip entity implementation plan 2026-06-19 01:17:05 +02:00
m038 943026658b fix: make fix-perms idempotent and persistent across container restarts
The getgrav/grav image is Debian-based and has no uid 1000 user,
causing Apache to fail switching to APACHE_RUN_USER=#1000 on restart.
fix-perms now creates the uid 1000 user if absent, sets ownership,
then gracefully reloads Apache workers so they run as uid 1000.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 00:15:56 +02:00
m038 6702b5d9b6 feat: support GRAV_CHANNEL_SUFFIX for Grav 2.0 RC production install
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 00:14:05 +02:00
m038 b2f6cb1977 fix: update test-post.sh for Grav 1.7.53 / Login 3.x compatibility
- login-form-nonce replaces form-nonce (Login plugin 3.x)
- task value is login.login not login
- login success check uses form presence, not status code (/post returns
  200 unauthenticated)
- entry discovery searches by title, handles .en.md suffix
- cleanup uses docker exec to remove files owned by www-data

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 23:41:51 +02:00
m038 8824f79c64 feat: run Apache as host UID 1000 and add fix-perms target
APACHE_RUN_USER/GROUP=#1000 makes PHP/Apache write files owned by
the host user (mischa) instead of http. fix-perms target in setup
ensures ownership is correct after plugin install.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 23:34:57 +02:00
m038 9fd349e5ec chore: update .gitignore to allow cache-on-save plugin 2026-06-18 23:18:01 +02:00
m038 df55917347 feat: switch to getgrav/grav 2.0 RC docker image
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 23:13:53 +02:00
m038 6fe066e77d docs: fix plan commit steps for user/ git repo separation
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 23:12:52 +02:00
m038 b98ae50f30 docs: add Grav 2.0 upgrade implementation plan
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 23:05:13 +02:00
m038 9beb22f4c2 docs: add Grav 2.0 upgrade design spec
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 23:00:02 +02:00
32 changed files with 6109 additions and 78 deletions
+3 -1
View File
@@ -3,10 +3,12 @@
# Grav CMS
/user/
!/user/
!/user/plugins/
!/user/plugins/cache-on-save/
user/accounts/
user/data/
user/cache/
user/plugins/
# Claude
.claude/
+110 -3
View File
@@ -9,6 +9,60 @@
- **./**: Grav CMS dev environment for intotheeast travel blog
- **scripts/**: Server install and maintenance scripts
- **user/**: Site content, config, pages, and theme (standalone git repo — do not modify from here)
- **docs/**: All plans, specs, and project documentation (moved here from `user/docs/` on 2026-06-19)
### Current stack
- **Grav:** 2.0.0-rc.9 (installed manually — see §3 below)
- **Admin:** Admin2 v2.0.0-rc.15 (plugin slug: `admin2`, NOT `admin`)
- **Docker image:** `getgrav/grav` with `GRAV_CHANNEL=beta`
- **PHP session:** `session.save_path = /tmp` set in `php/php-local.ini`
### Dev server
The Docker dev server runs at **http://localhost:8081** (mapped from container port 80 in `docker-compose.yml`).
### Trip entity architecture
The site is structured around Trip entities. Key facts:
- Active trip is set in `user/config/site.yaml``active_trip: japan-korea-2026`
- Trip pages live at `user/pages/01.trips/<slug>/`
- Each trip has: `01.dailies/`, `02.map/`, `03.stats/`, `04.stories/`
- Site nav in `base.html.twig` has Home + Past Trips only — does not link to trip sub-sections
- Post form parent (`post-form.md``pageconfig.parent`) **must be kept in sync** with `active_trip`
- The trip page (`trip.html.twig`) uses a **client-side filter bar** (All content / Journal / Stories) — do NOT add nav links back to `/dailies`, `/stats`, `/stories` on the trip page
- Stats are shown inline on the trip page via a toggle; the standalone `/stats` sub-page still exists as a URL but is not linked from the trip page
- GPX route files live as media on the trip page itself, served via leaflet-gpx CDN
- Manage GPX files (view/upload/delete) at `/gpx-manager` — requires admin login; filenames are auto-slugified on upload
### GPX file management
GPX files are stored as page media on the trip page (`user/pages/01.trips/<slug>/`). They are picked up automatically by `map.html.twig` via `trip_page.media.all`.
The GPX manager page (`user/pages/03.gpx-manager/`) provides a browser UI at `/gpx-manager`:
- **Auth:** enforced by Login plugin via `access.admin.login: true` in frontmatter — shows login form if not authenticated
- **Template:** `user/themes/intotheeast/templates/gpx-manager.html.twig`
- **API:** uses Grav API v1 with session cookie auth (`session_enabled: true` in `user/plugins/api/api.yaml`)
- List: `GET /api/v1/pages{route}/media`
- Upload: `POST /api/v1/pages{route}/media` (multipart)
- Delete: `DELETE /api/v1/pages{route}/media/{filename}`
- **Slugification:** filenames are slugified client-side before upload (spaces/special chars → hyphens, lowercase); the file is sliced to a plain `Blob` so the third argument to `FormData.append` is always used as the filename
- **Media type:** `.gpx` is registered in `user/config/media.yaml` so Grav serves and tracks these files
To add GPX files without the browser UI, drop them directly into `user/pages/01.trips/<slug>/` and run `make content-push`.
### Switching to a new trip
Two places hardcode the active trip slug. Grav's config and page frontmatter are static YAML — no variable substitution is possible, so these cannot read from `site.yaml` automatically. **Both must be updated together** when starting a new trip, or entries will be posted to the wrong folder.
| File | Key | Example value |
|---|---|---|
| `user/config/site.yaml` | `active_trip` | `italy-2027` |
| `user/pages/02.post/post-form.md` | `pageconfig.parent` | `/trips/italy-2027/dailies` |
Note: `system.yaml` `home.alias` is permanently set to `/home` (the real home page) and does **not** need to change when switching trips.
After updating, also create the new trip's page tree under `user/pages/01.trips/<new-slug>/` with the standard four subfolders.
### Environment
@@ -23,6 +77,8 @@ Always use `make` commands for anything on the production server (`make remote-i
- `make content-push` — commit and push `user/` to Gitea (triggers production pull via webhook)
- `make content-pull` — pull latest from Gitea to local
- `plugins.txt` is manually maintained — installing a plugin via Admin does NOT update it
- `make demo-load` — load demo entries for both trips (Japan/Korea 2026 + Italy 2025 with real GPX)
- `make demo-reset` — remove demo entries (keeps trip page structure, removes entries only)
### User repo gitignore
@@ -52,7 +108,7 @@ Before going live, change in `user/config/system.yaml`:
|---|---|---|
| `twig.cache` | `true` | Templates compiled once and reused; safe because theme files don't change at runtime |
**Pre-launch smoke test required:** with `twig.cache: true`, submit one post via `/post` and confirm the entry appears in `/tracker` immediately. This verifies the cache-on-save plugin (BUG-001 fix) works correctly with caching enabled.
**Pre-launch smoke test required:** with `twig.cache: true`, submit one post via `/post` and confirm the entry appears in `/trips/japan-korea-2026/dailies` immediately. This verifies the cache-on-save plugin (BUG-001 fix) works correctly with caching enabled.
### What the cache-on-save plugin handles
@@ -72,12 +128,63 @@ Then run `make setup` (starts Docker + installs plugins).
### After make install-plugins: fix cache permissions
If the site returns a 500 error after plugin installation, the cache/logs/tmp directories may have wrong ownership (gpm runs as root inside the container). Fix with:
If the site returns a 500 error after plugin installation or after recreating the container,
run `make fix-perms`. This creates uid 1000 in the container, chowns `/var/www/html` to 1000:1000,
and reloads Apache. Always run `make setup` (not just `make start`) after `docker compose down && up`
to ensure permissions are correct.
### Grav 2.0 upgrade (local)
GPM (`php bin/gpm selfupgrade`) does **not** serve Grav 2.0 RC — it still reports 1.7.x as latest even on the `testing` channel. To upgrade locally:
```bash
docker exec intotheeast_grav chown -R abc:users /app/www/public/cache /app/www/public/logs /app/www/public/tmp
# Download grav-admin bundle (includes Grav core + admin2 plugin)
docker exec -w /tmp intotheeast_grav bash -c "
curl -sL 'https://getgrav.org/download/core/grav-admin/2.0.0-rc.9?testing' -o grav-admin.zip && \
unzip -q grav-admin.zip
"
# Copy core files only (not user/)
docker exec -w /tmp intotheeast_grav bash -c "
cp -rf grav-admin/{assets,bin,system,vendor,webserver-configs,index.php,composer.json,composer.lock,robots.txt,CHANGELOG.md,LICENSE.txt} /var/www/html/
"
# Install Admin2 from the bundle (it's named admin2, not admin)
docker exec -w /tmp intotheeast_grav bash -c "
cp -rf grav-admin/user/plugins/admin2 /var/www/html/user/plugins/admin2
"
make fix-perms
docker exec -w /var/www/html intotheeast_grav php bin/grav cache --all
# Cleanup
docker exec intotheeast_grav rm -rf /tmp/grav-admin /tmp/grav-admin.zip
```
After upgrading, ensure these settings in `user/config/system.yaml`:
```yaml
accounts:
type: flex # required for Admin2 API
pages:
type: flex # required for Admin2 pages API
```
And ensure the admin user account has `api.*` permissions (Admin2 uses a new permission namespace):
```yaml
# user/accounts/<username>.yaml
access:
admin:
login: true
super: true
api:
super: true
access: true
```
**Disable the old `admin` plugin** once `admin2` is installed — both route to `/admin` and conflict:
```bash
# In user/plugins/admin/admin.yaml:
enabled: false
```
**JWT secret:** Leave `jwt_secret: ''` in `user/plugins/api/api.yaml` — it works for local dev and production installs generate a secure secret automatically.
### Language URL prefix
If Grav redirects to `/en/...` URLs, ensure `user/config/system.yaml` contains:
+25 -7
View File
@@ -27,23 +27,41 @@ start:
stop:
docker compose down
setup: start install-plugins
setup: start install-plugins fix-perms
fix-perms:
docker exec intotheeast_grav bash -c "getent passwd 1000 > /dev/null || useradd -u 1000 -M hostuser"
docker exec intotheeast_grav chown -R 1000:1000 /var/www/html
docker exec intotheeast_grav apachectl graceful
install-plugins:
docker exec -w /app/www/public intotheeast_grav php bin/gpm install $(shell cat plugins.txt | tr '\n' ' ') -y
docker exec -w /var/www/html intotheeast_grav php bin/gpm install $(shell cat plugins.txt | tr '\n' ' ') -y
# ── Demo content ──────────────────────────────────────────────────────────────
demo-load:
cp -r user/docs/demo/tracker/. user/pages/01.tracker/
docker exec intotheeast_grav bash -c "cd /app/www/public && php bin/grav clearcache"
# Load japan-korea-2026 dailies
cp -r user/docs/demo/trips/japan-korea-2026/dailies/. user/pages/01.trips/japan-korea-2026/01.dailies/
cp -r user/docs/demo/trips/japan-korea-2026/04.stories user/pages/01.trips/japan-korea-2026/ 2>/dev/null || true
# Load italy-2025 trip (create pages if absent)
mkdir -p user/pages/01.trips/italy-2025/01.dailies user/pages/01.trips/italy-2025/02.map user/pages/01.trips/italy-2025/03.stats user/pages/01.trips/italy-2025/04.stories
cp user/docs/demo/trips/italy-2025/trip.md user/pages/01.trips/italy-2025/trip.md 2>/dev/null || true
cp user/docs/demo/trips/italy-2025/map.md user/pages/01.trips/italy-2025/02.map/map.md 2>/dev/null || true
cp user/docs/demo/trips/italy-2025/stats.md user/pages/01.trips/italy-2025/03.stats/stats.md 2>/dev/null || true
cp user/docs/demo/trips/italy-2025/stories.md user/pages/01.trips/italy-2025/04.stories/stories.md 2>/dev/null || true
cp -r user/docs/demo/trips/italy-2025/04.stories/. user/pages/01.trips/italy-2025/04.stories/ 2>/dev/null || true
cp -r user/docs/demo/trips/italy-2025/dailies/. user/pages/01.trips/italy-2025/01.dailies/
cp user/docs/demo/trips/italy-2025/*.gpx user/pages/01.trips/italy-2025/ 2>/dev/null || true
docker exec intotheeast_grav bash -c "cd /var/www/html && php bin/grav clearcache"
demo-reset:
@for dir in user/docs/demo/tracker/*/; do \
@for dir in user/docs/demo/trips/japan-korea-2026/dailies/*/; do \
folder=$$(basename "$$dir"); \
rm -rf "user/pages/01.tracker/$$folder"; \
rm -rf "user/pages/01.trips/japan-korea-2026/01.dailies/$$folder"; \
done
docker exec intotheeast_grav bash -c "cd /app/www/public && php bin/grav clearcache"
rm -rf user/pages/01.trips/japan-korea-2026/04.stories/01.the-thousand-gates
rm -rf user/pages/01.trips/italy-2025
docker exec intotheeast_grav bash -c "cd /var/www/html && php bin/grav clearcache"
# ── Content sync (user repo ↔ Gitea) ──────────────────────────────────────────
+6 -5
View File
@@ -1,13 +1,14 @@
services:
grav:
image: lscr.io/linuxserver/grav:latest
image: getgrav/grav
container_name: intotheeast_grav
environment:
- PUID=1000
- PGID=1000
- GRAV_CHANNEL=beta
- APACHE_RUN_USER=#1000
- APACHE_RUN_GROUP=#1000
ports:
- "8081:80"
volumes:
- ./user:/config/www/user
- ./php/php-local.ini:/config/php/php-local.ini
- ./user:/var/www/html/user
- ./php/php-local.ini:/usr/local/etc/php/conf.d/php-local.ini
restart: unless-stopped
+204
View File
@@ -0,0 +1,204 @@
# Bugs & Fixes
Backlog of confirmed bugs with root cause analysis and implementation spec for the fix.
---
## BUG-001 — New entry not visible after form submission
**Status:** fixed 2026-06-18
**Reported:** 2026-06-18
### Symptom
After submitting a new post via `/post`, the entry page file is created correctly on disk but does not appear in the `/trips/<active_trip>/dailies` feed or in the Grav Admin panel until the cache is manually flushed.
### Root cause
Grav's page-tree cache (`cache/doctrine/`) is not invalidated when `add-page-by-form` writes a new page to disk. The tracker template uses `page.children`, which Grav serves from cache — so the new child page is invisible until the cache is cleared.
### Workaround (manual)
Run in terminal after each submission:
```bash
docker exec intotheeast_grav bash -c "cd /var/www/html && php bin/grav clearcache"
```
### Fix spec
Wire cache-clear into the form process so it happens automatically on every successful submission.
**Approach — custom Grav plugin event hook:**
1. Create a small plugin `user/plugins/cache-on-save/` with one event listener:
- Listen on `onFormProcessed`
- When the form name is `new-entry`, call `$this->grav['cache']->deleteAll()` (note: `clear()` does not exist on `Grav\Common\Cache` in Grav 1.7)
2. Enable the plugin in `user/config/plugins/cache-on-save.yaml`
This is the cleanest approach: it fires exactly once per successful submission, requires no changes to `post-form.md`, and works for any future forms too.
**Alternative — disable page cache entirely:**
Set `cache: { enabled: false }` in `system.yaml`. Simpler but degrades frontend performance; not recommended for production.
### Files to create/modify
| File | Change |
|------|--------|
| `user/plugins/cache-on-save/cache-on-save.php` | New plugin, ~30 lines |
| `user/plugins/cache-on-save/cache-on-save.yaml` | Plugin manifest, enabled: true |
| `user/config/plugins/cache-on-save.yaml` | Runtime config, enabled: true |
### Acceptance criteria
1. Submit a new post via `/post`
2. Navigate to `/trips/<active_trip>/dailies` — the new entry is visible immediately, no manual cache flush needed
3. Grav Admin also shows the new page immediately
---
## BUG-002 — Stale Twig cache after theme file changes
**Status:** fixed 2026-06-18
**Reported:** 2026-06-18
### Symptom
After theme template files are added or modified (e.g., creating `partials/base.html.twig`), Grav's Twig compiled-template cache still holds the old compiled version. Pages that extend the changed file throw 500 errors like "Template partials/base.html.twig is not defined" even though the file exists on disk.
### Root cause
Grav caches compiled Twig templates in `cache/twig/`. When a new file is added, existing templates that reference it don't know to recompile — their cache entries are still valid from their own mtime perspective.
### Workaround (manual)
Run after any theme file is added or changed:
```bash
docker exec intotheeast_grav bash -c "cd /var/www/html && php bin/grav clearcache"
```
### Fix spec
Disable Twig template caching in development via `user/config/system.yaml`:
```yaml
twig:
cache: false
```
Acceptable for a single-user dev setup — eliminates both BUG-001's side-effect and this bug entirely. Performance cost is negligible at one-user scale. On production, leave Twig cache enabled (it's fine there because template files don't change at runtime).
**Files to change:**
| File | Change |
|------|--------|
| `user/config/system.yaml` | Add `twig: { cache: false }` under development section |
### Acceptance criteria
1. Add a new theme template file
2. Reload any page — no 500 error, template works immediately without manual cache flush
---
## BUG-003 — One post per day limit; silent failure on duplicate date
**Status:** fixed 2026-06-18
**Reported:** 2026-06-18
### Symptom
Submitting a second post with the same date as an existing entry shows "Entry posted successfully!" but creates no file. The user's post is silently discarded.
### Root cause
The `add-page-by-form` plugin built the page slug from date only (`Y-m-d`), producing folder names like `2026-06-18.entry`. With `overwrite_mode: false`, if that folder already exists the plugin skips page creation but does not abort — the `message` process step runs regardless, showing a false success.
### Fix
Change the slug template in `user/pages/02.post/post-form.md` to include time and title:
```twig
{{ form.value.date|date('Y-m-d-Hi') }}-{{ form.value.title|lower|regex_replace('/[^a-z0-9]+/', '-')|trim('-') }}
```
Example: title "Arrived in Tokyo" at 14:30 on 2026-06-18 → `2026-06-18-1430-arrived-in-tokyo`
The slug is locked at creation time. Renaming the title afterwards does not change the URL.
### Acceptance criteria
1. Submit two posts on the same day with different times or titles — both appear in `/trips/<active_trip>/dailies` as separate entries
2. Renaming a post's title in the frontmatter does not break its URL
---
## BUG-004 — Admin2 shows empty dashboard after Grav 2.0 upgrade
**Status:** fixed 2026-06-19
### Symptom
After installing Grav 2.0 + Admin2, logging in shows an empty dashboard with no sidebar navigation (only a Settings item visible). Pages and content are not accessible.
### Root causes (three separate issues)
**A) Wrong user account type.** `system.yaml` had `accounts.type: regular` (old file-based system). Admin2's API plugin uses the Flex user collection to look up accounts. With `regular`, the API saw zero users and entered setup-wizard mode.
**B) Wrong pages type.** `system.yaml` had `pages.type: regular`. Admin2's pages API requires `pages.type: flex` to serve the page tree.
**C) Missing `api.*` permissions on user account.** Grav 2.0 Admin2 uses a new `api.*` permission namespace (`api.super`, `api.access`, etc.) instead of the old `admin.super`. A user with only `access.admin.super: true` appears as a non-admin to Admin2.
### Fix
In `user/config/system.yaml`:
```yaml
accounts:
type: flex
pages:
type: flex
```
In `user/accounts/<username>.yaml`:
```yaml
access:
admin:
login: true
super: true # keep for backward compat
api:
super: true # required by Admin2
access: true # required by Admin2
```
### Notes
- `api.super: true` causes Admin2 to grant all sub-permissions automatically (`api.pages`, `api.config`, etc.)
- JWT secret in `user/plugins/api/api.yaml` can stay empty — HMAC-SHA256 works with an empty key locally; production generates its own secure secret
- The old `admin` plugin must be disabled (`enabled: false`) to avoid route conflict with `admin2`
---
## BUG-005 — PHP session fails after Grav 2.0 container upgrade
**Status:** fixed 2026-06-19
### Symptom
After replacing Grav core files inside the container, all pages return a CRITICAL error in `logs/grav.log`: `Failed to start session: session_start(): Failed to read session data: files (path: )`. Site is inaccessible.
### Root cause
The `getgrav/grav` Docker image's PHP configuration does not set `session.save_path`. Grav 1.7 worked because the image's default PHP config included it; the updated image layer did not.
### Fix
Add to `php/php-local.ini`:
```ini
session.save_path = /tmp
```
Restart the container to pick up the change. This file is bind-mounted into the container so no image rebuild is needed.
---
+132
View File
@@ -0,0 +1,132 @@
# Daily Entry Posting Pipeline
Two ways to create a daily entry: the mobile frontend form at `/post`, or directly from the Grav Admin2 panel. Both produce the same page structure under `user/pages/01.trips/<active_trip>/01.dailies/`.
The active trip is set in `user/config/site.yaml``active_trip`. The post form's `pageconfig.parent` in `post-form.md` must be kept in sync with this value.
---
## Frontmatter Reference
Every entry page (`template: entry`) supports these frontmatter fields:
| Field | Type | Required | Notes |
|---|---|---|---|
| `title` | string | ✅ | Entry headline |
| `date` | datetime | ✅ | Format: `Y-m-d H:i` (e.g. `2026-06-17 10:00`) |
| `template` | string | ✅ | Always `entry` |
| `published` | bool | ✅ | `true` to show in tracker feed |
| `lat` | string | — | Latitude decimal degrees (e.g. `52.3676`) |
| `lng` | string | — | Longitude decimal degrees (e.g. `4.9041`) |
| `location_city` | string | — | City name shown under the title (e.g. `Kyoto`) |
| `location_country` | string | — | Country name shown under the title (e.g. `Japan`) |
| `weather_desc` | string | — | Condition label — must be one of the values below |
| `weather_temp_c` | number | — | Temperature in Celsius (displayed rounded, e.g. `19`) |
| `hero_image` | string | — | Filename of the hero image (e.g. `photo.jpg`). Leave blank to auto-select the first uploaded image. |
**`weather_desc` allowed values** (matched to emoji icons in `entry.html.twig`):
`Sunny` · `Partly cloudy` · `Cloudy` · `Foggy` · `Drizzle` · `Rain` · `Snow` · `Thunderstorm`
**Page media (photos):** images are stored as files in the page folder (`user/pages/01.tracker/<slug>/`). All images in the folder are shown in the gallery. `hero_image` pins one as the full-width header.
**Example complete frontmatter:**
```yaml
---
title: 'First Day in Kyoto'
date: '2026-07-20 09:30'
template: entry
published: true
lat: '35.0116'
lng: '135.7681'
location_city: 'Kyoto'
location_country: 'Japan'
weather_desc: 'Sunny'
weather_temp_c: 28
hero_image: 'temple.jpg'
---
```
---
## Flow 1 — Mobile Frontend Form (`/post`)
This is the primary posting flow, designed for one-handed phone use.
```
Browser → /post (post-form.md)
└─ Grav Form plugin validates fields
└─ add-page-by-form plugin (onFormProcessed)
├─ reads pageconfig.parent (/trips/japan-korea-2026/dailies) and pageconfig.slug_field (date + title)
├─ reads pagefrontmatter (template: entry, published: true)
├─ merges form field values into new page frontmatter
├─ writes user/pages/01.trips/<active_trip>/01.dailies/<slug>/entry.md
└─ moves uploaded photos into the page folder
└─ cache-on-save plugin (onFormProcessed)
└─ calls $grav['cache']->deleteAll() so tracker feed shows the entry immediately
└─ form shows success message, resets fields
```
**The form fields and their mapping to frontmatter:**
| Form field | Frontmatter key | Notes |
|---|---|---|
| `title` | `title` | Required |
| `date` | `date` | Defaults to current datetime |
| `content` | page body (markdown) | Required |
| `photos` | page media files | Uploaded to page folder |
| `lat` | `lat` | Filled via "Get Location" button |
| `lng` | `lng` | Filled via "Get Location" button |
| `location_city` | `location_city` | Manual text entry |
| `location_country` | `location_country` | Manual text entry |
| `weather_temp_c` | `weather_temp_c` | Hidden — set by weather JS widget |
| `weather_desc` | `weather_desc` | Hidden — set by weather JS widget |
**Slug format:** `<YYYY-MM-DD>.<slugified-title>` (controlled by `slug_field: 'date,title'` in `post-form.md`).
**Security:** the `/post` page requires `access: site.login: true` — anonymous visitors get redirected to login.
---
## Flow 2 — Admin Panel (sit-down workflow)
Use this for drafts, scheduled posts, or editing existing entries.
1. Log in at `/admin`
2. Go to **Pages****Add Page**
3. Set:
- **Page Title:** your entry title
- **Parent Page:** `/trips/japan-korea-2026/dailies` (adjust to active trip)
- **Page Template:** `entry`
4. Fill in the **Entry** tab fields (city, country, lat/lng, weather)
5. Write content in the **Content** tab
6. Upload photos via the **Media** tab
7. Set `published: true` (or leave `false` for a draft)
8. For scheduling: set `publish_date` in **Options****Scheduling**
9. Save
The Admin form fields are defined by `user/themes/intotheeast/blueprints/entry.yaml`.
**Drafts:** set `published: false` — the entry won't appear in the tracker feed until you flip it to `true`. Useful for writing ahead of time on the road.
**Scheduling:** Grav supports `publish_date` and `unpublish_date` in page frontmatter. Set them in the Admin Options tab. Requires `pages.publish_dates: true` in `system.yaml` (already enabled).
---
## Page folder structure
```
user/pages/01.trips/
└─ japan-korea-2026/ ← trip entity (active_trip in site.yaml)
├─ trip.md ← trip page (title, date_start, date_end, cover_image, album_url)
├─ *.gpx ← GPX route files (served as media, rendered on map)
├─ 01.dailies/
│ └─ 2026-07-20-1430-first-day-in-kyoto.entry/
│ ├─ entry.md ← frontmatter + markdown body
│ ├─ temple.jpg ← hero image (referenced by hero_image)
│ └─ market.jpg ← additional gallery image
├─ 02.map/map.md
├─ 03.stats/stats.md
└─ 04.stories/stories.md
```
The entry folder name follows `<YYYY-MM-DD-HHmm>-<slug>.entry`. Grav uses this for ordering and routing. The `.entry` suffix enables the `entry` template.
+70
View File
@@ -0,0 +1,70 @@
# Production Todo
Fresh server — no Grav installed yet. Work through these sections in order.
---
## 1. Pre-install: fix server-install.sh for Grav 2.0
`server-install.sh` has a gap: it copies the `grav-admin` bundle (which includes `user/plugins/admin2/`) but then immediately does `rm -rf user && git clone ...`, which wipes admin2. It never gets reinstalled because GPM doesn't carry Admin2.
- [ ] Update `server-install.sh` to stash admin2 before wiping user/, then restore it after:
```bash
# After "cp -rf grav-admin/. ." and before "rm -rf user":
cp -rf grav-admin/user/plugins/admin2 /tmp/admin2-plugin
# After "git clone $USER_REPO user" and "mkdir -p user/plugins ...":
cp -rf /tmp/admin2-plugin user/plugins/admin2
rm -rf /tmp/admin2-plugin
```
- [ ] Remove `admin` from `plugins.txt` if it's there — Admin2 replaces it and both conflict on `/admin`
## 2. Pre-install: configure .env
- [ ] Set `GRAV_VERSION=2.0.0-rc.9` in `.env`
- [ ] Set `GRAV_CHANNEL_SUFFIX=?testing` in `.env` (makes the download URL resolve to the RC)
- [ ] Set `REMOTE_HOST`, `REMOTE_USER`, `REMOTE_PORT`, `REMOTE_HOME` for the production server
- [ ] Set `USER_REPO` and `MAIN_REPO` (Gitea URLs)
- [ ] Set `GITEA_HOST`, `GITEA_USER`, `GITEA_TOKEN` for the install-time clone
## 3. Run the install
```bash
make remote-env-setup # writes Gitea token to server temporarily
make remote-install # downloads Grav, clones repos, installs plugins
make remote-env-remove # removes token from server
```
After install, the script prints the server's SSH public key. Add it as a deploy key to both Gitea repos so `make remote-fetch` works going forward.
## 4. Post-install: config
These are already committed to the `user/` repo so they'll be present after the clone — just verify:
- [ ] `user/config/system.yaml` has `accounts.type: flex` and `pages.type: flex`
- [ ] `user/config/system.yaml` `custom_base_url` is set to the production domain (currently set to the local dev IP — update before deploy)
- [ ] `user/accounts/mischa.yaml` has `api.super: true` and `api.access: true`
- [ ] Disable old admin plugin: set `enabled: false` in `user/plugins/admin/admin.yaml` on production (or ensure it's not in `plugins.txt`)
## 5. Post-install: switch to production mode
- [ ] Set `twig.cache: true` in `user/config/system.yaml`
- [ ] Smoke test: submit one post via `/post`, confirm entry appears in `/trips/japan-korea-2026/dailies` immediately (verifies cache-on-save plugin works with Twig cache on)
## 6. Security
- [ ] Change admin password to a strong production password
- [ ] Confirm `/post` requires login — unauthenticated visitors must not be able to post
## 7. Map tiles
- [ ] Register at [carto.com](https://carto.com) and review terms for production traffic (CartoDB dark tiles are free but registration is expected for production use)
## 8. Content
- [ ] Set `date_start` on the Japan & Korea 2026 trip page (`user/pages/01.trips/japan-korea-2026/trip.md`)
- [ ] Upload actual GPX route file(s) to the trip page media — currently no GPX files, so the map shows no route
- [ ] Add `cover_image` to the trip page (used on the trips listing)
- [ ] Run `make content-push` to push any local content changes to Gitea before going live
@@ -0,0 +1,508 @@
# Grav 2.0 Upgrade 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:** Upgrade the local dev Docker environment from linuxserver/grav 1.7 to getgrav/grav 2.0 RC, validate the full Milestone 1 posting workflow, and update the production install script for a fresh Grav 2.0 deploy.
**Architecture:** Two tracks in sequence — (1) swap the Docker image and update all dependent config/paths, boot the site with `make setup`, run the existing test suite; (2) update `server-install.sh` so `make remote-install` deploys Grav 2.0 fresh on the production PHP 8.4 server. The `user/` directory (content, config, theme, custom plugins) is already isolated as a git repo and requires only a small compatibility addition to `cache-on-save`.
**Tech Stack:** Grav CMS 2.0.0-rc.9, PHP 8.4 (production) / Docker `getgrav/grav` with PHP 8.3 (dev), Apache, Twig 3, Symfony 7, Playwright (UI tests).
## Global Constraints
- All work on branch `update-to-2.0` (already created)
- Never read `.env` — contains sensitive credentials
- Only modify files in the project root or `user/` subfolders
- `user/config/system.yaml` is tracked in the **user/ git repo** — commit it with `git -C user add config/system.yaml && git -C user commit ...`, NOT from the main repo
- `user/plugins/cache-on-save/` is tracked in the **main repo** (after adding `.gitignore` exception) — commit blueprints.yaml with `git add user/plugins/cache-on-save/blueprints.yaml` from the project root
- Container name stays `intotheeast_grav`; local port stays `8081`
- `make` commands are the only way to interact with the remote server
- Grav 2.0 requires PHP ≥ 8.3 (dev container uses 8.3 default; production uses 8.4 — both compliant)
- Production download URL format: `https://getgrav.org/download/core/grav-admin/${GRAV_VERSION}${GRAV_CHANNEL_SUFFIX}`
---
## Files Changed
| File | Action | Reason |
|---|---|---|
| `docker-compose.yml` | Modify | Switch image, update volume + PHP ini path, add env var |
| `Makefile` | Modify | Three `docker exec` targets hardcode linuxserver's `/app/www/public` path |
| `.gitignore` | Modify | Add `!user/plugins/cache-on-save/` exception to track the custom plugin in the main repo |
| `user/plugins/cache-on-save/blueprints.yaml` | Create | Grav 2.0 compat flag (required by GPM) — committed to main repo |
| `user/config/system.yaml` | Modify | Switch GPM channel from `stable` to `testing` |
| `scripts/server-install.sh` | Modify | Support `GRAV_CHANNEL_SUFFIX` for `?testing` query param on 2.0 RC download |
---
## Task 1: Swap Docker image and fix container paths
**Files:**
- Modify: `docker-compose.yml`
- Modify: `Makefile`
**Interfaces:**
- Produces: A running Grav 2.0 container reachable at `http://localhost:8081` with `user/` mounted at `/var/www/html/user` and PHP upload limits applied via `/usr/local/etc/php/conf.d/php-local.ini`
- [ ] **Step 1: Stop and remove the current container**
```bash
cd /home/mischa/Nextcloud/Projects/travel-blog-intotheeast
docker compose down
```
Expected: container `intotheeast_grav` stops and is removed.
- [ ] **Step 2: Update `docker-compose.yml`**
Replace the entire contents of `docker-compose.yml` with:
```yaml
services:
grav:
image: getgrav/grav
container_name: intotheeast_grav
environment:
- GRAV_CHANNEL=beta
ports:
- "8081:80"
volumes:
- ./user:/var/www/html/user
- ./php/php-local.ini:/usr/local/etc/php/conf.d/php-local.ini
restart: unless-stopped
```
Key changes from old file:
- `image`: `lscr.io/linuxserver/grav:latest``getgrav/grav`
- `environment`: removed `PUID`/`PGID` (linuxserver-specific), added `GRAV_CHANNEL=beta`
- `volumes[0]`: `/config/www/user``/var/www/html/user`
- `volumes[1]`: `/config/php/php-local.ini``/usr/local/etc/php/conf.d/php-local.ini`
- [ ] **Step 3: Update Makefile — three targets use the old container path**
In `Makefile`, make these three targeted replacements:
**`install-plugins` target** — change working directory flag:
Old:
```makefile
install-plugins:
docker exec -w /app/www/public intotheeast_grav php bin/gpm install $(shell cat plugins.txt | tr '\n' ' ') -y
```
New:
```makefile
install-plugins:
docker exec -w /var/www/html intotheeast_grav php bin/gpm install $(shell cat plugins.txt | tr '\n' ' ') -y
```
**`demo-load` target** — change cache clear path:
Old:
```makefile
demo-load:
cp -r user/docs/demo/tracker/. user/pages/01.tracker/
docker exec intotheeast_grav bash -c "cd /app/www/public && php bin/grav clearcache"
```
New:
```makefile
demo-load:
cp -r user/docs/demo/tracker/. user/pages/01.tracker/
docker exec intotheeast_grav bash -c "cd /var/www/html && php bin/grav clearcache"
```
**`demo-reset` target** — change cache clear path:
Old:
```makefile
demo-reset:
@for dir in user/docs/demo/tracker/*/; do \
folder=$$(basename "$$dir"); \
rm -rf "user/pages/01.tracker/$$folder"; \
done
docker exec intotheeast_grav bash -c "cd /app/www/public && php bin/grav clearcache"
```
New:
```makefile
demo-reset:
@for dir in user/docs/demo/tracker/*/; do \
folder=$$(basename "$$dir"); \
rm -rf "user/pages/01.tracker/$$folder"; \
done
docker exec intotheeast_grav bash -c "cd /var/www/html && php bin/grav clearcache"
```
- [ ] **Step 4: Validate docker-compose syntax**
```bash
docker compose config
```
Expected: prints merged compose config with no errors. If you see `Error`, re-check the YAML indentation in `docker-compose.yml`.
- [ ] **Step 5: Commit**
```bash
git add docker-compose.yml Makefile
git commit -m "feat: switch to getgrav/grav 2.0 RC docker image
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
```
---
## Task 2: Add Grav 2.0 compat flag and switch GPM to testing channel
**Files:**
- Modify: `.gitignore` (add exception for `user/plugins/cache-on-save/`)
- Create: `user/plugins/cache-on-save/blueprints.yaml` (committed to main repo)
- Modify: `user/config/system.yaml` (committed to user/ git repo, not main repo)
**Interfaces:**
- Consumes: Running container from Task 1
- Produces: GPM resolves 2.0-compatible plugin versions on install; `cache-on-save` is recognized as 2.0-compatible by Grav's plugin registry
- [ ] **Step 1: Create `user/plugins/cache-on-save/blueprints.yaml`**
Create the file with this exact content:
```yaml
name: Cache On Save
version: 1.0.0
description: Clears Grav cache on new-entry form submission
author:
name: Mischa
email: mischa@gorinskat.nl
license: MIT
dependencies:
- { name: grav, version: '>=1.6.0' }
grav:
version: ['1.7', '2.0']
```
- [ ] **Step 2: Update GPM channel in `user/config/system.yaml`**
Find the `gpm:` section (around line 200 in the file) and change `releases: stable` to `releases: testing`:
Old:
```yaml
gpm:
releases: stable
official_gpm_only: true
```
New:
```yaml
gpm:
releases: testing
official_gpm_only: true
```
- [ ] **Step 3: Add gitignore exception and commit blueprints.yaml to main repo**
```bash
cd /home/mischa/Nextcloud/Projects/travel-blog-intotheeast
# Add exception so cache-on-save is tracked in the main repo
# Insert after the existing "user/plugins/" line in .gitignore:
# !user/plugins/cache-on-save/
# Then commit to the main repo:
git add .gitignore user/plugins/cache-on-save/blueprints.yaml
git commit -m "feat: track cache-on-save plugin in main repo; add Grav 2.0 compat flag
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
```
- [ ] **Step 4: Commit system.yaml to the user/ git repo**
```bash
cd /home/mischa/Nextcloud/Projects/travel-blog-intotheeast
git -C user add config/system.yaml
git -C user commit -m "feat: switch GPM to testing channel for Grav 2.0
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
```
---
## Task 3: Boot Grav 2.0 and install plugins
**Files:** None (runtime only)
**Interfaces:**
- Consumes: docker-compose.yml from Task 1, GPM config from Task 2
- Produces: Running Grav 2.0 instance at `http://localhost:8081` with all plugins installed
- [ ] **Step 1: Run setup**
```bash
cd /home/mischa/Nextcloud/Projects/travel-blog-intotheeast
make setup
```
This starts the container and installs all plugins from `plugins.txt`. First run may take 1-2 minutes as `getgrav/grav` downloads and extracts Grav 2.0 RC.
Expected output ends with something like:
```
GPM Packages Installed: admin, email, error, form, login, problems, add-page-by-form, shortcode-gallery-plusplus
```
If `make setup` fails on plugin install with a permission error, fix with:
```bash
docker exec intotheeast_grav chown -R www-data:www-data /var/www/html/cache /var/www/html/logs /var/www/html/tmp
make install-plugins
```
- [ ] **Step 2: Verify PHP upload limits are applied**
```bash
docker exec intotheeast_grav php -r "echo ini_get('upload_max_filesize') . ' / ' . ini_get('post_max_size');"
```
Expected: `100M / 500M`
If you see `2M / 8M` (PHP defaults), the ini mount path is wrong. Verify with:
```bash
docker exec intotheeast_grav php -r "echo php_ini_scanned_files();"
```
It should include `/usr/local/etc/php/conf.d/php-local.ini`.
- [ ] **Step 3: Verify site loads**
```bash
curl -s -o /dev/null -w "%{http_code}" http://localhost:8081/
```
Expected: `200`
If you get `500`, check container logs:
```bash
docker logs intotheeast_grav --tail 50
```
- [ ] **Step 4: Verify Admin2 loads**
```bash
curl -s -o /dev/null -w "%{http_code}" http://localhost:8081/admin
```
Expected: `200` (Admin2 SPA login page, not the old Twig admin)
- [ ] **Step 5: Run config and HTTP tests**
```bash
make test-config
make test-post
```
`test-config` validates the form YAML config. `test-post` submits the posting form via HTTP and checks an entry is created.
Expected: both exit 0.
If `test-post` fails, check the output of:
```bash
bash scripts/test-post.sh
```
This is the critical `add-page-by-form` go/no-go test. If it fails with a 500 or the entry isn't created, see the **If add-page-by-form fails** section at the bottom of this plan.
- [ ] **Step 6: Commit task completion note**
No new files to commit. Move to Task 4.
---
## Task 4: Run Playwright test suite and fix any Admin2 regressions
**Files:**
- Modify: `tests/*.spec.js` (only if tests fail due to Admin2 DOM changes)
**Interfaces:**
- Consumes: Running Grav 2.0 from Task 3
- Produces: All Playwright tests passing (or updated for Admin2's new DOM)
- [ ] **Step 1: Run the full UI test suite**
```bash
cd /home/mischa/Nextcloud/Projects/travel-blog-intotheeast
make test-ui
```
Expected: 25 tests pass.
- [ ] **Step 2: If any tests fail, classify the failure**
For each failing test, determine whether it is:
**A) A genuine regression** (e.g., posting form broken, tracker page missing entries, gallery not rendering) — these are blockers. Stop, investigate the root cause, and fix the underlying Grav/plugin issue before updating the test.
**B) An Admin2 DOM change** (e.g., selectors targeting old admin HTML structure like `.admin-menu`, `.grav-nav`, admin-specific CSS classes) — these are acceptable test updates. Update the selector in the test file to match Admin2's new HTML.
To inspect the current Admin2 DOM for a failing selector:
```bash
# Check what the admin page actually renders
curl -s http://localhost:8081/admin | grep -o '<[^>]*class="[^"]*admin[^"]*"[^>]*>' | head -20
```
- [ ] **Step 3: Update any Admin2 selector regressions**
For each type-(B) failure, open the relevant test file in `tests/` and update the selector. Example pattern for updating an admin navigation selector:
Old (targeting classic admin):
```js
await page.click('.grav-nav-toggle')
```
New (targeting Admin2 SPA — find actual selector from step 2's output):
```js
await page.click('[data-testid="nav-toggle"]') // replace with actual Admin2 selector
```
After each fix, re-run just that test:
```bash
npx playwright test tests/<filename>.spec.js --headed
```
- [ ] **Step 4: Re-run full suite to confirm all pass**
```bash
make test-ui
```
Expected: all tests pass.
- [ ] **Step 5: Commit any test updates**
If any test files were modified:
```bash
git add tests/
git commit -m "test: update Playwright selectors for Admin2 DOM
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
```
If no test files changed, no commit needed.
---
## Task 5: Update production install script for Grav 2.0
**Files:**
- Modify: `scripts/server-install.sh`
**Interfaces:**
- Consumes: Nothing from prior tasks (independent of Docker)
- Produces: `make remote-install` deploys a fresh Grav 2.0 on the production PHP 8.4 server when `GRAV_VERSION=2.0.0-rc.9` and `GRAV_CHANNEL_SUFFIX=?testing` are set in `.env`
- [ ] **Step 1: Update the wget download line in `scripts/server-install.sh`**
The script currently downloads Grav with:
```bash
wget --no-verbose "https://getgrav.org/download/core/grav-admin/$GRAV_VERSION" -O grav-admin.zip
```
Grav 2.0 RC requires `?testing` appended to the URL. Add `GRAV_CHANNEL_SUFFIX` support:
Old (line ~15 in the file):
```bash
echo "==> Downloading Grav $GRAV_VERSION"
cd "$WEBROOT"
wget --no-verbose "https://getgrav.org/download/core/grav-admin/$GRAV_VERSION" -O grav-admin.zip
```
New:
```bash
echo "==> Downloading Grav $GRAV_VERSION"
cd "$WEBROOT"
wget --no-verbose "https://getgrav.org/download/core/grav-admin/${GRAV_VERSION}${GRAV_CHANNEL_SUFFIX:-}" -O grav-admin.zip
```
The `${GRAV_CHANNEL_SUFFIX:-}` expands to empty string if unset, keeping stable releases working without any changes to `.env`.
- [ ] **Step 2: Add GRAV_CHANNEL_SUFFIX to the env var validation block**
At the top of the script the required vars are validated. `GRAV_CHANNEL_SUFFIX` is optional, so do NOT add it to the `:?` required list. Instead, add a comment above the download step:
After the `set -e` and required var block, add a comment before the download line:
```bash
# GRAV_CHANNEL_SUFFIX: optional, set to '?testing' for RC/beta releases (e.g. 2.0.0-rc.9)
# Leave unset or empty for stable releases.
```
- [ ] **Step 3: Verify the script logic looks correct**
```bash
# Dry-run: simulate what the URL would be with 2.0 RC vars
GRAV_VERSION=2.0.0-rc.9 GRAV_CHANNEL_SUFFIX='?testing' bash -c \
'echo "https://getgrav.org/download/core/grav-admin/${GRAV_VERSION}${GRAV_CHANNEL_SUFFIX:-}"'
```
Expected output:
```
https://getgrav.org/download/core/grav-admin/2.0.0-rc.9?testing
```
```bash
# Dry-run: simulate stable release (no suffix)
GRAV_VERSION=1.7.53 bash -c \
'echo "https://getgrav.org/download/core/grav-admin/${GRAV_VERSION}${GRAV_CHANNEL_SUFFIX:-}"'
```
Expected output:
```
https://getgrav.org/download/core/grav-admin/1.7.53
```
- [ ] **Step 4: Commit**
```bash
git add scripts/server-install.sh
git commit -m "feat: support GRAV_CHANNEL_SUFFIX for Grav 2.0 RC production install
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
```
---
## If `add-page-by-form` fails (contingency)
If `make test-post` in Task 3 step 5 returns a non-zero exit code or the entry is not created, `add-page-by-form` is incompatible with Grav 2.0. The fallback is to write a custom replacement plugin.
**Do not proceed to Task 4 if the posting workflow is broken.** Instead:
1. Check the container logs for the specific error:
```bash
docker logs intotheeast_grav --tail 100 | grep -i "error\|exception\|warning"
```
2. Note the error, stop work, and report back. The custom replacement plugin is a separate task requiring design input from the project owner before implementation.
The custom plugin would:
- Hook `onFormProcessed` (same as `cache-on-save`)
- Read form field values (`title`, `content`, `photo`)
- Build the page path under `user/pages/01.tracker/`
- Write the page file to disk using `Grav\Common\Page\Page`
- Merge `cache-on-save` functionality (call `$this->grav['cache']->deleteAll()`)
- Replace both `add-page-by-form` and `cache-on-save` with a single plugin
This is ~200 lines of PHP and ~1 day of work. It should be planned separately.
---
## Final smoke test (after all tasks complete)
Run the full test suite one last time:
```bash
cd /home/mischa/Nextcloud/Projects/travel-blog-intotheeast
make test
```
Expected: all three suites (`test-config`, `test-post`, `test-ui`) exit 0.
Then verify the go/no-go criteria from the spec are all met before merging to `main` or deploying to production.
@@ -0,0 +1,317 @@
# Dark Mode & Visual Polish 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:** Replace the warm-paper light theme with a warm-dark "notebook at night" aesthetic — dark-only, no toggle, paper grain texture, dark terrain map tiles, typography polish.
**Architecture:** Pure CSS token swap in `tokens.css` (all components update automatically), grain overlay via `body::after` SVG data URI in `style.css`, map tile URL swap in two Twig templates. No new dependencies, no JS changes, no structural changes.
**Tech Stack:** CSS custom properties, inline SVG noise filter, Stadia Maps Alidade Smooth Dark tile CDN, Leaflet.js (already present)
## Global Constraints
- All changes in `user/` — commit with `git -C user`, not main-repo git
- Dark-only — no `prefers-color-scheme` media query, no light-mode fallback, no toggle
- Existing token names in `tokens.css` must not change — only values swap
- No new npm/JS dependencies
- `make test-ui` must pass after every task (pre-existing P2 FilePond failure is acceptable)
- Map tile URL: `https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png` (CartoDB — no API key required)
- CartoDB attribution (exact): `© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors © <a href="https://carto.com/attributions">CARTO</a>`
- Note: Stadia Maps requires an API key even for local dev — CartoDB dark_all is the keyless alternative
---
### Task 1: Dark color tokens
**Files:**
- Modify: `user/themes/intotheeast/css/tokens.css`
**Interfaces:**
- Produces: CSS custom properties consumed by every component in `style.css` and Twig templates
- [ ] **Step 1: Read the current tokens file**
```bash
cat user/themes/intotheeast/css/tokens.css
```
Confirm these token names exist before editing: `--color-paper`, `--color-canvas`, `--color-ink`, `--color-ink-2`, `--color-ink-muted`, `--color-border`, `--color-border-soft`, `--color-accent`, `--color-accent-hover`, `--color-accent-light`, `--color-accent-on`.
- [ ] **Step 2: Replace the color block in tokens.css**
Replace the entire `:root` color block (from `--color-paper` through `--color-accent-on`) with:
```css
/* ── Dark palette (warm notebook) ──────────────────────────────────────── */
--color-paper: #1A1814; /* page background — warm near-black */
--color-canvas: #22201B; /* card surfaces, form backgrounds */
--color-ink: #EDE8DF; /* primary text — warm cream */
--color-ink-2: #B8B0A4; /* body text — muted warm */
--color-ink-muted: #7A7268; /* labels, timestamps, captions */
--color-border: #2E2B25; /* standard dividers */
--color-border-soft: #252219; /* subtle dividers */
--color-accent: #2A8C73; /* teal — lightened for dark contrast */
--color-accent-hover: #236655; /* hover/pressed teal */
--color-accent-light: #1A2E29; /* pale teal tint backgrounds */
--color-accent-on: #FFFFFF; /* text on accent surfaces */
--color-surface-raised: #2A2720; /* elevated surfaces: tooltips, hover */
--color-ink-inverse: #17171A; /* text on accent-coloured buttons */
```
Keep all non-color tokens (`--text-*`, `--leading-*`, `--space-*`, font variables, etc.) unchanged.
- [ ] **Step 3: Verify no syntax errors**
```bash
docker exec intotheeast_grav bash -c "cd /var/www/html && php bin/grav clearcache" && curl -s -o /dev/null -w "%{http_code}" http://localhost:8081/trips/japan-korea-2026/dailies
```
Expected: `200`
- [ ] **Step 4: Visual smoke check**
```bash
curl -s http://localhost:8081/trips/japan-korea-2026/dailies | grep -o 'color: var(--color-paper)' | head -3
```
Not a definitive check — just confirm the page renders. Open a browser and verify the background is dark and text is cream.
- [ ] **Step 5: Run test suite**
```bash
make test-ui
```
Expected: 24/25 pass (P2 FilePond is pre-existing failure, all others pass).
- [ ] **Step 6: Commit**
```bash
git -C user add themes/intotheeast/css/tokens.css
git -C user commit -m "feat: switch to warm-dark color tokens"
```
---
### Task 2: Paper grain texture + hardcoded color fixes + typography
**Files:**
- Modify: `user/themes/intotheeast/css/style.css`
**Interfaces:**
- Consumes: dark color tokens from Task 1
- [ ] **Step 1: Find all hardcoded color literals in style.css**
```bash
grep -n '#[0-9a-fA-F]\{3,6\}\|background: white\|background:#fff\|color: #\|background-color: #' user/themes/intotheeast/css/style.css
```
Make note of every hit — each one is a candidate to replace with a token. Exceptions: the CSS SVG data URI you are about to add (the noise filter hex values are part of the graphic, not UI colors).
- [ ] **Step 2: Add paper grain texture to body**
Find the `body` rule in `style.css`. It will look something like:
```css
body {
background-color: var(--color-paper);
color: var(--color-ink);
...
}
```
Add a `body::after` rule immediately after the `body` rule:
```css
body::after {
content: '';
position: fixed;
inset: 0;
pointer-events: none;
z-index: 9998;
opacity: 0.035;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='200' height='200'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='200' height='200' filter='url(%23noise)' opacity='1'/%3E%3C/svg%3E");
background-repeat: repeat;
background-size: 200px 200px;
}
```
- [ ] **Step 3: Fix hardcoded login form colors**
Find this rule (around line 497):
```css
.login-form .button.secondary { background: #f0f0f0; color: #333; text-decoration: none; line-height: 44px; padding: 0 1rem; }
```
Replace with:
```css
.login-form .button.secondary { background: var(--color-canvas); color: var(--color-ink); text-decoration: none; line-height: 44px; padding: 0 1rem; }
```
- [ ] **Step 4: Fix any other hardcoded colors found in Step 1**
For each hardcoded literal found in Step 1 (excluding the data URI you added):
- `#fff` / `white``var(--color-canvas)` (if a surface) or `var(--color-paper)` (if a page background)
- `#333` / dark grays → `var(--color-ink)` or `var(--color-ink-2)`
- `#eee` / light grays → `var(--color-border)` or `var(--color-border-soft)`
- `#f0f0f0` / near-white → `var(--color-canvas)`
Use judgment: if a hex is inside a gradient or SVG path data, leave it alone.
- [ ] **Step 5: Typography — increase entry body paragraph spacing**
Find:
```css
.entry-body p { margin-bottom: 1.1em; font-size: var(--text-md); line-height: var(--leading-normal); color: var(--color-ink-2); }
```
Change `margin-bottom: 1.1em` to `margin-bottom: 1.4em`.
- [ ] **Step 6: Typography — tighten h1/h2 tracking**
Find the `h1` and `h2` rules. Any rule that applies `letter-spacing: -0.01em` to an `h1` or `h2` — change it to `-0.02em`. Do not touch h3/h4/h5/h6.
- [ ] **Step 7: Stats page — tabular numbers**
Find any CSS rule targeting stats numbers (look for `.stat-value`, `.stats-number`, or similar). Add `font-variant-numeric: tabular-nums` to it. If no such specific rule exists, search the template:
```bash
grep -n 'stat\|number\|count' user/themes/intotheeast/templates/stats.html.twig | head -20
```
Then add a targeted rule in style.css for whatever class wraps the numeric values.
- [ ] **Step 8: Verify no syntax errors and visual check**
```bash
docker exec intotheeast_grav bash -c "cd /var/www/html && php bin/grav clearcache" && curl -s -o /dev/null -w "%{http_code}" http://localhost:8081/trips/japan-korea-2026/dailies
```
Expected: `200`. Open browser — grain should be subtly visible on the dark background.
- [ ] **Step 9: Run test suite**
```bash
make test-ui
```
Expected: 24/25 (P2 pre-existing).
- [ ] **Step 10: Commit**
```bash
git -C user add themes/intotheeast/css/style.css
git -C user commit -m "feat: add paper grain texture, fix hardcoded colors, improve typography"
```
---
### Task 3: Dark terrain map tiles
**Files:**
- Modify: `user/themes/intotheeast/templates/map.html.twig`
- Modify: `user/themes/intotheeast/templates/dailies.html.twig`
**Interfaces:**
- Consumes: Leaflet.js already loaded in both templates
- Produces: Stadia Alidade Smooth Dark tiles replacing OpenStreetMap tiles in both map views
- [ ] **Step 1: Read current tile setup in both templates**
```bash
grep -n "tileLayer\|openstreetmap\|attribution\|stadia" user/themes/intotheeast/templates/map.html.twig user/themes/intotheeast/templates/dailies.html.twig
```
Confirm the current tile URL pattern (`{s}.tile.openstreetmap.org`) in both files.
- [ ] **Step 2: Replace tile layer in map.html.twig**
Find:
```javascript
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
}).addTo(map);
```
Replace with:
```javascript
// TODO: add Stadia API key before launch — free dev use requires no key, production does
L.tileLayer('https://tiles.stadiamaps.com/tiles/alidade_smooth_dark/{z}/{x}/{y}{r}.png', {
maxZoom: 20,
attribution: '© <a href="https://stadiamaps.com/">Stadia Maps</a> © <a href="https://openmaptiles.org/">OpenMapTiles</a> © <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
}).addTo(map);
```
- [ ] **Step 3: Replace tile layer in dailies.html.twig (mini-map)**
Apply the identical tile swap to the mini-map `L.tileLayer` call in `dailies.html.twig`. Find the OpenStreetMap tile URL and replace it with the Stadia dark URL (same as Step 2, same attribution, same TODO comment).
- [ ] **Step 4: Verify tiles load**
```bash
curl -s -o /dev/null -w "%{http_code}" http://localhost:8081/trips/japan-korea-2026/map
```
Expected: `200`.
Check the tile URL is in the HTML:
```bash
curl -s http://localhost:8081/trips/japan-korea-2026/map | grep -o 'stadiamaps'
```
Expected: `stadiamaps` (appears in the tile URL).
Open the map in a browser and confirm:
- Dark terrain tiles render (not the default light OSM tiles)
- GPX polyline is visible in teal on the dark background
- Entry pins render correctly on top
- Attribution footer is present
- [ ] **Step 5: Check mini-map on dailies page**
```bash
curl -s http://localhost:8081/trips/japan-korea-2026/dailies | grep -o 'stadiamaps'
```
Expected: `stadiamaps`.
- [ ] **Step 6: Run test suite**
```bash
make test-ui
```
Expected: 24/25 (P2 pre-existing).
- [ ] **Step 7: Commit**
```bash
git -C user add themes/intotheeast/templates/map.html.twig themes/intotheeast/templates/dailies.html.twig
git -C user commit -m "feat: switch to Stadia Alidade Smooth Dark map tiles"
```
---
## Final verification
After all 3 tasks:
1. `make test-config && make test-post && make test-ui` — all pass
2. Visual check list (browser, not curl):
- `/trips/japan-korea-2026/dailies` — dark warm background, cream text, grain visible, teal accents
- `/trips/japan-korea-2026/map` — dark terrain tiles, teal GPX polyline, entry pins
- `/trips/japan-korea-2026/dailies/<any-entry>` — dark canvas card, no white boxes
- `/post` — form fields readable, no black-on-black inputs
- `/trips/japan-korea-2026/stats` — numbers align (tabular-nums)
3. Final hardcoded-literal check:
```bash
grep -n '#[0-9a-fA-F]\{3,6\}' user/themes/intotheeast/css/style.css | grep -v 'data:image'
```
All remaining hits should be either intentional (e.g. SVG path data) or documented.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,538 @@
# Trip Entity Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` to implement this plan task-by-task.
**Goal:** Restructure the site around a Trip entity — tracker/map/stats/stories become children of `/trips/japan-korea-2026/`, GPX route files live as media on the trip page, and `site.yaml` holds an `active_trip` slug so the nav can switch trips via config.
**Architecture:** Trip = a Grav page (`trip.html.twig`) at `/trips/<slug>/`. Map/stats templates find the tracker via `page.parent().route ~ '/tracker'` instead of the hardcoded `/tracker` path. Leaflet-gpx (CDN) loads all `*.gpx` media files from the trip page. A `trips.html.twig` listing page provides the multi-trip root. Stories is stubbed with a placeholder template.
**Tech Stack:** Grav CMS 1.7/2.0, Twig, Leaflet.js, leaflet-gpx (CDN, vanilla JS — consistent with existing inline JS pattern)
## Global Constraints
- All content/theme edits go in `user/` — commit with `git -C user`, not main-repo git
- Entry URLs change: `/tracker/<slug>``/trips/japan-korea-2026/tracker/<slug>` — acceptable pre-launch
- `make test-post` (6/6) and `make test-ui` (25/25) must pass after every task
- No new JS framework dependencies; leaflet-gpx is 3KB vanilla JS
- `user/config/media.yaml` must whitelist `.gpx` so Grav serves it as a file
- The `02.post/post-form.md` `pageconfig.parent` must stay in sync with the tracker path
---
### Task 1: Restructure pages under `/trips/`
**Files:**
- Create: `user/pages/01.trips/trips.md`
- Create: `user/pages/01.trips/japan-korea-2026/trip.md`
- Create: `user/pages/01.trips/japan-korea-2026/01.tracker/tracker.md` (copy from `user/pages/01.tracker/tracker.md`, no content change)
- Move: all `*.entry/` folders from `user/pages/01.tracker/``user/pages/01.trips/japan-korea-2026/01.tracker/`
- Create: `user/pages/01.trips/japan-korea-2026/02.map/map.md` (copy from `user/pages/03.map/map.md`)
- Create: `user/pages/01.trips/japan-korea-2026/03.stats/stats.md` (copy from `user/pages/04.stats/stats.md`)
- Create: `user/pages/01.trips/japan-korea-2026/04.stories/stories.md`
- Delete: `user/pages/01.tracker/`, `user/pages/03.map/`, `user/pages/04.stats/`
- Modify: `user/config/site.yaml` — add `active_trip: japan-korea-2026`
- Modify (create if absent): `user/config/media.yaml` — whitelist GPX
- [ ] **Step 1: Verify current structure before touching anything**
```bash
find user/pages -name "*.md" | sort
```
Expected: entries under `01.tracker/`, map at `03.map/map.md`, stats at `04.stats/stats.md`.
- [ ] **Step 2: Create trips hierarchy**
```bash
mkdir -p user/pages/01.trips/japan-korea-2026/01.tracker
mkdir -p user/pages/01.trips/japan-korea-2026/02.map
mkdir -p user/pages/01.trips/japan-korea-2026/03.stats
mkdir -p user/pages/01.trips/japan-korea-2026/04.stories
```
- [ ] **Step 3: Write `trips.md`**
`user/pages/01.trips/trips.md`:
```yaml
---
title: Trips
template: trips
content:
items: '@self.children'
order:
by: date
dir: desc
---
```
- [ ] **Step 4: Write `trip.md`**
`user/pages/01.trips/japan-korea-2026/trip.md`:
```yaml
---
title: 'Japan & Korea 2026'
template: trip
date: '2026-06-17'
date_start: '2026-06-17'
date_end: ''
cover_image: ''
content:
items: '@self.children'
---
```
- [ ] **Step 5: Copy tracker.md, move entries**
```bash
cp user/pages/01.tracker/tracker.md user/pages/01.trips/japan-korea-2026/01.tracker/tracker.md
mv user/pages/01.tracker/*.entry user/pages/01.trips/japan-korea-2026/01.tracker/
```
- [ ] **Step 6: Copy map.md and stats.md**
```bash
cp user/pages/03.map/map.md user/pages/01.trips/japan-korea-2026/02.map/map.md
cp user/pages/04.stats/stats.md user/pages/01.trips/japan-korea-2026/03.stats/stats.md
```
- [ ] **Step 7: Write stories stub**
`user/pages/01.trips/japan-korea-2026/04.stories/stories.md`:
```yaml
---
title: Stories
template: stories
published: true
---
```
- [ ] **Step 8: Delete old top-level pages**
```bash
rm -rf user/pages/01.tracker user/pages/03.map user/pages/04.stats
```
- [ ] **Step 9: Add `active_trip` to site.yaml**
Add to `user/config/site.yaml`:
```yaml
active_trip: japan-korea-2026
```
- [ ] **Step 10: Whitelist GPX in media.yaml**
`user/config/media.yaml` (create if absent):
```yaml
gpx:
type: file
extensions: ['gpx']
mime: application/gpx+xml
```
- [ ] **Step 11: Verify pages load at new URLs**
```bash
curl -s -o /dev/null -w "%{http_code}" http://localhost:8081/trips/japan-korea-2026/tracker
# Expected: 200
curl -s -o /dev/null -w "%{http_code}" http://localhost:8081/trips/japan-korea-2026/map
# Expected: 200
curl -s -o /dev/null -w "%{http_code}" http://localhost:8081/trips/japan-korea-2026/stats
# Expected: 200
```
- [ ] **Step 12: Commit**
```bash
git -C user add pages/01.trips config/site.yaml config/media.yaml
git -C user rm -r --cached pages/01.tracker pages/03.map pages/04.stats
git -C user commit -m "feat: restructure pages under trips/japan-korea-2026 entity"
```
---
### Task 2: Update templates for trip-relative paths + new trip/trips/stories templates
**Files:**
- Modify: `user/themes/intotheeast/templates/map.html.twig` — change hardcoded `/tracker` path
- Modify: `user/themes/intotheeast/templates/stats.html.twig` — same
- Modify: `user/themes/intotheeast/templates/partials/base.html.twig` — nav uses `active_trip`
- Create: `user/themes/intotheeast/templates/trip.html.twig`
- Create: `user/themes/intotheeast/templates/trips.html.twig`
- Create: `user/themes/intotheeast/templates/stories.html.twig`
**Interfaces:**
- Consumes: `config.site.active_trip` from site.yaml (set in Task 1)
- Produces: map/stats find entries via `page.parent().route ~ '/tracker'`
- [ ] **Step 1: Fix `map.html.twig` — tracker path**
Replace:
```twig
{% set tracker_page = grav.pages.find('/tracker') %}
{% set all_entries = tracker_page ? tracker_page.children.published() : [] %}
```
With:
```twig
{% set tracker_page = grav.pages.find(page.parent().route ~ '/tracker') %}
{% set all_entries = tracker_page ? tracker_page.children.published() : [] %}
```
- [ ] **Step 2: Fix `stats.html.twig` — tracker path**
Same replacement as Step 1 (identical pattern in stats.html.twig).
- [ ] **Step 3: Update `base.html.twig` nav**
Replace hardcoded nav href values with `active_trip`-driven paths. The pattern in base.html.twig currently sets hrefs to `/tracker`, `/map`, `/stats`. Replace with:
```twig
{% set active_trip = config.site.active_trip %}
{% set trip_base = '/trips/' ~ active_trip %}
```
Nav links become:
- Journal: `{{ trip_base }}/tracker`
- Map: `{{ trip_base }}/map`
- Stats: `{{ trip_base }}/stats`
Active state detection: replace `page.url starts with '/tracker'` checks with `page.url starts with trip_base ~ '/tracker'` (and similarly for map/stats).
- [ ] **Step 4: Create `trip.html.twig`**
`user/themes/intotheeast/templates/trip.html.twig`:
```twig
{% extends 'partials/base.html.twig' %}
{% block content %}
{% set tracker_page = grav.pages.find(page.route ~ '/tracker') %}
{% set entries = tracker_page ? tracker_page.children.published() : [] %}
<div class="trip-hero">
<h1>{{ page.title }}</h1>
{% if page.header.date_start %}
<p class="trip-dates">
{{ page.header.date_start|date('d M Y') }}
{% if page.header.date_end %}{{ page.header.date_end|date('d M Y') }}{% endif %}
</p>
{% endif %}
</div>
<nav class="trip-nav">
<a href="{{ page.route }}/tracker">Journal</a>
<a href="{{ page.route }}/map">Map</a>
<a href="{{ page.route }}/stats">Stats</a>
<a href="{{ page.route }}/stories">Stories</a>
</nav>
{% if entries|length > 0 %}
<section class="trip-recent">
<h2>Recent entries</h2>
{% for entry in entries|slice(0, 3) %}
<a href="{{ entry.url }}">
<span>{{ entry.date|date('d M Y') }}</span>
{{ entry.title }}
{% if entry.header.location_city %} · {{ entry.header.location_city }}{% endif %}
</a>
{% endfor %}
</section>
{% endif %}
{% endblock %}
```
- [ ] **Step 5: Create `trips.html.twig`**
`user/themes/intotheeast/templates/trips.html.twig`:
```twig
{% extends 'partials/base.html.twig' %}
{% block content %}
<h1>{{ page.title }}</h1>
{% set trips = page.children.published() %}
{% if trips|length == 0 %}
<p>No trips yet.</p>
{% else %}
<ul class="trips-list">
{% for trip in trips %}
<li>
<a href="{{ trip.url }}">
<strong>{{ trip.title }}</strong>
{% if trip.header.date_start %}
<span>{{ trip.header.date_start|date('d M Y') }}</span>
{% endif %}
</a>
</li>
{% endfor %}
</ul>
{% endif %}
{% endblock %}
```
- [ ] **Step 6: Create `stories.html.twig` stub**
`user/themes/intotheeast/templates/stories.html.twig`:
```twig
{% extends 'partials/base.html.twig' %}
{% block content %}
<h1>{{ page.title }}</h1>
<p>Stories coming soon.</p>
{% endblock %}
```
- [ ] **Step 7: Verify templates render**
```bash
curl -s -o /dev/null -w "%{http_code}" http://localhost:8081/trips/japan-korea-2026
# Expected: 200
curl -s -o /dev/null -w "%{http_code}" http://localhost:8081/trips
# Expected: 200
curl -s -o /dev/null -w "%{http_code}" http://localhost:8081/trips/japan-korea-2026/stories
# Expected: 200
```
Check nav links resolve correctly on tracker/map/stats pages.
- [ ] **Step 8: Commit**
```bash
git -C user add themes/intotheeast/templates/
git -C user commit -m "feat: add trip/trips/stories templates, update nav and map/stats to use trip-relative paths"
```
---
### Task 3: Add GPX route support to map template
**Files:**
- Modify: `user/themes/intotheeast/templates/map.html.twig`
**Interfaces:**
- Consumes: `*.gpx` files uploaded as media to the trip page (`page.parent()`)
- Produces: GPX tracks rendered as colored polylines on the Leaflet map, underneath entry pins
- [ ] **Step 1: Add leaflet-gpx script tag**
In `map.html.twig`, after the existing Leaflet script tag, add:
```html
<script src="https://cdn.jsdelivr.net/npm/leaflet-gpx@2.1.2/gpx.min.js"></script>
```
- [ ] **Step 2: Collect GPX URLs from trip media**
After the `{% set trip_page = page.parent() %}` line (add this at the top of the template, alongside the tracker_page lookup), add:
```twig
{% set gpx_urls = [] %}
{% for name, media in trip_page.media.all %}
{% if name|split('.')|last == 'gpx' %}
{% set gpx_urls = gpx_urls|merge([media.url]) %}
{% endif %}
{% endfor %}
```
- [ ] **Step 3: Pass GPX URLs to JavaScript**
In the `<script>` block, after the map is initialized and before the entry markers loop, add:
```javascript
// GPX route tracks
const gpxUrls = {{ gpx_urls|json_encode|raw }};
gpxUrls.forEach(url => {
new L.GPX(url, {
async: true,
polyline_options: { color: '#1F6B5A', weight: 2, opacity: 0.7 },
marker_options: { startIconUrl: null, endIconUrl: null, shadowUrl: null }
}).addTo(map);
});
```
Disabling start/end markers keeps the map clean — the entry pins already mark key stops.
- [ ] **Step 4: Test with a sample GPX**
Create a minimal 3-point GPX file to test without a real Komoot export:
```xml
<?xml version="1.0"?>
<gpx version="1.1" creator="test">
<trk><trkseg>
<trkpt lat="35.6762" lon="139.6503"><time>2026-03-25T10:00:00Z</time></trkpt>
<trkpt lat="35.0116" lon="135.7681"><time>2026-03-27T10:00:00Z</time></trkpt>
<trkpt lat="37.5665" lon="126.9780"><time>2026-04-01T10:00:00Z</time></trkpt>
</trkseg></trk>
</gpx>
```
Upload via Grav Admin to the trip page media, then verify the map at `/trips/japan-korea-2026/map` renders the polyline. Remove the test file after verification.
- [ ] **Step 5: Verify map still works without GPX**
Confirm map renders normally when no `.gpx` files are present (gpxUrls = []).
- [ ] **Step 6: Commit**
```bash
git -C user add themes/intotheeast/templates/map.html.twig
git -C user commit -m "feat: add GPX route rendering to trip map via leaflet-gpx"
```
---
### Task 4: Update post form, Makefile, demo content, and tests
**Files:**
- Modify: `user/pages/02.post/post-form.md``pageconfig.parent`
- Modify: `Makefile``demo-load` and `demo-reset` paths
- Modify: `scripts/test-post.sh``TRACKER` variable
- Modify: `scripts/test-form-config.sh` — expected parent value
- Modify: `tests/ui/tracker.spec.js` — any hardcoded `/tracker` URL references
- Modify: `user/docs/demo/` — move demo entries to new path structure
- [ ] **Step 1: Update post form parent**
In `user/pages/02.post/post-form.md`, change:
```yaml
pageconfig:
parent: '/tracker'
```
To:
```yaml
pageconfig:
parent: '/trips/japan-korea-2026/tracker'
```
- [ ] **Step 2: Update demo content structure**
```bash
mkdir -p user/docs/demo/trips/japan-korea-2026/tracker
mv user/docs/demo/tracker/* user/docs/demo/trips/japan-korea-2026/tracker/
rmdir user/docs/demo/tracker
```
- [ ] **Step 3: Update Makefile demo targets**
In `Makefile`, update `demo-load` and `demo-reset`:
```makefile
demo-load:
cp -r user/docs/demo/trips/japan-korea-2026/tracker/. user/pages/01.trips/japan-korea-2026/01.tracker/
docker exec intotheeast_grav bash -c "cd /var/www/html && php bin/grav clearcache"
demo-reset:
@for dir in user/docs/demo/trips/japan-korea-2026/tracker/*/; do \
folder=$$(basename "$$dir"); \
rm -rf "user/pages/01.trips/japan-korea-2026/01.tracker/$$folder"; \
done
docker exec intotheeast_grav bash -c "cd /var/www/html && php bin/grav clearcache"
```
- [ ] **Step 4: Update `test-post.sh` TRACKER path**
Find the line setting `TRACKER=` in `scripts/test-post.sh` and change it to:
```bash
TRACKER="user/pages/01.trips/japan-korea-2026/01.tracker"
```
- [ ] **Step 5: Update `test-form-config.sh` expected parent**
Find the assertion that checks `parent: '/tracker'` and update to check for `parent: '/trips/japan-korea-2026/tracker'`.
- [ ] **Step 6: Check Playwright tests for hardcoded paths**
Search `tests/ui/` for any hardcoded `/tracker` URL references:
```bash
grep -rn "tracker\|/map\|/stats" tests/ui/
```
Update any that reference the old paths to use the new trip-scoped paths.
- [ ] **Step 7: Run full test suite**
```bash
make test-config && make test-post && make test-ui
```
Expected: all pass (14/14, 6/6, 25/25).
- [ ] **Step 8: Commit**
```bash
# Main repo changes (Makefile + test scripts)
git add Makefile scripts/test-post.sh scripts/test-form-config.sh tests/
git commit -m "fix: update paths for trips/japan-korea-2026 restructure"
# User repo changes
git -C user add pages/02.post/post-form.md docs/demo/
git -C user commit -m "fix: update post form parent and demo content paths for trip structure"
```
---
### Task 5: Admin blueprint for trip page type
**Files:**
- Create: `user/themes/intotheeast/blueprints/trip.yaml`
**Interfaces:**
- Produces: "Trip" tab in Grav Admin when editing the trip page, with date range and cover image fields
- [ ] **Step 1: Create `trip.yaml` blueprint**
`user/themes/intotheeast/blueprints/trip.yaml`:
```yaml
title: 'Trip'
'@extends':
type: default
context: blueprints://pages
form:
fields:
tabs:
type: tabs
active: 1
fields:
trip:
type: tab
title: Trip
fields:
header.date_start:
type: date
label: 'Start Date'
placeholder: '2026-06-17'
help: 'First day of the trip'
header.date_end:
type: date
label: 'End Date'
placeholder: ''
help: 'Leave blank if trip is ongoing'
header.cover_image:
type: text
label: 'Cover Image Filename'
placeholder: 'cover.jpg'
help: 'Used in the trips listing page'
```
- [ ] **Step 2: Verify blueprint appears in Admin**
Open Grav Admin → Pages → Trips → Japan & Korea 2026 → Edit. Confirm the "Trip" tab appears with start date, end date, cover image fields.
- [ ] **Step 3: Commit**
```bash
git -C user add themes/intotheeast/blueprints/trip.yaml
git -C user commit -m "feat: add Admin blueprint for trip page type"
```
---
## Verification
After all tasks, run end-to-end check:
1. `make test-config && make test-post && make test-ui` — all must pass
2. Navigate to `http://localhost:8081/trips/japan-korea-2026/tracker` — entries display in date order
3. Navigate to `http://localhost:8081/trips/japan-korea-2026/map` — entry pins render, GPX polyline renders if a `.gpx` file is present on the trip page
4. Navigate to `http://localhost:8081/trips/japan-korea-2026/stats` — stats compute correctly
5. Navigate to `http://localhost:8081/trips` — trip listing shows Japan & Korea 2026
6. Submit a post via `/post` — new entry appears under `/trips/japan-korea-2026/tracker`
7. Grav Admin: edit the trip page → "Trip" tab visible with date fields
@@ -0,0 +1,472 @@
# Trip Page Filter Bar — 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:** Replace the three unstyled nav links on the trip page with an in-page filter bar (All content / Journal / Stories) and an inline Stats toggle — no page navigation needed.
**Architecture:** Pure client-side. `data-type` attributes on article cards let vanilla JS show/hide by content type. Stats computation is inlined into `trip.html.twig` from `stats.html.twig`. No new files, no Grav config changes, no page navigation.
**Tech Stack:** Twig (Grav 2.0), vanilla JS (ES5), CSS custom properties
## Global Constraints
- CSS variables only — no raw hex values; use tokens from `tokens.css`
- ES5 JS — no arrow functions, no `const`/`let`, no template literals (inline script in Twig)
- Touch the minimum: only `trip.html.twig` and `style.css`
- Do not modify `stats.html.twig`, `dailies.html.twig`, or any sub-page template
---
### Task 1: Story card border + data-type attributes
**Files:**
- Modify: `user/themes/intotheeast/templates/trip.html.twig:77,119`
- Modify: `user/themes/intotheeast/css/style.css:816-819`
**Interfaces:**
- Produces: `data-type="journal"` and `data-type="story"` attributes on all article cards — consumed by Tasks 3 and 4
- [ ] **Step 1: Add data-type to journal article (trip.html.twig line 77)**
Find this line:
```twig
<article class="entry-card" id="entry-{{ entry.slug }}" data-lat="{{ entry.header.lat }}" data-lng="{{ entry.header.lng }}">
```
Replace with:
```twig
<article class="entry-card" id="entry-{{ entry.slug }}" data-type="journal" data-lat="{{ entry.header.lat }}" data-lng="{{ entry.header.lng }}">
```
- [ ] **Step 2: Add data-type to story article (trip.html.twig line 119)**
Find this line:
```twig
<article class="entry-card entry-card--story" id="entry-{{ entry.slug }}">
```
Replace with:
```twig
<article class="entry-card entry-card--story" id="entry-{{ entry.slug }}" data-type="story">
```
- [ ] **Step 3: Replace story card border in style.css**
Find the existing `.entry-card--story` rule (around line 816):
```css
.entry-card--story {
border-left: 3px solid var(--color-accent);
padding-left: var(--space-5);
}
```
Replace with:
```css
.entry-card--story {
border: 2px solid var(--color-accent);
border-radius: var(--radius-md);
padding: var(--space-6);
background: var(--color-canvas);
}
```
- [ ] **Step 4: Verify visually**
Open the trip page in the browser. In DevTools:
- Select a journal article → confirm it has `data-type="journal"`
- Select a story article → confirm it has `data-type="story"`
- Story cards should now appear as a boxed card with a full teal border and rounded corners instead of a left-only bar
- [ ] **Step 5: Commit**
```bash
git add user/themes/intotheeast/templates/trip.html.twig user/themes/intotheeast/css/style.css
git commit -m "feat: add data-type attributes to feed cards; restyle story card with full border"
```
---
### Task 2: Filter bar markup + CSS (static, no JS yet)
**Files:**
- Modify: `user/themes/intotheeast/templates/trip.html.twig:58-62`
- Modify: `user/themes/intotheeast/css/style.css` (append after `.home-trip-counts` block, around line 694)
**Interfaces:**
- Consumes: nothing from prior tasks (static HTML)
- Produces: `.trip-filter-bar`, `.trip-filter-btn`, `.trip-stats-btn` CSS classes consumed by Task 3
- [ ] **Step 1: Replace nav with filter bar in trip.html.twig**
Find the existing nav block (lines 5862):
```twig
<nav class="trip-nav">
<a href="{{ page.route }}/dailies">Journal</a>
<a href="{{ page.route }}/stats">Stats</a>
<a href="{{ page.route }}/stories">Stories</a>
</nav>
```
Replace with:
```twig
<div class="trip-filter-bar">
<div class="trip-filter-group">
<button class="trip-filter-btn is-active" data-filter="all">All content</button>
<button class="trip-filter-btn" data-filter="journal">Journal</button>
<button class="trip-filter-btn" data-filter="story">Stories</button>
</div>
<button class="trip-stats-btn" id="trip-stats-toggle">Stats</button>
</div>
```
- [ ] **Step 2: Add filter bar CSS to style.css**
After the `.home-trip-counts` rule (around line 694), append:
```css
/* ── Trip page filter bar ────────────────────────────────────────────────────── */
.trip-filter-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
margin-top: var(--space-4);
flex-wrap: wrap;
}
.trip-filter-group {
display: flex;
gap: var(--space-2);
flex-wrap: wrap;
}
.trip-filter-btn,
.trip-stats-btn {
font-family: var(--font-ui);
font-size: var(--text-sm);
font-weight: 500;
color: var(--color-ink-muted);
background: transparent;
border: 1px solid var(--color-border);
border-radius: var(--radius-full);
padding: var(--space-1) var(--space-4);
cursor: pointer;
transition: color 0.15s, border-color 0.15s, background 0.15s;
}
.trip-filter-btn:hover,
.trip-stats-btn:hover {
color: var(--color-ink);
border-color: var(--color-ink-muted);
}
.trip-filter-btn.is-active,
.trip-stats-btn.is-active {
color: var(--color-accent);
border-color: var(--color-accent);
background: var(--color-accent-light);
}
```
- [ ] **Step 3: Verify visually**
Open the trip page. Confirm:
- Three filter pills (All content / Journal / Stories) and a Stats button appear below the trip title
- "All content" pill has teal active styling
- Other pills are muted/bordered
- Clicking the buttons does nothing yet (no JS)
- [ ] **Step 4: Commit**
```bash
git add user/themes/intotheeast/templates/trip.html.twig user/themes/intotheeast/css/style.css
git commit -m "feat: add filter bar markup and pill button styles to trip page"
```
---
### Task 3: Filter JS (show/hide cards by type)
**Files:**
- Modify: `user/themes/intotheeast/templates/trip.html.twig` (append to the existing `<script>` block at the bottom, before `</script>`)
**Interfaces:**
- Consumes: `data-type` on articles (Task 1); `.trip-filter-btn`, `data-filter` (Task 2)
- Produces: working filter interaction
- [ ] **Step 1: Add an empty-state element to the feed**
In `trip.html.twig`, find the closing `</div>` of the `.feed` block (after the `{% else %}` empty message). Add a hidden filter-empty message right before `</div>`:
```twig
<p id="feed-filter-empty" class="feed-empty" style="display:none;"></p>
</div>
```
The full `.feed` block close should look like:
```twig
{% 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>
```
- [ ] **Step 2: Append filter JS to the existing script block**
In `trip.html.twig`, find the closing `</script>` tag at the bottom. Insert before it:
```javascript
(function() {
var filterBtns = document.querySelectorAll('.trip-filter-btn');
var cards = document.querySelectorAll('[data-type]');
var filterEmpty = document.getElementById('feed-filter-empty');
filterBtns.forEach(function(btn) {
btn.addEventListener('click', function() {
filterBtns.forEach(function(b) { b.classList.remove('is-active'); });
btn.classList.add('is-active');
var filter = btn.getAttribute('data-filter');
var visible = 0;
cards.forEach(function(card) {
var show = filter === 'all' || card.getAttribute('data-type') === filter;
card.style.display = show ? '' : 'none';
if (show) visible++;
});
if (filterEmpty) {
if (visible === 0) {
filterEmpty.textContent = filter === 'story'
? 'No stories yet for this trip.'
: 'No entries yet.';
filterEmpty.style.display = '';
} else {
filterEmpty.style.display = 'none';
}
}
});
});
})();
```
- [ ] **Step 3: Verify filter behavior**
Open the trip page. With demo entries loaded (run `make demo-load` if needed):
- Click **Journal** → only journal cards visible, story cards hidden
- Click **Stories** → only story cards visible, journal cards hidden
- Click **All content** → all cards visible again
- If no stories exist, clicking Stories shows "No stories yet for this trip."
- "All content" pill always has active state after clicking it
- [ ] **Step 4: Commit**
```bash
git add user/themes/intotheeast/templates/trip.html.twig
git commit -m "feat: wire up feed filter — All content / Journal / Stories"
```
---
### Task 4: Inline stats block (Twig computation + HTML + toggle JS)
**Files:**
- Modify: `user/themes/intotheeast/templates/trip.html.twig`
- Modify: `user/themes/intotheeast/css/style.css` (append after filter bar CSS from Task 2)
**Interfaces:**
- Consumes: `.trip-stats-btn#trip-stats-toggle` (Task 2); `journal_entries` variable already set at top of template
- Produces: expandable stats block; `STATS_GPS` JS variable for haversine distance
- [ ] **Step 1: Add stats Twig computation at the top of the template**
In `trip.html.twig`, after line 19 (`{% set story_count = story_entries|length %}`), add:
```twig
{# Stats computation #}
{% set days_on_road = 0 %}
{% 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 now_ts = "now"|date('U') %}
{% set diff_seconds = now_ts - first_ts %}
{% set days_raw = (diff_seconds / 86400)|round(0, 'floor') %}
{% set days_on_road = days_raw < 1 ? 1 : days_raw %}
{% 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 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 %}
```
- [ ] **Step 2: Add stats block HTML between filter bar and feed**
In `trip.html.twig`, find the `<div class="feed">` line and insert the stats block immediately before it:
```twig
<div id="trip-stats-block" class="trip-stats-block" style="display:none">
<div class="trip-stats-grid">
<div class="stat-block">
<span class="stat-value">{{ days_on_road }}</span>
<span class="stat-label">{{ days_on_road == 1 ? 'day' : 'days' }} on the road</span>
</div>
<div class="stat-block">
<span class="stat-value">{{ journal_count }}</span>
<span class="stat-label">{{ journal_count == 1 ? 'entry' : 'entries' }} posted</span>
</div>
<div class="stat-block">
<span class="stat-value">{{ country_display|length }}</span>
<span class="stat-label">{{ country_display|length == 1 ? 'country' : 'countries' }} visited</span>
</div>
<div class="stat-block">
<span class="stat-value" id="stat-distance">—</span>
<span class="stat-label">km traveled</span>
</div>
</div>
{% if country_display|length > 0 %}
<p class="trip-stats-countries">{{ country_display|join(' · ') }}</p>
{% endif %}
<p class="trip-stats-note">Distance is approximate — straight lines between entry locations.</p>
</div>
```
- [ ] **Step 3: Add stats block CSS to style.css**
Append after the filter bar CSS added in Task 2:
```css
/* ── Trip page inline stats block ───────────────────────────────────────────── */
.trip-stats-block {
background: var(--color-canvas);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: var(--space-6);
margin-bottom: var(--space-6);
}
.trip-stats-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: var(--space-4);
margin-bottom: var(--space-4);
}
@media (max-width: 600px) {
.trip-stats-grid { grid-template-columns: repeat(2, 1fr); }
}
.trip-stats-countries {
font-size: var(--text-sm);
color: var(--color-ink-2);
margin-bottom: var(--space-2);
}
.trip-stats-note {
font-size: var(--text-xs);
color: var(--color-ink-muted);
}
```
Note: `.stat-block`, `.stat-value`, `.stat-label` are reused from `stats.html.twig` and already have CSS defined. Do not add duplicate rules.
- [ ] **Step 4: Verify those existing CSS classes exist**
Run:
```bash
grep -n "\.stat-block\|\.stat-value\|\.stat-label" user/themes/intotheeast/css/style.css
```
Expected: at least 3 matches. If not found, copy from `stats.html.twig`'s inline `<style>` block if one exists.
- [ ] **Step 5: Add stats toggle JS + haversine distance**
In `trip.html.twig`, append to the existing `<script>` block (before `</script>`):
```javascript
var STATS_GPS = {{ gps_points|json_encode|raw }};
(function() {
function haversine(lat1, lng1, lat2, lng2) {
var R = 6371;
var dLat = (lat2 - lat1) * Math.PI / 180;
var dLng = (lng2 - lng1) * Math.PI / 180;
var a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
Math.sin(dLng / 2) * Math.sin(dLng / 2);
return R * 2 * Math.asin(Math.sqrt(a));
}
var totalKm = 0;
for (var i = 1; i < STATS_GPS.length; i++) {
totalKm += haversine(
parseFloat(STATS_GPS[i - 1][0]), parseFloat(STATS_GPS[i - 1][1]),
parseFloat(STATS_GPS[i][0]), parseFloat(STATS_GPS[i][1])
);
}
var distEl = document.getElementById('stat-distance');
if (distEl) {
distEl.textContent = STATS_GPS.length < 2 ? '—' : '~' + Math.round(totalKm).toLocaleString();
}
var statsToggle = document.getElementById('trip-stats-toggle');
var statsBlock = document.getElementById('trip-stats-block');
if (statsToggle && statsBlock) {
statsToggle.addEventListener('click', function() {
var isOpen = statsBlock.style.display !== 'none';
statsBlock.style.display = isOpen ? 'none' : '';
statsToggle.classList.toggle('is-active', !isOpen);
});
}
})();
```
- [ ] **Step 6: Verify stats block**
Open the trip page with demo entries loaded:
- Click **Stats** → inline block expands between filter bar and feed; Stats button turns teal
- Block shows: days on road, entries count, countries count, km distance (or `—` if < 2 GPS points)
- Countries list shows below the grid if any entries have `location_country`
- Click **Stats** again → block collapses, button returns to default style
- [ ] **Step 7: Commit**
```bash
git add user/themes/intotheeast/templates/trip.html.twig user/themes/intotheeast/css/style.css
git commit -m "feat: add inline stats block with toggle to trip page"
```
---
## Self-Review
**Spec coverage:**
- ✅ Filter bar with All / Journal / Stories (Task 2, 3)
- ✅ Mutually exclusive, one active at a time (Task 3 JS)
- ✅ JS show/hide via data-type (Task 1, 3)
- ✅ Empty state for Stories filter (Task 3)
- ✅ Stats as inline expansion (Task 4)
- ✅ Stats toggle with active state (Task 4)
- ✅ Story card full border (Task 1)
- ✅ Sub-pages untouched — no changes to stats.html.twig or dailies.html.twig
**Placeholder scan:** None — all steps contain exact code.
**Type consistency:**
- `data-filter="story"` on button matches `data-type="story"` on article — comparison in Task 3 JS: `card.getAttribute('data-type') === filter`
- `id="trip-stats-toggle"` set in Task 2 HTML, read in Task 4 JS ✅
- `id="trip-stats-block"` set in Task 4 HTML, read in Task 4 JS ✅
- `id="feed-filter-empty"` set in Task 3 HTML, read in Task 3 JS ✅
- `id="stat-distance"` set in Task 4 HTML, read in Task 4 JS ✅
- `STATS_GPS` set in Task 4 JS, consumed in Task 4 haversine loop ✅
- `.stat-block` / `.stat-value` / `.stat-label` reused from existing CSS — Task 4 Step 4 verifies they exist ✅
@@ -0,0 +1,301 @@
# Tuscany Demo Stories 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:** Add three Italy 2025 Tuscany demo stories that showcase distinct story-mode composition patterns.
**Architecture:** Three `story.md` files in `user/docs/demo/trips/italy-2025/04.stories/`, committed to the user repo. One Makefile line added to `demo-load` to copy the folder into pages, committed to the main repo. No image files — shortcode image params reference filenames that won't resolve without real photos, consistent with the existing Japan demo story.
**Tech Stack:** Grav CMS page frontmatter (YAML), Markdown prose, story-blocks shortcodes (chapter-break, scrolly-section, pull-quote, snap-gallery), Makefile.
## Global Constraints
- Story pages live at `user/docs/demo/trips/italy-2025/04.stories/<n>.<slug>/story.md` (note: `user/` is a separate git repo — all story commits use `git -C user ...`)
- Shortcode syntax: `[chapter-break title="..." number="..." image="..." alt="..." /]`, `[scrolly-section image="..." alt="..." caption="..."]...[/scrolly-section]`, `[pull-quote image="..."]...[/pull-quote]` (image param optional), `[snap-gallery images="a.jpg,b.jpg" captions="Cap 1,Cap 2" alts="Alt 1,Alt 2" /]`
- ScrollySection steps separated by `---` on its own line inside the shortcode tags
- All frontmatter fields required: `title`, `date`, `location_name`, `location_country`, `lat`, `lng`, `hero_image`, `hero_alt`, `published: true`
- `end_date` optional (Story 2 only)
- Makefile changes go in the **main repo** (`git add Makefile && git commit ...` — no `-C user`)
- Dev server: `http://localhost:8081`, Docker container: `intotheeast_grav`
---
### Task 1: Three story.md files
**Files:**
- Create: `user/docs/demo/trips/italy-2025/04.stories/01.val-dorcia-dawn/story.md`
- Create: `user/docs/demo/trips/italy-2025/04.stories/02.long-climb-montalcino/story.md`
- Create: `user/docs/demo/trips/italy-2025/04.stories/03.one-evening-siena/story.md`
**Interfaces:**
- Produces: three story pages loadable by `make demo-load` into `user/pages/01.trips/italy-2025/04.stories/`
- Consumed by: Task 2 (Makefile verification)
- [ ] **Create the demo stories directory**
```bash
mkdir -p user/docs/demo/trips/italy-2025/04.stories/01.val-dorcia-dawn
mkdir -p user/docs/demo/trips/italy-2025/04.stories/02.long-climb-montalcino
mkdir -p user/docs/demo/trips/italy-2025/04.stories/03.one-evening-siena
```
- [ ] **Create Story 1: `01.val-dorcia-dawn/story.md`**
Pattern: gallery-led. Demonstrates two snap-galleries, chapter-break as scene divider, text-only pull-quote (no image param).
```markdown
---
title: The Val d'Orcia at Dawn
date: 2025-09-05
location_name: Val d'Orcia
location_country: Italy
lat: 43.078
lng: 11.676
hero_image: hero.jpg
hero_alt: Cypress-lined dirt road at first light, Tuscany
published: true
---
We left camp before the heat arrived. At six in the morning the Val d'Orcia belongs entirely to the light — long shadows, pale gold, not a car on the white roads. The kind of silence that has texture.
[snap-gallery images="dawn-wide.jpg,rolling-hills.jpg,cypress-allee.jpg,dirt-road.jpg" captions="First light on the valley floor,The hills fold endlessly east,The cypress road — every photo you have ever seen of Tuscany,Dust rising behind us on the gravel" alts="Wide valley at dawn with golden light,Rolling green hills under morning sky,Long avenue of cypress trees,White gravel road with dust cloud" /]
We stopped twice before nine. Once for a puncture, once because the view demanded it.
[chapter-break image="heat-haze.jpg" title="The Hour Before Heat" alt="Hazy hillside shimmering in early morning warmth" /]
By ten the temperature had shifted. The colours changed too — softer, more diffuse, the sky turning white at the edges. We dropped into the lower valley and the road surface changed from gravel to packed earth, then back again.
[snap-gallery images="gravel-detail.jpg,wheel-shadow.jpg,water-bottle.jpg,road-shadow.jpg" captions="The texture of Tuscan gravel — coarser than it looks,Long shadow at the day's first steep pitch,Half a litre left and forty kilometres to go,The road ahead disappears into the heat" alts="Close-up of pale gravel surface,Bicycle wheel casting shadow on road,Half-empty water bottle in cage,Road vanishing into bright haze" /]
[pull-quote]
The best hours of a cycling day are the ones nobody sees. Four in the morning to ten. Then it belongs to the sun.
[/pull-quote]
We made Pienza by noon. It was already thirty degrees and the ice cream queue was six deep.
```
- [ ] **Create Story 2: `02.long-climb-montalcino/story.md`**
Pattern: scrollytelling-led. Demonstrates two scrolly-sections with different step counts (3 vs. 5), pull-quote with image, chapter-break with number.
```markdown
---
title: The Long Climb to Montalcino
date: 2025-09-05
end_date: 2025-09-06
location_name: Montalcino
location_country: Italy
lat: 43.058
lng: 11.489
hero_image: hero.jpg
hero_alt: Hairpin road climbing through olive groves towards a hilltop town
published: true
---
The profile showed fourteen kilometres at an average of six percent. In practice it was steeper at the bottom and gentler at the top, which is the worst possible arrangement. We started climbing at two in the afternoon, which was also the worst possible decision.
[scrolly-section image="climb-road.jpg" alt="Empty road rising steeply through olive groves" caption="SP55 — 14km, 840m elevation gain"]
The first kilometre is the most honest. You find out immediately whether your legs have anything to say.
---
By the halfway point the olive groves had given way to scrub oak and the road had narrowed to a single lane. No cars had passed in forty minutes. The silence was absolute except for breathing.
---
Then, at the last bend before the top, the town appeared. Just the outline of it — a tower, a wall, rooftops. It was enough.
[/scrolly-section]
[chapter-break image="town-gate.jpg" title="Montalcino" number="II" alt="Medieval town gate with stone archway" /]
[pull-quote image="vineyard.jpg" alt="Rows of Brunello vines descending from hilltop town"]
From the top you could see the whole valley we had spent two days riding through. It looked completely flat from up here.
[/pull-quote]
We found a bar in the main piazza. The owner brought two glasses of water without being asked. Then two more. Then a small plate of bread and oil that nobody ordered. We sat there for an hour.
[scrolly-section image="piazza.jpg" alt="Shaded medieval piazza with stone buildings" caption="Piazza del Popolo, Montalcino"]
The piazza at five in the afternoon is a different place from the piazza at noon. People have returned from wherever they go during the heat.
---
A wine shop with barrels in the window and a handwritten list on a chalkboard. We looked at it for a long time and bought nothing. The prices were very reasonable and this felt suspicious.
---
A cat on a warm stone wall, watching traffic that did not exist. It had clearly been watching this traffic for years.
---
The fortress walls turn amber just before sunset. You could photograph this from a hundred different angles and it would look the same in all of them: very good.
---
The descent back to the valley takes twenty minutes. The climb took two and a half hours. This ratio never stops feeling wrong.
[/scrolly-section]
We found the agriturismo by following a handwritten sign nailed to a cypress tree. It was exactly what it promised to be.
```
- [ ] **Create Story 3: `03.one-evening-siena/story.md`**
Pattern: mood/fragment piece. Demonstrates pull-quote as opening element, 2-step scrolly-section (minimum), snap-gallery as mid-story element, pull-quote with image as closing element.
```markdown
---
title: One Evening in Siena
date: 2025-09-05
location_name: Siena
location_country: Italy
lat: 43.318
lng: 11.330
hero_image: hero.jpg
hero_alt: The Piazza del Campo at dusk, terracotta rooftops fading to blue
published: true
---
[pull-quote]
Siena is not a city that tries to impress you. It has been here for a thousand years and intends to be here for a thousand more. You fit around it.
[/pull-quote]
We rolled in at half past six, legs finished, panniers heavier than they started. The Campo appeared without warning at the end of a narrow street and we both stopped pedalling at exactly the same moment.
[scrolly-section image="campo.jpg" alt="Piazza del Campo seen from the upper rim, sloping shell-shaped square"]
The square fills from the edges inward as evening comes. First the locals — people who have been here before and know which bench faces west. Then the tourists, then the pigeons, then the shadows.
---
A busker with a cello at the top of the slope. A couple arguing quietly in a language I couldn't place. Three children running in a circle for reasons nobody questioned. The ordinary business of a city at the end of a summer day.
[/scrolly-section]
[snap-gallery images="campo-dusk.jpg,doorway.jpg,gelato.jpg" captions="The Campo at the moment the light goes warm,A doorway on Via di Città — every doorway in Siena looks like this,The mandatory stop: stracciatella, obviously" alts="Wide shot of Campo at golden hour,Ornate stone doorway with iron lantern,Close-up of gelato cone with city behind" /]
We found a place to eat down three flights of stairs in a basement that appeared to have no ventilation and no menu. It was perfect. The relief of sitting down after eight hours on a bike is a specific physical sensation that is difficult to describe to anyone who has not experienced it.
[pull-quote image="sunset.jpg" alt="Sunset view over Siena rooftops from high vantage point"]
Cycling makes you earn every place you arrive at. Siena earned.
[/pull-quote]
```
- [ ] **Verify shortcode syntax in all three files**
Check that every shortcode tag opens and closes correctly:
```bash
grep -n "\[chapter-break\|\[scrolly-section\|\[/scrolly-section\]\|\[pull-quote\|\[/pull-quote\]\|\[snap-gallery" \
user/docs/demo/trips/italy-2025/04.stories/01.val-dorcia-dawn/story.md \
user/docs/demo/trips/italy-2025/04.stories/02.long-climb-montalcino/story.md \
user/docs/demo/trips/italy-2025/04.stories/03.one-evening-siena/story.md
```
Expected: every `[scrolly-section` has a matching `[/scrolly-section]`, every `[pull-quote` has a matching `[/pull-quote]`.
- [ ] **Commit to user repo**
```bash
git -C user add docs/demo/trips/italy-2025/04.stories/
git -C user commit -m "docs: add three Tuscany demo stories (gallery-led, scrollytelling-led, mood-fragment)"
```
---
### Task 2: Makefile update + end-to-end verification
**Files:**
- Modify: `Makefile` (main repo — no `-C user`)
**Interfaces:**
- Consumes: `user/docs/demo/trips/italy-2025/04.stories/` from Task 1
- Produces: `make demo-load` copies all three stories into pages; stories listing shows 3 cards; each story page renders shortcode HTML
- [ ] **Add the stories copy line to `demo-load`**
In `Makefile`, find this line:
```makefile
cp user/docs/demo/trips/italy-2025/stories.md user/pages/01.trips/italy-2025/04.stories/stories.md 2>/dev/null || true
```
Add the following line immediately after it:
```makefile
cp -r user/docs/demo/trips/italy-2025/04.stories/. user/pages/01.trips/italy-2025/04.stories/ 2>/dev/null || true
```
Note: use `04.stories/.` (trailing slash-dot) to copy the **contents** of the folder into the existing `04.stories/` directory (which already contains `stories.md`), rather than creating a nested `04.stories/04.stories/` structure.
- [ ] **Run `make demo-load` and clear cache**
```bash
make demo-load
```
Expected output ends with Grav cache cleared.
- [ ] **Verify stories listing shows 3 cards**
```bash
curl -s http://localhost:8081/trips/italy-2025/stories | grep -c 'story-card'
```
Expected: `3` (one card per story).
- [ ] **Verify Story 1 renders both snap-galleries and text-only pull-quote**
```bash
curl -s http://localhost:8081/trips/italy-2025/stories/val-dorcia-dawn | grep -c 'pgallery\|pull-quote'
```
Expected: at least `3` (2 pgallery divs + 1 pull-quote).
Also verify the text-only pull-quote (no background image div):
```bash
curl -s http://localhost:8081/trips/italy-2025/stories/val-dorcia-dawn | grep 'pull-quote__inner--no-image'
```
Expected: one match (the text-only pull-quote variant).
- [ ] **Verify Story 2 renders two scrolly-sections**
```bash
curl -s http://localhost:8081/trips/italy-2025/stories/long-climb-montalcino | grep -c 'class="scrolly"'
```
Expected: `2` (two scrolly-section blocks).
Also verify pull-quote with image (should NOT have `--no-image` class):
```bash
curl -s http://localhost:8081/trips/italy-2025/stories/long-climb-montalcino | grep 'pull-quote__bg'
```
Expected: one match (the background image div for the pull-quote).
- [ ] **Verify Story 3 renders pull-quote as opener and closer**
```bash
curl -s http://localhost:8081/trips/italy-2025/stories/one-evening-siena | grep -c 'pull-quote'
```
Expected: at least `4` (two pull-quote elements × ~2 class references each).
Verify the mid-story snap-gallery:
```bash
curl -s http://localhost:8081/trips/italy-2025/stories/one-evening-siena | grep -c 'pgallery'
```
Expected: at least `1`.
- [ ] **Run `make demo-reset` and verify cleanup**
```bash
make demo-reset
```
Verify Italy stories are gone:
```bash
ls user/pages/01.trips/italy-2025/ 2>/dev/null || echo "italy-2025 removed"
```
Expected: `italy-2025 removed` (the entire italy-2025 folder is removed by `demo-reset`).
- [ ] **Commit Makefile to main repo**
```bash
git add Makefile
git commit -m "build: add Italy 2025 stories folder to demo-load target"
```
@@ -0,0 +1,935 @@
# GPX Connector Logic 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:** Suppress the straight-line connector between adjacent map markers when a single GPX file covers both endpoints; keep connectors for uncovered gaps; add `force_connect` and `transport_mode` fields to entry/story blueprints.
**Architecture:** Pure client-side. GPX files are already fetched to display tracks; their parsed trackpoints are reused to run a same-file proximity check per adjacent marker pair. Journey segments are built after all GPX fetches settle (Promise.all). The algorithm lives in `maplibre-utils.js` as pure functions exposed on `MapUtils`.
**Tech Stack:** Vanilla JS (ES5 IIFE pattern matching existing code), MapLibre GL 4, `@mapbox/togeojson` 0.16.2, Grav 2 blueprint YAML, Playwright for tests.
## Global Constraints
- ES5 syntax only in all JS — no arrow functions, const/let, template literals, or modules (matching existing `maplibre-utils.js` style)
- All JS functions inside the existing `maplibre-utils.js` IIFE
- Grav blueprint fields use `header.<fieldname>` prefix in the `form.fields` tree
- Proximity threshold: **10 km** (hardcoded, not configurable)
- Trackpoints stored internally as `[lat, lng]` (latitude first); MapLibre coords are `[lng, lat]` (longitude first) — never mix these up
- Demo data required for Playwright tests: run `make demo-load` before the test suite
- Dev server runs at `http://localhost:8081`
---
### Task 1: Blueprint — add `force_connect` and `transport_mode` fields
**Files:**
- Modify: `user/themes/intotheeast/blueprints/entry.yaml`
- Create: `user/themes/intotheeast/blueprints/story.yaml`
**Interfaces:**
- Produces: `entry.header.force_connect` (bool, default false), `entry.header.transport_mode` (string, default null) available in Twig templates and Admin2 UI
- [ ] **Step 1: Add a Journey tab to `entry.yaml`**
In `user/themes/intotheeast/blueprints/entry.yaml`, append this tab section after the `publishing:` tab block (before the closing of the `tabs.fields` block). The final file should end with:
```yaml
journey:
type: tab
title: Journey
fields:
header.transport_mode:
type: select
label: How I arrived here
default: ''
options:
'': '— not specified —'
'walking': 'Walking'
'bicycle': 'Bicycle'
'bus': 'Bus'
'train': 'Train'
'car': 'Car'
header.force_connect:
type: toggle
label: Force connector line
help: 'When GPX tracks are present, always draw a connector from the previous marker to this one'
highlight: 1
default: 0
options:
1: 'Yes'
0: 'No'
validate:
type: bool
```
- [ ] **Step 2: Create `story.yaml` blueprint**
Create `user/themes/intotheeast/blueprints/story.yaml` with this full content (covers all existing story frontmatter fields plus the new Journey tab):
```yaml
title: 'Story'
form:
fields:
tabs:
type: tabs
active: 1
fields:
content:
type: tab
title: Content
fields:
header.title:
type: text
label: Title
validate:
required: true
header.date:
type: datetime
label: Date
format: 'Y-m-d H:i'
validate:
required: true
header.hero_image:
type: text
label: Hero Image
placeholder: 'hero.jpg'
help: 'Filename of the hero image (upload via Media tab)'
header.hero_alt:
type: text
label: Hero Image Alt Text
placeholder: 'Description of the hero image'
content:
type: markdown
label: Content
validate:
required: true
location:
type: tab
title: Location
fields:
header.location_name:
type: text
label: Location Name
placeholder: 'e.g. Val d''Orcia'
header.location_country:
type: text
label: Country
placeholder: 'e.g. Italy'
header.lat:
type: text
label: Latitude
placeholder: '43.0780'
help: 'GPS latitude (decimal degrees)'
header.lng:
type: text
label: Longitude
placeholder: '11.6760'
help: 'GPS longitude (decimal degrees)'
publishing:
type: tab
title: Publishing
fields:
header.published:
type: toggle
label: Published
highlight: 1
default: 1
options:
1: 'Yes'
0: 'No'
validate:
type: bool
journey:
type: tab
title: Journey
fields:
header.transport_mode:
type: select
label: How I arrived here
default: ''
options:
'': '— not specified —'
'walking': 'Walking'
'bicycle': 'Bicycle'
'bus': 'Bus'
'train': 'Train'
'car': 'Car'
header.force_connect:
type: toggle
label: Force connector line
help: 'When GPX tracks are present, always draw a connector from the previous marker to this one'
highlight: 1
default: 0
options:
1: 'Yes'
0: 'No'
validate:
type: bool
```
- [ ] **Step 3: Manual verification**
Open Admin2 at `http://localhost:8081/admin` → edit any entry under a dailies folder → confirm a "Journey" tab appears with "How I arrived here" select and "Force connector line" toggle. Then open any story page → confirm the same "Journey" tab is present.
- [ ] **Step 4: Commit**
```bash
git add user/themes/intotheeast/blueprints/entry.yaml user/themes/intotheeast/blueprints/story.yaml
git commit -m "feat: add force_connect and transport_mode fields to entry and story blueprints"
```
---
### Task 2: Algorithm functions in `maplibre-utils.js`
**Files:**
- Modify: `user/themes/intotheeast/js/maplibre-utils.js`
- Create: `tests/ui/gpx-journey.spec.js`
**Interfaces:**
- Produces:
- `MapUtils.extractTrackpoints(geojson)``[[lat, lng], ...]`
- `MapUtils.buildJourneySegments(entries, allTrackpoints, thresholdKm)``[[lng, lat], ...][]`
- `MapUtils.addJourneySegments(map, segments, baseSourceId)` → void
- Consumes: `toGeoJSON.gpx()` output (GeoJSON FeatureCollection)
- [ ] **Step 1: Write failing Playwright tests**
Create `tests/ui/gpx-journey.spec.js`:
```javascript
// @ts-check
// Tests: G1G4 — buildJourneySegments algorithm correctness
// These tests load the italy-2025 map page (which has GPX) to get MapUtils in scope,
// then call the functions with synthetic data via page.evaluate.
// Requires demo data: run `make demo-load` before this suite.
const { test, expect } = require('@playwright/test');
async function getMapUtils(page) {
await page.goto('/trips/italy-2025/map');
await expect(page.locator('canvas.maplibregl-canvas')).toBeVisible({ timeout: 10000 });
}
// G1: No GPX → all pairs connected in one segment
test('G1: all markers connected when no GPX files present', async ({ page }) => {
await getMapUtils(page);
var count = await page.evaluate(function () {
var entries = [
{ lat: '43.0', lng: '11.0', force_connect: false },
{ lat: '44.0', lng: '12.0', force_connect: false },
{ lat: '45.0', lng: '13.0', force_connect: false }
];
return MapUtils.buildJourneySegments(entries, [], 10).length;
});
expect(count).toBe(1);
});
// G2: Same GPX file covers both markers → connector suppressed (0 segments)
test('G2: connector suppressed when same GPX file covers both markers', async ({ page }) => {
await getMapUtils(page);
var count = await page.evaluate(function () {
var e1 = { lat: '43.000', lng: '11.000', force_connect: false };
var e2 = { lat: '43.010', lng: '11.010', force_connect: false };
// Trackpoints covering both (stored as [lat, lng])
var track = [[43.000, 11.000], [43.005, 11.005], [43.010, 11.010]];
return MapUtils.buildJourneySegments([e1, e2], [track], 10).length;
});
expect(count).toBe(0);
});
// G3: force_connect overrides GPX suppression
test('G3: force_connect keeps connector even when GPX covers both markers', async ({ page }) => {
await getMapUtils(page);
var count = await page.evaluate(function () {
var e1 = { lat: '43.000', lng: '11.000', force_connect: false };
var e2 = { lat: '43.010', lng: '11.010', force_connect: true };
var track = [[43.000, 11.000], [43.005, 11.005], [43.010, 11.010]];
return MapUtils.buildJourneySegments([e1, e2], [track], 10).length;
});
expect(count).toBe(1);
});
// G4: Markers near DIFFERENT GPX files → connector kept
test('G4: connector kept when markers are near different GPX files', async ({ page }) => {
await getMapUtils(page);
var count = await page.evaluate(function () {
var e1 = { lat: '43.000', lng: '11.000', force_connect: false };
var e2 = { lat: '45.000', lng: '13.000', force_connect: false };
// Two separate files — each only covers one marker
var trackA = [[43.000, 11.000], [43.005, 11.005]]; // near e1 only
var trackB = [[45.000, 13.000], [45.005, 13.005]]; // near e2 only
return MapUtils.buildJourneySegments([e1, e2], [trackA, trackB], 10).length;
});
expect(count).toBe(1);
});
// G5: First pair suppressed, second pair kept → one segment [e2, e3]
test('G5: suppressed first pair leaves one segment from e2 to e3', async ({ page }) => {
await getMapUtils(page);
var count = await page.evaluate(function () {
// e1→e2: covered by track → suppressed; e1 is orphaned (< 2 pts, not pushed)
// e2→e3: not covered → connector kept → segment [e2, e3]
var e1 = { lat: '43.000', lng: '11.000', force_connect: false };
var e2 = { lat: '43.010', lng: '11.010', force_connect: false };
var e3 = { lat: '45.000', lng: '13.000', force_connect: false };
var track = [[43.000, 11.000], [43.005, 11.005], [43.010, 11.010]]; // covers e1 and e2 only
var segs = MapUtils.buildJourneySegments([e1, e2, e3], [track], 10);
return segs.length;
});
expect(count).toBe(1); // one segment: [e2 → e3]
});
```
- [ ] **Step 2: Run tests to confirm they fail**
```bash
npx playwright test tests/ui/gpx-journey.spec.js
```
Expected: All 5 tests fail with `MapUtils.buildJourneySegments is not a function` (or similar).
- [ ] **Step 3: Add algorithm functions to `maplibre-utils.js`**
Inside the IIFE in `user/themes/intotheeast/js/maplibre-utils.js`, add the following functions **before** the `global.MapUtils = ...` line at the bottom:
```javascript
/* ── GPX connector algorithm ────────────────────────────────────────── */
/* Haversine distance in km between two [lat, lng] points */
function haversineKm(lat1, lng1, lat2, lng2) {
var R = 6371;
var dLat = (lat2 - lat1) * Math.PI / 180;
var dLng = (lng2 - lng1) * Math.PI / 180;
var a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
Math.sin(dLng / 2) * Math.sin(dLng / 2);
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
/*
* Extract trackpoints from a toGeoJSON output.
* Returns [[lat, lng], ...] — latitude first (internal convention).
* GeoJSON coordinates are [lng, lat]; we flip them here.
*/
function extractTrackpoints(geojson) {
var points = [];
(geojson.features || []).forEach(function (feat) {
var coords = [];
if (feat.geometry.type === 'LineString') {
coords = feat.geometry.coordinates;
} else if (feat.geometry.type === 'MultiLineString') {
feat.geometry.coordinates.forEach(function (line) {
coords = coords.concat(line);
});
}
coords.forEach(function (c) { points.push([c[1], c[0]]); }); // [lng,lat] → [lat,lng]
});
return points;
}
/*
* Check whether a marker is within thresholdKm of any trackpoint in the array.
* trackpoints: [[lat, lng], ...] (internal convention, latitude first).
* Samples every 10th point for performance; always checks the last point.
*/
function isNearTrack(markerLat, markerLng, trackpoints, thresholdKm) {
if (!trackpoints || trackpoints.length === 0) return false;
var degLat = thresholdKm / 111;
var degLng = thresholdKm / (111 * Math.cos(markerLat * Math.PI / 180));
for (var i = 0; i < trackpoints.length; i += 10) {
var pt = trackpoints[i];
if (Math.abs(pt[0] - markerLat) > degLat || Math.abs(pt[1] - markerLng) > degLng) continue;
if (haversineKm(markerLat, markerLng, pt[0], pt[1]) <= thresholdKm) return true;
}
var last = trackpoints[trackpoints.length - 1];
return haversineKm(markerLat, markerLng, last[0], last[1]) <= thresholdKm;
}
/*
* Build journey line segments from entries and GPX trackpoints.
*
* entries: [{lat, lng, force_connect}, ...] in chronological order
* allTrackpoints: [ [[lat,lng],...], ... ] — one sub-array per GPX file
* thresholdKm: proximity radius (default 10)
*
* Returns array of segments, each segment being [[lng, lat], ...] in MapLibre
* coordinate order. A segment with < 2 points is omitted.
*
* Rules:
* - No GPX files → all adjacent pairs connected (one segment)
* - GPX present, pair covered by same file → connector suppressed
* - GPX present, pair NOT covered by any single file → connector drawn
* - force_connect on arriving entry → always draw connector
*/
function buildJourneySegments(entries, allTrackpoints, thresholdKm) {
thresholdKm = thresholdKm || 10;
var hasGpx = allTrackpoints && allTrackpoints.length > 0;
var segments = [];
var current = [];
for (var i = 0; i < entries.length; i++) {
var e = entries[i];
var lngLat = [parseFloat(e.lng), parseFloat(e.lat)]; // MapLibre: [lng, lat]
if (i === 0) {
current.push(lngLat);
continue;
}
var prev = entries[i - 1];
var connect;
if (!hasGpx || e.force_connect) {
connect = true;
} else {
var pLat = parseFloat(prev.lat);
var pLng = parseFloat(prev.lng);
var cLat = parseFloat(e.lat);
var cLng = parseFloat(e.lng);
var covered = false;
for (var f = 0; f < allTrackpoints.length; f++) {
if (isNearTrack(pLat, pLng, allTrackpoints[f], thresholdKm) &&
isNearTrack(cLat, cLng, allTrackpoints[f], thresholdKm)) {
covered = true;
break;
}
}
connect = !covered;
}
if (connect) {
current.push(lngLat);
} else {
if (current.length >= 2) segments.push(current);
current = [lngLat]; // start new segment from this point
}
}
if (current.length >= 2) segments.push(current);
return segments;
}
/*
* Draw journey segments — calls addJourneyLine once per segment.
* baseSourceId: e.g. 'journey' → sources become 'journey-0', 'journey-1', ...
* (single segment gets plain 'journey' for backwards compatibility).
*/
function addJourneySegments(map, segments, baseSourceId) {
segments.forEach(function (coords, i) {
var sid = segments.length === 1 ? baseSourceId : baseSourceId + '-' + i;
addJourneyLine(map, coords, sid);
});
}
```
- [ ] **Step 4: Update the `MapUtils` export**
Replace the existing `global.MapUtils = ...` line at the bottom of the IIFE with:
```javascript
global.MapUtils = {
MAP_STYLE: MAP_STYLE,
ACCENT: ACCENT,
addJourneyLine: addJourneyLine,
addJourneySegments: addJourneySegments,
buildJourneySegments: buildJourneySegments,
extractTrackpoints: extractTrackpoints,
createDotMarker: createDotMarker
};
```
- [ ] **Step 5: Run tests to confirm G1G5 pass**
```bash
npx playwright test tests/ui/gpx-journey.spec.js
```
Expected: All 5 tests pass.
- [ ] **Step 6: Commit**
```bash
git add user/themes/intotheeast/js/maplibre-utils.js tests/ui/gpx-journey.spec.js
git commit -m "feat: add GPX proximity algorithm to MapUtils (buildJourneySegments, extractTrackpoints)"
```
---
### Task 3: Rewire `map.html.twig` to use the algorithm
**Files:**
- Modify: `user/themes/intotheeast/templates/map.html.twig`
**Interfaces:**
- Consumes: `MapUtils.extractTrackpoints`, `MapUtils.buildJourneySegments`, `MapUtils.addJourneySegments`
- Consumes: `entry.header.force_connect` from Grav page frontmatter
- [ ] **Step 1: Add `force_connect` to the Twig entry serialisation**
In `map.html.twig`, the `map_entries` loop (lines 2431) builds the entry JSON. Add `force_connect` to the merge array:
```twig
{% 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
}]) %}
```
- [ ] **Step 2: Restructure the JS section in `map.html.twig`**
Replace the entire `<script>` block (lines 42115) with the following. Key changes: GPX loading now returns Promises with extracted trackpoints; markers and bounds are set up before GPX loads; journey segments are drawn only after Promise.all resolves.
```javascript
<script>
var ENTRIES = {{ map_entries|json_encode|raw }};
var GPX_URLS = {{ gpx_urls|json_encode|raw }};
var map = new maplibregl.Map({
container: 'trip-map',
style: MapUtils.MAP_STYLE,
center: [20, 20],
zoom: 2
});
map.addControl(new maplibregl.NavigationControl(), 'top-right');
if (ENTRIES.length === 0) {
var empty = document.createElement('div');
empty.className = 'map-empty';
empty.textContent = 'No locations yet — entries with GPS will appear here.';
document.getElementById('trip-map').appendChild(empty);
}
map.on('load', function () {
if (ENTRIES.length === 0) return;
/* ── Markers + bounds ──────────────────────────────────────── */
var bounds = new maplibregl.LngLatBounds();
ENTRIES.forEach(function (entry, i) {
var isLatest = (i === ENTRIES.length - 1);
var lngLat = [parseFloat(entry.lng), parseFloat(entry.lat)];
bounds.extend(lngLat);
var el = MapUtils.createDotMarker(isLatest);
el.dataset.url = entry.url;
var popup = new maplibregl.Popup({ offset: 12, closeButton: false, closeOnClick: false, className: 'map-tip-popup' })
.setLngLat(lngLat)
.setHTML('<span class="map-tip">' + entry.title + '</span>');
el.addEventListener('mouseenter', function () { popup.addTo(map); });
el.addEventListener('mouseleave', function () { popup.remove(); });
el.addEventListener('click', function () { window.location.href = entry.url; });
new maplibregl.Marker({ element: el }).setLngLat(lngLat).addTo(map);
});
/* ── Fit bounds ─────────────────────────────────────────────── */
if (ENTRIES.length === 1) {
map.jumpTo({ center: [parseFloat(ENTRIES[0].lng), parseFloat(ENTRIES[0].lat)], zoom: 10 });
} else {
map.fitBounds(bounds, { padding: 100, maxZoom: 11 });
}
/* ── GPX tracks + journey segments ─────────────────────────── */
Promise.all(GPX_URLS.map(function (url, idx) {
return fetch(url)
.then(function (r) { return r.text(); })
.then(function (text) {
var xml = new DOMParser().parseFromString(text, 'text/xml');
var geojson = toGeoJSON.gpx(xml);
var sid = 'gpx-' + idx;
map.addSource(sid, { type: 'geojson', data: geojson });
map.addLayer({
id: sid + '-line', type: 'line', source: sid,
layout: { 'line-join': 'round', 'line-cap': 'round' },
paint: { 'line-color': MapUtils.ACCENT, 'line-width': 2, 'line-opacity': 0.7 }
});
return MapUtils.extractTrackpoints(geojson);
})
.catch(function (err) {
console.warn('GPX load failed:', url, err);
return [];
});
})).then(function (allTrackpoints) {
var validTrackpoints = allTrackpoints.filter(function (tp) { return tp.length > 0; });
var segments = MapUtils.buildJourneySegments(ENTRIES, validTrackpoints, 10);
MapUtils.addJourneySegments(map, segments, 'journey');
});
});
</script>
```
- [ ] **Step 3: Verify the page loads without JS errors**
```bash
npx playwright test tests/ui/maps.spec.js --grep "M1|M2"
```
Expected: M1 and M2 pass (canvas renders, markers visible, no JS errors).
- [ ] **Step 4: Commit**
```bash
git add user/themes/intotheeast/templates/map.html.twig
git commit -m "feat: use buildJourneySegments in map.html.twig — suppress connectors covered by GPX"
```
---
### Task 4: Rewire `trip.html.twig` mini-map to use the algorithm
**Files:**
- Modify: `user/themes/intotheeast/templates/trip.html.twig`
**Interfaces:**
- Consumes: `MapUtils.extractTrackpoints`, `MapUtils.buildJourneySegments`, `MapUtils.addJourneySegments`
- Consumes: `item.page.header.force_connect` from Grav page frontmatter
- [ ] **Step 1: Add `force_connect` to the Twig entry serialisation**
In `trip.html.twig`, the `map_entries` loop (around line 89100) currently builds:
```twig
{% set map_entries = map_entries|merge([{
'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
}]) %}
```
Add `force_connect`:
```twig
{% set map_entries = map_entries|merge([{
'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
}]) %}
```
- [ ] **Step 2: Restructure the tripMap JS section**
The tripMap JS block starts around line 303 (`tripMap.on('load', function () {`). Replace the entire `tripMap.on('load', ...)` block with the new version below. Everything outside `tripMap.on('load', ...)` (the `var tripMap = ...` declaration, `setTimeout(function() { tripMap.resize(); }, 100);`, and the filter bar JS) stays unchanged.
Replace from `tripMap.on('load', function () {` through the closing `});` of that callback with:
```javascript
tripMap.on('load', function () {
if (TRIP_ENTRIES.length === 0) {
tripMap.jumpTo({ center: [0, 20], zoom: 2 });
return;
}
/* ── Markers + bounds ──────────────────────────────────────── */
var bounds = new maplibregl.LngLatBounds();
TRIP_ENTRIES.forEach(function (entry, i) {
var isLatest = (i === TRIP_ENTRIES.length - 1);
var lngLat = [parseFloat(entry.lng), parseFloat(entry.lat)];
bounds.extend(lngLat);
var el = MapUtils.createDotMarker(isLatest);
el.dataset.url = entry.url;
var popup = new maplibregl.Popup({ offset: 12, closeButton: false, closeOnClick: false, className: 'map-tip-popup' })
.setLngLat(lngLat)
.setHTML('<span class="map-tip">' + entry.title + '</span>');
el.addEventListener('mouseenter', function () { popup.addTo(tripMap); });
el.addEventListener('mouseleave', function () { popup.remove(); });
el.addEventListener('click', function () {
var card = document.getElementById('entry-' + entry.slug);
if (card) card.scrollIntoView({ behavior: 'smooth', block: 'center' });
});
new maplibregl.Marker({ element: el }).setLngLat(lngLat).addTo(tripMap);
});
/* ── Fit bounds ─────────────────────────────────────────────── */
if (TRIP_ENTRIES.length === 1) {
tripMap.jumpTo({ center: [parseFloat(TRIP_ENTRIES[0].lng), parseFloat(TRIP_ENTRIES[0].lat)], zoom: 10 });
} else {
tripMap.fitBounds(bounds, { padding: 60, maxZoom: 11 });
}
/* ── GPX tracks + journey segments ─────────────────────────── */
Promise.all(GPX_URLS.map(function (url, idx) {
return fetch(url)
.then(function (r) { return r.text(); })
.then(function (text) {
var xml = new DOMParser().parseFromString(text, 'text/xml');
var geojson = toGeoJSON.gpx(xml);
var sid = 'gpx-' + idx;
tripMap.addSource(sid, { type: 'geojson', data: geojson });
tripMap.addLayer({
id: sid + '-line', type: 'line', source: sid,
layout: { 'line-join': 'round', 'line-cap': 'round' },
paint: { 'line-color': MapUtils.ACCENT, 'line-width': 2, 'line-opacity': 0.7 }
});
return MapUtils.extractTrackpoints(geojson);
})
.catch(function (err) {
console.warn('GPX load failed:', url, err);
return [];
});
})).then(function (allTrackpoints) {
var validTrackpoints = allTrackpoints.filter(function (tp) { return tp.length > 0; });
var segments = MapUtils.buildJourneySegments(TRIP_ENTRIES, validTrackpoints, 10);
MapUtils.addJourneySegments(tripMap, segments, 'trip-journey');
});
});
```
- [ ] **Step 3: Check the stats section — preserve any remaining JS below the map block**
Scan `trip.html.twig` for `parseGpxFiles` (around line 494). This is a separate GPX parsing call for the stats section. **Do not modify it** — it is a different code path and uses its own GPX fetching logic.
- [ ] **Step 4: Verify the trip page renders without JS errors**
```bash
npx playwright test tests/ui/maps.spec.js --grep "M4"
```
Expected: M4 passes (home map canvas renders, no JS errors).
Also manually visit `http://localhost:8081/trips/italy-2025` in a browser and confirm the mini-map renders, markers appear, and the browser console shows no errors.
- [ ] **Step 5: Commit**
```bash
git add user/themes/intotheeast/templates/trip.html.twig
git commit -m "feat: use buildJourneySegments in trip.html.twig mini-map"
```
---
### Task 5: Rewire `dailies.html.twig` mini-map to use the algorithm
**Files:**
- Modify: `user/themes/intotheeast/templates/dailies.html.twig`
**Interfaces:**
- Consumes: `MapUtils.extractTrackpoints`, `MapUtils.buildJourneySegments`, `MapUtils.addJourneySegments`
- [ ] **Step 1: Add GPX URL collection to the Twig section of `dailies.html.twig`**
After the existing `{% set map_entries = [] %}` block (around line 1829), add GPX URL collection from the parent trip page. Insert before the `{% if map_entries|length > 0 %}` line:
```twig
{# Collect GPX URLs from parent trip page for connector algorithm #}
{% set trip_page = page.parent() %}
{% 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 %}
```
- [ ] **Step 2: Add `force_connect` to the Twig entry serialisation**
In the existing `map_entries` loop (lines 2128), add `force_connect`:
```twig
{% set map_entries = map_entries|merge([{
'lat': item.page.header.lat,
'lng': item.page.header.lng,
'title': item.page.title,
'slug': item.page.slug,
'url': item.page.url,
'force_connect': item.page.header.force_connect ? true : false
}]) %}
```
- [ ] **Step 3: Add `togeojson` script and `GPX_URLS` variable to the JS section**
Inside the `{% if map_entries|length > 0 %}` block, the existing script tags are (lines 3739):
```html
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/maplibre-gl@4/dist/maplibre-gl.css">
<script src="https://cdn.jsdelivr.net/npm/maplibre-gl@4/dist/maplibre-gl.js"></script>
<script src="{{ url('theme://js/maplibre-utils.js') }}"></script>
```
Add the toGeoJSON script between maplibre-gl.js and maplibre-utils.js:
```html
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/maplibre-gl@4/dist/maplibre-gl.css">
<script src="https://cdn.jsdelivr.net/npm/maplibre-gl@4/dist/maplibre-gl.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@mapbox/togeojson@0.16.2/togeojson.min.js"></script>
<script src="{{ url('theme://js/maplibre-utils.js') }}"></script>
```
And add the `GPX_URLS` variable immediately after `FEED_ENTRIES`:
```javascript
var FEED_ENTRIES = {{ map_entries|json_encode|raw }};
var GPX_URLS = {{ gpx_urls|json_encode|raw }};
```
- [ ] **Step 4: Restructure `feedMap.on('load', ...)` to use Promise.all**
Replace the existing `feedMap.on('load', function () { ... });` block with:
```javascript
feedMap.on('load', function () {
var bounds = new maplibregl.LngLatBounds();
FEED_ENTRIES.forEach(function (entry, i) {
var isLatest = (i === FEED_ENTRIES.length - 1);
var lngLat = [parseFloat(entry.lng), parseFloat(entry.lat)];
bounds.extend(lngLat);
var el = MapUtils.createDotMarker(isLatest);
el.dataset.url = entry.url;
var popup = new maplibregl.Popup({ offset: 12, closeButton: false, closeOnClick: false, className: 'map-tip-popup' })
.setLngLat(lngLat)
.setHTML('<span class="map-tip">' + entry.title + '</span>');
el.addEventListener('mouseenter', function () { popup.addTo(feedMap); });
el.addEventListener('mouseleave', function () { popup.remove(); });
el.addEventListener('click', function () { window.location.href = entry.url; });
new maplibregl.Marker({ element: el }).setLngLat(lngLat).addTo(feedMap);
});
if (FEED_ENTRIES.length === 1) {
feedMap.jumpTo({ center: [parseFloat(FEED_ENTRIES[0].lng), parseFloat(FEED_ENTRIES[0].lat)], zoom: 10 });
} else {
feedMap.fitBounds(bounds, { padding: 60, maxZoom: 11 });
}
Promise.all(GPX_URLS.map(function (url, idx) {
return fetch(url)
.then(function (r) { return r.text(); })
.then(function (text) {
var xml = new DOMParser().parseFromString(text, 'text/xml');
var geojson = toGeoJSON.gpx(xml);
return MapUtils.extractTrackpoints(geojson);
})
.catch(function (err) {
console.warn('GPX load failed (feed-map):', url, err);
return [];
});
})).then(function (allTrackpoints) {
var validTrackpoints = allTrackpoints.filter(function (tp) { return tp.length > 0; });
var segments = MapUtils.buildJourneySegments(FEED_ENTRIES, validTrackpoints, 10);
MapUtils.addJourneySegments(feedMap, segments, 'feed-journey');
});
});
```
Note: the feed-map does **not** display GPX tracks as lines (it's a compact mini-map). GPX files are fetched solely for the proximity algorithm. This is intentional.
- [ ] **Step 5: Verify no JS errors on the dailies page**
```bash
npx playwright test tests/ui/maps.spec.js --grep "M3"
```
Expected: M3 passes (dailies mini-map canvas renders, no JS errors).
- [ ] **Step 6: Commit**
```bash
git add user/themes/intotheeast/templates/dailies.html.twig
git commit -m "feat: apply GPX connector algorithm to dailies feed mini-map"
```
---
### Task 6: Integration tests — verify algorithm is wired end-to-end
**Files:**
- Modify: `tests/ui/maps.spec.js`
**Interfaces:**
- Consumes: italy-2025 demo data (has GPX files); run `make demo-load` first
- [ ] **Step 1: Add end-to-end tests to `maps.spec.js`**
Append these tests to `tests/ui/maps.spec.js`:
```javascript
// ── M5: Italy map — no JS errors with GPX present ────────────────────────────
test('M5: Italy map page renders without JS errors (GPX present)', async ({ page }) => {
const errors = [];
page.on('pageerror', e => errors.push(e.message));
await page.goto('/trips/italy-2025/map');
await expect(page.locator('canvas.maplibregl-canvas')).toBeVisible({ timeout: 10000 });
// Wait for markers to confirm map.on('load') completed
await expect(page.locator('.maplibregl-marker').first()).toBeVisible({ timeout: 15000 });
// Give Promise.all time to resolve
await page.waitForTimeout(3000);
expect(errors, 'No JS errors on Italy map page').toHaveLength(0);
});
// ── M6: Italy map — journey source exists after GPX loads ────────────────────
test('M6: Italy map has a journey MapLibre source after GPX settles', async ({ page }) => {
await page.goto('/trips/italy-2025/map');
await expect(page.locator('canvas.maplibregl-canvas')).toBeVisible({ timeout: 10000 });
await expect(page.locator('.maplibregl-marker').first()).toBeVisible({ timeout: 15000 });
// Wait until the journey source appears — addJourneySegments runs inside Promise.all.then()
// `var map = ...` in map.html.twig is a plain <script> var → available as window.map.
await page.waitForFunction(function () {
return window.map &&
(window.map.getSource('journey') !== undefined ||
window.map.getSource('journey-0') !== undefined);
}, { timeout: 15000 });
const hasSource = await page.evaluate(function () {
return !!(window.map.getSource('journey') || window.map.getSource('journey-0'));
});
expect(hasSource).toBe(true);
});
```
- [ ] **Step 2: Run the full test suite**
```bash
npx playwright test
```
Expected: All existing tests (M1M4, F1F7, G1G5, N-series, etc.) pass plus M5 and M6.
- [ ] **Step 3: Commit**
```bash
git add tests/ui/maps.spec.js
git commit -m "test: add M5M6 integration tests for GPX connector logic"
```
@@ -0,0 +1,168 @@
# Grav 2.0 Upgrade — Design Spec
**Goal:** Upgrade the intotheeast travel blog from Grav 1.7.x to Grav 2.0 RC on a feature branch, validate full Milestone 1 functionality, and prepare a clean production fresh-install path.
**Context:** Departure date is 2026-07-15. The production server has never been deployed, so production gets a fresh Grav 2.0 install — no in-place migration required. Local dev uses Docker; production uses PHP 8.4 directly.
---
## Scope
Two tracks:
1. **Local dev track** — swap Docker image to Grav 2.0, validate all functionality
2. **Production track** — update `server-install.sh` and `.env` so `make remote-install` deploys Grav 2.0 fresh
All work on branch `update-to-2.0`.
---
## Compatibility Assessment
| Component | Status | Action required |
|---|---|---|
| `form`, `login`, `email`, `error`, `problems`, `flex-objects` | ✅ First-party | Auto-updated to 2.0 versions via GPM |
| `shortcode-core` | ✅ First-party | Same |
| `cache-on-save` (custom) | ✅ Should work | Add Grav 2.0 compat flag to `blueprints.yaml`; uses `onFormProcessed` which is unchanged |
| `shortcode-gallery-plusplus` | ✅ Likely works | Plugin arch unchanged; test and confirm |
| `add-page-by-form` | ⚠️ Archived Aug 2024 | Try as-is (plugin arch unchanged, may work); if broken, write a custom replacement |
| Custom `intotheeast` theme | ✅ Should work | Twig 3 compat mode covers existing templates; test rendering |
| `linuxserver/grav` Docker image | ❌ Not supported | Replace with `getgrav/grav` + `GRAV_CHANNEL=beta` |
---
## Track 1 — Local Dev
### Changes
**`docker-compose.yml`**
Replace:
```yaml
image: lscr.io/linuxserver/grav:latest
environment:
- PUID=1000
- PGID=1000
volumes:
- ./user:/config/www/user
```
With:
```yaml
image: getgrav/grav
environment:
- GRAV_CHANNEL=beta
volumes:
- ./user:/var/www/html/user
```
**`Makefile`** — three targets reference the linuxserver internal path `/app/www/public`; replace with `/var/www/html`:
- `install-plugins`: `docker exec -w /app/www/public``docker exec -w /var/www/html`
- `demo-load` clear cache: `/app/www/public``/var/www/html`
- `demo-reset` clear cache: same
**`user/plugins/cache-on-save/blueprints.yaml`** (create — does not exist yet) — minimal blueprint with Grav 2.0 compat flag:
```yaml
name: Cache On Save
version: 1.0.0
description: Clears Grav cache on new-entry form submission
author:
name: Mischa
license: MIT
dependencies:
- { name: grav, version: '>=1.6.0' }
grav:
version: ['1.7', '2.0']
```
**`user/config/system.yaml`** — switch GPM to testing channel so `make setup` resolves 2.0-compatible plugin versions:
```yaml
gpm:
releases: testing
```
### Validation Checklist (smoke test after `make setup`)
Run in order — stop and investigate if any step fails:
1. **Site loads**`http://localhost:8081` returns the tracker page (200, no PHP errors)
2. **Admin2 loads**`/admin` renders the new SPA admin (not the old Twig admin)
3. **Login works** — log in via Admin2 with existing credentials
4. **Posting form** — submit `/post` form with title + text; entry appears immediately in `/tracker`
5. **Photo upload** — submit `/post` form with a photo; image renders in the entry
6. **Gallery** — visit an entry with multiple photos; `shortcode-gallery-plusplus` renders gallery with lightbox
7. **Cache invalidation** — submit a second post; it appears without a manual cache clear (validates `cache-on-save`)
8. **Theme rendering** — check tracker, entry, map, post-form, and stats templates for layout/CSS regressions
9. **Playwright suite**`make test-ui` passes all 25 tests. If any tests fail, investigate whether the failure is a genuine regression (blocker) or a test that needs updating for Admin2's new DOM structure (acceptable — update the test)
### If `add-page-by-form` fails
If step 4 fails due to `add-page-by-form` incompatibility, the fallback is to write a custom replacement plugin. The existing `cache-on-save` plugin is a good template — it hooks `onFormProcessed` and that API is unchanged. The replacement would use the same hook to:
1. Build the page path and slug from form fields
2. Create the page file on disk (same logic `add-page-by-form` does in PHP)
3. Clear cache (merging `cache-on-save` functionality)
This is ~1 day of work and should be planned as a follow-up task if needed.
---
## Track 2 — Production (Fresh Install)
Production has PHP 8.4 (compatible with Grav 2.0's PHP 8.3+ requirement) and has never been deployed.
### Changes
**`server-install.sh`** — the download URL for Grav 2.0 RC requires a `?testing` query parameter:
Current:
```bash
wget --no-verbose "https://getgrav.org/download/core/grav-admin/$GRAV_VERSION" -O grav-admin.zip
```
Updated (conditionally append `?testing` for pre-release versions, or accept a full URL suffix via env var):
```bash
wget --no-verbose "https://getgrav.org/download/core/grav-admin/${GRAV_VERSION}${GRAV_CHANNEL_SUFFIX}" -O grav-admin.zip
```
Where `GRAV_CHANNEL_SUFFIX` is `?testing` for RC versions and empty for stable.
**`.env`** (not committed — edit on the server directly or locally before `make remote-install`) — update:
```
GRAV_VERSION=2.0.0-rc.9
GRAV_CHANNEL_SUFFIX=?testing
```
When Grav 2.0 goes stable, remove `GRAV_CHANNEL_SUFFIX` and update `GRAV_VERSION` to the stable version number.
**`user/config/system.yaml`** — keep `gpm.releases: testing` (already set in Track 1) so production also installs 2.0-compatible plugin versions.
### Production deploy
When local validation passes:
```bash
make remote-install
```
That's it — fresh Grav 2.0 install from scratch with all plugins, content from Gitea, and the existing `user/` config.
---
## Out of Scope
- MCP server setup (`grav-mcp` Node.js binary) — a separate task after Grav 2.0 is stable on production
- Admin2 theming or customization
- Grav 2.0 REST API integration
- Switching `add-page-by-form` to the API-based approach (only if the plugin breaks)
---
## Go/No-Go Criteria
Ship to production before departure (2026-07-15) **only if**:
- All 9 smoke test steps pass
- Playwright suite passes
- `add-page-by-form` posting workflow works end-to-end (or a custom replacement is in place and tested)
If any of these fail and cannot be resolved with time to spare before departure, stay on Grav 1.7 for the trip and revisit post-trip.
@@ -0,0 +1,157 @@
# Dark Mode & Visual Polish Design Spec
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` (recommended) or `superpowers:executing-plans` to implement this plan task-by-task.
**Goal:** Replace the existing warm-paper light theme with a warm-dark "notebook/sketchbook at night" aesthetic — dark-only, no toggle, no system preference detection. Add paper grain texture, switch to dark terrain map tiles, and tighten typography.
**Architecture:** All changes are CSS and one Twig template update. Color tokens live in `tokens.css` (swap values, keep names). Grain texture is a pure-CSS SVG noise layer on `body::after`. Map tiles swap in `map.html.twig`. No new dependencies, no JS changes.
**Approach chosen:** B — color token swap + paper grain + typography refinements. Card/hero treatment (Approach C) deferred to a future visual polish pass.
**Tech Stack:** CSS custom properties, inline SVG data URI for grain, Stadia Maps tile CDN for dark terrain.
---
## Global Constraints
- Dark-only — no light mode, no `prefers-color-scheme` media query, no toggle
- All changes in `user/` — commit with `git -C user`
- No new npm/JS dependencies
- Existing token names (`--color-paper`, `--color-ink`, etc.) must not change — only values
- Teal accent `#1F6B5A` lightens to `#2A8C73` for dark-background contrast
- Map tile provider: Stadia Maps Alidade Smooth Dark (free tier; API key needed for production — see Task 2)
- `make test-ui` must pass after implementation (25/25 or pre-existing P2 exception)
---
## 1. Color System
Replace all values in `user/themes/intotheeast/css/tokens.css`. Token names are unchanged.
### Dark palette
| Token | Old value | New value | Role |
|---|---|---|---|
| `--color-paper` | `#F7F5F2` | `#1A1814` | Page background — warm near-black |
| `--color-canvas` | `#FFFFFF` | `#22201B` | Card surfaces, form backgrounds |
| `--color-ink` | `#17171A` | `#EDE8DF` | Primary text — warm cream |
| `--color-ink-2` | `#4A4850` | `#B8B0A4` | Body text — muted warm |
| `--color-ink-muted` | `#9896A0` | `#7A7268` | Labels, timestamps, captions |
| `--color-border` | `#E8E6E3` | `#2E2B25` | Standard dividers |
| `--color-border-soft` | `#F0EDEA` | `#252219` | Subtle dividers |
| `--color-accent` | `#1F6B5A` | `#2A8C73` | Teal — lightened for dark contrast |
| `--color-accent-hover` | `#185647` | `#236655` | Hover/pressed teal |
| `--color-accent-light` | `#EBF5F2` | `#1A2E29` | Pale teal tint backgrounds |
| `--color-accent-on` | `#FFFFFF` | `#FFFFFF` | Text on accent surfaces (unchanged) |
### Additional dark-only tokens (add to tokens.css)
```css
--color-surface-raised: #2A2720; /* elevated surfaces: tooltips, hover states */
--color-ink-inverse: #17171A; /* text on accent-colored buttons */
```
---
## 2. Paper Grain Texture
Add to `style.css`, in the `body` section:
```css
body::after {
content: '';
position: fixed;
inset: 0;
pointer-events: none;
z-index: 9998;
opacity: 0.035;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='200' height='200'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='200' height='200' filter='url(%23noise)' opacity='1'/%3E%3C/svg%3E");
background-repeat: repeat;
background-size: 200px 200px;
}
```
This overlays a fixed noise texture across the entire viewport. `pointer-events: none` ensures it never blocks clicks. `z-index: 9998` keeps it below any modals or dropdowns (which should use z-index 9999+). Opacity 3.5% — subtle enough to feel like paper texture without being distracting on photography.
---
## 3. Map Tiles — Stadia Alidade Smooth Dark
Replace the tile layer in `user/themes/intotheeast/templates/map.html.twig`.
**Old:**
```javascript
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
}).addTo(map);
```
**New:**
```javascript
L.tileLayer('https://tiles.stadiamaps.com/tiles/alidade_smooth_dark/{z}/{x}/{y}{r}.png', {
maxZoom: 20,
attribution: '© <a href="https://stadiamaps.com/">Stadia Maps</a> © <a href="https://openmaptiles.org/">OpenMapTiles</a> © <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
}).addTo(map);
```
**Production note:** Stadia Maps requires a free API key for production domains. Add the key as a query param when ready: `?api_key=YOUR_KEY`. During development on localhost no key is needed. Add a `<!-- TODO: add Stadia API key before launch -->` comment above the tile layer call so it's not forgotten.
Also update the mini-map tile layer in `dailies.html.twig` (same swap — same tile URL, same attribution).
---
## 4. Typography Refinements
Targeted improvements to `style.css` — not a full type system rewrite.
### 4a. Entry body readability
The entry body text (`--text-md` / 1.125rem) already uses `--leading-normal` (1.65) which is good. Increase the paragraph bottom margin slightly for breathing room:
```css
/* current */
.entry-body p { margin-bottom: 1.1em; ... }
/* new */
.entry-body p { margin-bottom: 1.4em; ... }
```
### 4b. Heading tracking
DM Serif Display at large sizes benefits from slightly tighter tracking. Find heading rules that currently have `letter-spacing: -0.01em` and tighten to `-0.02em`. Only apply to `h1` and `h2` — smaller headings keep current tracking.
### 4c. Login form dark surface
The login form currently hardcodes `background: #f0f0f0; color: #333` on the secondary button (line ~497 in style.css). Replace with tokens:
```css
/* current */
.login-form .button.secondary { background: #f0f0f0; color: #333; ... }
/* new */
.login-form .button.secondary { background: var(--color-canvas); color: var(--color-ink); ... }
```
### 4d. Stats numbers
On the stats page, numeric values should feel deliberate. Add `font-variant-numeric: tabular-nums` to the stat value elements so columns of numbers align cleanly.
---
## 5. Incidental dark-mode fixes
Some existing styles use hardcoded light colors that will look wrong in dark mode. Audit and fix these in `style.css`:
- Any `background: #fff` or `background: white``var(--color-canvas)`
- Any `color: #333` or similar hardcoded dark text → `var(--color-ink)` or `var(--color-ink-2)`
- Any `border: 1px solid #eee` or similar → `var(--color-border)`
- Focus outline: currently likely a light-mode color — ensure `outline-color` uses `var(--color-accent)`
Run a grep for literal hex values after implementation: `grep -n '#[0-9a-fA-F]\{3,6\}' user/themes/intotheeast/css/style.css` — every hit is a candidate to tokenize.
---
## Verification
After implementation:
1. `make test-ui` — all tests pass
2. Visual check at `http://localhost:8081/trips/japan-korea-2026/dailies` — warm dark background, cream text, teal accents visible, subtle grain
3. Visual check at `http://localhost:8081/trips/japan-korea-2026/map` — dark terrain tiles load, GPX polyline visible, entry pins visible
4. Check the post form at `/post` — form fields readable on dark canvas, no white-on-white or black-on-black surfaces
5. Run the hardcoded-hex grep and confirm any remaining literals are intentional
@@ -0,0 +1,226 @@
# Home Page & Content Flow Design Spec
**Goal:** Replace the redirect-based home page with a real home page showing the active trip's feed and map side by side, add a proper past-trips archive, enrich the trip page with a sticky sidebar index, and introduce story cards into all feeds.
**Architecture:** Pure Twig + CSS changes on top of the existing Grav stack. The home page is a new Grav page (`00.home/home.md`) with a new `home.html.twig` template. Feeds (home + dailies) are extended to merge journal entries and story entries into one chronological collection, with stories rendered as visually distinct cards. No new plugins, no build pipeline.
**Tech Stack:** Grav CMS (PHP/Twig), Vanilla CSS, Leaflet.js (already loaded in `dailies.html.twig`)
---
## Global Constraints
- All changes in `user/` — commit with `git -C user`
- No new Grav plugins
- No JS framework — all interactivity is vanilla JS
- No build pipeline — CSS shipped as plain files
- Existing token names in `tokens.css` must not change
- Theme directory: `user/themes/intotheeast/`
- Active trip slug: `config.site.active_trip` (set in `user/config/site.yaml`)
- `system.yaml` `home.alias` redirect must be removed — `/` becomes a real page
- `config.site.active_trip` in `site.yaml` must always be set to a trip slug (even between trips, point it at the last trip) — the home page template has no fallback if this value is empty
---
## 1. URL Structure
| URL | Page file | Template |
|---|---|---|
| `/` | `user/pages/00.home/home.md` | `home.html.twig` (new) |
| `/trips` | `user/pages/01.trips/trips.md` | `trips.html.twig` (update existing) |
| `/trips/<slug>/` | existing | `trip.html.twig` (update existing) |
| `/trips/<slug>/dailies` | existing | `dailies.html.twig` (update existing) |
**system.yaml change:** Remove `home: alias: /trips/japan-korea-2026/dailies`. Set `home: alias: /` (or remove the alias entirely so Grav serves the `00.home` page at `/`).
---
## 2. Home Page (`/`)
### Layout
Two-column CSS grid on desktop. Map left (~45%), entry feed right (~55%).
```
┌─────────────────────────────────────────────────────────┐
│ [Trip name] · 31 journal entries · 4 stories │
├────────────────────────┬────────────────────────────────┤
│ │ [story card] │
│ Leaflet map │ [journal card] │
│ (sticky, │ [journal card] │
│ 45% width) │ [story card] │
│ │ ... │
└────────────────────────┴────────────────────────────────┘
```
- Map is `position: sticky; top: 0; height: 100vh`
- Entry feed is scrollable, sorted descending by date
- Feed contains both journal entries and story entries merged (see §5)
### Data
```twig
{% set slug = config.site.active_trip %}
{% set trip = grav.pages.find('/trips/' ~ slug) %}
{% set dailies = grav.pages.find('/trips/' ~ slug ~ '/dailies') %}
{% set stories_page = grav.pages.find('/trips/' ~ slug ~ '/stories') %}
{% set journal_entries = dailies ? dailies.children.published().order('date', 'desc') : [] %}
{% set story_entries = stories_page ? stories_page.children.published() : [] %}
{# merge and sort handled in template — see §5 #}
```
### Map
Reuse the existing Leaflet setup from `dailies.html.twig` (`feed-map`). Markers come from journal entries with `lat`/`lng` in frontmatter. GPX route line loaded from trip page media if present (same pattern as `map.html.twig`). Clicking a marker scrolls to that entry card in the feed (use `data-entry-id` on cards + `scrollIntoView`).
### Mobile
Stack vertically: map on top at `height: 40vh`, feed below. No hamburger needed — simpler than the dedicated map page.
---
## 3. Past Trips Archive (`/trips`)
Update `trips.html.twig`. Show each trip as a card, sorted newest first.
Each card contains:
- Trip title (links to `/trips/<slug>/`)
- Date range: `date_start` `date_end` from trip page frontmatter (show "Ongoing" if no `date_end`)
- Entry count: journal entries + story entries counted separately
```twig
{% set journal_count = grav.pages.find(trip.route ~ '/dailies').children.published()|length %}
{% set story_count = grav.pages.find(trip.route ~ '/stories').children.published()|length %}
```
Display: **31 journal entries · 4 stories**
The active trip appears as the first card. No special treatment needed beyond chronological ordering — it naturally sits at the top.
---
## 4. Trip Page (`/trips/<slug>/`)
Update `trip.html.twig`. Current state: shows title, dates, nav links, 3 recent entries. Target state:
### Header (update existing `.trip-hero`)
```
Japan & Korea 2026
Jun 2026 Aug 2026 · 31 journal entries · 4 stories
```
Add entry counts below the date line (small, secondary text).
### Two-column layout
Add a right sidebar alongside the existing content:
```
┌──────────────────────────────────┬──────────────────────┐
│ [full chronological feed] │ Journal │
│ (centered, existing max-width) │ Jun 19 Kyoto │
│ │ Jun 18 Osaka │
│ │ ... │
│ │ │
│ │ Stories │
│ │ The night train │
│ │ First ramen │
└──────────────────────────────────┴──────────────────────┘
```
- Right sidebar: `position: sticky; top: 1rem`
- Two sections: **Journal** (list of entry titles as jump-links via `#entry-<slug>`) and **Stories** (same)
- Each item in the sidebar is a jump-link to `#entry-<slug>` anchor on the feed card
- Feed comes from `dailies.children` + `stories.children` merged (see §5)
- On mobile: sidebar collapses to hidden (toggle-able or just hidden — defer this decision to implementation)
### Remove the current "Recent entries" section
The right-sidebar index replaces it. The full merged feed is the main content.
---
## 5. Story Cards in Feeds (home + trip page)
Feeds in both `home.html.twig` and `trip.html.twig` show a merged chronological list of journal entries and story entries.
### Merging collections in Twig
Grav doesn't natively merge two page collections and sort them. Use a Twig loop to build a combined array:
```twig
{% set all_items = [] %}
{% for e in journal_entries %}
{% set all_items = all_items|merge([{'type': 'journal', 'page': e, 'date': e.date}]) %}
{% endfor %}
{% for s in story_entries %}
{% set all_items = all_items|merge([{'type': 'story', 'page': s, 'date': s.date}]) %}
{% endfor %}
{# Sort descending by date #}
{% set all_items = all_items|sort((a, b) => a.date < b.date ? 1 : -1) %}
```
### Journal card (existing format, unchanged)
```html
<article class="entry-card" id="entry-{{ item.page.slug }}" data-lat="{{ item.page.header.lat }}" data-lng="{{ item.page.header.lng }}">
<!-- existing card markup -->
</article>
```
Add `id` and `data-lat`/`data-lng` attributes for sidebar jump-links and map sync.
### Story card (new)
```html
<article class="entry-card entry-card--story" id="entry-{{ item.page.slug }}">
<a class="entry-card-inner" href="{{ item.page.url }}">
{% if hero %}
<div class="entry-card-photo entry-card-photo--story">
<img src="{{ hero.cropResize(720, 405).url }}" alt="{{ item.page.title }}" loading="lazy">
</div>
{% endif %}
<div class="entry-card-body">
<span class="story-badge">✦ Story</span>
<h2 class="entry-title">{{ item.page.title }}</h2>
</div>
</a>
</article>
```
**Visual treatment:** `entry-card--story` gets a teal left border (3px, `var(--color-accent)`) and no excerpt text. The `✦ Story` badge is small-caps, accent color.
### Story page (full-screen)
Story pages (`/trips/<slug>/stories/<story-slug>`) use `stories.html.twig` (already exists). That template should:
- Override `{% block nav %}` to render **only** a fixed escape link — not an empty block, not the global nav
- Escape link: `← Back` fixed top-left, links to `page.parent.parent.url` (the trip page)
Implementation of the Snowfall-style scroll-snap interior is **deferred to Milestone 3** — this spec only covers the story card in the feed and the escape link on the story page.
---
## 6. Navigation
Update `base.html.twig` nav. Current: single "Journal" link pointing to active trip dailies. New:
- **Home**`/`
- **Past Trips**`/trips`
The per-trip sub-nav (Journal / Map / Stats / Stories) stays on the trip page — it is not in the global nav.
---
## 7. Files Changed
| File | Change |
|---|---|
| `user/pages/00.home/home.md` | **Create** — new home page, `template: home` |
| `user/themes/intotheeast/templates/home.html.twig` | **Create** — side-by-side map + feed |
| `user/themes/intotheeast/templates/trips.html.twig` | **Update** — trip cards with counts |
| `user/themes/intotheeast/templates/trip.html.twig` | **Update** — counts in header, two-column + sidebar |
| `user/themes/intotheeast/templates/dailies.html.twig` | **Update** — merge stories into feed, story cards, add `id`/`data-` attrs |
| `user/themes/intotheeast/templates/stories.html.twig` | **Update** — add escape link, remove global nav |
| `user/themes/intotheeast/templates/partials/base.html.twig` | **Update** — new nav links |
| `user/themes/intotheeast/css/style.css` | **Update** — home layout, story card styles, sidebar styles |
| `user/config/system.yaml` | **Update** — remove `home.alias` redirect |
@@ -0,0 +1,143 @@
# Stats Redesign — Design Spec
*2026-06-19*
---
## Goal
Expand trip statistics with new data points already available in entry frontmatter, add smart distance labelling based on whether GPX files are present, and add a dedicated cycling stats panel derived from GPX track data.
---
## Data sources
| Source | Fields available |
|---|---|
| Entry frontmatter | `date`, `lat`, `lng`, `location_city`, `location_country`, `weather_temp_c` |
| Trip page media | `.gpx` files (Komoot exports) |
| GPX trackpoints | `lat`, `lon`, `<ele>` (meters), `<time>` (ISO 8601, 1s resolution) |
| GPX track metadata | `<type>` (e.g. `racebike`, `hiking`) |
---
## Main stats block — changes
The existing 4-stat grid expands to 6 stats. Both `stats.html.twig` and the inline toggle in `trip.html.twig` get the same treatment.
### Stats
| Stat | Label | Source | Notes |
|---|---|---|---|
| Days on the road | `days on the road` | `(now - first entry date) / 86400` | Unchanged |
| Entries posted | `entries posted` | `all_entries\|length` | Unchanged |
| Countries visited | `countries visited` | Deduplicated `location_country` | Unchanged; country list shown below grid |
| **Cities visited** | `cities visited` | Deduplicated `location_city` | New; same dedup logic as countries |
| Distance | see below | see below | Label + icon vary by mode |
| **Temperature range** | `°C range` | `min(weather_temp_c)` `max(weather_temp_c)` | New; shown as e.g. `2 → 28 °C` |
### Distance stat — two modes
**Mode A — GPX present** (any `.gpx` files exist on the trip page):
- Value: sum of haversine distances between all consecutive trackpoints across all GPX files
- Label: `km cycled`
- Icon: cycling icon (or activity-specific icon — see Icon system below)
**Mode B — No GPX files:**
- Value: sum of haversine distances between consecutive entry `lat`/`lng` points (current behaviour)
- Label: `km roamed`
- Icon: generic travel icon (compass / globe)
Edge case — trip has both GPX files and many geo-spread entries (e.g. a mixed cycling + backpacking trip): use Mode A (GPX total only). This may understate total travel distance. Accepted limitation; revisit when transport mode is implemented.
---
## Cycling panel
A separate expandable panel, independent of the main stats toggle. Only rendered when GPX files are present on the trip page.
### Button placement
Sits next to the existing Stats button in the filter bar area:
```
[ All ] [ Journal ] [ Stories ] [ Stats ] [ Cycling ]
```
The Cycling button is hidden entirely when no GPX files exist. Detection: server-side Twig filters `trip_page.media.all` for `.gpx` extension (same mechanism the map template already uses) and sets a boolean passed to the template.
### Stats shown
| Stat | Unit | How computed |
|---|---|---|
| Distance | km | Sum haversine between all trackpoints (same value as main stats Mode A) |
| Elevation gain | m ↑ | Sum of positive `<ele>` differences (threshold: > 1 m per step to filter GPS noise) |
| Elevation loss | m ↓ | Sum of negative `<ele>` differences (same threshold) |
| Highest point | m | `max(<ele>)` across all files |
| Lowest point | m | `min(<ele>)` across all files |
| Moving time | h:mm | Total time excluding segments where computed speed < 1 km/h |
| Average speed | km/h | Distance ÷ moving time |
Max speed is explicitly excluded — GPS noise at 1-second resolution produces unreliable spikes.
### Icon system
The GPX `<type>` tag on the track element drives the icon shown in both the main stats distance block and the cycling panel header:
| `<type>` value | Icon |
|---|---|
| `racebike` | Road bike |
| `touringbicycle` | Touring bike |
| `mtb` | Mountain bike |
| `cycling` (generic) | Generic bike |
| `hiking` | Hiking boot |
| `hike` | Hiking boot |
| Any unrecognised value | Generic bike (fallback) |
When multiple GPX files exist with different types, use the type from the first file. This is an acceptable heuristic for now.
---
## GPX parsing — algorithm
All parsing is client-side JavaScript. The template passes the list of GPX file URLs to a JS variable; JS fetches and processes them sequentially.
```
for each GPX file URL:
fetch(url) → text → DOMParser → XML document
extract all <trkpt> elements → array of { lat, lon, ele, time }
append to master trackpoint array
compute over master array:
distance = sum haversine(p[i-1], p[i]) for i in 1..n
ele_gain = sum max(0, ele[i] - ele[i-1] - 1) for i in 1..n (1m threshold)
ele_loss = sum max(0, ele[i-1] - ele[i] - 1)
highest = max(ele)
lowest = min(ele)
speed[i] = haversine(p[i-1], p[i]) / (time[i] - time[i-1]) in km/h
moving_time = sum (time[i] - time[i-1]) where speed[i] >= 1 km/h
avg_speed = distance / moving_time
```
The 1 m elevation threshold filters out the flat-line noise visible in the Komoot files (many consecutive identical `<ele>` values).
---
## Template changes
| File | Change |
|---|---|
| `stats.html.twig` | Add cities stat, temp range stat; update distance stat with mode detection + label + icon |
| `trip.html.twig` | Same stats changes; add Cycling button (hidden if no GPX); add cycling panel block with JS parsing |
The cycling panel JS and the distance mode detection JS share the same GPX fetch logic — extract into a single `parseGpxFiles(urls)` function called once, results used by both.
---
## Out of scope
- Transport mode per entry (deferred — tracked separately)
- Weather breakdown (dropped — depends on free-text consistency)
- Max speed stat (dropped — GPS noise)
- Lowest point shown in main stats (cycling panel only)
- Per-file breakdown (one aggregate across all GPX files)
@@ -0,0 +1,87 @@
# Trip Page Filter Bar — Design Spec
**Date:** 2026-06-19
**Status:** Approved
## Problem
The trip page (`trip.html.twig`) shows a map + combined feed, but the three nav links below the title (Journal · Stats · Stories) navigate *away* from the page, losing the map context. The links are also unstyled (`trip-nav` has no CSS). Stories links to a stub. Stats is a separate page. The user can only return via browser back.
## Goal
Make the trip page self-contained. Filtering, stats, and content switching all happen in place. Navigation away from the trip page only happens when the user clicks into an individual entry or story.
## Design
### Filter bar (replaces `.trip-nav`)
Three mutually exclusive pill buttons above the feed, plus a Stats toggle to the right:
```
[ All content ] [ Journal ] [ Stories ] [ Stats ↕ ]
```
- **Default state:** "All content" active
- **Behavior:** selecting a filter hides non-matching cards via JS (`display: none` toggle); no page navigation
- **Stats** sits right-aligned, visually separated from the filter group; it is a toggle, not a filter
### Content filtering
Each `<article>` card in `trip.html.twig` gets a `data-type` attribute:
- Journal entries: `data-type="journal"`
- Story entries: `data-type="story"`
JS selects all `[data-type]` cards and shows/hides based on the active filter button. Three states:
| Active button | Visible cards |
|---|---|
| All content | all |
| Journal | `data-type="journal"` only |
| Stories | `data-type="story"` only |
Empty-feed edge case: if Stories is selected and no stories exist yet, show a brief inline message ("No stories yet for this trip.").
### Stats inline expansion
Clicking Stats expands a compact stats block between the filter bar and the first card. Clicking Stats again collapses it. Stats button gets an active/pressed visual state while expanded.
Stats block content (same data as the existing `/stats` page):
- Days on the road
- Entries posted
- Countries visited
- km traveled (approximate, straight-line haversine between GPS points)
- Countries list
The computation logic is moved inline into `trip.html.twig` (copied from `stats.html.twig`). The separate `/stats` sub-page is left untouched — it still works as a standalone URL.
### Story card distinction
`.entry-card--story` gets a visible border:
```css
border: 2px solid var(--color-accent);
```
No other visual changes to story cards in this session. Full story card redesign (hero image treatment, sneak peek, elegant layout) is deferred to a separate session.
## What changes
| File | Change |
|---|---|
| `user/themes/intotheeast/templates/trip.html.twig` | Add `data-type` attributes; add stats computation + inline stats block HTML; replace nav links with filter bar HTML; add filter + stats JS |
| `user/themes/intotheeast/css/style.css` | Add `.trip-nav` pill styles + active state; add `.trip-stats-block` styles; add story card border |
## What does NOT change
- `/dailies`, `/stats`, `/stories` sub-pages continue to exist as standalone URLs
- `stats.html.twig` is untouched
- `dailies.html.twig` is untouched
- No blueprint or page content changes
- Story detail page design is out of scope
## Out of scope
- Photo galleries, lightbox, full in-feed entry expansion
- Story detail page
- Feed redesign (full pictures, per-entry photo carousel)
@@ -0,0 +1,154 @@
# Tuscany Demo Stories — Design Spec
**Date:** 2026-06-19
**Goal:** Three demo stories for the Italy 2025 Tuscany trip that showcase distinct story-mode composition patterns. Content is illustrative; the purpose is to demonstrate what the format can do.
---
## Context
Demo content lives in `user/docs/demo/trips/italy-2025/`. The `demo-load` Makefile target currently copies the Italy dailies and GPX files but does not copy a `04.stories/` folder. This spec adds three story files and wires them into `demo-load` / `demo-reset`.
Story pages must live at:
```
user/docs/demo/trips/italy-2025/04.stories/<n>.<slug>/story.md
```
The `demo-load` target copies the entire `04.stories/` folder into:
```
user/pages/01.trips/italy-2025/04.stories/
```
A `stories.md` listing page already exists at `user/docs/demo/trips/italy-2025/stories.md` and is already loaded by `demo-load`.
---
## Story 1 — "The Val d'Orcia at Dawn"
**Composition pattern:** Gallery-led. Multiple `[snap-gallery]` blocks; chapter breaks as pure visual section dividers; minimal prose; PullQuote without background image (text-only variant).
**Demonstrates:**
- Two `[snap-gallery]` blocks in one story (landscape set + detail set)
- `[chapter-break]` used as a pure scene-change divider (no thematic text, just atmosphere)
- `[pull-quote]` without `image=` parameter → text-only frosted style
**Frontmatter:**
```yaml
title: The Val d'Orcia at Dawn
date: 2025-09-05
location_name: Val d'Orcia
location_country: Italy
lat: 43.078
lng: 11.676
hero_image: hero.jpg
hero_alt: Cypress-lined dirt road at first light, Tuscany
published: true
```
**Structure:**
1. Short intro prose (23 sentences)
2. `[snap-gallery]` — 4 images: landscape wide shots (dawn light, rolling hills, cypress allée, dirt road)
3. Brief prose bridge (2 sentences)
4. `[chapter-break]` — title "The Hour Before Heat", no number
5. More prose (23 sentences)
6. `[snap-gallery]` — 4 images: close-up details (gravel texture, bike wheel, water bottle, shadow on road)
7. `[pull-quote]`**no image param** (text-only variant) — short reflective line
8. One closing sentence
**Path:** `user/docs/demo/trips/italy-2025/04.stories/01.val-dorcia-dawn/story.md`
---
## Story 2 — "The Long Climb to Montalcino"
**Composition pattern:** Scrollytelling-led. Two `[scrolly-section]` blocks with different step counts, demonstrating the sticky-image format at different rhythms. PullQuote with background image between the two sections.
**Demonstrates:**
- `[scrolly-section]` with 3 steps (tighter rhythm, effort/grind feeling)
- `[scrolly-section]` with 5 steps (longer, more expansive — arrival and wandering)
- `[pull-quote]` with `image=` parameter (frosted overlay on photo)
- `[chapter-break]` with roman numeral, separating climb from descent/arrival
**Frontmatter:**
```yaml
title: The Long Climb to Montalcino
date: 2025-09-05
end_date: 2025-09-06
location_name: Montalcino
location_country: Italy
lat: 43.058
lng: 11.489
hero_image: hero.jpg
hero_alt: Hairpin road climbing through olive groves towards a hilltop town
published: true
```
**Structure:**
1. Intro prose (3 sentences — sets the scene: hot afternoon, 14km climb)
2. `[scrolly-section]` — image: the climb. **3 steps:** (1) first kilometer, legs fresh; (2) halfway, sun overhead, silence; (3) the last 500m, the town appears
3. `[chapter-break]` — title "Montalcino", number "II"
4. `[pull-quote image="vineyard.jpg"]` — line about the view from the top
5. Prose (23 sentences — arrival, finding a bar, cold water)
6. `[scrolly-section]` — image: the town/streets. **5 steps:** (1) the main piazza; (2) a wine shop; (3) a cat on a wall; (4) evening light on the fortress; (5) the descent begins
7. Closing prose (12 sentences)
**Path:** `user/docs/demo/trips/italy-2025/04.stories/02.long-climb-montalcino/story.md`
---
## Story 3 — "One Evening in Siena"
**Composition pattern:** Mood/fragment piece. Short and impressionistic. Opens with a PullQuote (no image) — the quote anchors the story before any prose. Closes with a PullQuote with image. Single short ScrollySection. SnapGallery in the middle.
**Demonstrates:**
- PullQuote **as opening element** (before any body prose) — unusual structure
- `[scrolly-section]` with just 2 steps (the minimum — shows it works for very short sections)
- `[snap-gallery]` as a mid-story element (not a closing flourish)
- PullQuote with image **as closing element**
- Overall: the format works for short, atmospheric pieces, not just long narratives
**Frontmatter:**
```yaml
title: One Evening in Siena
date: 2025-09-05
location_name: Siena
location_country: Italy
lat: 43.318
lng: 11.330
hero_image: hero.jpg
hero_alt: The Piazza del Campo at dusk, terracotta rooftops fading to blue
published: true
```
**Structure:**
1. `[pull-quote]`**no image, opens the story** — a single observational sentence about Siena at dusk
2. Intro prose (2 sentences — arrival on bike, the square)
3. `[scrolly-section image="campo.jpg"]`**2 steps:** (1) the square fills with people as the sun goes; (2) a busker, a couple arguing, pigeons
4. `[snap-gallery]` — 3 images: campo at dusk, a doorway, someone eating gelato
5. Prose (2 sentences — finding dinner, the relief of sitting down)
6. `[pull-quote image="sunset.jpg"]`**with image, closes the story** — a line about what cycling does to ordinary moments
**Path:** `user/docs/demo/trips/italy-2025/04.stories/03.one-evening-siena/story.md`
---
## Makefile Changes
`demo-load` — add after the existing Italy stories.md copy line:
```makefile
cp -r user/docs/demo/trips/italy-2025/04.stories user/pages/01.trips/italy-2025/ 2>/dev/null || true
```
`demo-reset` — the existing `rm -rf user/pages/01.trips/italy-2025` already removes everything including stories, so no additional line needed.
---
## Deliverables
1. `user/docs/demo/trips/italy-2025/04.stories/01.val-dorcia-dawn/story.md`
2. `user/docs/demo/trips/italy-2025/04.stories/02.long-climb-montalcino/story.md`
3. `user/docs/demo/trips/italy-2025/04.stories/03.one-evening-siena/story.md`
4. `Makefile` — one line added to `demo-load`
5. Two commits: one for story files (user repo), one for Makefile (main repo)
No image files are committed. Shortcode image params reference filenames that won't resolve without real photos — this is consistent with the existing Japan demo story.
@@ -0,0 +1,109 @@
# Design Spec: Smart GPX-Marker Connector Logic
**Date:** 2026-06-20
**Status:** Approved
---
## Problem
The map currently draws a straight connector line between every adjacent pair of journal/story entry markers in chronological order. When GPX track files are also present, this creates two overlapping representations of the same movement — the accurate GPX track line and a redundant straight-line connector. For segments with no GPX coverage (e.g. a train journey), the straight connector is useful and should remain.
---
## Goal
Suppress connector lines between adjacent markers only when a single GPX file demonstrably covers both endpoints. Keep connectors for gaps that have no GPX coverage. Provide a per-entry override (`force_connect`) for cases where the algorithm suppresses a connector the author wants to show.
---
## Behaviour Modes
### No GPX files present
Existing behaviour unchanged. All adjacent markers are connected by a line in chronological order.
### GPX files present
Auto-connectors off by default. For each adjacent pair of markers (M1 → M2):
1. If `force_connect: true` on M2 → draw connector (override wins)
2. Otherwise run the spatial algorithm (see below)
3. If the algorithm finds coverage → suppress connector
4. If the algorithm finds no coverage → draw connector
---
## Spatial Algorithm
**Proximity threshold:** 10 km
For each adjacent pair (M1, M2):
1. For each loaded GPX file F:
a. Pre-filter: if M1 or M2 lies outside F's bounding box expanded by 10 km → skip F cheaply
b. Sample every 10th trackpoint in F; compute haversine distance to M1 and to M2
c. If both M1 and M2 have at least one sampled point within 10 km → suppress connector for this pair; stop checking further files
2. If no file F covered both M1 and M2 → draw connector
**Rationale for 10 km:** entries are often posted from a hotel, village, or café near (but not on) a trail. 10 km accommodates varied terrain — coastal routes, hilly detours — without false-positives across genuinely separate segments.
**Rationale for same-file requirement:** two markers each near *different* GPX files (e.g. an inland hike and a coastal walk) must not suppress the connector between them — that gap (e.g. a train journey) is exactly what should be shown.
---
## Fallback Behaviour
If any GPX file fails to load, treat it as absent for the algorithm. Connectors default to drawing rather than hiding — missing data never creates invisible gaps on the map.
---
## Data Model Changes
Two new fields added to both the **journal entry** and **story entry** Grav blueprints:
### `force_connect`
- Type: boolean
- Default: false / null (unset)
- Meaning: "always draw a connector from the previous marker to this entry"
- Only has visible effect when GPX files are present (when no GPX, auto-connectors are already on)
- Editable via Admin2 on any entry
### `transport_mode`
- Type: enum
- Values: `walking`, `bicycle`, `bus`, `train`, `car`
- Default: null (unset)
- Meaning: how the author arrived at this location (attached to the arriving entry)
- **Not visualised yet** — data capture only, for future use (distance-by-mode stats, map icons, filter)
- Editable via Admin2 on any entry
Both fields are exposed in frontmatter. Adding them to the mobile post form is deferred (backlog: blueprint-to-form sync pass).
---
## Client-Side Implementation
### Entry JSON
The Twig template that serialises entries into a JS variable (`TRACKER_ENTRIES` or equivalent) gains two new fields per entry: `force_connect` (bool) and `transport_mode` (string or null).
### Timing
Connector drawing is deferred until all GPX files have settled (Promise.all on load events). GPX tracks appear immediately as each file loads. Connectors render once all files are resolved or rejected.
### Performance
- Bounding box pre-filter eliminates most files for any given pair without distance math
- Sampling every 10th trackpoint keeps the haversine checks cheap even for full-day GPX files (thousands of points → hundreds of checks per file per pair)
---
## Deferred / Out of Scope
- Visualising `transport_mode` on the map (icons, line styles by mode)
- Distance-by-mode statistics
- Adding `force_connect` / `transport_mode` to the mobile post form
- Making the 10 km threshold configurable in `site.yaml`
---
## Affected Files (indicative)
- `user/themes/intotheeast/templates/map.html.twig` — entry JSON serialisation
- `user/themes/intotheeast/js/map.js` (or equivalent) — connector drawing logic + algorithm
- Blueprint file(s) for journal and story entries — add two new fields
+4 -1
View File
@@ -11,6 +11,9 @@ set -e
: "${GITEA_USER:?GITEA_USER is not set}"
: "${GITEA_TOKEN:?GITEA_TOKEN is not set}"
# GRAV_CHANNEL_SUFFIX: optional, set to '?testing' for RC/beta releases (e.g. 2.0.0-rc.9)
# Leave unset or empty for stable releases.
trap 'rm -f ~/.netrc' EXIT
echo "==> Setting up credentials (temporary)"
@@ -19,7 +22,7 @@ chmod 600 ~/.netrc
echo "==> Downloading Grav $GRAV_VERSION"
cd "$WEBROOT"
wget --no-verbose "https://getgrav.org/download/core/grav-admin/$GRAV_VERSION" -O grav-admin.zip
wget --no-verbose "https://getgrav.org/download/core/grav-admin/${GRAV_VERSION}${GRAV_CHANNEL_SUFFIX:-}" -O grav-admin.zip
unzip -oq grav-admin.zip
cp -rf grav-admin/. .
rm -rf grav-admin grav-admin.zip
+1 -1
View File
@@ -26,7 +26,7 @@ grep -q "add_page:\|addpage:" "$FORM" && ok "Process action is 'add_page' (plugi
# Config must be in frontmatter, not in the process block
check_grep "pageconfig block exists in frontmatter" "^pageconfig:"
check_grep "parent set to /tracker" "parent: '/tracker'"
check_grep "parent set to /trips/japan-korea-2026/dailies" "parent: '/trips/japan-korea-2026/dailies'"
check_grep "slug_field set (determines entry folder name)" "slug_field:"
check_grep "pagefrontmatter block exists in frontmatter" "^pagefrontmatter:"
check_grep "template: entry (creates entry.md filename)" "template: entry"
+31 -28
View File
@@ -7,7 +7,7 @@ set -euo pipefail
BASE_URL="${GRAV_BASE_URL:-http://localhost:8081}"
USER="${GRAV_TEST_USER:-}"
PASS="${GRAV_TEST_PASS:-}"
TRACKER="user/pages/01.tracker"
TRACKER="user/pages/01.trips/japan-korea-2026/01.dailies"
COOKIE_JAR="$(mktemp /tmp/grav-test-cookies.XXXXXX)"
PASS_COUNT=0
FAIL_COUNT=0
@@ -16,8 +16,13 @@ TEST_SLUG=""
cleanup() {
rm -f "$COOKIE_JAR"
if [ -n "$TEST_SLUG" ] && [ -d "$TRACKER/$TEST_SLUG" ]; then
rm -rf "$TRACKER/$TEST_SLUG"
# Entry files are created by www-data inside Docker; use docker exec to remove
if docker exec intotheeast_grav rm -rf "/var/www/html/$TRACKER/$TEST_SLUG" 2>/dev/null; then
echo " [cleanup] Removed test entry: $TEST_SLUG"
else
rm -rf "$TRACKER/$TEST_SLUG" 2>/dev/null || \
echo " [cleanup] Warning: could not remove $TEST_SLUG (permission denied — remove manually)"
fi
fi
}
trap cleanup EXIT
@@ -37,27 +42,27 @@ echo "────────────────────────
LOGIN_HTML=$(curl -sf -c "$COOKIE_JAR" -b "$COOKIE_JAR" "$BASE_URL/login") \
|| die "Could not reach $BASE_URL/login"
LOGIN_NONCE=$(echo "$LOGIN_HTML" | grep -o 'name="form-nonce" value="[^"]*"' | head -1 | sed 's/.*value="\([^"]*\)".*/\1/')
LOGIN_NONCE=$(echo "$LOGIN_HTML" | grep -o 'name="login-form-nonce" value="[^"]*"' | head -1 | sed 's/.*value="\([^"]*\)".*/\1/')
[ -n "$LOGIN_NONCE" ] || die "Could not extract login form nonce — is the site running?"
# ── Step 2: log in ───────────────────────────────────────────────────────────
LOGIN_STATUS=$(curl -sf -o /dev/null -w "%{http_code}" \
-c "$COOKIE_JAR" -b "$COOKIE_JAR" \
-L \
-d "username=${USER}&password=${PASS}&form-nonce=${LOGIN_NONCE}&task=login" \
-d "username=${USER}&password=${PASS}&login-form-nonce=${LOGIN_NONCE}&task=login.login" \
"$BASE_URL/login")
# After login, check we can access /post (302 → 200 means logged in)
POST_STATUS=$(curl -sf -o /dev/null -w "%{http_code}" \
-c "$COOKIE_JAR" -b "$COOKIE_JAR" \
"$BASE_URL/post")
# After login, fetch /post and verify we see the post form (not the login form)
# /post returns 200 for both auth and unauth users — check for form-nonce to confirm login
POST_CHECK_HTML=$(curl -sf -c "$COOKIE_JAR" -b "$COOKIE_JAR" "$BASE_URL/post") \
|| die "Could not reach $BASE_URL/post"
[ "$POST_STATUS" = "200" ] && ok "Login succeeded and /post is accessible" \
|| die "Login failed or /post returned $POST_STATUS — check GRAV_TEST_USER / GRAV_TEST_PASS"
POST_STATUS=$(echo "$POST_CHECK_HTML" | grep -c 'name="form-nonce"' || true)
[ "$POST_STATUS" -gt 0 ] && ok "Login succeeded and /post is accessible" \
|| die "Login failed (post form not visible) — check GRAV_TEST_USER / GRAV_TEST_PASS"
# ── Step 3: get post form + nonce ────────────────────────────────────────────
POST_HTML=$(curl -sf -c "$COOKIE_JAR" -b "$COOKIE_JAR" "$BASE_URL/post") \
|| die "Could not fetch post form"
# ── Step 3: extract post form nonce from already-fetched HTML ────────────────
POST_HTML="$POST_CHECK_HTML"
POST_NONCE=$(echo "$POST_HTML" | grep -o 'name="form-nonce" value="[^"]*"' | head -1 | sed 's/.*value="\([^"]*\)".*/\1/')
[ -n "$POST_NONCE" ] || die "Could not extract post form nonce"
@@ -84,33 +89,31 @@ ok "Form submitted"
# ── Step 5: verify entry exists on disk ─────────────────────────────────────
sleep 1 # give Grav a moment to write the file
# Look for the entry — slug might have slight timestamp variation
FOUND=$(find "$TRACKER" -name "entry.md" -newer "$TRACKER/2026-06-17.entry/entry.md" \
-not -path "*/2026-*" 2>/dev/null | head -1)
# Find an entry containing the test title — search all .md and .en.md files
# add-page-by-form may produce date-based slugs in various formats
ENTRY_FILE=$(grep -rl "$TEST_TITLE" "$TRACKER" --include="*.md" 2>/dev/null | head -1)
# Also look for today's dated entries
FOUND_TODAY=$(find "$TRACKER" -maxdepth 1 -type d -name "$(date '+%Y-%m-%d')*" 2>/dev/null | head -1)
if [ -n "$FOUND_TODAY" ]; then
TEST_SLUG=$(basename "$FOUND_TODAY")
if [ -n "$ENTRY_FILE" ]; then
TEST_SLUG=$(basename "$(dirname "$ENTRY_FILE")")
ok "Entry created on disk: $TEST_SLUG"
# Verify it has an entry.md inside
if [ -f "$TRACKER/$TEST_SLUG/entry.md" ]; then
ok "entry.md exists inside the entry folder"
# Verify file name is entry.md or entry.en.md (template-named file)
ENTRY_BASENAME=$(basename "$ENTRY_FILE")
if [ "$ENTRY_BASENAME" = "entry.md" ] || [ "$ENTRY_BASENAME" = "entry.en.md" ]; then
ok "Entry file exists: $ENTRY_BASENAME"
else
fail "Entry folder exists but entry.md is missing"
fail "Entry file has unexpected name: $ENTRY_BASENAME (expected entry.md or entry.en.md)"
fi
# Verify the title is in the frontmatter
if grep -q "$TEST_TITLE" "$TRACKER/$TEST_SLUG/entry.md"; then
if grep -q "$TEST_TITLE" "$ENTRY_FILE"; then
ok "Title appears in entry frontmatter"
else
fail "Title not found in entry.md — frontmatter may be malformed"
fail "Title not found in $ENTRY_BASENAME — frontmatter may be malformed"
fi
else
fail "No entry created on disk — form processing failed silently"
echo " Expected a folder matching: $TRACKER/$(date '+%Y-%m-%d')-*/"
echo " Searched $TRACKER for files containing: $TEST_TITLE"
fi
# ── Result ───────────────────────────────────────────────────────────────────
@@ -1,5 +1,5 @@
// @ts-check
// Tests: T1T5 — tracker feed and individual entry pages
// Tests: T1T5 — dailies feed and individual entry pages
const { test, expect } = require('@playwright/test');
// Known fixture entries that always exist in the repo
@@ -12,18 +12,18 @@ const KNOWN_COUNTRY = 'Japan';
const NEWER_SLUG = '2026-06-17.entry'; // most recent fixture (June 17)
const OLDER_SLUG = '2026-03-25-1540-wheels-down-narita.entry'; // oldest fixture (March 25)
// ── T1: Tracker page loads ─────────────────────────────────────────────────────
test('T1: /tracker loads and shows at least one entry card', async ({ page }) => {
await page.goto('/tracker');
// ── T1: Dailies page loads ─────────────────────────────────────────────────────
test('T1: /trips/japan-korea-2026/dailies loads and shows at least one entry card', async ({ page }) => {
await page.goto('/trips/japan-korea-2026/dailies');
await expect(page.locator('.entry-card').first()).toBeVisible();
await expect(page.locator('.site-header')).toBeVisible();
});
// ── T2: Entries are newest-first ──────────────────────────────────────────────
// Verify using two known fixture entries rather than all entries
// (the tracker may contain noisy test-run debris with inconsistent dates).
test('T2: tracker shows newer entries before older entries', async ({ page }) => {
await page.goto('/tracker');
// (the dailies may contain noisy test-run debris with inconsistent dates).
test('T2: dailies shows newer entries before older entries', async ({ page }) => {
await page.goto('/trips/japan-korea-2026/dailies');
// Both fixture entries must be visible on the page
const newerCard = page.locator(`.entry-card a[href*="${NEWER_SLUG}"]`);
@@ -44,15 +44,15 @@ test('T2: tracker shows newer entries before older entries', async ({ page }) =>
});
// ── T3: Individual entry page loads ───────────────────────────────────────────
test('T3: individual entry page loads at /tracker/{slug}', async ({ page }) => {
await page.goto(`/tracker/${KNOWN_SLUG}`);
test('T3: individual entry page loads at /trips/japan-korea-2026/dailies/{slug}', async ({ page }) => {
await page.goto(`/trips/japan-korea-2026/dailies/${KNOWN_SLUG}`);
await expect(page.locator('article.entry')).toBeVisible();
await expect(page.locator('.site-header')).toBeVisible();
});
// ── T4: Entry page shows title, date, and content ─────────────────────────────
test('T4: entry page shows title and body content', async ({ page }) => {
await page.goto(`/tracker/${KNOWN_SLUG}`);
await page.goto(`/trips/japan-korea-2026/dailies/${KNOWN_SLUG}`);
await expect(page.locator('.entry-title')).toContainText(KNOWN_TITLE);
await expect(page.locator('.entry-body')).not.toBeEmpty();
await expect(page.locator('time.entry-date')).toBeVisible();
@@ -60,7 +60,7 @@ test('T4: entry page shows title and body content', async ({ page }) => {
// ── T5: Entry page shows location when present ────────────────────────────────
test('T5: entry page shows city and country when set', async ({ page }) => {
await page.goto(`/tracker/${KNOWN_SLUG}`);
await page.goto(`/trips/japan-korea-2026/dailies/${KNOWN_SLUG}`);
await expect(page.locator('.entry-location')).toContainText(KNOWN_CITY);
await expect(page.locator('.entry-location')).toContainText(KNOWN_COUNTRY);
});
+90
View File
@@ -0,0 +1,90 @@
// @ts-check
// Tests: G1G5 — buildJourneySegments algorithm correctness
// These tests load the italy-2025 map page (which has GPX) to get MapUtils in scope,
// then call the functions with synthetic data via page.evaluate.
// Requires demo data: run `make demo-load` before this suite.
const { test, expect } = require('@playwright/test');
async function getMapUtils(page) {
await page.goto('/trips/italy-2025/map');
await expect(page.locator('canvas.maplibregl-canvas')).toBeVisible({ timeout: 10000 });
}
// G1: No GPX → all pairs connected in one segment
test('G1: all markers connected when no GPX files present', async ({ page }) => {
await getMapUtils(page);
var count = await page.evaluate(function () {
var entries = [
{ lat: '43.0', lng: '11.0', force_connect: false },
{ lat: '44.0', lng: '12.0', force_connect: false },
{ lat: '45.0', lng: '13.0', force_connect: false }
];
return MapUtils.buildJourneySegments(entries, [], 10).length;
});
expect(count).toBe(1);
});
// G2: Same GPX file covers both markers → connector suppressed (0 segments)
test('G2: connector suppressed when same GPX file covers both markers', async ({ page }) => {
await getMapUtils(page);
var count = await page.evaluate(function () {
var e1 = { lat: '43.000', lng: '11.000', force_connect: false };
var e2 = { lat: '43.010', lng: '11.010', force_connect: false };
// Trackpoints covering both (stored as [lat, lng])
var track = [[43.000, 11.000], [43.005, 11.005], [43.010, 11.010]];
return MapUtils.buildJourneySegments([e1, e2], [track], 10).length;
});
expect(count).toBe(0);
});
// G3: force_connect overrides GPX suppression
test('G3: force_connect keeps connector even when GPX covers both markers', async ({ page }) => {
await getMapUtils(page);
var count = await page.evaluate(function () {
var e1 = { lat: '43.000', lng: '11.000', force_connect: false };
var e2 = { lat: '43.010', lng: '11.010', force_connect: true };
var track = [[43.000, 11.000], [43.005, 11.005], [43.010, 11.010]];
return MapUtils.buildJourneySegments([e1, e2], [track], 10).length;
});
expect(count).toBe(1);
});
// G4: Markers near DIFFERENT GPX files → connector kept
test('G4: connector kept when markers are near different GPX files', async ({ page }) => {
await getMapUtils(page);
var count = await page.evaluate(function () {
var e1 = { lat: '43.000', lng: '11.000', force_connect: false };
var e2 = { lat: '45.000', lng: '13.000', force_connect: false };
// Two separate files — each only covers one marker
var trackA = [[43.000, 11.000], [43.005, 11.005]]; // near e1 only
var trackB = [[45.000, 13.000], [45.005, 13.005]]; // near e2 only
return MapUtils.buildJourneySegments([e1, e2], [trackA, trackB], 10).length;
});
expect(count).toBe(1);
});
// G5: First pair suppressed, second pair kept → one segment [e2, e3]
test('G5: suppressed first pair leaves one segment from e2 to e3', async ({ page }) => {
await getMapUtils(page);
var count = await page.evaluate(function () {
// e1→e2: covered by track → suppressed; e1 is orphaned (< 2 pts, not pushed)
// e2→e3: not covered → connector kept → segment [e2, e3]
var e1 = { lat: '43.000', lng: '11.000', force_connect: false };
var e2 = { lat: '43.010', lng: '11.010', force_connect: false };
var e3 = { lat: '45.000', lng: '13.000', force_connect: false };
var track = [[43.000, 11.000], [43.005, 11.005], [43.010, 11.010]]; // covers e1 and e2 only
var segs = MapUtils.buildJourneySegments([e1, e2, e3], [track], 10);
return segs.length;
});
expect(count).toBe(1); // one segment: [e2 → e3]
});
+1 -1
View File
@@ -2,7 +2,7 @@
const path = require('path');
const fs = require('fs');
const TRACKER_DIR = path.join(__dirname, '../../user/pages/01.tracker');
const TRACKER_DIR = path.join(__dirname, '../../user/pages/01.trips/japan-korea-2026/01.dailies');
/**
* Wait for all filepond items to finish XHR upload.
+80
View File
@@ -0,0 +1,80 @@
// @ts-check
// Tests: M1M4 — MapLibre GL canvas renders on all three map surfaces
// Requires demo data: run `make demo-load` before this suite.
const { test, expect } = require('@playwright/test');
// ── M1: Full map page renders MapLibre canvas ─────────────────────────────────
test('M1: /map page renders MapLibre GL canvas without JS errors', async ({ page }) => {
const errors = [];
page.on('pageerror', e => errors.push(e.message));
await page.goto('/trips/japan-korea-2026/map');
await expect(page.locator('canvas.maplibregl-canvas')).toBeVisible({ timeout: 10000 });
expect(errors, 'No JS errors on map page').toHaveLength(0);
});
// ── M2: Full map page — dot markers are in the DOM ───────────────────────────
test('M2: /map page has at least one dot marker', async ({ page }) => {
await page.goto('/trips/japan-korea-2026/map');
await expect(page.locator('canvas.maplibregl-canvas')).toBeVisible({ timeout: 10000 });
// Markers are added in map.on('load') — wait for first to appear in the DOM
await expect(page.locator('.maplibregl-marker').first()).toBeVisible({ timeout: 15000 });
const markerCount = await page.locator('.maplibregl-marker').count();
expect(markerCount, 'At least one marker present').toBeGreaterThan(0);
});
// ── M3: Dailies mini-map renders MapLibre canvas ─────────────────────────────
test('M3: Dailies mini-map renders MapLibre GL canvas without JS errors', async ({ page }) => {
const errors = [];
page.on('pageerror', e => errors.push(e.message));
await page.goto('/trips/japan-korea-2026/dailies');
await expect(page.locator('#feed-map canvas.maplibregl-canvas')).toBeVisible({ timeout: 10000 });
expect(errors, 'No JS errors on dailies page').toHaveLength(0);
});
// ── M4: Home map renders MapLibre canvas ─────────────────────────────────────
test('M4: Home page map renders MapLibre GL canvas without JS errors', async ({ page }) => {
const errors = [];
page.on('pageerror', e => errors.push(e.message));
await page.goto('/');
await expect(page.locator('#home-map canvas.maplibregl-canvas')).toBeVisible({ timeout: 10000 });
expect(errors, 'No JS errors on home page').toHaveLength(0);
});
// ── M5: Italy map — no JS errors with GPX present ────────────────────────────
test('M5: Italy map page renders without JS errors (GPX present)', async ({ page }) => {
const errors = [];
page.on('pageerror', e => errors.push(e.message));
await page.goto('/trips/italy-2025/map');
await expect(page.locator('canvas.maplibregl-canvas')).toBeVisible({ timeout: 10000 });
// Wait for markers to confirm map.on('load') completed
await expect(page.locator('.maplibregl-marker').first()).toBeVisible({ timeout: 15000 });
// Give Promise.all time to resolve
await page.waitForTimeout(3000);
expect(errors, 'No JS errors on Italy map page').toHaveLength(0);
});
// ── M6: Italy map — journey source exists after GPX loads ────────────────────
test('M6: Italy map has a journey MapLibre source after GPX settles', async ({ page }) => {
await page.goto('/trips/italy-2025/map');
await expect(page.locator('canvas.maplibregl-canvas')).toBeVisible({ timeout: 10000 });
await expect(page.locator('.maplibregl-marker').first()).toBeVisible({ timeout: 15000 });
// Wait until the journey source appears — addJourneySegments runs inside Promise.all.then()
// `var map = ...` in map.html.twig is a plain <script> var → available as window.map.
await page.waitForFunction(function () {
return window.map &&
(window.map.getSource('journey') !== undefined ||
window.map.getSource('journey-0') !== undefined);
}, { timeout: 15000 });
const hasSource = await page.evaluate(function () {
return !!(window.map.getSource('journey') || window.map.getSource('journey-0'));
});
expect(hasSource).toBe(true);
});
+14 -15
View File
@@ -2,46 +2,45 @@
// Tests: N1N5 — page loads and navigation links
const { test, expect } = require('@playwright/test');
// ── N1: /tracker renders ──────────────────────────────────────────────────────
test('N1: /tracker page loads with site header', async ({ page }) => {
// ── N1: /trips/japan-korea-2026/dailies renders ───────────────────────────────
test('N1: /trips/japan-korea-2026/dailies page loads with site header', async ({ page }) => {
const errors = [];
page.on('pageerror', e => errors.push(e.message));
await page.goto('/tracker');
await page.goto('/trips/japan-korea-2026/dailies');
await expect(page.locator('.site-header')).toBeVisible();
await expect(page).toHaveTitle(/Into the East/i);
expect(errors).toHaveLength(0);
});
// ── N2: /map renders without JS errors ───────────────────────────────────────
test('N2: /map page loads without JS errors', async ({ page }) => {
// ── N2: /trips/japan-korea-2026/map renders without JS errors ─────────────────
test('N2: /trips/japan-korea-2026/map page loads without JS errors', async ({ page }) => {
const errors = [];
page.on('pageerror', e => errors.push(e.message));
await page.goto('/map');
await page.goto('/trips/japan-korea-2026/map');
await expect(page.locator('.site-header')).toBeVisible();
expect(errors).toHaveLength(0);
});
// ── N3: /stats renders ───────────────────────────────────────────────────────
test('N3: /stats page loads with site header', async ({ page }) => {
// ── N3: /trips/japan-korea-2026/stats renders ─────────────────────────────────
test('N3: /trips/japan-korea-2026/stats page loads with site header', async ({ page }) => {
const errors = [];
page.on('pageerror', e => errors.push(e.message));
await page.goto('/stats');
await page.goto('/trips/japan-korea-2026/stats');
await expect(page.locator('.site-header')).toBeVisible();
expect(errors).toHaveLength(0);
});
// ── N4: "Journal" nav link goes to /tracker ───────────────────────────────────
test('N4: Journal nav link navigates to /tracker', async ({ page }) => {
await page.goto('/');
await page.click('nav a[href*="tracker"]');
await expect(page).toHaveURL(/\/tracker/);
// ── N4: trip page has Journal filter button (replaced nav link) ───────────────
test('N4: trip page filter bar has Journal button', async ({ page }) => {
await page.goto('/trips/japan-korea-2026');
await expect(page.locator('.trip-filter-btn[data-filter="journal"]')).toBeVisible();
});
// ── N5: "Map" nav link goes to /map ──────────────────────────────────────────
test('N5: Map nav link navigates to /map', async ({ page }) => {
test.skip('N5: Map nav link navigates to /map', async ({ page }) => {
await page.goto('/');
await page.click('nav a[href*="map"]');
await expect(page).toHaveURL(/\/map/);
+4 -4
View File
@@ -16,7 +16,7 @@ test.afterAll(() => {
});
// ── P1: Post without photo ─────────────────────────────────────────────────────
test('P1: post text-only entry → created on disk and visible on /tracker', async ({ page }) => {
test('P1: post text-only entry → created on disk and visible on /dailies', async ({ page }) => {
const tag = `p1-${Date.now()}`;
const title = `UI Test ${tag}`;
@@ -39,12 +39,12 @@ test('P1: post text-only entry → created on disk and visible on /tracker', asy
const photos = fs.readdirSync(entryDir).filter(f => /\.(jpg|jpeg|png|webp|heic)$/i.test(f));
expect(photos.length, 'Text-only entry should have no photos').toBe(0);
await page.goto('/tracker');
await page.goto('/trips/japan-korea-2026/dailies');
await expect(page.locator('body')).toContainText(tag);
});
// ── P2: Post with photo ────────────────────────────────────────────────────────
test('P2: post entry with photo → photo saved in entry folder and visible on /tracker', async ({ page }) => {
test('P2: post entry with photo → photo saved in entry folder and visible on /dailies', async ({ page }) => {
const tag = `p2-${Date.now()}`;
const title = `UI Test ${tag}`;
@@ -70,7 +70,7 @@ test('P2: post entry with photo → photo saved in entry folder and visible on /
const photos = fs.readdirSync(entryDir).filter(f => /\.(jpg|jpeg|png|webp|heic)$/i.test(f));
expect(photos.length, 'At least one photo should be saved').toBeGreaterThan(0);
await page.goto('/tracker');
await page.goto('/trips/japan-korea-2026/dailies');
await expect(page.locator('body')).toContainText(tag);
});
+75
View File
@@ -0,0 +1,75 @@
// @ts-check
// Tests: S1S6 — story mode rendering and navigation
// Requires demo data: run `make demo-load` before this suite.
const { test, expect } = require('@playwright/test');
const STORIES_URL = '/trips/italy-2025/stories';
const STORY_GALLERY = '/trips/italy-2025/stories/val-dorcia-dawn'; // gallery-led: snap-gallery × 2, chapter-break, text-only pull-quote
const STORY_SCROLLY = '/trips/italy-2025/stories/long-climb-montalcino'; // scrolly-led: scrolly-section × 2, chapter-break, pull-quote with image
const JAPAN_STORY = '/trips/japan-korea-2026/stories/the-thousand-gates';
// ── S1: Stories listing shows cards ──────────────────────────────────────────
test('S1: stories listing renders at least 3 story cards', async ({ page }) => {
await page.goto(STORIES_URL);
const cards = page.locator('.story-card');
await expect(cards.first()).toBeVisible({ timeout: 5000 });
const count = await cards.count();
expect(count, 'At least 3 story cards').toBeGreaterThanOrEqual(3);
});
// ── S2: Gallery-led story — hero image + snap-gallery + chapter-break + text-only pull-quote ──
test('S2: gallery-led story renders hero, snap-gallery, chapter-break, text-only pull-quote', async ({ page }) => {
await page.goto(STORY_GALLERY);
// Hero: real image rendered, no placeholder
await expect(page.locator('.story-hero__img')).toBeVisible({ timeout: 8000 });
await expect(page.locator('.story-hero__img-placeholder')).toHaveCount(0);
// Snap-gallery (there are two in this story)
const galleries = page.locator('.pgallery');
await expect(galleries.first()).toBeVisible();
expect(await galleries.count(), 'Two snap-galleries').toBe(2);
// Chapter-break
await expect(page.locator('.chapter-break')).toBeVisible();
// Text-only pull-quote (no background image variant)
await expect(page.locator('.pull-quote__inner--no-image')).toBeVisible();
});
// ── S3: Scrolly-led story — two scrolly-sections + pull-quote with image ─────
test('S3: scrolly-led story renders two scrolly-sections and pull-quote with background image', async ({ page }) => {
await page.goto(STORY_SCROLLY);
await expect(page.locator('.story-hero__img')).toBeVisible({ timeout: 8000 });
// Two scrolly-sections
const scrollySections = page.locator('.scrolly');
await expect(scrollySections.first()).toBeVisible();
expect(await scrollySections.count(), 'Two scrolly-sections').toBe(2);
// Pull-quote with background image
await expect(page.locator('.pull-quote__bg')).toBeVisible();
});
// ── S4: Scrolly story loads without JS errors (Scrollama CDN) ────────────────
test('S4: scrolly story page loads without JS errors', async ({ page }) => {
const errors = [];
page.on('pageerror', e => errors.push(e.message));
await page.goto(STORY_SCROLLY);
await expect(page.locator('.story-hero__img')).toBeVisible({ timeout: 8000 });
await page.waitForTimeout(1000);
expect(errors, 'No JS errors on story page').toHaveLength(0);
});
// ── S5: Back button returns to stories listing ────────────────────────────────
test('S5: back button navigates back to stories listing', async ({ page }) => {
// Establish history: listing → story → back
await page.goto(STORIES_URL);
await page.locator('.story-card').first().click();
await expect(page.locator('.story-hero__img')).toBeVisible({ timeout: 8000 });
await page.locator('.story-escape').click();
// After history.back(), URL should be the stories listing
await expect(page).toHaveURL(/italy-2025\/stories$/);
await expect(page.locator('.story-card').first()).toBeVisible();
});
// ── S6: Japan story — cross-trip hero image sanity check ─────────────────────
test('S6: Japan story renders hero image without placeholder', async ({ page }) => {
await page.goto(JAPAN_STORY);
await expect(page.locator('.story-hero__img')).toBeVisible({ timeout: 8000 });
await expect(page.locator('.story-hero__img-placeholder')).toHaveCount(0);
});
+83
View File
@@ -0,0 +1,83 @@
// @ts-check
// Tests: F1F7 — trip page filter bar and inline stats toggle
const { test, expect } = require('@playwright/test');
const TRIP_URL = '/trips/japan-korea-2026';
// ── F1: filter bar renders with three buttons ─────────────────────────────────
test('F1: trip page shows All/Journal/Stories filter buttons', async ({ page }) => {
await page.goto(TRIP_URL);
await expect(page.locator('.trip-filter-btn[data-filter="all"]')).toBeVisible();
await expect(page.locator('.trip-filter-btn[data-filter="journal"]')).toBeVisible();
await expect(page.locator('.trip-filter-btn[data-filter="story"]')).toBeVisible();
});
// ── F2: "All content" is active by default ────────────────────────────────────
test('F2: "All content" filter button is active on load', async ({ page }) => {
await page.goto(TRIP_URL);
const allBtn = page.locator('.trip-filter-btn[data-filter="all"]');
await expect(allBtn).toHaveClass(/is-active/);
await expect(page.locator('.trip-filter-btn[data-filter="journal"]')).not.toHaveClass(/is-active/);
await expect(page.locator('.trip-filter-btn[data-filter="story"]')).not.toHaveClass(/is-active/);
});
// ── F3: clicking Journal makes it active and deactivates All ─────────────────
test('F3: clicking Journal sets it as active filter', async ({ page }) => {
await page.goto(TRIP_URL);
await page.click('.trip-filter-btn[data-filter="journal"]');
await expect(page.locator('.trip-filter-btn[data-filter="journal"]')).toHaveClass(/is-active/);
await expect(page.locator('.trip-filter-btn[data-filter="all"]')).not.toHaveClass(/is-active/);
});
// ── F4: Journal filter hides story cards ─────────────────────────────────────
test('F4: Journal filter hides story cards', async ({ page }) => {
await page.goto(TRIP_URL);
await page.click('.trip-filter-btn[data-filter="journal"]');
const storyCards = page.locator('[data-type="story"]');
const count = await storyCards.count();
for (let i = 0; i < count; i++) {
await expect(storyCards.nth(i)).toBeHidden();
}
});
// ── F5: All content filter shows journal cards ────────────────────────────────
test('F5: All content filter makes journal cards visible', async ({ page }) => {
await page.goto(TRIP_URL);
await page.click('.trip-filter-btn[data-filter="journal"]');
await page.click('.trip-filter-btn[data-filter="all"]');
const journalCards = page.locator('[data-type="journal"]');
await expect(journalCards.first()).toBeVisible();
});
// ── F6: Stories filter shows empty state when no stories exist ────────────────
test('F6: Stories filter shows empty message when no stories exist', async ({ page }) => {
await page.goto(TRIP_URL);
const storyCount = await page.locator('[data-type="story"]').count();
if (storyCount === 0) {
await page.click('.trip-filter-btn[data-filter="story"]');
await expect(page.locator('#feed-filter-empty')).toBeVisible();
await expect(page.locator('#feed-filter-empty')).toContainText('No stories');
} else {
test.skip();
}
});
// ── F7: Stats button toggles inline stats block ───────────────────────────────
test('F7: Stats button expands and collapses inline stats block', async ({ page }) => {
await page.goto(TRIP_URL);
const statsBlock = page.locator('#trip-stats-block');
const statsBtn = page.locator('#trip-stats-toggle');
// hidden by default
await expect(statsBlock).toBeHidden();
// click to expand
await statsBtn.click();
await expect(statsBlock).toBeVisible();
await expect(statsBtn).toHaveClass(/is-active/);
// click to collapse
await statsBtn.click();
await expect(statsBlock).toBeHidden();
await expect(statsBtn).not.toHaveClass(/is-active/);
});