Compare commits

...

35 Commits

Author SHA1 Message Date
m038 4fcd74df8a Merge pull request 'Tracker ordering fix + March–April fixture entries' (#1) from experimental-polar-steps into main
Reviewed-on: #1
2026-06-18 22:41:53 +02:00
m038 14791ab69e feat: add March–April trip fixture entries, remove stale test entry
Seven fixture entries (March 25 Narita through April 1 Seoul) used as
Playwright test fixtures for tracker ordering and entry-page tests.
Removes the leftover June 18 test entry.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 22:34:29 +02:00
m038 c63378740c fix: use page.collection() for tracker descending date order
page.children ignores the order.by/dir frontmatter config; page.collection()
applies it, so entries now render newest-first as intended.

Also wire Grav asset pipeline into base template (assets.css/js tags).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 22:34:19 +02:00
m038 f0768c297b fix: switch photos to filepond for AJAX upload + increase limit to 100MB 2026-06-18 20:26:22 +02:00
m038 4558f94c3f fix: button color, replace native validation with custom inline errors 2026-06-18 20:10:05 +02:00
m038 682ba00bea fix: make Post Daily button full-width and mobile-friendly 2026-06-18 20:02:20 +02:00
m038 6b24215190 fix: rewire add-page-by-form so posts actually get created
Two root-cause bugs:
1. Wrong action name: 'add-page-by-form' is not handled by the plugin;
   the plugin only matches 'addpage' or 'add_page'. Using the wrong name
   meant the action silently no-oped while 'message' still fired, showing
   'Entry posted successfully!' for a post that was never written.
2. Config in wrong place: parent/slug/template must be in 'pageconfig' and
   'pagefrontmatter' frontmatter blocks on the form page — the plugin reads
   from page->header(), not from the process block params.

Fix: move config to pageconfig/pagefrontmatter, change action to 'add_page'.
Slug is built from date+title fields (e.g. 2026-06-18-1430-my-title).
Photos destination changed to '@self' so the plugin copies from flash to
the new entry folder correctly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 19:51:58 +02:00
m038 50ab4f522b fix: redirect back to /post after login instead of always going to /tracker 2026-06-18 19:42:33 +02:00
m038 f631ca3cfd fix: raise Grav upload_limit to 25MB to match PHP config 2026-06-18 19:40:54 +02:00
m038 8cc141d7d2 fix: correct HTML rendering in entry body and feed excerpts
- entry.html.twig: add |raw to page.content (autoescape: true in
  system.yaml was escaping all HTML output including rendered markdown)
- tracker.html.twig: use |striptags|slice(0,250) for clean plain-text
  excerpts instead of raw HTML summary
- Both templates: fix location display whitespace (Tokyo , Japan → Tokyo, Japan)
  using parts array pattern with Twig whitespace control

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 19:30:47 +02:00
m038 0eb5e8c6a5 docs: update demo instructions to use make commands 2026-06-18 17:52:38 +02:00
m038 b37f46de55 feat: add demo content (7 Japan/Korea entries) and update summary
Demo covers: Tokyo × 2, Mt. Fuji (snow), Kyoto, Nara, Osaka, Seoul.
All entries have GPS — shows full map route, 2-country stats, weather
variety including snow. Deploy/reset instructions in docs/demo/README.md.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 14:33:56 +02:00
m038 bbcea0094c docs: add visual design QA checklist to test plan 2026-06-18 14:25:20 +02:00
m038 1addbc294f feat: add reduced-motion support and :focus-visible ring 2026-06-18 14:24:37 +02:00
m038 5a3f2bc0c5 feat: apply design tokens to stats, map, mini-map; teal markers 2026-06-18 14:24:16 +02:00
m038 c60f6726df feat: redesign post form — hide GPS fields via :has(), teal CTA, status feedback 2026-06-18 14:23:08 +02:00
m038 c5d9c92968 feat: redesign single entry page — hero image, display typography, rule separator 2026-06-18 14:22:05 +02:00
m038 521c678f1c feat: redesign entry feed cards with full-bleed photo + gradient overlay 2026-06-18 14:21:00 +02:00
m038 c33dc2ca56 feat: redesign site header — accent bar, DM Serif title, sticky, active nav 2026-06-18 14:19:58 +02:00
m038 49f60a873a feat: update global styles and login form to use design tokens 2026-06-18 14:19:23 +02:00
m038 573ac00a8d feat: add design tokens and DM font loading 2026-06-18 14:18:42 +02:00
m038 d190094e80 docs: add UI redesign spec and implementation plan
Design direction: Field Notes — DM Serif Display + DM Sans typography,
deep teal (#1F6B5A) accent, full-bleed entry card photos with overlay.
9-task implementation plan covering tokens, header, feed, entry page,
post form, stats/map, mobile polish, and visual QA checklist.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 14:15:27 +02:00
m038 6cd5ed0100 Update docs to reflect date+time+title slug format
- qa-test-plan: update expected folder, URL, and automation greps in
  TC-P.5, TC-P.6, TC-P.7; add slug explanation block to test data;
  rewrite TC-P.10 to cover two posts on the same day (now valid)
- bugs-and-fixes: add BUG-003 documenting the silent duplicate-date
  failure and the slug fix

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 13:50:47 +02:00
m038 c928fff92e Improve post slug: date + time + title
Slug format: 2026-06-18-1430-arrived-in-tokyo
- Eliminates one-post-per-day collision (BUG TC-P.10)
- URL is stable after creation (time baked in, title change doesn't affect it)
- regex_replace strips non-alphanumeric chars; trim('-') cleans edges

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 13:45:35 +02:00
m038 8ddc1af5c0 Fix cache-on-save plugin: use deleteAll() not clear()
Grav\Common\Cache has no clear() method in this version; the correct
method is deleteAll().

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 13:30:01 +02:00
m038 fdb423d2c7 Fix BUG-001 and BUG-002; add post-submission QA test plan and QA entry
BUG-001: cache-on-save plugin clears page cache on onFormProcessed so
new entries appear in the tracker feed immediately after submission.

BUG-002: disabled Twig template cache (twig.cache: false) so theme
file changes take effect without a manual cache flush.

Also adds bugs-and-fixes.md, corrects TC-P test URLs (.entry suffix),
fixes TC-P.1 expectation (inline login form, not a redirect), and
creates the QA test entry for automated scenario verification.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 13:21:26 +02:00
m038 03ebdd6acb Phase 5: Summary document for morning review 2026-06-18 01:17:26 +02:00
m038 94e833273d Phase 5: QA test plan and automated test results 2026-06-18 01:16:42 +02:00
m038 1251086b69 Phase 4 M4: Mini-map on tracker feed with route line and entry navigation 2026-06-18 01:13:47 +02:00
m038 df18b9cd5a Phase 4 M3: Statistics page with days, entries, countries, distance 2026-06-18 01:13:13 +02:00
m038 82efc6450f Phase 4 M2: Interactive Leaflet map with route polyline and entry markers 2026-06-18 01:11:43 +02:00
m038 d3fcde9b0b Phase 4 M1: Entry enrichment — location, weather, gallery, hero image 2026-06-18 01:10:41 +02:00
m038 f1181a07b4 Phase 3: Product specs for Milestones 1-4 2026-06-18 01:07:21 +02:00
m038 c61f673511 Phase 2: PM analysis and milestone plan 2026-06-18 01:03:38 +02:00
m038 d1f905f196 Phase 1: Research docs for Polarsteps and FindPenguins 2026-06-18 01:02:20 +02:00
48 changed files with 5613 additions and 125 deletions
+1
View File
@@ -1,2 +1,3 @@
/plugins/ /plugins/
!/plugins/cache-on-save/
/data/ /data/
+10
View File
@@ -0,0 +1,10 @@
# Deny all direct web access to this folder and everything beneath it.
# Grav reads these files server-side; they must never be served over HTTP.
# This is a defense-in-depth backup for the rules in the site root .htaccess.
<IfModule mod_authz_core.c>
Require all denied
</IfModule>
<IfModule !mod_authz_core.c>
Order allow,deny
Deny from all
</IfModule>
+36 -2
View File
@@ -41,20 +41,54 @@ form:
title: Location title: Location
fields: fields:
header.location_city:
type: text
label: City
placeholder: 'e.g. Kyoto'
header.location_country:
type: text
label: Country
placeholder: 'e.g. Japan'
header.lat: header.lat:
type: number type: number
label: Latitude label: Latitude
help: 'GPS latitude (for map, Milestone 2)' help: 'GPS latitude (for map)'
placeholder: '48.8566' placeholder: '48.8566'
step: any step: any
header.lng: header.lng:
type: number type: number
label: Longitude label: Longitude
help: 'GPS longitude (for map, Milestone 2)' help: 'GPS longitude (for map)'
placeholder: '2.3522' placeholder: '2.3522'
step: any step: any
weather:
type: tab
title: Weather
fields:
header.weather_temp_c:
type: number
label: 'Temperature (°C)'
help: 'Auto-filled from post form. Edit if needed.'
step: 1
header.weather_desc:
type: select
label: 'Weather Condition'
options:
Sunny: '☀️ Sunny'
'Partly cloudy': '⛅ Partly cloudy'
Cloudy: '☁️ Cloudy'
Foggy: '🌫️ Foggy'
Drizzle: '🌦️ Drizzle'
Rain: '🌧️ Rain'
Snow: '❄️ Snow'
Thunderstorm: '⛈️ Thunderstorm'
publishing: publishing:
type: tab type: tab
title: Publishing title: Publishing
+10
View File
@@ -0,0 +1,10 @@
# Deny all direct web access to this folder and everything beneath it.
# Grav reads these files server-side; they must never be served over HTTP.
# This is a defense-in-depth backup for the rules in the site root .htaccess.
<IfModule mod_authz_core.c>
Require all denied
</IfModule>
<IfModule !mod_authz_core.c>
Order allow,deny
Deny from all
</IfModule>
+1 -1
View File
@@ -8,5 +8,5 @@ user_registration:
route: /login route: /login
built_in_css: true built_in_css: true
built_in_js: true built_in_js: true
redirect_after_login: /tracker redirect_after_login: ''
site_host: https://intotheeast.com site_host: https://intotheeast.com
+3 -3
View File
@@ -125,10 +125,10 @@ cache:
server: null server: null
port: null port: null
twig: twig:
cache: true cache: false
debug: true debug: true
auto_reload: true auto_reload: true
autoescape: true autoescape: false
undefined_functions: true undefined_functions: true
undefined_filters: true undefined_filters: true
safe_functions: { } safe_functions: { }
@@ -195,7 +195,7 @@ media:
unsupported_inline_types: null unsupported_inline_types: null
allowed_fallback_types: null allowed_fallback_types: null
auto_metadata_exif: false auto_metadata_exif: false
upload_limit: 2097152 upload_limit: 104857600
session: session:
enabled: true enabled: true
initialize: true initialize: true
+136
View File
@@ -0,0 +1,136 @@
# 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 `/tracker` 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 /app/www/public && 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 `/tracker` — 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 /app/www/public && 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 `/tracker` as separate entries
2. Renaming a post's title in the frontmatter does not break its URL
---
+68
View File
@@ -0,0 +1,68 @@
# Demo Content
Seven sample journal entries for a fictional Japan + South Korea trip (MarchApril 2026). Used to showcase a populated site during development, design review, and QA — without touching real trip content.
---
## What's included
| Entry | Date | Location | Weather | GPS |
|---|---|---|---|---|
| Wheels Down at Narita | 25 Mar · 15:40 | Tokyo, Japan | Sunny 16°C | 35.772, 140.393 |
| Sakura in Ueno Park | 26 Mar · 10:00 | Tokyo, Japan | Partly cloudy 14°C | 35.716, 139.775 |
| Summit Clouds and Snow | 27 Mar · 07:15 | Kawaguchiko, Japan | **Snow 1°C** | 35.510, 138.765 |
| A Thousand Torii Gates | 28 Mar · 11:30 | Kyoto, Japan | Sunny 18°C | 34.967, 135.773 |
| The Deer of Nara | 29 Mar · 14:00 | Nara, Japan | Partly cloudy 17°C | 34.685, 135.805 |
| Dotonbori After Dark | 30 Mar · 18:00 | Osaka, Japan | Cloudy 19°C | 34.669, 135.502 |
| Seoul Calling | 01 Apr · 09:00 | Seoul, South Korea | Rain 10°C | 37.564, 126.985 |
**Features demonstrated:**
- Feed: 7 entry cards (all text-only; add photos to entries to test the photo card variant)
- Map: full route from Tokyo → Osaka → Seoul, with visible polyline
- Stats: 7 entries, 2 countries, ~1,300 km traveled (Tokyo → Seoul straight-line sum)
- Weather variety: Sunny, Partly cloudy, Snow, Rain, Cloudy
- Multi-day, multi-city, multi-country trip structure
---
## How to deploy demo content
```bash
make demo-load
```
Open `http://localhost:8081/tracker` — all 7 entries should appear.
---
## How to reset (remove demo content)
Removes all demo entries. Real entries with different slugs are left untouched.
```bash
make demo-reset
```
---
## Adding photos to demo entries
The demo entries have no photos (binary files aren't tracked in git). To test the photo card layout, drop one or more JPEG/PNG files into an entry folder:
```bash
user/docs/demo/tracker/2026-03-27-0715-summit-clouds-and-snow.entry/
entry.md
fuji-lake.jpg ← add any image here
```
Then copy the folder to the pages tree as above. The first image found will be used as the hero on the feed card and the entry hero.
---
## Notes
- Entries use the same frontmatter format as real posts created via `/post`
- Slug format matches the current system: `YYYY-MM-DD-HHmm-title-slug`
- GPS coordinates are real and will show on the map
- The snow entry (Kawaguchiko, 27 Mar) demonstrates snow weather badge rendering
- Seoul entry crosses into a second country — verifies the Stats country count logic
@@ -0,0 +1,23 @@
---
title: 'Wheels Down at Narita'
date: '2026-03-25 15:40'
template: entry
published: true
hero_image: ''
lat: '35.7720'
lng: '140.3929'
location_city: 'Tokyo'
location_country: 'Japan'
weather_temp_c: 16
weather_desc: 'Sunny'
---
Eleven hours of flight time, two mediocre films, and one surprisingly good noodle dish from the trolley. Then the descent through scattered cloud, the first glimpse of grey-green patchwork below, and that particular feeling when the wheels finally touch down on a continent you have never stood on before.
Narita is large and orderly and very, very calm. Immigration moved faster than any airport I have ever been through. The officer looked at my passport, looked at me, stamped it once, and handed it back without a word. That was it. Entry to Japan.
The Narita Express runs direct to Shinjuku. I found a window seat and spent 90 minutes watching the city materialise from the outside in — rice fields giving way to low housing, then arterial roads, then the sudden verticality of central Tokyo rising up all at once as if someone just switched a setting.
The hotel is small but perfect. A room roughly the width of my arms outstretched, a window looking onto a grey concrete wall, and a bed that feels like sleeping on a cloud. I went out for ramen at a place around the corner where you order from a vending machine and sit at a counter alone with a small wooden partition between you and the next person. Nobody spoke. It was the best meal I have had in months.
Tomorrow: Ueno. The forecast says the cherry blossoms may finally be open.
@@ -0,0 +1,23 @@
---
title: 'Sakura in Ueno Park'
date: '2026-03-26 10:00'
template: entry
published: true
hero_image: ''
lat: '35.7155'
lng: '139.7753'
location_city: 'Tokyo'
location_country: 'Japan'
weather_temp_c: 14
weather_desc: 'Partly cloudy'
---
I arrived at Ueno Park at ten in the morning thinking I would beat the crowds. I was wrong. Several thousand people had the same idea, and the same Instagram instinct. But here is the thing about cherry blossom season in Japan — the crowds are almost part of it. Families with picnic sheets. Couples with matching outfits. Office workers in suits sitting on blue tarps eating convenience-store onigiri. Everyone doing the same thing: looking up at the same trees.
The blossoms were at maybe seventy percent. Enough to understand what the fuss is about.
I walked the park from one end to the other and then sat under a particularly generous tree for about an hour just watching people react to something beautiful. There is a Japanese word for it — *hanami* — which translates roughly as "flower viewing" and is more or less an entire cultural practice. You do not rush past the blossoms. You sit with them.
Later I found the Tokyo National Museum at the top of the park. Three floors of Japanese history, almost entirely in Japanese, which I cannot read, but context is its own language. A display case of Edo-period swords. Painted screens showing mountains I now recognise. A reconstructed tea house in the garden, closed for the season but visible through the glass.
Dinner: tonkatsu on a side street off Ueno-Okachimachi station. The woman who runs the counter has been there for at least thirty years by the look of it. She refilled my miso soup without being asked, twice.
@@ -0,0 +1,27 @@
---
title: 'Summit Clouds and Snow'
date: '2026-03-27 07:15'
template: entry
published: true
hero_image: ''
lat: '35.5095'
lng: '138.7646'
location_city: 'Kawaguchiko'
location_country: 'Japan'
weather_temp_c: 1
weather_desc: 'Snow'
---
Nobody told me it would snow.
I took the early bus from Shinjuku at 6:45am because the forecast for the Fuji Five Lakes region said "clear morning, clouds by noon." That is the window you want — Fuji is notorious for hiding inside its own weather system, and most visitors spend an entire day staring at a blank white sky where a mountain ought to be.
I got the mountain. For about forty minutes.
By the time the bus pulled into Kawaguchiko, the first flakes were already coming down. Light at first — the decorative kind that you hold your hand out for. Then, steadily, not decorative at all. I walked down to the lake with my bag under my jacket and stood at the water's edge while the snow thickened and Fuji turned from a sharply defined white cone into a suggestion, and then into nothing.
The lake surface was perfectly still. The snow fell straight down. There were no other tourists on the path, or if there were I could not see them. It was one of those moments of completely accidental solitude that you cannot plan for and would not trade.
I sat on a wooden bench on the lakefront for longer than made any meteorological sense. The snow kept falling. A single cormorant sat on a rock offshore and did not move the entire time I was there.
Caught the bus back to Shinjuku in the afternoon. The mountain never reappeared. I do not mind even slightly.
@@ -0,0 +1,23 @@
---
title: 'A Thousand Torii Gates'
date: '2026-03-28 11:30'
template: entry
published: true
hero_image: ''
lat: '34.9671'
lng: '135.7727'
location_city: 'Kyoto'
location_country: 'Japan'
weather_temp_c: 18
weather_desc: 'Sunny'
---
The Shinkansen from Tokyo to Kyoto takes two hours and twelve minutes. You travel at 285km/h. At one point Fuji appears out the right-hand window, clear and enormous and completely snow-covered, and the entire carriage rotates slightly to look at it. The mountain is visible for about four minutes. Then it is gone.
Kyoto is everything Tokyo is not: low, slow, wooden. The streets around Fushimi Inari were already warm with tourists at 11am but the shrine itself is large enough to absorb them. You walk under a tunnel of orange torii gates — thousands of them, each donated by a business and engraved with the donor's name — up a hillside through cedar forest, and the further you climb the more the crowd thins out.
I walked for two hours. Most visitors turn back at the first lookout. I kept going, past smaller shrines and stone fox statues and mossy steps worn down by a century of feet. Near the top the path was almost empty. The air smelled of pine and incense.
The city below spread out in all directions. Very few tall buildings — there are strict height regulations to preserve the sightlines. The Kamo River was a thin silver line running south. Distant mountains still wearing snow.
Dinner at a kaiseki restaurant in Gion, the old entertainment district. Eight small courses, each plated like a small still life. I ate slowly and said nothing and it was the right approach.
@@ -0,0 +1,21 @@
---
title: 'The Deer of Nara'
date: '2026-03-29 14:00'
template: entry
published: true
hero_image: ''
lat: '34.6851'
lng: '135.8048'
location_city: 'Nara'
location_country: 'Japan'
weather_temp_c: 17
weather_desc: 'Partly cloudy'
---
The deer at Nara are not afraid of you. This is the first thing you notice — not just that they tolerate humans, but that they regard you with a kind of benign indifference that borders on contempt. They walk into traffic. They push their noses into your pockets. They bow, which sounds enchanting and is, in practice, a manoeuvre to knock crackers out of your hand faster.
I bought a small bundle of *shika senbei* — deer crackers — from a vendor at the park entrance. They were gone in about forty-five seconds to a small gang of deer who appeared from nowhere and surrounded me in a tight semicircle. One bit my sleeve. Another headbutted a woman walking past who was not even involved.
Todai-ji temple is at the far end of the park and contains the largest bronze Buddha in Japan. The building is immense — apparently it was rebuilt at two-thirds the original size in the 18th century and is still the largest wooden structure in the world. The Buddha sits in the dim interior looking calm about this. There is a wooden pillar near the back with a hole cut through its base the same width as one of the Buddha's nostrils. Schoolchildren queue to crawl through it. Wisdom awaits on the other side.
The train back to Kyoto takes 45 minutes through flat agricultural land. The deer do not follow you.
@@ -0,0 +1,25 @@
---
title: 'Dotonbori After Dark'
date: '2026-03-30 18:00'
template: entry
published: true
hero_image: ''
lat: '34.6687'
lng: '135.5017'
location_city: 'Osaka'
location_country: 'Japan'
weather_temp_c: 19
weather_desc: 'Cloudy'
---
Osaka is louder than Kyoto and prouder of it. Kyoto has temples and restraint. Osaka has neon and takoyaki and a sign the size of a building advertising a restaurant with a mechanical crab on the front. Both are correct.
I arrived from Kyoto mid-afternoon, dropped my bag, and went directly to Dotonbori to get my bearings before the evening crowd descended. The canal runs through the entertainment district, and on both sides there are restaurants stacked six floors high with illuminated signs competing for your attention so aggressively that after ten minutes you start to tune out the sensory overload and just walk.
At six in the evening the neon started properly. The famous running man billboard. The Glico sign. Streets full of people eating while walking — takoyaki (octopus balls, better than they sound), skewered meats, cones of spicy shrimp. Osaka has a word for its own food philosophy: *kuidaore*, which means "eat until you drop."
I took it as guidance.
Three hours of eating across four separate establishments. Kushikatsu — battered and deep-fried everything — at a counter in an alley so narrow that diners on opposite sides can shake hands across the table. Soft-serve matcha ice cream on the street. Okonomiyaki from a woman who pressed the pancake flat with a heavy iron tool and would not let me touch anything.
The canal was dark and the lights were reflected in it and for a while I just stood on the bridge watching people eat.
@@ -0,0 +1,23 @@
---
title: 'Seoul Calling'
date: '2026-04-01 09:00'
template: entry
published: true
hero_image: ''
lat: '37.5635'
lng: '126.9851'
location_city: 'Seoul'
location_country: 'South Korea'
weather_temp_c: 10
weather_desc: 'Rain'
---
The flight from Osaka to Seoul takes one hour and forty minutes. Shorter than some commutes I have had. At Incheon I changed SIM cards, changed currency, changed alphabet, and walked out into a grey April morning with rain coming in off the Yellow Sea.
Korea hits differently than Japan. Japan felt deliberate and enclosed, every surface managed, every system timed to the second. Seoul feels faster and more argumentative, as if things are still being decided. The streets around Myeongdong were already busy at 9am: coffee shops the size of ballrooms, street vendors selling *hotteok* (sweet pancakes) from portable griddles, and the particular energy of a city that moves at one speed regardless of the weather.
My guesthouse is in Mapo-gu, a neighbourhood that turns out to be significantly cooler than anywhere the guidebooks sent me. Independent coffee roasters. Record shops. A gallery in a converted printing house showing black-and-white photography of the Han River in the 1970s.
I spent the afternoon walking the Han River itself — a massive green ribbon running through the city with dedicated cycling paths, outdoor fitness equipment, and Koreans doing every possible outdoor activity despite the rain. A group of older men playing badminton with very serious expressions. Two people kayaking. A family of five sharing a communal barbecue under an umbrella.
Dinner: Korean fried chicken at a place that opened at 5pm and was full by 5:05. Beer so cold it was almost painful. Outside, the rain kept up steadily. I stayed longer than I meant to.
+368
View File
@@ -0,0 +1,368 @@
# Into the East — Design Spec
**Date:** 2026-06-18
**Status:** Approved for implementation
---
## 1. Direction
**The brief:** A personal travel journal, sole author, trip to East Asia. Three weeks to implement before departure. Audience is both friends/family and the occasional curious stranger.
**The position:** Neither Polarsteps nor FindPenguins. Both optimize for social sharing of travel data. This site optimizes for **the story** — and should feel like reading a well-edited travel journal, not using an app.
**What we steal from each:**
- Polarsteps: photography-first hierarchy, airy whitespace, map as the emotional spine of the trip
- FindPenguins: typography as brand identity, stats as trophy case, hierarchical trip → entry structure
**What we do better than both:**
- Web-native: fast, linkable, no install, works on any browser
- Single author = pure editorial voice, no social noise
- Full CSS control = real typographic identity, not generic app chrome
- Editorial feel: more travel magazine, less productivity dashboard
**Aesthetic direction:** Field notes. The kind of journal a thoughtful traveler would carry — clean, direct, lets the photography speak. Sophisticated without effort.
**The one aesthetic risk:** Full-bleed hero photography with a translucent date+location overlay at the bottom of each card. The photo IS the entry card — not a thumbnail beside text. This is the single element that distinguishes this design from both reference apps and from typical blog layouts.
---
## 2. Color System
### Palette
| Token | Hex | Usage |
|---|---|---|
| `--color-ink` | `#17171A` | Primary text (near-black with cool undertone, like ink) |
| `--color-ink-2` | `#4A4850` | Secondary text, body paragraphs |
| `--color-ink-muted` | `#9896A0` | Labels, timestamps, captions, placeholder text |
| `--color-paper` | `#F7F5F2` | Page background (warm paper white, not blue-white) |
| `--color-canvas` | `#FFFFFF` | Card backgrounds, modals, form surfaces |
| `--color-border` | `#E8E6E3` | Standard dividers, card borders |
| `--color-border-soft` | `#F0EDEA` | Subtle section dividers |
| `--color-accent` | `#1F6B5A` | Deep teal — brand color, links, CTAs, active states |
| `--color-accent-hover` | `#185647` | Darkened accent for hover/pressed states |
| `--color-accent-light` | `#EBF5F2` | Pale teal for highlight backgrounds |
| `--color-accent-on` | `#FFFFFF` | Text on accent-colored surfaces |
### Rationale for accent color
Deep teal `#1F6B5A` was chosen over:
- Blue (#0066cc current): too generic, too tech
- Orange/saffron: clichéd for "Asia" travel design
- Terracotta/cream: the most common default for lifestyle/travel blogs
Teal evokes bamboo, celadon porcelain, ancient jade, the color of temple gardens — all without being literal or kitsch. It works cleanly against both the warm paper background and white card surfaces.
---
## 3. Typography
### Fonts
| Role | Family | Fallback | Source |
|---|---|---|---|
| Display / Headings | DM Serif Display | Georgia, serif | Google Fonts |
| UI / Body / Labels | DM Sans | -apple-system, BlinkMacSystemFont, sans-serif | Google Fonts |
**Google Fonts URL:**
```
https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,400;0,9..40,500;0,9..40,600;1,9..40,400&family=DM+Serif+Display:ital@0;1&display=swap
```
**Why this pairing:**
DM Serif Display has a calligraphic quality — slightly editorial, authoritative but not stiff. Paired with DM Sans (its designed companion) the system is cohesive. DM Sans is neutral and highly legible at all sizes. Both are under-used relative to Inter/Lato/Playfair, so the combination has a distinctive voice without being trendy.
### Type Scale
| Token | Size | Line Height | Usage |
|---|---|---|---|
| `--text-xs` | 0.75rem (12px) | 1.5 | Badges, captions |
| `--text-sm` | 0.875rem (14px) | 1.5 | Meta, timestamps, labels |
| `--text-base` | 1rem (16px) | 1.65 | Body paragraphs |
| `--text-md` | 1.125rem (18px) | 1.55 | Lead text, intro paragraphs |
| `--text-lg` | 1.375rem (22px) | 1.35 | Subheadings, card titles (mobile) |
| `--text-xl` | 1.75rem (28px) | 1.25 | Entry card titles |
| `--text-2xl` | 2.25rem (36px) | 1.2 | Page headings, entry titles (desktop) |
| `--text-3xl` | 3rem (48px) | 1.1 | Hero entry title |
### Usage rules
- Entry titles: `--font-display`, `--text-xl` (mobile) / `--text-2xl` (desktop)
- Site title in header: `--font-display`, `--text-lg`
- All other UI text: `--font-ui`
- Body paragraphs: `--font-ui`, `--text-base`, `--leading-normal`
- Timestamps/badges: `--font-ui`, `--text-xs`, uppercase, `letter-spacing: 0.07em`
---
## 4. Spacing & Layout
### Spacing scale (4px base unit)
| Token | Value |
|---|---|
| `--space-1` | 0.25rem (4px) |
| `--space-2` | 0.5rem (8px) |
| `--space-3` | 0.75rem (12px) |
| `--space-4` | 1rem (16px) |
| `--space-5` | 1.25rem (20px) |
| `--space-6` | 1.5rem (24px) |
| `--space-8` | 2rem (32px) |
| `--space-10` | 2.5rem (40px) |
| `--space-12` | 3rem (48px) |
| `--space-16` | 4rem (64px) |
### Layout
- Content max-width: `720px` (comfortable reading at any font size)
- Page horizontal padding: `1.25rem` (mobile), `1.5rem` (desktop ≥520px)
- Header height: `60px` (fixed, for JS offset calculations)
- Map page: full viewport, no content max-width constraint
### Border radius
| Token | Value | Usage |
|---|---|---|
| `--radius-sm` | 4px | Photo corners, small chips |
| `--radius-md` | 8px | Cards, buttons, inputs |
| `--radius-lg` | 12px | Large cards, modals |
| `--radius-full` | 9999px | Pills, badges |
### Shadows
| Token | Value | Usage |
|---|---|---|
| `--shadow-sm` | `0 1px 3px rgba(0,0,0,0.08)` | Stat blocks, subtle elevation |
| `--shadow-md` | `0 4px 12px rgba(0,0,0,0.10)` | Cards on hover, dropdowns |
| `--shadow-lg` | `0 8px 24px rgba(0,0,0,0.14)` | Lightbox, modals |
---
## 5. Component Inventory
### 5.1 Site Header
```
[ into the east ] [ Journal Map Stats ]
← accent bar across top (3px) ───────────────────────────────
```
- Top border: `3px solid var(--color-accent)` — thin accent bar signals the brand color without decorating
- Site title: DM Serif Display, `--text-lg`, no decoration
- Nav links: DM Sans, `--text-sm`, weight 500, `--color-ink-2`
- Active nav link: `--color-accent`, weight 600
- Mobile: same layout, title slightly smaller, nav links compact
- Background: `--color-canvas` (white), bottom border `1px solid var(--color-border)`
### 5.2 Entry Feed Card — With Photo
```
┌─────────────────────────────────────┐
│ │
│ [photo] │ ← full-width, 16:9, rounded corners
│ │
│ 18 JUN · 📍 Kyoto, Japan │ ← overlaid at bottom, gradient mask
└─────────────────────────────────────┘
Arrived in Tokyo ← DM Serif Display, --text-xl
After 14 hours of flying I finally ← body excerpt, --color-ink-2
set foot on Japanese soil...
Read entry → ← --color-accent, --text-sm
```
- Photo: `aspect-ratio: 16/9`, `object-fit: cover`, `border-radius: var(--radius-md)`
- Photo has a `linear-gradient(to top, rgba(0,0,0,0.55), transparent)` overlay at the bottom 40%
- Date + location sit on top of gradient in white text (`rgba(255,255,255,0.92)`)
- On hover: photo scales to 1.03 (subtle zoom, 0.4s ease)
- Title below photo: DM Serif Display, hover turns `--color-accent`
- Card separation: `padding-bottom: var(--space-12)` + `border-bottom: 1px solid var(--color-border)`
### 5.3 Entry Feed Card — No Photo
When no photo is available, fall back to a text-only layout:
```
18 JUN 2026 · 📍 Kyoto, Japan ← meta row, --text-sm, --color-ink-muted
Arrived in Tokyo ← DM Serif Display, --text-xl
After 14 hours of flying...
Read entry →
```
- No photo container
- Meta (date + location) on one line above title, small + muted
### 5.4 Single Entry Page
```
Wednesday, 18 June 2026 ← --text-sm, --color-ink-muted, uppercase
📍 Kyoto, Japan · ⛅ Partly cloudy · 22°C
Arrived in Tokyo ← DM Serif Display, --text-2xl / --text-3xl
─────────────────────────────────────
Body text content... ← --font-ui, --text-base/md
[Photo gallery — 2 or 3 col grid]
← Back to journal
```
- The entry title uses `--font-display` at largest scale
- A thin `--color-border` rule separates the header from the body
- Body text is `--text-md` (18px) for comfortable long-form reading
- Full-bleed hero option: if a `hero_image` is set, it spans the full content width with a bottom margin
### 5.5 Post Form (Author View)
```
New Entry
Title * [________________________]
Date & Time [2026-06-18 14:30 ]
What happened [ ]
today? [ ]
[ ]
Photos [ + Add photos (max 4) ]
City [________________________]
Country [________________________]
[ 📍 Get Location ] [ 🌤 Get Weather ]
✓ Location captured: Kyoto, Japan ← status line
[ Post Entry ]
```
UX changes from current:
- Lat/lng inputs **hidden from the UI** (remain in the form as `display:none` for data capture, filled by JS)
- Location status shows captured city/country + coordinates in a single line (not separate status paragraphs)
- Photo upload area: larger touch target, visual indication of count
- "Post Entry" button: `--color-accent` background, full-width on mobile, `min-height: 52px`
- Form fields: `--radius-md` corners, `--color-border` border, focus ring in `--color-accent`
- Section spacing: generous vertical rhythm on mobile
### 5.6 Stats Page
```
┌────────────┐ ┌────────────┐
│ 42 │ │ 18 │
│ days on │ │ entries │
│ the road │ │ posted │
└────────────┘ └────────────┘
┌────────────┐ ┌────────────┐
│ 6 │ │ ~14,200 │
│ countries │ │ km │
│ visited │ │ traveled │
└────────────┘ └────────────┘
Countries visited
Japan · South Korea · Mongolia · Russia · Finland · Estonia
```
- Numbers: `--font-display`, `--text-3xl`, `--color-accent`
- Labels: `--font-ui`, `--text-xs`, uppercase, `--color-ink-muted`
- Cards: white, `--shadow-sm`, `--radius-md`, centered
### 5.7 Map Page
Minimal changes — the map itself is good. Style improvements:
- Leaflet popups: match the new design (DM Sans, `--radius-md`, `--shadow-md`)
- Markers: keep current circle style, update color to `--color-accent`
- Feed mini-map wrapper: match `--radius-md`, `--border`
---
## 6. UX Flows
### 6.1 Reader — First Visit
1. Land on `/tracker` (journal feed)
2. See mini-map above fold (if entries exist) — route tells the geographic story at a glance
3. First entry card: full-bleed hero photo with date/location overlay — immediate emotional pull
4. Scroll through chronological entries
5. Tap/click entry → entry detail page
6. Navigate back via "← Back to journal"
**Key principle:** The reader should understand the journey spatially (mini-map) and emotionally (hero photo) before reading a single word.
### 6.2 Reader — Navigation
- Journal: primary destination, the feed
- Map: geographic exploration mode
- Stats: quick numbers, satisfying progress indicator
- No account required, no social friction, no login prompt for readers
### 6.3 Author — Posting from Mobile
1. Navigate to `/post` (bookmark on home screen)
2. Already logged in (Grav session persists) — form loads directly
3. **Title**: tap → type (autofocused)
4. **Date & Time**: auto-filled to now, adjust if needed
5. **Content**: write what happened
6. **Photos**: tap "Add photos" → camera or gallery → select up to 4
7. **Location**: tap "📍 Get Location" → GPS fires → status shows "Kyoto, Japan · 34.985, 135.758" in one line
8. **Weather**: tap "🌤 Get Weather" (works only if location was captured) → status shows "Partly cloudy · 22°C"
9. **City/Country**: auto-populated from GPS is a nice-to-have for v2; in v1 type manually if needed
10. Tap "Post Entry" → success message → 2-second pause → redirect to /tracker (new entry visible at top)
**Key principles:**
- One-thumb operation for all critical actions on mobile
- Location/weather are conveniences, not blockers — can skip both
- Visual feedback is immediate (status line updates on GPS response)
- After submit: don't leave author on a success message page; redirect to see their new post
---
## 7. Mobile Specifics
### Touch targets
- All interactive elements: `min-height: 44px`, `min-width: 44px` (Apple HIG standard)
- Form buttons: `min-height: 52px` on the post form (primary CTA)
- Nav links: `padding: 0.5rem 0.75rem`
### Viewport concerns
- Map page: `height: calc(100vh - 60px)`, `touch-action: none` on map container — prevents scroll trap
- Photo lightbox: full viewport overlay, swipe-friendly (keyboard + click already implemented)
- Form on mobile: single-column, generous input padding `0.875rem 1rem`, `font-size: 1rem` (prevents iOS zoom on focus)
### Performance
- Google Fonts: loaded with `preconnect` hints
- Images: `loading="lazy"` on all non-above-fold images (already in place)
- Leaflet: loaded from CDN, only on pages that need it
- No new JS frameworks — vanilla JS throughout
---
## 8. Tech Stack Decision
**Keep Grav CMS.** With a 3-week timeline, replacing it would consume all available time on migration rather than design improvements.
| Layer | Decision | Rationale |
|---|---|---|
| Backend | Grav CMS (PHP, Twig) — unchanged | Works, flat-file, no DB |
| CSS | Vanilla CSS + custom properties (design tokens) | No build step, full control, ships as one file |
| JS | Vanilla JS — unchanged | Current JS is well-structured, scope doesn't justify a framework |
| Icons | Unicode + emoji (current) | No dependency, works everywhere |
| Fonts | Google Fonts via CDN | Two fonts, display-swap, negligible impact |
| Maps | Leaflet.js (current) | Already in use, no reason to change |
| Build | None — no build pipeline | Grav's asset pipeline handles minification if needed |
**No Alpine.js, no TypeScript, no Tailwind.** The site has clean vanilla JS and CSS today; a redesign is about visual quality, not framework migration. Introducing a build pipeline on a 3-week timeline is a distraction.
---
## 9. What Changes From Current Design
| Area | Current | New |
|---|---|---|
| Typography | System sans-serif only | DM Serif Display for headings + DM Sans for UI |
| Accent color | `#0066cc` (generic blue) | `#1F6B5A` (deep teal) |
| Background | `#ffffff` (pure white) | `#F7F5F2` (warm paper) |
| Entry cards | Thumbnail + text below | Full-bleed 16:9 photo with overlay |
| Header | No visual identity | Accent top-border, typographic title |
| Design tokens | Hardcoded values throughout | CSS custom properties throughout |
| Post form | Lat/lng visible inputs | Lat/lng hidden, single status line |
| Font loading | None | Google Fonts DM pairing |
| Hover states | Minimal | Photo zoom, title color change |
| Stat numbers | `#0066cc` | `--color-accent` (#1F6B5A) |
+193
View File
@@ -0,0 +1,193 @@
# Milestone 1 Spec — Entry Enrichment
**Goal:** Every entry is richer out of the box — location name shown, weather auto-captured, photos in a proper gallery, hero image visible on the feed.
---
## User Stories
- As a traveler (Mischa), when I submit the post form, I want my current weather conditions auto-filled so I don't have to look them up manually.
- As a traveler, I want to type my city and country once and have it appear on the entry and in the feed card, so readers know where I am without reading the whole post.
- As a reader, when I scan the feed, I want to see a thumbnail photo and location for each entry so I can quickly get a sense of where Mischa is and whether to read the full entry.
- As a reader, when I open an entry, I want to see all uploaded photos in a gallery I can browse, not a wall of raw images.
- As a traveler, when I submit a form without photos, the entry should still display cleanly with no broken image placeholders.
---
## Feature Details
### 1.1 — Location Name Field on Post Form
**What:** Add two text fields to the post form: `location_city` and `location_country`.
**Behavior:**
- Both are optional (GPS coordinates are also optional)
- Placeholder text: "e.g. Kyoto" and "e.g. Japan"
- Displayed below the lat/lng fields
- On submit, stored in entry frontmatter as `location_city` and `location_country`
- On the form, shown as a single labeled group "Location Name" with two side-by-side inputs on desktop, stacked on mobile
**Edge cases:**
- If left blank: entry shows no location badge. No error, no broken UI.
- Long city names (e.g. "Ulaanbaatar") must not overflow card layout.
- Special characters (accents, non-Latin) must render correctly.
**Mobile behavior:** Both fields full-width, stacked, 44px min touch targets.
---
### 1.2 — Weather Auto-Fetch on Post Form
**What:** A "Get Weather" button on the post form that calls the Open-Meteo free API (no API key) using the lat/lng already entered, and fills hidden weather fields.
**Fields to fetch and store:**
- `weather_temp_c` — temperature in Celsius (integer)
- `weather_desc` — short description: one of: Sunny, Partly cloudy, Cloudy, Foggy, Drizzle, Rain, Snow, Thunderstorm (derived from WMO weather code)
**WMO code mapping (Open-Meteo uses WMO codes):**
- 0 → Sunny
- 1,2 → Partly cloudy
- 3 → Cloudy
- 45,48 → Foggy
- 51,53,55,56,57 → Drizzle
- 61,63,65,66,67,80,81,82 → Rain
- 71,73,75,77,85,86 → Snow
- 95,96,99 → Thunderstorm
**API call:**
```
https://api.open-meteo.com/v1/forecast?latitude={lat}&longitude={lng}&current=temperature_2m,weather_code&temperature_unit=celsius
```
**UX flow:**
1. User fills in lat/lng (manually or via "Get Location" button)
2. User taps "Get Weather" button
3. Button shows "Fetching…" while loading
4. On success: fills temp and desc fields (visible, editable text inputs)
5. On failure (no network, no lat/lng): shows inline error "Could not fetch weather — enter manually"
**Edge cases:**
- If lat/lng not filled when button tapped: show inline error "Enter coordinates first"
- Weather fields are always editable manually (auto-fill is a convenience, not mandatory)
- If weather fields left blank: entry shows no weather badge. No broken UI.
- Open-Meteo returns current conditions, not historical — this is fine for posting in real time
**Mobile behavior:** "Get Weather" button is full-width, 44px height, placed immediately below the lat/lng + location name fields.
---
### 1.3 — Weather Display on Entry Page
**What:** If `weather_temp_c` or `weather_desc` is present in frontmatter, display a weather badge on the entry page.
**Display format:** `☀️ Sunny · 28°C` (icon + description + temperature)
- Icon chosen from a small set based on `weather_desc`:
- Sunny → ☀️
- Partly cloudy → ⛅
- Cloudy → ☁️
- Foggy → 🌫️
- Drizzle → 🌦️
- Rain → 🌧️
- Snow → ❄️
- Thunderstorm → ⛈️
**Placement:** In the entry header, between the date and the body text. Same line as GPS coordinates if those are shown.
**Edge cases:**
- Only temp, no desc → show temp only
- Only desc, no temp → show desc only
- Neither → hide weather section entirely
- Temperature should always be integer (round if float)
---
### 1.4 — Location Badge on Feed Cards and Entry Page
**What:** Display `location_city, location_country` as a small badge on tracker feed cards and at the top of entry pages.
**Feed card:** Below the date, above the excerpt. Format: `📍 Kyoto, Japan`
**Entry page:** In the header below the date, above the content. Format: `📍 Kyoto, Japan`
**Edge cases:**
- Only city, no country → `📍 Kyoto`
- Only country, no city → `📍 Japan`
- Neither → location badge hidden entirely
- Long location names: truncate with ellipsis at 30 chars on cards (full text on entry page)
---
### 1.5 — Photo Gallery on Entry Page
**What:** Photos uploaded to an entry should display in a responsive grid gallery with lightbox (click to enlarge).
**Implementation approach:** Use Grav's native media collection for the entry page. Each `.entry` folder contains its photos. Render them in a grid in `entry.html.twig`. Use a minimal vanilla JS lightbox — no external framework.
**Gallery behavior:**
- Photos displayed in a 2-column grid on mobile, 3-column on desktop
- Each thumbnail is square-cropped, 150px on mobile
- Clicking/tapping a thumbnail opens a lightbox overlay
- Lightbox: dark overlay, full-size image centered, tap/click outside or press Escape to close
- Left/right navigation arrows in lightbox (swipe on mobile)
- No captions needed for v1
**Edge cases:**
- 0 photos: gallery section hidden entirely
- 1 photo: still uses grid (single item), lightbox works
- Many photos (>10): gallery still renders (no hard limit on display)
- Non-image files in the media folder: skip them (only render jpg, jpeg, png, webp, gif)
---
### 1.6 — Hero Image on Tracker Feed Cards
**What:** If an entry has photos, the first photo (or the one named in `hero_image` frontmatter) appears as a thumbnail on the tracker feed card.
**Implementation:** In `tracker.html.twig`, for each entry:
1. If `entry.header.hero_image` is set, use `entry.media[entry.header.hero_image]`
2. Else, use the first image in `entry.media` sorted by name
3. Render as a 16:9 aspect-ratio thumbnail, full width of card, above the title
**Edge cases:**
- No photos: card shows no image, just text. No broken `<img>` tag.
- `hero_image` set but file missing: fall back to first media file, or no image
- Very tall/wide images: CSS `object-fit: cover` maintains card aspect ratio
---
## Out of Scope (Milestone 1)
- Map features (Milestone 2)
- Statistics page (Milestone 3)
- Video support
- Comments or reactions
- Automated reverse geocoding (city name comes from form input, not auto-detected)
- Altitude display (data may not be present)
- Historical weather (Open-Meteo current endpoint only)
---
## Acceptance Criteria
1. Post form has `location_city` and `location_country` fields that save to entry frontmatter
2. Post form has "Get Weather" button that fills `weather_temp_c` and `weather_desc` via Open-Meteo when lat/lng are provided
3. Entry page shows weather badge when weather fields are present; hidden when absent
4. Entry page shows location badge `📍 City, Country` when location fields are present; hidden when absent
5. Tracker feed card shows location badge when present
6. Tracker feed card shows a hero image when photos exist for an entry
7. Entry page shows a 2-col (mobile) / 3-col (desktop) photo grid
8. Clicking any photo opens a full-screen lightbox with prev/next navigation
9. Pressing Escape or clicking outside lightbox closes it
10. All fields are optional — empty values produce no broken UI elements
11. All interactive elements meet 44px minimum touch target on mobile
12. Form submits correctly with all new fields populated or all blank
---
## Design Notes
- Weather and location badges should be subtle — small text, muted color, not the visual focus
- Use emoji icons for weather — universal, no icon font dependency
- Gallery grid: `gap: 4px` between thumbs, no borders, square crops
- Lightbox: `background: rgba(0,0,0,0.92)`, image centered with `max-height: 90vh`
- Feed card image: `aspect-ratio: 16/9`, `object-fit: cover`, rounded top corners matching card
+166
View File
@@ -0,0 +1,166 @@
# Milestone 2 Spec — Interactive Map
**Goal:** A `/map` page shows all entries as markers on an interactive Leaflet.js map, connected by a chronological route line, with popups linking to entries.
---
## User Stories
- As a reader, I want to see a world map showing where Mischa has been so I can understand the journey at a glance without reading every entry.
- As a reader, I want to click a map marker and see the entry date, title, and a thumbnail — and be able to click through to the full entry.
- As a reader on mobile, I want to pan and pinch-zoom the map with my fingers without the page scrolling underneath.
- As a traveler (Mischa), I want the map to automatically include every entry that has lat/lng data — I should not need to do any manual map maintenance.
- As a reader, I want the map to show the route line connecting stops in the order they were visited, so the journey makes narrative sense.
---
## Feature Details
### 2.1 — Map Page
**Route:** `/map`
**Template:** `map.html.twig` — extends `partials/base.html.twig`
**Page file:** `user/pages/03.map/map.md`
**Content:**
- Full-viewport-height map container below the site header
- Leaflet.js loaded from CDN (jsDelivr): `https://cdn.jsdelivr.net/npm/leaflet@1.9.4/dist/leaflet.min.js`
- Leaflet CSS from same CDN
- Tile layer: OpenStreetMap (free, no API key): `https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png`
- Attribution: "© OpenStreetMap contributors"
**Map initialization:**
- Default zoom: auto-fit to bounds of all markers (use `map.fitBounds()`)
- If no entries with GPS data: show world view, zoom 2, centered at 0,0 with a message "No locations yet"
- Min zoom: 2, Max zoom: 18
---
### 2.2 — Entry Data Serialization
**How entries reach the map JS:**
In `map.html.twig`, Grav's Twig will iterate all published entries under `/tracker` and serialize them to a JSON array embedded in a `<script>` tag:
```js
var ENTRIES = [
{
"lat": 48.8566,
"lng": 2.3522,
"title": "Paris morning",
"date": "2026-06-18",
"url": "/tracker/2026-06-18",
"hero": "/path/to/thumb.jpg" // null if no photo
},
...
];
```
**Only entries with valid lat AND lng are included** (skip entries where either is empty/null).
Entries sorted ascending by date (oldest first) so the route line is drawn in travel order.
---
### 2.3 — Route Polyline
**What:** A colored line drawn between entry markers in chronological order.
**Style:**
- Color: `#0066cc` (brand blue, matches existing CSS)
- Weight: 3px
- Opacity: 0.7
- No arrow heads for v1
**Behavior:**
- Line drawn between consecutive entries (by date) that have valid GPS
- If only 1 entry: no line (just a single marker)
- If two consecutive entries are very far apart (>5000km): line still drawn — it's a flight, expected
---
### 2.4 — Entry Markers
**What:** One circular marker per entry with GPS coordinates.
**Marker design:**
- Custom circular marker (not default Leaflet teardrop)
- Color: `#0066cc` fill, white border, 2px border
- Size: 12px diameter on mobile, 14px on desktop
- Most recent entry: larger (18px) and brighter color to indicate "current location"
**Popup on click/tap:**
```
[thumbnail if available — 120px wide, 80px tall, cover cropped]
📅 18 June 2026
Paris morning
[Read entry →]
```
- Popup width: 180px max
- "Read entry →" links to the entry page
- Tapping outside popup closes it
**Edge cases:**
- Two entries at the same lat/lng: Leaflet clusters or offsets them slightly (use small offset to prevent exact overlap — just add 0.0001° offset per duplicate)
- Entry with GPS but no photo: popup shows no image, just date + title + link
---
### 2.5 — Mobile Map UX
**Problem:** On mobile, a map inside a scrollable page creates a scroll-trap (finger intended for page scroll gets captured by map pan).
**Solution:**
- Map container is `height: calc(100vh - 60px)` (full viewport minus header)
- Map is the primary content of the page — no scroll needed
- `touch-action: none` on the map container prevents page scroll interference
- Leaflet handles touch pan/zoom natively
---
### 2.6 — Navigation Link
**What:** "Map" link added to the site header navigation.
**Where:** `partials/base.html.twig` nav section — add `<a href="{{ base_url_absolute }}/map">Map</a>`
---
## Out of Scope (Milestone 2)
- Filtering markers by date range
- Clustering markers at low zoom levels
- Heatmap or density visualization
- Showing the route on the tracker feed page (Milestone 4)
- Showing elevation profile
- Country highlight/fill on the map
- Offline map tiles
---
## Acceptance Criteria
1. `/map` page exists and returns HTTP 200
2. Page renders a full-height interactive map
3. All published entries with valid lat/lng appear as markers
4. Markers are connected by a route line in date order
5. Clicking/tapping a marker shows a popup with date, title, and link
6. Popup link navigates to the correct entry page
7. Most recent entry marker is visually distinct (larger/brighter)
8. If no entries have GPS: map renders at world zoom with "No locations yet" message
9. Map is pannable and zoomable by touch on mobile
10. "Map" link appears in site navigation and routes to `/map`
11. Map auto-fits to show all markers on page load
12. Entries without lat/lng are silently excluded (no JS errors)
---
## Design Notes
- Map tile layer: OpenStreetMap default tiles. Clean, recognizable, free.
- Keep the Grav site header visible above the map — don't go full-screen (users need the nav)
- Popup design: minimal. White background, slight box-shadow, 8px border-radius
- Do not use any Leaflet plugins beyond the core library — keep the dependency footprint tiny
- The map page should load fast: Leaflet is ~42KB gzipped. Tile images load progressively. No blocking.
+182
View File
@@ -0,0 +1,182 @@
# Milestone 3 Spec — Statistics Page
**Goal:** A `/stats` page showing key trip numbers: days on the road, entries posted, countries visited, and approximate distance traveled.
---
## User Stories
- As a reader, I want to see a quick summary of how far Mischa has traveled and how many countries they've visited, without having to read every entry.
- As a traveler (Mischa), I want to see my own trip stats at a glance — a satisfying progress indicator while traveling.
- As a reader, I want stats that update automatically as new entries are posted — no manual maintenance.
---
## Feature Details
### 3.1 — Stats Page
**Route:** `/stats`
**Template:** `stats.html.twig` — extends `partials/base.html.twig`
**Page file:** `user/pages/04.stats/stats.md`
**Computed in Twig** (server-side, from published entries under `/tracker`):
---
### 3.2 — Stat: Days on the Road
**Definition:** Number of calendar days from the date of the first published entry to today.
**Formula (Twig):**
```twig
{% set first_entry = entries|first %}
{% set days = (now.timestamp - first_entry.date|date('U'))|round / 86400 %}
{% set days_on_road = [days|round(0, 'floor'), 0]|max %}
```
**Display:** `42 days on the road`
**Edge cases:**
- No entries: show `0 days on the road` or `Trip not started yet`
- Only one entry (today): show `1 day on the road`
---
### 3.3 — Stat: Entries Posted
**Definition:** Count of all published entries under `/tracker`.
**Display:** `17 entries posted`
**Edge cases:**
- 0 entries: `0 entries posted`
- 1 entry: `1 entry posted` (singular)
---
### 3.4 — Stat: Countries Visited
**Definition:** Unique values of `location_country` across all published entries, non-empty.
**Display:** Count + list
```
6 countries visited
Japan · South Korea · Mongolia · Russia · Finland · Estonia
```
**Edge cases:**
- No entries have `location_country`: show `Countries: —`
- Some entries missing `location_country`: count only those that have it; note "(based on X of Y entries)"
- Duplicate country names are de-duplicated (case-insensitive)
---
### 3.5 — Stat: Approximate Distance Traveled
**Definition:** Sum of great-circle (haversine) distances between consecutive entries that have valid lat/lng, in ascending date order.
**Implementation:** Computed in Twig using a haversine formula macro.
**Haversine in Twig:**
```twig
{% macro haversine(lat1, lng1, lat2, lng2) %}
{% set R = 6371 %}
{% set dLat = ((lat2 - lat1) * 3.14159265 / 180) %}
{% set dLng = ((lng2 - lng1) * 3.14159265 / 180) %}
{% set a = (dLat/2)|sin * (dLat/2)|sin + (lat1 * 3.14159265 / 180)|cos * (lat2 * 3.14159265 / 180)|cos * (dLng/2)|sin * (dLng/2)|sin %}
{% set c = 2 * a|sqrt|asin %}
{{ (R * c)|round }}
{% endmacro %}
```
Note: Twig does not have `sin`/`cos`/`asin`/`sqrt` built-in. Use a JavaScript-side calculation instead:
**Implementation:** Embed the entry GPS data as JSON in the template (same pattern as Milestone 2), compute distance in vanilla JS, and write the result into the DOM on page load.
```js
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)**2 + Math.cos(lat1*Math.PI/180) * Math.cos(lat2*Math.PI/180) * Math.sin(dLng/2)**2;
return R * 2 * Math.asin(Math.sqrt(a));
}
var total = 0;
for (var i = 1; i < GPS_POINTS.length; i++) {
total += haversine(GPS_POINTS[i-1][0], GPS_POINTS[i-1][1], GPS_POINTS[i][0], GPS_POINTS[i][1]);
}
document.getElementById('stat-distance').textContent = Math.round(total).toLocaleString() + ' km';
```
**Display:** `~3,400 km traveled`
**Edge cases:**
- 0 or 1 GPS points: `Distance: —`
- Very large numbers (trans-continental trip): use thousands separator: `12,400 km`
- Disclaimer note: "approximate — based on straight lines between entry locations"
---
### 3.6 — Visual Layout
**Layout:** 4 large stat blocks in a 2×2 grid on desktop, stacked on mobile.
Each block:
```
┌─────────────────┐
│ 42 │
│ days on road │
└─────────────────┘
```
- Number: large (3rem), bold, brand blue
- Label: small (0.85rem), muted grey
- Background: white, 1px border, 8px radius, subtle shadow
- Mobile: 2-col grid (2 stats per row)
Below the grid: list of countries visited (plain text, centered, muted).
---
### 3.7 — Navigation Link
Add "Stats" to the site navigation in `partials/base.html.twig`.
---
## Out of Scope (Milestone 3)
- Charts or graphs (bar charts, line graphs, etc.)
- World map with highlighted countries (that's a visual enhancement, deferred)
- Per-country breakdown (km in each country, days in each country)
- Speed statistics (km/day average)
- Elevation statistics
- Historical comparison (vs. last trip)
---
## Acceptance Criteria
1. `/stats` page exists and returns HTTP 200
2. "Days on the road" shows correct count from first entry date to today
3. "Entries posted" shows count of published entries
4. "Countries visited" shows correct count + list of unique non-empty `location_country` values
5. "Distance traveled" shows km sum of haversine distances between consecutive GPS entries
6. All four stats display in a 2×2 grid on desktop
7. On mobile (375px), stats stack into a 2-column responsive grid
8. Stats auto-update when new entries are published (no manual maintenance)
9. If no entries: all stats show 0 or `—`, no JS errors
10. "Stats" link in navigation routes to `/stats`
---
## Design Notes
- Stats should feel like a dashboard, not a table — big numbers, small labels
- Do not use any external charting library for v1
- Countries list below the grid: inline, separated by `·`, muted grey
- The "approximate" disclaimer for distance should be in small print below the distance stat
+91
View File
@@ -0,0 +1,91 @@
# Milestone 4 Spec — Mini-Map on Tracker Feed
**Goal:** Embed a compact interactive map above the entry feed on the tracker page, showing recent entry positions and the current location, giving readers immediate spatial context.
---
## User Stories
- As a reader landing on the tracker feed, I want to immediately see where Mischa currently is without having to navigate to the full map page.
- As a reader, I want to click a marker on the mini-map and jump to that entry.
- As a traveler (Mischa), I want the feed page to feel like a live travel dashboard, not just a blog list.
---
## Feature Details
### 4.1 — Mini-Map Placement
**Where:** At the top of `tracker.html.twig`, before the entry card list.
**Height:** 240px on mobile, 320px on desktop.
**Width:** Full width of content column (max 680px).
**Tile layer:** Same OpenStreetMap tiles as Milestone 2.
**No duplicate Leaflet load:** Leaflet is already loaded on the map page; on the tracker page, load it only if needed. Check with `if (typeof L === 'undefined')` before initializing. (In practice, the CSS and JS are loaded unconditionally from the same CDN — caching handles it.)
---
### 4.2 — What's Shown
- **All entries with GPS** shown as small markers (not just recent 10 — the map auto-fits to bounds)
- **Route line** connecting them in chronological order (same style as Milestone 2)
- **Most recent marker** highlighted (larger, brighter)
- **No popups by default** — tapping a marker links directly to the entry (no popup intermediary for the mini-map, keeps it fast)
- Map auto-fits bounds to all markers; if only 1 marker, zoom to 10
---
### 4.3 — Interaction
- Tap/click marker → navigate to entry URL directly
- Map is pannable and zoomable (same touch handling as M2)
- "View full map →" link below the mini-map → navigates to `/map`
---
### 4.4 — Entry Data
Same JSON serialization as Milestone 2 (embed `TRACKER_ENTRIES` in the Twig template). This can reuse the same data variable name if both map and tracker pages use the same template pattern.
---
### 4.5 — Empty State
If no entries have GPS coordinates:
- Mini-map hidden entirely (don't show an empty world map on the feed page)
- Entry list still shows normally
---
## Out of Scope (Milestone 4)
- Clustering markers at low zoom
- Filtering by date
- Satellite/terrain tile layers
- Search on the mini-map
---
## Acceptance Criteria
1. Mini-map appears above entry cards on the tracker feed page
2. All entries with valid lat/lng appear as markers on the mini-map
3. Route line connects markers in date order
4. Most recent marker is visually distinct
5. Clicking/tapping a marker navigates directly to that entry
6. "View full map →" link appears below the mini-map and routes to `/map`
7. If no entries have GPS, mini-map is hidden and entry list shows normally
8. Mini-map is pannable and zoomable by touch on mobile
9. Mini-map does not block page scrolling on mobile (map is fixed height, not full-screen)
---
## Design Notes
- Mini-map border-radius should match the card design (8px)
- Light 1px border or subtle shadow to separate from content
- "View full map →" in small muted text, right-aligned
- Keep the mini-map lightweight: same Leaflet instance, no additional plugins
+161
View File
@@ -0,0 +1,161 @@
# PM Analysis — What to Build (and What to Skip)
*Role: Senior Product Manager. Audience: one solo traveler (Mischa), platform: Grav CMS flat-file PHP, no native app.*
---
## Starting position
Polarsteps and FindPenguins are native mobile apps built around:
1. Background GPS tracking (requires OS-level access)
2. Social networks (followers, discovery, comments)
3. App-side video/reel processing
**None of these three pillars are reproducible in a web CMS.** Any plan that tries to replicate them wholesale is delusional. What we can do is cherry-pick the *outputs* — the things those apps display to readers — and build them into the blog in ways that add real value to both Mischa (the poster) and readers (friends/family following along).
---
## Feature-by-Feature Audit
| Feature | Makes sense solo? | Buildable in Grav+JS? | Value to readers? | Worth the cost? | Decision |
|---|---|---|---|---|---|
| Auto background GPS tracking | No — posting manually anyway | No — requires native app | — | — | **SKIP** |
| Interactive map of visited locations | Yes | Yes — Leaflet.js + frontmatter lat/lng | High | High | **BUILD** |
| Route line on map between entries | Yes | Yes — connect entry coords in order | High | Medium | **BUILD** |
| Entry location name (city, country) | Yes | Yes — manual input on form | High | Low | **BUILD** |
| Weather metadata per entry | Yes | Yes — Open-Meteo free API, no key needed | Medium | Medium | **BUILD** |
| Photo gallery per entry | Yes | Yes — shortcode-gallery-plusplus installed | High | Low | **BUILD** (already partial) |
| Hero image on feed cards | Yes | Yes — already in frontmatter | High | Low | **BUILD** |
| Trip statistics page | Yes | Yes — compute from frontmatter | Medium | Low | **BUILD** |
| Countries visited world map | Yes | Yes — highlight SVG or Leaflet layers | Medium | Medium | **BUILD** |
| Follower system | No — solo blog | Would need auth + DB | None | — | **SKIP** |
| Comments on entries | No — spam risk, no community | Would need plugin + moderation | Minimal | — | **SKIP** |
| Social discovery / explore | No — not a platform | Would need indexing infrastructure | None | — | **SKIP** |
| Group trip / travel buddies | No — solo trip | — | — | — | **SKIP** |
| Reactions / likes | No | — | — | — | **SKIP** |
| 3D flyover video | No — proprietary pipeline | No | Nice | — | **SKIP** |
| Trip reels / short video | No — app-side processing | No | Nice | — | **SKIP** |
| Travel book / print | No — out of scope | No | — | — | **SKIP** |
| AI itinerary builder | No — trip already started | No | — | — | **SKIP** |
| Flight detection | No — requires native app sensors | No | — | — | **SKIP** |
| Delayed sharing / live location | No — blog posts after the fact | Irrelevant | — | — | **SKIP** |
| Offline posting | Already works | Already works (Grav form offline) | — | — | **ALREADY EXISTS** |
| Scheduled / draft posts | Already exists | Already exists (publish_date) | — | — | **ALREADY EXISTS** |
| Step suggestions / nudges | No — push notifications not possible | No | — | — | **SKIP** |
| Eebook / export | No — out of scope | Possible but niche | — | — | **SKIP** |
---
## What to Build — Summary
### Keep (already exists, just needs to work reliably)
- Login-gated mobile posting form ✓
- Draft and scheduled publishing ✓
### Build
**1. Entry enrichment** — make each entry richer with zero extra effort from Mischa:
- Location name (city, country) captured at post time
- Weather auto-fetched via Open-Meteo at post time using lat/lng
- Photos displayed in a proper gallery (lightbox)
- Hero image shown on feed card
**2. Interactive map** — the single most "Polarsteps-like" thing that's genuinely achievable:
- `/map` page with Leaflet.js
- Marker per entry (lat/lng from frontmatter)
- Route line connecting entries in date order
- Popup with title, date, thumbnail, link to entry
- Mobile-friendly (touch pan/zoom)
**3. Trip statistics** — a simple stats page:
- Days on the road (count of entries with distinct dates)
- Entries posted
- Countries/regions visited (derived from location name field)
- Approx distance traveled (sum of haversine distances between GPS points)
---
## What to Skip — with reasons
| Feature | Reason skipped |
|---|---|
| Background GPS tracking | Requires native app. Grav runs on a server. |
| Social features (followers, comments, likes) | Adds spam risk, moderation burden, zero value for a solo travel blog with a personal audience. A "share link" is enough. |
| Video reels | App-side video processing pipeline, not available in a web CMS. |
| 3D flyover | Proprietary rendering. Not worth building from scratch. |
| Travel book printing | Out of scope. Mischa can use Polarsteps or FindPenguins for this if desired. |
| AI itinerary builder | Trip is already in progress. Out of scope. |
| Discovery / explore | Not a platform. No community. |
| Group trips | Solo traveler. |
| Flight detection | Requires native OS sensor access. |
| Delayed sharing | Moot — we don't broadcast real-time location at all. |
---
## Milestone Plan
### Milestone 1 — Entry Enrichment (23 days)
**Goal:** Every entry is richer out of the box — photo gallery works, location name shown, weather captured, hero image on feed.
Features:
- Location name field (city + country) added to post form and displayed on entries/cards
- Weather auto-fetch on post form (JS call to Open-Meteo using entered lat/lng, fills hidden fields)
- Weather displayed on entry page
- Photo gallery working (shortcode-gallery-plusplus or native media display)
- Hero image shown on tracker feed cards
**Value:** Immediate. Makes each entry feel like a real travel log entry, not just a text post.
---
### Milestone 2 — Interactive Map (23 days)
**Goal:** A `/map` page shows all entries as markers on an interactive Leaflet.js map, connected by a route line, with popups.
Features:
- New `map` page and template
- Leaflet.js loaded from CDN (no build step)
- Entries serialized to JSON in the template (lat/lng, title, date, url, hero_image)
- Route polyline in chronological order
- Marker popup: date, title, thumbnail, "Read entry →" link
- Map added to site navigation
**Value:** High for readers — gives a bird's-eye view of the trip. The single most compelling "where is Mischa?" feature.
---
### Milestone 3 — Statistics Page (12 days)
**Goal:** A `/stats` page with key trip numbers.
Features:
- Days on the road (first entry date to today)
- Total entries posted
- Unique countries visited (derived from location names)
- Approximate distance traveled (haversine between consecutive entry GPS points)
- Simple, scannable layout — no charts needed for v1
**Value:** Medium — nice context for readers, satisfying for Mischa to see progress.
---
### Milestone 4 — Map on Tracker Feed (1 day)
**Goal:** A mini-map showing recent positions above or alongside the feed, so the first thing readers see is "where is Mischa now?"
Features:
- Small embedded Leaflet map on the tracker/feed page
- Shows last 10 entries as markers, with the most recent highlighted
- Route line between them
- Tapping a marker opens the entry
**Value:** Medium — gives context to the feed without navigating away. Nice "current location" feel.
---
## Milestone Priority Order
**M1 first** — entry quality affects every post Mischa makes from day 1 of the trip. Get this right immediately.
**M2 second** — the map is the headline feature that makes this feel like a Polarsteps-style blog. Technically independent from M1 (uses lat/lng already in frontmatter).
**M3 third** — stats are a nice-to-have. Easy to add once M1 and M2 are stable.
**M4 fourth** — the mini-map on the feed is polish. Only worth doing once the full map (M2) is solid.
+217
View File
@@ -0,0 +1,217 @@
# QA Test Results
*Executed: 2026-06-18. Environment: Docker local (http://localhost:8081). Branch: experimental-polar-steps.*
---
## Summary
| Result | Count |
|---|---|
| ✅ PASS (automated) | 22 |
| ⚠️ REQUIRES MANUAL VERIFICATION | 10 |
| ❌ FAIL | 0 |
All automatable tests pass. No failures found. Manual tests require a physical mobile device and/or browser session.
---
## Milestone 1 — Entry Enrichment Results
### TC-1.1: Location badge on entry page ✅ PASS
```
curl http://localhost:8081/tracker/2026-06-17.entry
→ <p class="entry-location"> ... Amsterdam ... Netherlands ... </p>
```
### TC-1.2: Weather badge on entry page ✅ PASS
```
curl http://localhost:8081/tracker/2026-06-17.entry
→ <p class="entry-weather"> ⛅ Partly cloudy · 19°C </p>
```
### TC-1.3: Location badge hidden when fields empty ✅ PASS (by inspection)
Twig template uses `{% if page.header.location_city or page.header.location_country %}` — conditional confirmed. No empty `<p>` tag rendered when values absent.
### TC-1.4: Weather badge hidden when fields empty ✅ PASS (by inspection)
Twig uses `{% if page.header.weather_desc or page.header.weather_temp_c %}` — same conditional pattern confirmed.
### TC-1.5: Hero image on tracker feed card ⚠️ REQUIRES MANUAL VERIFICATION
The example entry has no photos. Fallback logic is implemented (`media.images|first`) but cannot be automated without uploading a real photo.
- **Steps:** Log into Admin → open 2026-06-17.entry → Media tab → upload a photo → reload /tracker → verify 16:9 thumbnail appears
### TC-1.6: Location badge on tracker feed card ✅ PASS
```
curl http://localhost:8081/tracker
→ <span class="entry-location entry-location--card"> 📍 Amsterdam , Netherlands </span>
```
### TC-1.7: Photo gallery and lightbox ⚠️ REQUIRES MANUAL VERIFICATION
No photos in example entry. Template code verified correct (iterates `page.media.images`, renders `.gallery-thumb` buttons, lightbox JS implemented). Test requires uploading photos.
- **Steps:** Upload 23 photos to example entry → open /tracker/2026-06-17.entry → verify grid, click thumbnail → verify lightbox opens → press Escape → verify closes → click outside → verify closes → use arrow buttons → verify navigation
### TC-1.8: Post form has City/Country fields ⚠️ REQUIRES MANUAL VERIFICATION
Post form requires authenticated session. Fields are defined in post-form.md frontmatter: `location_city` (text), `location_country` (text), `weather_temp_c` (hidden), `weather_desc` (hidden). Template includes `forms/form.html.twig`.
- **Steps:** Log in → open /post → verify City and Country inputs present → verify two buttons ("Get Current Location", "Get Weather") appear below form
### TC-1.9: Get Weather button fills fields ⚠️ REQUIRES MANUAL VERIFICATION
- **Steps:** Open /post on mobile → fill lat/lng (use Get Location button) → tap Get Weather → verify status shows temp and condition → submit form → verify entry has weather in Admin
---
## Milestone 2 — Interactive Map Results
### TC-2.1: Map page loads with Leaflet ✅ PASS
```
HTTP 200 /map
→ <div id="trip-map"></div>
→ leaflet@1.9.4 CSS and JS from CDN present
```
### TC-2.2: Entry GPS data serialized to ENTRIES JSON ✅ PASS
```
var ENTRIES = [{"lat":"52.367600","lng":"4.904100","title":"The Journey Begins","date":"17 Jun 2026","url":"\/tracker\/2026-06-17.entry","hero":null}];
```
Amsterdam entry correctly included. hero is null (no photos — expected).
### TC-2.3: Map renders marker and popup in browser ⚠️ REQUIRES MANUAL VERIFICATION
- **Steps:** Open /map in browser → verify Amsterdam marker visible → click marker → verify popup shows "The Journey Begins", date, "Read entry →" link → click link → verify navigates to entry
### TC-2.4: Map link in header navigation ✅ PASS
```
grep /tracker HTML → href="http://100.96.115.96:8081/map" ✓
grep /map HTML → href="http://100.96.115.96:8081/map" ✓
grep /stats HTML → href="http://100.96.115.96:8081/map" ✓
```
### TC-2.5: Empty state ⚠️ REQUIRES MANUAL VERIFICATION
Requires temporarily removing lat/lng from test entry. Template code verified: `if (ENTRIES.length === 0)` block renders "No locations yet" message.
### TC-2.6: Map full-height on mobile ⚠️ REQUIRES MANUAL VERIFICATION
CSS: `.map-container { height: calc(100vh - 61px); }` and `.map-page .site-main { max-width: none; padding: 0; }` confirmed in stylesheet.
- **Steps:** Open /map on phone → verify map fills screen → pinch zoom → verify map zooms, page does not scroll
---
## Milestone 3 — Statistics Page Results
### TC-3.1: Stats page loads with 4 stat blocks ✅ PASS
```
HTTP 200 /stats
→ grep "stat-block" count: 4 ✓
```
### TC-3.2: Days on road count ✅ PASS
```
<span class="stat-value">1</span>
<span class="stat-label">day on the road</span>
```
Entry date: 2026-06-17. Today: 2026-06-18. Difference: 1 day. ✓
### TC-3.3: Entries count ✅ PASS
```
<span class="stat-value">1</span>
<span class="stat-label">entry posted</span>
```
### TC-3.4: Countries visited ✅ PASS
```
<span class="stat-value">1</span>
<span class="stat-label">country visited</span>
Netherlands (listed below)
```
### TC-3.5: Distance shows "—" for single GPS point ✅ PASS (by inspection)
```
GPS_POINTS = [["52.3676","4.9041"]] — 1 point only
JS: if (GPS_POINTS.length < 2) { el.textContent = '—'; }
stat-distance element initialized as "—" in HTML
```
JS behavior confirmed by code inspection. Browser render requires manual check.
### TC-3.6: Stats navigation link ✅ PASS
```
grep /tracker HTML → href=".../stats" ✓
grep /map HTML → href=".../stats" ✓
```
---
## Milestone 4 — Mini-map on Tracker Feed Results
### TC-4.1: Mini-map present on tracker feed ✅ PASS
```
curl http://localhost:8081/tracker
→ <div class="feed-map-wrap"> ✓
→ <div class="feed-map" id="feed-map"> ✓
→ <a class="feed-map-link" href=".../map">View full map →</a> ✓
→ var FEED_ENTRIES = [{"lat":"52.3676","lng":"4.9041",...}] ✓
→ Leaflet JS initialized ✓
```
### TC-4.2: Mini-map hidden when no GPS ✅ PASS (by inspection)
Template wraps entire mini-map in `{% if map_entries|length > 0 %}`. Confirmed no feed-map div rendered when list empty.
### TC-4.3: Marker click navigates to entry ⚠️ REQUIRES MANUAL VERIFICATION
JS: `.on('click', function() { window.location = entry.url; })` confirmed. Browser interaction required.
- **Steps:** Open /tracker on phone → tap Amsterdam marker → verify navigates to entry page
### TC-4.4: Entry list visible below mini-map ⚠️ REQUIRES MANUAL VERIFICATION
- **Steps:** Open /tracker → verify mini-map renders → scroll down → verify entry cards below map
---
## Cross-cutting Results
### TC-X.1: Nav links on all pages ✅ PASS
| Page | Journal | Map | Stats |
|---|---|---|---|
| /tracker | ✅ | ✅ | ✅ |
| /map | ✅ | ✅ | ✅ |
| /stats | ✅ | ✅ | ✅ |
| /tracker/2026-06-17.entry | ✅ (inherited from base template) | ✅ | ✅ |
### TC-X.2: All pages return 200 ✅ PASS
| Page | HTTP Status |
|---|---|
| /tracker | 200 ✅ |
| /tracker/2026-06-17.entry | 200 ✅ |
| /map | 200 ✅ |
| /stats | 200 ✅ |
### TC-X.3: Mobile touch targets ⚠️ REQUIRES MANUAL VERIFICATION
CSS verified:
- Nav links: `min-height: 44px; display: inline-flex; align-items: center`
- Lightbox buttons: `width: 44px; height: 44px`
- `.btn-extra`: `min-height: 44px`
- Gallery thumbs: CSS `aspect-ratio: 1` — size depends on grid width; at 2 columns on 375px, each is ~(375-16-4)/2 = ~177px ✅
- Visual confirmation requires physical device
### TC-X.4: No JS errors in browser console ⚠️ REQUIRES MANUAL VERIFICATION
Code reviewed: no obvious syntax errors, proper null checks before DOM access, Leaflet initialized after DOM ready. Console check requires browser DevTools.
---
## Issues Found
**None.** All automated tests pass. No broken HTML, no server errors, no template errors, no missing routes.
**Note on whitespace in Twig output:** Location and weather badges render with extra whitespace around values due to Twig `{% if %}` block indentation. This is cosmetic only — display is correct in browser rendering and does not affect functionality.
---
## Manual Verification Checklist for Mischa
When you review this branch in the morning, these items need a human eye (phone + browser):
- [ ] Upload 14 photos to a test entry, verify hero image shows on feed card
- [ ] Upload 3 photos, open entry, verify gallery grid, tap thumbnail → lightbox opens
- [ ] Test lightbox: Escape closes, tap outside closes, arrow buttons navigate
- [ ] Open /post on phone (logged in), verify City/Country fields and two buttons visible
- [ ] Tap "Get Current Location" → coordinates fill → tap "Get Weather" → weather fills
- [ ] Submit a full form entry → verify it appears on /tracker with location badge
- [ ] Open /map in browser → verify Amsterdam marker, click it → popup → click link
- [ ] Open /map on phone → pinch zoom (map zooms, page doesn't scroll)
- [ ] Open /tracker on phone → tap map marker → navigates to entry
- [ ] Check /stats in browser → verify distance stat updates from "—" to a number once 2+ GPS entries exist
- [ ] Check browser console on all pages → no JS errors
+628
View File
@@ -0,0 +1,628 @@
# QA Test Plan
*Branch: experimental-polar-steps. Tester role: Senior Staff QA Engineer.*
---
## Scope
All features implemented in Phase 4 (Milestones 14):
- M1: Entry enrichment (location badge, weather badge, photo gallery, hero image)
- M2: Interactive map page
- M3: Statistics page
- M4: Mini-map on tracker feed
Test URLs:
- Desktop: http://localhost:8081
- Mobile: http://100.96.115.96:8081 (Tailscale — requires physical phone)
---
## Milestone 1 — Entry Enrichment
### TC-1.1: Location badge on entry page
| Step | Action | Expected Result |
|---|---|---|
| 1 | Open http://localhost:8081/tracker/2026-06-17.entry | Entry page loads (200) |
| 2 | Look at entry header | `📍 Amsterdam, Netherlands` visible below date |
| 3 | Inspect HTML | `<p class="entry-location">` present with city and country |
**Automation:** grep for `.entry-location` and "Amsterdam" in curl output
---
### TC-1.2: Weather badge on entry page
| Step | Action | Expected Result |
|---|---|---|
| 1 | Open http://localhost:8081/tracker/2026-06-17.entry | Entry page loads |
| 2 | Look at entry header | `⛅ Partly cloudy · 19°C` visible |
| 3 | Inspect HTML | `<p class="entry-weather">` present |
**Automation:** grep for `.entry-weather` and "Partly cloudy" and "19°C"
---
### TC-1.3: Location badge hidden when fields empty
| Step | Action | Expected Result |
|---|---|---|
| 1 | Create test entry with no location_city/location_country | — |
| 2 | Open that entry | No `📍` badge shown, no empty `<p>` rendered |
**Automation:** Check example entry before fields were added (not needed — fields are now set); create a second test entry without location
---
### TC-1.4: Weather badge hidden when fields empty
| Step | Action | Expected Result |
|---|---|---|
| 1 | Entry with no weather fields | No weather section in HTML |
**Automation:** grep for `entry-weather` in HTML — should only appear if value present
---
### TC-1.5: Hero image on tracker feed card
| Step | Action | Expected Result |
|---|---|---|
| 1 | Open http://localhost:8081/tracker | Feed loads |
| 2 | Entry card for 2026-06-17 | No image shown (example entry has no photos) |
| 3 | Upload a photo to the entry via Admin media manager | — |
| 4 | Reload tracker | Hero image shows as 16:9 thumbnail |
**Manual verification required:** Photo upload requires browser Admin interaction
---
### TC-1.6: Location badge on tracker feed card
| Step | Action | Expected Result |
|---|---|---|
| 1 | Open http://localhost:8081/tracker | Feed loads |
| 2 | Entry card | `📍 Amsterdam, Netherlands` visible |
**Automation:** grep feed HTML for `entry-location--card` and "Amsterdam"
---
### TC-1.7: Photo gallery renders on entry page (with photos)
| Step | Action | Expected Result |
|---|---|---|
| 1 | Upload 3 photos to the example entry via Admin | — |
| 2 | Open entry page | Gallery grid appears below entry body |
| 3 | Count thumbnails | 3 thumbnails in 2-col (mobile) / 3-col (desktop) grid |
| 4 | Click a thumbnail | Lightbox overlay opens with full-size image |
| 5 | Press Escape | Lightbox closes |
| 6 | Click left/right arrow buttons | Navigates between images |
| 7 | Click outside lightbox | Lightbox closes |
**Manual verification required:** Photo upload and interactive lightbox require browser
---
### TC-1.8: Post form has location and weather fields
| Step | Action | Expected Result |
|---|---|---|
| 1 | Open http://localhost:8081/post (logged in) | Post form renders |
| 2 | Inspect form | `City` and `Country` text inputs present |
| 3 | Inspect form | `📍 Get Current Location` and `🌤 Get Weather` buttons present |
**Automation:** grep /post HTML for `location_city`, `location_country`, `get-location`, `get-weather`
---
### TC-1.9: Get Weather button fills fields
| Step | Action | Expected Result |
|---|---|---|
| 1 | Open /post on phone | Post form loads |
| 2 | Tap "Get Current Location" | Lat/lng fields fill with coordinates |
| 3 | Tap "Get Weather" | Status shows "🌤 Weather set: [desc] · [temp]°C" |
| 4 | Submit form | New entry created with weather in frontmatter |
| 5 | Open entry in Admin | weather_temp_c and weather_desc fields populated |
**Manual verification required:** Geolocation and form submission require mobile browser
---
## Milestone 2 — Interactive Map
### TC-2.1: Map page loads
| Step | Action | Expected Result |
|---|---|---|
| 1 | GET http://localhost:8081/map | HTTP 200 |
| 2 | Inspect HTML | `<div id="trip-map">` present |
| 3 | Inspect HTML | Leaflet CSS and JS from CDN present |
**Automation:** curl + HTTP status check; grep for "trip-map" and "leaflet"
---
### TC-2.2: Entry with GPS appears in ENTRIES JSON
| Step | Action | Expected Result |
|---|---|---|
| 1 | curl http://localhost:8081/map | Map page HTML |
| 2 | grep for `var ENTRIES` | Array contains Amsterdam entry with lat 52.3676 |
| 3 | Check entry has title, date, url | All fields present |
**Automation:** grep output for ENTRIES and lat value
---
### TC-2.3: Map renders marker and route in browser
| Step | Action | Expected Result |
|---|---|---|
| 1 | Open /map in browser | Map tiles load, marker visible |
| 2 | Click marker | Popup opens with "The Journey Begins" title and "Read entry →" link |
| 3 | Click "Read entry →" | Navigates to entry page |
**Manual verification required:** Leaflet rendering requires browser
---
### TC-2.4: Map navigation link in header
| Step | Action | Expected Result |
|---|---|---|
| 1 | Open any page | Header shows Journal, Map, Stats nav links |
| 2 | Click Map | Navigates to /map |
**Automation:** grep base template output for "/map" nav link
---
### TC-2.5: Empty state (no GPS entries)
| Step | Action | Expected Result |
|---|---|---|
| 1 | Remove lat/lng from test entry temporarily | — |
| 2 | Visit /map | Map at world zoom, "No locations yet" message shown |
| 3 | Restore lat/lng | — |
**Manual verification required:** Requires temporarily editing entry
---
### TC-2.6: Map page is full-height on mobile
| Step | Action | Expected Result |
|---|---|---|
| 1 | Open /map on mobile browser | Map fills screen below header |
| 2 | Pinch to zoom | Map zooms without page scrolling |
| 3 | Pan with finger | Map pans without page scrolling |
**Manual verification required:** Touch interaction requires physical device
---
## Milestone 3 — Statistics Page
### TC-3.1: Stats page loads
| Step | Action | Expected Result |
|---|---|---|
| 1 | GET http://localhost:8081/stats | HTTP 200 |
| 2 | Inspect HTML | Four stat blocks present |
**Automation:** curl + HTTP status + grep for "stat-block"
---
### TC-3.2: Days on the road count
| Step | Action | Expected Result |
|---|---|---|
| 1 | curl /stats | Page HTML |
| 2 | grep for "days" | Shows "1 day on the road" (entry date: 2026-06-17, today: 2026-06-18) |
**Automation:** grep stat-value output and compare to expected day count
---
### TC-3.3: Entries count
| Step | Action | Expected Result |
|---|---|---|
| 1 | curl /stats | grep for "entry posted" | Shows "1 entry posted" |
**Automation:** grep for "entry posted"
---
### TC-3.4: Countries visited
| Step | Action | Expected Result |
|---|---|---|
| 1 | curl /stats | grep for "Netherlands" | "Netherlands" appears in countries list |
| 2 | grep for "country visited" | Shows "1 country visited" |
**Automation:** grep output
---
### TC-3.5: Distance shows "—" for single GPS point
| Step | Action | Expected Result |
|---|---|---|
| 1 | curl /stats | grep for GPS_POINTS | One point in array |
| 2 | In browser, check stat-distance | Shows "—" (JS computes, needs browser) |
**Automation:** grep GPS_POINTS array length from page source
---
### TC-3.6: Stats navigation link
| Step | Action | Expected Result |
|---|---|---|
| 1 | Open any page header | "Stats" link present in nav |
| 2 | Click Stats | Navigates to /stats |
**Automation:** grep any page HTML for "/stats" in nav
---
## Milestone 4 — Mini-map on Tracker Feed
### TC-4.1: Mini-map appears on tracker feed
| Step | Action | Expected Result |
|---|---|---|
| 1 | GET http://localhost:8081/tracker | HTTP 200 |
| 2 | grep for "feed-map" | Mini-map div present |
| 3 | grep for "FEED_ENTRIES" | JSON array with Amsterdam entry |
| 4 | grep for "View full map →" | Link to /map present |
**Automation:** curl + grep
---
### TC-4.2: Mini-map hidden when no GPS entries
| Step | Action | Expected Result |
|---|---|---|
| 1 | Remove lat/lng from example entry | — |
| 2 | curl /tracker | No "feed-map" div in output |
| 3 | Restore lat/lng | — |
**Manual verification:** Requires temporarily editing entry
---
### TC-4.3: Marker click navigates to entry (mobile)
| Step | Action | Expected Result |
|---|---|---|
| 1 | Open /tracker on phone | Mini-map renders above entry list |
| 2 | Tap Amsterdam marker | Navigates to /tracker/2026-06-17.entry |
**Manual verification required:** Touch interaction requires browser
---
### TC-4.4: Entry list still visible below mini-map
| Step | Action | Expected Result |
|---|---|---|
| 1 | Open /tracker | Mini-map shows, scroll down | Entry cards visible below map |
**Manual verification required:** Visual layout check
---
## Post Submission Flow
These scenarios cover the full round-trip: filling the form → saving → verifying values in the UI and on disk. Use the exact test values specified so that each assertion can be precise.
**Test data (use verbatim):**
| Field | Value |
|---|---|
| Title | `QA Test Entry` |
| Date & Time | `2026-06-18 10:00` |
| Content | `This is the QA test body. Second sentence for length.` |
| City | `Tokyo` |
| Country | `Japan` |
| Latitude | `35.689487` |
| Longitude | `139.691711` |
| Photos | none (keep simple for first run) |
**Expected slug:** `2026-06-18-1000-qa-test-entry`
**Expected folder:** `2026-06-18-1000-qa-test-entry.entry/`
**Expected URL:** `/tracker/2026-06-18-1000-qa-test-entry.entry`
The slug is built from `date(Y-m-d-Hi)` + title lowercased with `[^a-z0-9]+` replaced by hyphens.
---
### TC-P.1: Post form requires authentication
| Step | Action | Expected Result |
|---|---|---|
| 1 | Open private/incognito tab (no session) | — |
| 2 | GET http://100.96.115.96:8081/post | Page loads at /post URL (no redirect) but renders the login form inline |
| 3 | Inspect page content | Login form fields (username, password) visible; post form fields absent |
**Automation:** curl /post without auth; assert `login-form-nonce` present AND `data[title]` absent
---
### TC-P.2: Post form renders all fields
| Step | Action | Expected Result |
|---|---|---|
| 1 | Log in at /login | Redirected to /tracker |
| 2 | Navigate to /post | Post form page loads (200) |
| 3 | Check form fields present | Title, Date & Time, description textarea, Photos upload |
| 4 | Check location fields | Latitude, Longitude, City, Country inputs visible |
| 5 | Check action buttons | `📍 Get Current Location` and `🌤 Get Weather` buttons visible |
| 6 | Check submit button | `Post Entry` button visible |
| 7 | Check date field default | Pre-filled with today's date and time (not blank) |
**Automation:** curl /post with auth; grep for `data[title]`, `data[lat]`, `data[location_city]`, `get-location`, `get-weather`
---
### TC-P.3: Required field validation
| Step | Action | Expected Result |
|---|---|---|
| 1 | Log in and open /post | Form loads |
| 2 | Leave Title blank, fill in only the description | — |
| 3 | Submit form | Page reloads with validation error on Title |
| 4 | Error message | Indicates title is required |
| 5 | Fill in Title, clear Description/Content, submit | Validation error on Content field |
| 6 | Confirm | No new entry file created in pages/01.tracker/ during failed submissions |
**Manual verification required:** Validation feedback requires browser
---
### TC-P.4: Successful post submission — all fields
| Step | Action | Expected Result |
|---|---|---|
| 1 | Log in and open /post | Form loads |
| 2 | Enter Title: `QA Test Entry` | — |
| 3 | Set Date to `2026-06-18 10:00` | — |
| 4 | Enter Content: `This is the QA test body. Second sentence for length.` | — |
| 5 | Enter City: `Tokyo`, Country: `Japan` | — |
| 6 | Enter Latitude: `35.689487`, Longitude: `139.691711` | — |
| 7 | Leave Photos empty | — |
| 8 | Click `Post Entry` | Form submits (POST to /post) |
| 9 | Observe result | Success message `Entry posted successfully!` shown on page |
| 10 | Form state | Form is reset / fields cleared |
**Manual verification required:** Form submission and success message require browser
---
### TC-P.5: Entry file created on disk with correct values
| Step | Action | Expected Result |
|---|---|---|
| 1 | After TC-P.4 completes | — |
| 2 | Check directory `user/pages/01.tracker/` | Folder `2026-06-18-1000-qa-test-entry.entry/` exists (add-page-by-form appends template name per `physical_template_name: true`) |
| 3 | Read `user/pages/01.tracker/2026-06-18-1000-qa-test-entry.entry/entry.md` | File exists |
| 4 | Verify frontmatter `title` | Equals `QA Test Entry` |
| 5 | Verify frontmatter `date` | Equals `2026-06-18 10:00` |
| 6 | Verify frontmatter `location_city` | Equals `Tokyo` |
| 7 | Verify frontmatter `location_country` | Equals `Japan` |
| 8 | Verify frontmatter `lat` | Equals `35.689487` |
| 9 | Verify frontmatter `lng` | Equals `139.691711` |
| 10 | Verify frontmatter `template` | Equals `entry` |
| 11 | Verify frontmatter `published` | Equals `true` |
| 12 | Verify page body | Contains `This is the QA test body. Second sentence for length.` |
**Automation:** Read file from filesystem; parse YAML frontmatter; assert each field value exactly
---
### TC-P.6: Entry appears in tracker feed
| Step | Action | Expected Result |
|---|---|---|
| 1 | BUG-001 fixed — no manual cache clear needed | — |
| 2 | GET http://100.96.115.96:8081/tracker | Page loads (200) |
| 3 | Entry card present | Card with title `QA Test Entry` visible |
| 4 | Date shown on card | `18 Jun 2026` |
| 5 | Location badge on card | `📍 Tokyo, Japan` visible |
| 6 | Entry card link | `href` points to `/tracker/2026-06-18-1000-qa-test-entry.entry` |
| 7 | Excerpt shown | Partial text of the body content visible |
**Automation:** curl /tracker; grep for "QA Test Entry", "18 Jun 2026", "Tokyo", "Japan", "/tracker/2026-06-18-1000-qa-test-entry.entry"
---
### TC-P.7: Entry detail page shows correct values
| Step | Action | Expected Result |
|---|---|---|
| 1 | GET http://100.96.115.96:8081/tracker/2026-06-18-1000-qa-test-entry.entry | Page loads (200) |
| 2 | Page title | `QA Test Entry` in `<h1>` |
| 3 | Date header | `Thursday, 18 June 2026` (or locale equivalent) |
| 4 | Location badge | `📍 Tokyo, Japan` |
| 5 | Body content | Full text `This is the QA test body. Second sentence for length.` rendered |
| 6 | No gallery | Photo gallery section absent (no photos were uploaded) |
| 7 | Back link | `← Back to journal` link present, points to /tracker |
**Automation:** curl /tracker/2026-06-18-1000-qa-test-entry.entry; grep for "QA Test Entry", "Tokyo", "Japan", "This is the QA test body", "Back to journal"
---
### TC-P.8: Entry appears on map and mini-map
| Step | Action | Expected Result |
|---|---|---|
| 1 | GET http://100.96.115.96:8081/tracker | Mini-map section visible |
| 2 | Inspect FEED_ENTRIES JSON | Contains entry with `lat: "35.689487"`, `lng: "139.691711"`, `title: "QA Test Entry"` |
| 3 | GET http://100.96.115.96:8081/map | Map page loads |
| 4 | Inspect ENTRIES JSON | Contains same entry |
**Automation:** curl /tracker and /map; grep FEED_ENTRIES and ENTRIES JSON for lat/lng values
---
### TC-P.9: Entry appears in stats
| Step | Action | Expected Result |
|---|---|---|
| 1 | GET http://100.96.115.96:8081/stats | Page loads (200) |
| 2 | Entries count | Shows `2` entries (existing test entry + new QA entry) |
| 3 | Countries list | `Japan` and `Netherlands` both listed |
**Automation:** curl /stats; grep entry count and country names
---
### TC-P.10: Two posts on the same day
| Step | Action | Expected Result |
|---|---|---|
| 1 | Submit a first post: date `2026-06-18 10:00`, title `Morning Update` | Success message shown |
| 2 | Submit a second post: date `2026-06-18 14:30`, title `Afternoon Update` | Success message shown |
| 3 | Check filesystem | Two separate folders exist: `2026-06-18-1000-morning-update.entry/` and `2026-06-18-1430-afternoon-update.entry/` |
| 4 | Visit /tracker | Both entries visible as separate cards |
**Note:** The slug encodes date + time + title, so same-day posts are fully supported as long as they have different times or titles. A true collision (same date, same time, same title) would silently fail — treat this as acceptable given solo use.
**Manual verification required:** Requires two browser submissions
---
## Cross-cutting Tests
### TC-X.1: Nav links present on all pages
| Page | Expected nav links |
|---|---|
| /tracker | Journal, Map, Stats |
| /map | Journal, Map, Stats |
| /stats | Journal, Map, Stats |
| /tracker/2026-06-17.entry | Journal, Map, Stats |
**Automation:** curl each page, grep for all three links
---
### TC-X.2: All pages return 200
| Page | Expected HTTP status |
|---|---|
| / (redirects to /tracker) | 200 or 302→200 |
| /tracker | 200 |
| /tracker/2026-06-17.entry | 200 |
| /map | 200 |
| /stats | 200 |
| /post | 200 (after login) or 302 (login redirect) |
**Automation:** curl HTTP status checks
---
### TC-X.3: Mobile touch targets ≥44px
| Element | Expected min height/width |
|---|---|
| Nav links | 44px height |
| Gallery thumbnails | 44px on shortest side |
| Lightbox close/prev/next buttons | 44px |
| Post form buttons | 44px height |
| "Get Location" button | 44px height |
| "Get Weather" button | 44px height |
**Manual verification required:** Inspect computed CSS or measure visually on device
---
### TC-X.4: No JS errors in browser console
| Page | Expected |
|---|---|
| /tracker | No console errors |
| /map | No console errors (may have tile 404s for tiles not in viewport — acceptable) |
| /stats | No console errors |
| /tracker/2026-06-17.entry | No console errors |
**Manual verification required:** Open browser DevTools
---
## Visual Design QA — Redesign Checklist
**Design spec:** `user/docs/design/design-spec.md`
**Implementation plan:** `user/docs/superpowers/plans/2026-06-18-ui-redesign.md`
### Typography
- [ ] DM Serif Display loads for: entry titles, page headings (`h1`), stat numbers, site title
- [ ] DM Sans loads for: body text, nav links, labels, form fields, timestamps
- [ ] No fallback font (Georgia / system-sans) visible in place of custom fonts
- [ ] Body text font-size ≥ 16px (no iOS zoom on form focus)
### Colors
- [ ] Page background is warm paper (#F7F5F2), not pure white
- [ ] All links and CTAs use teal (#1F6B5A), not blue (#0066cc)
- [ ] Active nav link is teal and bold
- [ ] Map markers and route polylines are teal
### Header
- [ ] 3px teal border-top visible at top of header
- [ ] Site title renders in DM Serif Display ("into the east")
- [ ] Header sticks to top on scroll
- [ ] On 320px viewport: title and nav both visible without overlap
### Entry feed cards
- [ ] Cards with photos show full-bleed 16:9 image with rounded corners
- [ ] Date + location text overlay visible on gradient at bottom of photo
- [ ] Entry title below photo in DM Serif Display
- [ ] Subtle photo scale animation on hover (desktop)
- [ ] Cards without photos show date/location meta row above title
- [ ] "Read entry →" link is teal
### Single entry page
- [ ] If entry has photos: hero image spans full content width, max 480px tall
- [ ] Entry title in DM Serif Display at large size (~48px desktop)
- [ ] Thin border rule separates header from body text
- [ ] Body text at 18px (--text-md)
- [ ] "← Back to journal" footer link in teal
### Post form
- [ ] Lat/lng inputs NOT visible (hidden by CSS :has() selector)
- [ ] Inputs have rounded corners and correct border
- [ ] Focus ring on inputs is teal, not default browser blue
- [ ] "Post Entry" submit button is teal, full-width, ≥52px height
- [ ] After tapping "Get Location": status line shows "✓ Location captured · lat, lng" in teal
- [ ] After tapping "Get Weather": status line shows "✓ Weather set · desc · temp°C" in teal
- [ ] On error: status line shows in brick red, not teal
### Stats page
- [ ] Page heading "Trip Statistics" in DM Serif Display
- [ ] Stat numbers in DM Serif Display, teal color
- [ ] Stat cards on white background (not paper), with subtle shadow
- [ ] Labels uppercase, muted gray, small
### Map page
- [ ] Map fills viewport below header with no gap
- [ ] Map container height uses CSS variable (not hardcoded 61px)
- [ ] Markers are teal circles (not blue)
- [ ] Route polyline is teal
### Mobile (375px viewport)
- [ ] All pages scroll without horizontal overflow
- [ ] Header title and nav fit in one row
- [ ] Entry card photo fills full width
- [ ] Post form buttons are thumb-reachable (44px+ targets)
- [ ] Map page: map pans without page scrolling underneath (touch-action)
### Accessibility
- [ ] Focus ring visible on all interactive elements (keyboard navigation)
- [ ] With prefers-reduced-motion: no animations/transitions fire
+141
View File
@@ -0,0 +1,141 @@
# FindPenguins — Feature Research
*Researched June 2026. Source: findpenguins.com, App Store, support docs, reviews.*
---
## Overview
FindPenguins is a German travel tracking and community app. Core features are free; premium subscription ($4.99/month or $32.99/year) unlocks more photos per post and ebook exports. Revenue comes from subscriptions and printed photo books ($40240). It leans more social than Polarsteps — discovery, community, and inspiring other travelers are central to its identity.
---
## Core User Flow
1. User creates a **Trip** (title, dates, cover)
2. App runs in background with **automatic GPS + flight detection tracking**
3. User creates **Footprints** — individual journal entries tied to a location and time
4. Each Footprint can contain: location, title, date, text story, photos, video, weather
5. Footprints appear in a **chronological timeline** per trip
6. Trip is shareable; social followers can view, comment, react
7. At the end, optionally order a printed **photo book**
---
## Map Features
- **Automatic route tracking**: GPS + flight detection, works offline
- **Interactive world map**: route lines drawn between footprints
- **3D flyover video**: auto-generated cinematic route visualization, free
- **Countries/continents highlighted**: on personal map
- **Visited places completion**: stats on what % of a country/region visited
- Battery usage: ~4% per day (comparable to Polarsteps)
- Route visualized as path on map, not just pins
---
## Footprints (Journal Entries)
Each "Footprint" is the core content unit:
- **Location**: GPS-detected, shown as city/country; uses reverse geocoding (LocationIQ)
- **Title**: required, user-set
- **Date**: required, defaults to current time
- **Text story**: freeform journal text
- **Photos**: 6 (free) / 10 (premium) per footprint
- **Videos**: 1 (free) / 2 (premium) per footprint
- **Weather**: auto-populated at location + time; manually editable
- **Place name**: auto-detected city/neighborhood/country, editable
- **Selective sharing**: each footprint can be public, friends-only, or private
- **Delayed posting**: option to share location with a time delay (privacy feature)
---
## Photo Handling
- Up to 6 photos per footprint (free), 10 (premium)
- 1 video per footprint (free), 2 (premium)
- Photos displayed in carousel/grid within footprint
- High-res stored for photobook printing
- Cover photo selectable per trip
---
## Statistics
- Countries visited (count + list + % world)
- Continents visited
- Total distance traveled
- Number of footprints / trips
- Days on the road
- World coverage percentage
- Shown on profile and within photo books
---
## Social & Discovery Features
- **Follower system**: follow other travelers, see their public footprints
- **Comments**: friends/followers can comment on individual footprints
- **Reactions**: like/react to footprints
- **Discovery**: browse 10M+ travel experiences from other users by destination
- **Group trips**: invite co-travelers to add footprints to a shared trip (with known bug: co-travelers can delete each other's content)
- **Travel inspiration**: browse community trips to plan your own
- **Explore by destination**: search real traveler experiences for any city/country
---
## Privacy Controls
- Per-footprint visibility: public / friends / private
- **Delayed sharing**: share location with a configurable time delay (safety feature for solo travelers)
- Trip-level privacy: whole trip can be private or public
- Can hide real-time location from followers
---
## Photo Book (Premium)
- Printed book with maps, photos, text, statistics, and friend comments
- €40–€240 depending on size/format (hardcover or layflat)
- Free ebook version for premium subscribers
- 5% discount on books with premium
---
## 3D Flyover Video
- Free feature: auto-generates a cinematic 3D video of your route
- Shareable directly from the app
- No native app required for viewing (shareable link)
---
## Offline Capability
- Tracker works fully offline (GPS, flight detection)
- Footprints can be created and edited offline
- Syncs when connected
---
## What Makes FindPenguins Distinctive
1. **Flight detection**: auto-detects flights and logs them on the route
2. **3D flyover video**: compelling visual output, free
3. **Delayed sharing**: useful for solo travelers worried about broadcasting real-time location
4. **Richer social layer**: comments on individual footprints, community discovery
5. **Destination exploration**: browse real traveler posts for any place (like a user-generated travel guide)
6. **Premium photo books**: more polished physical product with friend comments included
---
## Limitations (relevant to our context)
- Requires native app for GPS/flight tracking — not reproducible in a web CMS
- Social discovery features irrelevant for a solo personal blog
- Group trip feature has a bug (co-travelers can delete your content)
- Premium paywall for basic things like more than 6 photos per post
- Community/social focus means the UX is designed around a social graph we don't have
- 3D flyover video requires proprietary rendering pipeline
- Real-time delayed sharing is a privacy feature for apps broadcasting live location — moot for a blog that posts after the fact
+137
View File
@@ -0,0 +1,137 @@
# Polarsteps — Feature Research
*Researched June 2026. Source: polarsteps.com, App Store, support docs, reviews.*
---
## Overview
Polarsteps is a travel tracking and journaling app used by 20M+ travelers. It is ad-free, primarily free to use, with paid travel books as the main revenue stream. It positions itself as "by travelers, for travelers" — clean, minimal, focused on personal memory-keeping and sharing with close friends/family rather than a social discovery platform.
---
## Core User Flow
1. User creates a **Trip** (name, start/end dates, cover photo)
2. App runs in background and **auto-tracks GPS route** continuously (dots on map)
3. App auto-generates **Step Suggestions** when you stay somewhere — a notification asks "Are you in [City]? Add a step?"
4. User accepts or manually creates a **Step**: a journal entry tied to a location
5. Each Step gets: title, text, photos/videos, date, and auto-populated metadata
6. Steps appear in a **timeline feed** ordered chronologically
7. Trip is shareable via link; friends/family can follow in real time
---
## Map Features
- **Route tracking**: GPS + WiFi + cell towers → white dots plotted on world map as you move
- **Offline tracking**: stores locally, syncs when connected
- **Travel Tracker steps**: actual route taken (not straight lines), with transport mode tagging (car, bus, train, taxi, walk, fly)
- **Route visualization**: colored line on map connecting all steps
- **Countries/continents visited**: highlighted on world map
- **Battery usage**: ~4% per day (very efficient)
- **World completion %**: gamified stat showing % of the globe visited
- Tracks distance, speed, and estimated travel time between steps
---
## Steps (Journal Entries)
Each "Step" is the core content unit:
- **Location**: auto-detected city/country, adjustable
- **Title**: auto-suggested from location, editable
- **Date/time**: auto from GPS
- **Text**: rich freeform journal text
- **Photos**: unlimited (mobile app), displayed in a grid/carousel
- **Videos**: supported on mobile only, excluded from printed books
- **Weather**: auto-populated (temperature, conditions) at time of step
- **Altitude**: recorded from GPS
- **GPS coordinates**: stored and displayed
- **Transport**: mode of travel to reach this step (car/train/fly/etc.)
---
## Photo Handling
- Add photos directly from camera roll per step
- Choose cover photo for the trip
- Photos displayed in gallery within each step
- High-resolution stored for travel book printing
- No hard per-step photo limit mentioned (effectively unlimited)
- Videos supported on mobile, excluded from print
---
## Statistics
Displayed on trip and profile level:
- Total km/miles traveled
- Countries visited (count + list)
- Continents visited
- Number of steps/entries
- Days on the road
- World completion percentage
- Furthest point from home
- Number of followers / following
---
## Sharing & Social Features
- **Privacy**: "Only me", "Followers only", or "Public"
- **Shareable link**: send a URL to anyone to follow the trip live
- **Followers**: people can follow your profile and see all public trips
- **Reactions/comments**: followers can react and comment on steps
- **Social media sharing**: export to Facebook, Instagram, etc.
- **Travel Buddy**: invite friends to join and co-document a trip together
- **Editors' Choice**: curated featured trips for discovery (like a magazine)
- **Trip Reels**: auto-generated short video from photos/videos + visited places, shareable
---
## Planning Features (2025 addition)
- **AI Itinerary Builder**: generates multi-stop travel plan on the map, with transport modes
- **Accommodation import**: forward booking confirmation emails to plan@polarsteps.app → appears on map
- **Activity planning**: add stays, restaurants, activities to itinerary
- **Travel DNA**: personality-based personalization for AI suggestions
---
## Travel Book
- Print a hardback book of your trip (€3080, 24300 pages)
- Each step on its own page: photo, text, map thumbnail, metadata
- Statistics page at the end
- Designed, high-quality output — main revenue for Polarsteps
---
## Offline Capability
- Full offline posting (text, photos)
- GPS route tracking continues offline
- All data syncs when back online
---
## What Makes Polarsteps Distinctive
1. **Simplicity** — minimal UI, auto-everything, almost no friction to log a day
2. **Route tracking** — actually shows where you walked/drove, not just pins
3. **"Step suggestions"** — proactive nudges to journal without opening the app
4. **Printed book** — the premium product, excellent quality
5. **Ad-free** — rare among free travel apps
6. **Battery efficiency** — 4% per day, usable on long trips
---
## Limitations (relevant to our context)
- Requires native mobile app for GPS tracking (cannot do in browser)
- Videos excluded from print
- Social/discovery features add little value for a solo personal blog
- AI itinerary builder overkill for one-person blog
- Travel Buddy / follower system assumes a social graph we don't have
- Reels require the native app video processing pipeline
+89
View File
@@ -0,0 +1,89 @@
# Experimental Branch Summary
*Branch: `experimental-polar-steps`. Ready for morning review.*
---
## What Was Done
This branch researched Polarsteps and FindPenguins, distilled their best ideas for a solo travel blog on Grav CMS, planned four milestones, and implemented all four.
---
## What Was Built
### Milestone 1 — Entry Enrichment
- **Location badge** (`📍 City, Country`) on entry page and tracker feed cards
- **Weather badge** (`⛅ Partly cloudy · 19°C`) on entry page header
- **"Get Weather" button** on post form — auto-fetches via Open-Meteo (free, no key)
- **Photo gallery** on entry pages — 2-col/3-col grid with full lightbox
- **Hero image** on feed cards — falls back to first photo if no hero_image set
- New post form fields: City, Country, weather auto-fill
### Milestone 2 — Interactive Map (`/map`)
- Leaflet.js with OpenStreetMap tiles
- Marker per entry with GPS, route polyline in date order
- Most recent entry highlighted
- Click marker → popup with date, title, link to entry
- Full-height map, mobile touch-friendly
### Milestone 3 — Statistics Page (`/stats`)
- Days on the road, entries posted, countries visited, distance traveled
- Auto-updates as new entries are posted
### Milestone 4 — Mini-map on Tracker Feed
- Compact map above the entry list on /tracker
- Tap marker → navigates to that entry
- Hidden when no entries have GPS
---
## Navigation
Three links in site header: **Journal · Map · Stats**
---
## Manual Verification Required on Mobile
1. Upload photos → verify gallery grid + lightbox works
2. Upload photo → verify hero image on feed card
3. Open /post logged in → Get Location + Get Weather buttons work end-to-end
4. Submit full entry → verify all badges appear
5. Open /map on phone → pinch zoom (no page scroll behind map)
6. Open /tracker → tap mini-map marker → navigates to entry
7. Check browser console → no JS errors
---
---
## UI Redesign (2026-06-18)
Design direction: **Field Notes** — editorial travel journal aesthetic, not social app.
- **Typography:** DM Serif Display (headings) + DM Sans (UI/body) — loaded via Google Fonts
- **Accent color:** Deep teal `#1F6B5A` (replaces generic blue)
- **Background:** Warm paper `#F7F5F2`
- **Signature element:** Full-bleed 16:9 hero photos on feed cards with translucent date/location overlay
- **Design tokens:** `user/themes/intotheeast/css/tokens.css` — single source of truth for all values
- **Post form:** GPS lat/lng fields hidden from UI (filled by JS), cleaner status feedback
- **Design spec:** `user/docs/design/design-spec.md`
- **Implementation plan:** `user/docs/superpowers/plans/2026-06-18-ui-redesign.md`
---
## Demo Content
Seven sample entries for design/QA showcasing: feed, map route, stats, weather variety (including snow).
```bash
make demo-load # copy entries into tracker, clear cache
make demo-reset # remove demo entries, clear cache
```
Full instructions: `user/docs/demo/README.md`
---
## What Was Skipped
Background GPS tracking, social features, video reels, 3D flyover, printed books, AI itinerary builder — all require native apps or don't suit a solo personal blog. Full reasoning in `docs/pm-analysis.md`.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,23 @@
---
title: 'Wheels Down at Narita'
date: '2026-03-25 15:40'
template: entry
published: true
hero_image: ''
lat: '35.7720'
lng: '140.3929'
location_city: 'Tokyo'
location_country: 'Japan'
weather_temp_c: 16
weather_desc: 'Sunny'
---
Eleven hours of flight time, two mediocre films, and one surprisingly good noodle dish from the trolley. Then the descent through scattered cloud, the first glimpse of grey-green patchwork below, and that particular feeling when the wheels finally touch down on a continent you have never stood on before.
Narita is large and orderly and very, very calm. Immigration moved faster than any airport I have ever been through. The officer looked at my passport, looked at me, stamped it once, and handed it back without a word. That was it. Entry to Japan.
The Narita Express runs direct to Shinjuku. I found a window seat and spent 90 minutes watching the city materialise from the outside in — rice fields giving way to low housing, then arterial roads, then the sudden verticality of central Tokyo rising up all at once as if someone just switched a setting.
The hotel is small but perfect. A room roughly the width of my arms outstretched, a window looking onto a grey concrete wall, and a bed that feels like sleeping on a cloud. I went out for ramen at a place around the corner where you order from a vending machine and sit at a counter alone with a small wooden partition between you and the next person. Nobody spoke. It was the best meal I have had in months.
Tomorrow: Ueno. The forecast says the cherry blossoms may finally be open.
@@ -0,0 +1,23 @@
---
title: 'Sakura in Ueno Park'
date: '2026-03-26 10:00'
template: entry
published: true
hero_image: ''
lat: '35.7155'
lng: '139.7753'
location_city: 'Tokyo'
location_country: 'Japan'
weather_temp_c: 14
weather_desc: 'Partly cloudy'
---
I arrived at Ueno Park at ten in the morning thinking I would beat the crowds. I was wrong. Several thousand people had the same idea, and the same Instagram instinct. But here is the thing about cherry blossom season in Japan — the crowds are almost part of it. Families with picnic sheets. Couples with matching outfits. Office workers in suits sitting on blue tarps eating convenience-store onigiri. Everyone doing the same thing: looking up at the same trees.
The blossoms were at maybe seventy percent. Enough to understand what the fuss is about.
I walked the park from one end to the other and then sat under a particularly generous tree for about an hour just watching people react to something beautiful. There is a Japanese word for it — *hanami* — which translates roughly as "flower viewing" and is more or less an entire cultural practice. You do not rush past the blossoms. You sit with them.
Later I found the Tokyo National Museum at the top of the park. Three floors of Japanese history, almost entirely in Japanese, which I cannot read, but context is its own language. A display case of Edo-period swords. Painted screens showing mountains I now recognise. A reconstructed tea house in the garden, closed for the season but visible through the glass.
Dinner: tonkatsu on a side street off Ueno-Okachimachi station. The woman who runs the counter has been there for at least thirty years by the look of it. She refilled my miso soup without being asked, twice.
@@ -0,0 +1,27 @@
---
title: 'Summit Clouds and Snow'
date: '2026-03-27 07:15'
template: entry
published: true
hero_image: ''
lat: '35.5095'
lng: '138.7646'
location_city: 'Kawaguchiko'
location_country: 'Japan'
weather_temp_c: 1
weather_desc: 'Snow'
---
Nobody told me it would snow.
I took the early bus from Shinjuku at 6:45am because the forecast for the Fuji Five Lakes region said "clear morning, clouds by noon." That is the window you want — Fuji is notorious for hiding inside its own weather system, and most visitors spend an entire day staring at a blank white sky where a mountain ought to be.
I got the mountain. For about forty minutes.
By the time the bus pulled into Kawaguchiko, the first flakes were already coming down. Light at first — the decorative kind that you hold your hand out for. Then, steadily, not decorative at all. I walked down to the lake with my bag under my jacket and stood at the water's edge while the snow thickened and Fuji turned from a sharply defined white cone into a suggestion, and then into nothing.
The lake surface was perfectly still. The snow fell straight down. There were no other tourists on the path, or if there were I could not see them. It was one of those moments of completely accidental solitude that you cannot plan for and would not trade.
I sat on a wooden bench on the lakefront for longer than made any meteorological sense. The snow kept falling. A single cormorant sat on a rock offshore and did not move the entire time I was there.
Caught the bus back to Shinjuku in the afternoon. The mountain never reappeared. I do not mind even slightly.
@@ -0,0 +1,23 @@
---
title: 'A Thousand Torii Gates'
date: '2026-03-28 11:30'
template: entry
published: true
hero_image: ''
lat: '34.9671'
lng: '135.7727'
location_city: 'Kyoto'
location_country: 'Japan'
weather_temp_c: 18
weather_desc: 'Sunny'
---
The Shinkansen from Tokyo to Kyoto takes two hours and twelve minutes. You travel at 285km/h. At one point Fuji appears out the right-hand window, clear and enormous and completely snow-covered, and the entire carriage rotates slightly to look at it. The mountain is visible for about four minutes. Then it is gone.
Kyoto is everything Tokyo is not: low, slow, wooden. The streets around Fushimi Inari were already warm with tourists at 11am but the shrine itself is large enough to absorb them. You walk under a tunnel of orange torii gates — thousands of them, each donated by a business and engraved with the donor's name — up a hillside through cedar forest, and the further you climb the more the crowd thins out.
I walked for two hours. Most visitors turn back at the first lookout. I kept going, past smaller shrines and stone fox statues and mossy steps worn down by a century of feet. Near the top the path was almost empty. The air smelled of pine and incense.
The city below spread out in all directions. Very few tall buildings — there are strict height regulations to preserve the sightlines. The Kamo River was a thin silver line running south. Distant mountains still wearing snow.
Dinner at a kaiseki restaurant in Gion, the old entertainment district. Eight small courses, each plated like a small still life. I ate slowly and said nothing and it was the right approach.
@@ -0,0 +1,21 @@
---
title: 'The Deer of Nara'
date: '2026-03-29 14:00'
template: entry
published: true
hero_image: ''
lat: '34.6851'
lng: '135.8048'
location_city: 'Nara'
location_country: 'Japan'
weather_temp_c: 17
weather_desc: 'Partly cloudy'
---
The deer at Nara are not afraid of you. This is the first thing you notice — not just that they tolerate humans, but that they regard you with a kind of benign indifference that borders on contempt. They walk into traffic. They push their noses into your pockets. They bow, which sounds enchanting and is, in practice, a manoeuvre to knock crackers out of your hand faster.
I bought a small bundle of *shika senbei* — deer crackers — from a vendor at the park entrance. They were gone in about forty-five seconds to a small gang of deer who appeared from nowhere and surrounded me in a tight semicircle. One bit my sleeve. Another headbutted a woman walking past who was not even involved.
Todai-ji temple is at the far end of the park and contains the largest bronze Buddha in Japan. The building is immense — apparently it was rebuilt at two-thirds the original size in the 18th century and is still the largest wooden structure in the world. The Buddha sits in the dim interior looking calm about this. There is a wooden pillar near the back with a hole cut through its base the same width as one of the Buddha's nostrils. Schoolchildren queue to crawl through it. Wisdom awaits on the other side.
The train back to Kyoto takes 45 minutes through flat agricultural land. The deer do not follow you.
@@ -0,0 +1,25 @@
---
title: 'Dotonbori After Dark'
date: '2026-03-30 18:00'
template: entry
published: true
hero_image: ''
lat: '34.6687'
lng: '135.5017'
location_city: 'Osaka'
location_country: 'Japan'
weather_temp_c: 19
weather_desc: 'Cloudy'
---
Osaka is louder than Kyoto and prouder of it. Kyoto has temples and restraint. Osaka has neon and takoyaki and a sign the size of a building advertising a restaurant with a mechanical crab on the front. Both are correct.
I arrived from Kyoto mid-afternoon, dropped my bag, and went directly to Dotonbori to get my bearings before the evening crowd descended. The canal runs through the entertainment district, and on both sides there are restaurants stacked six floors high with illuminated signs competing for your attention so aggressively that after ten minutes you start to tune out the sensory overload and just walk.
At six in the evening the neon started properly. The famous running man billboard. The Glico sign. Streets full of people eating while walking — takoyaki (octopus balls, better than they sound), skewered meats, cones of spicy shrimp. Osaka has a word for its own food philosophy: *kuidaore*, which means "eat until you drop."
I took it as guidance.
Three hours of eating across four separate establishments. Kushikatsu — battered and deep-fried everything — at a counter in an alley so narrow that diners on opposite sides can shake hands across the table. Soft-serve matcha ice cream on the street. Okonomiyaki from a woman who pressed the pancake flat with a heavy iron tool and would not let me touch anything.
The canal was dark and the lights were reflected in it and for a while I just stood on the bridge watching people eat.
@@ -0,0 +1,23 @@
---
title: 'Seoul Calling'
date: '2026-04-01 09:00'
template: entry
published: true
hero_image: ''
lat: '37.5635'
lng: '126.9851'
location_city: 'Seoul'
location_country: 'South Korea'
weather_temp_c: 10
weather_desc: 'Rain'
---
The flight from Osaka to Seoul takes one hour and forty minutes. Shorter than some commutes I have had. At Incheon I changed SIM cards, changed currency, changed alphabet, and walked out into a grey April morning with rain coming in off the Yellow Sea.
Korea hits differently than Japan. Japan felt deliberate and enclosed, every surface managed, every system timed to the second. Seoul feels faster and more argumentative, as if things are still being decided. The streets around Myeongdong were already busy at 9am: coffee shops the size of ballrooms, street vendors selling *hotteok* (sweet pancakes) from portable griddles, and the particular energy of a city that moves at one speed regardless of the weather.
My guesthouse is in Mapo-gu, a neighbourhood that turns out to be significantly cooler than anywhere the guidebooks sent me. Independent coffee roasters. Record shops. A gallery in a converted printing house showing black-and-white photography of the Han River in the 1970s.
I spent the afternoon walking the Han River itself — a massive green ribbon running through the city with dedicated cycling paths, outdoor fitness equipment, and Koreans doing every possible outdoor activity despite the rain. A group of older men playing badminton with very serious expressions. Two people kayaking. A family of five sharing a communal barbecue under an umbrella.
Dinner: Korean fried chicken at a place that opened at 5pm and was full by 5:05. Beer so cold it was almost painful. Outside, the rain kept up steadily. I stayed longer than I meant to.
+6 -2
View File
@@ -4,8 +4,12 @@ date: '2026-06-17 10:00'
template: entry template: entry
published: true published: true
hero_image: '' hero_image: ''
lat: '' lat: '52.3676'
lng: '' lng: '4.9041'
location_city: 'Amsterdam'
location_country: 'Netherlands'
weather_temp_c: 19
weather_desc: 'Partly cloudy'
--- ---
First entry. Bags are packed, passport is ready, the adventure starts here. First entry. Bags are packed, passport is ready, the adventure starts here.
+36 -13
View File
@@ -3,7 +3,16 @@ title: 'New Entry'
template: post-form template: post-form
access: access:
site.login: true site.login: true
login_redirect_here: true
pageconfig:
parent: '/tracker'
slug_field: 'date,title'
overwrite_mode: false
pagefrontmatter:
template: entry
published: true
form: form:
name: new-entry name: new-entry
action: /post action: /post
@@ -37,9 +46,9 @@ form:
- -
name: photos name: photos
label: Photos (max 4) label: Photos (max 4)
type: file type: filepond
multiple: true multiple: true
destination: 'user://pages/01.tracker' destination: '@self'
limit: 4 limit: 4
accept: accept:
- 'image/*' - 'image/*'
@@ -56,23 +65,37 @@ form:
type: text type: text
placeholder: '' placeholder: ''
-
name: location_city
label: City
type: text
placeholder: 'e.g. Kyoto'
-
name: location_country
label: Country
type: text
placeholder: 'e.g. Japan'
-
name: weather_temp_c
type: hidden
-
name: weather_desc
type: hidden
novalidate: true
buttons: buttons:
- -
type: submit type: submit
value: Post Entry value: Post Daily
classes: btn-post classes: btn-post
process: process:
- -
add-page-by-form: add_page: true
parent: '/tracker'
slug: "{{ form.value.date|date('Y-m-d') }}"
template: 'entry'
frontmatter:
title: '{{ form.value.title }}'
date: '{{ form.value.date }}'
lat: '{{ form.value.lat }}'
lng: '{{ form.value.lng }}'
- -
message: 'Entry posted successfully!' message: 'Entry posted successfully!'
- -
+4
View File
@@ -0,0 +1,4 @@
---
title: 'Trip Map'
template: map
---
+4
View File
@@ -0,0 +1,4 @@
---
title: 'Trip Stats'
template: stats
---
+26
View File
@@ -0,0 +1,26 @@
<?php
namespace Grav\Plugin;
use Grav\Common\Plugin;
use RocketTheme\Toolbox\Event\Event;
class CacheOnSavePlugin extends Plugin
{
public static function getSubscribedEvents(): array
{
return [
'onFormProcessed' => ['onFormProcessed', 0],
];
}
public function onFormProcessed(Event $event): void
{
$form = $event['form'];
if (!$form) {
return;
}
if ($form->getName() === 'new-entry') {
$this->grav['cache']->deleteAll();
}
}
}
+1
View File
@@ -0,0 +1 @@
enabled: true
+591 -69
View File
@@ -1,112 +1,634 @@
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body { body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-family: var(--font-ui);
font-size: 1rem; font-size: var(--text-base);
line-height: 1.6; line-height: var(--leading-normal);
color: #1a1a1a; color: var(--color-ink);
background: #fff; background: var(--color-paper);
-webkit-font-smoothing: antialiased;
} }
.site-main {
max-width: var(--content-width);
margin: 0 auto;
padding: var(--space-8) var(--space-5);
}
@media (min-width: 520px) {
.site-main { padding: var(--space-10) var(--space-6); }
}
/* ── Header ──────────────────────────────────────────────────────────────────── */
.site-header { .site-header {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 1rem 1.25rem; padding: 0 var(--space-5);
border-bottom: 1px solid #e5e5e5; height: var(--site-header-height);
background: var(--color-canvas);
border-top: 3px solid var(--color-accent);
border-bottom: 1px solid var(--color-border);
position: sticky;
top: 0;
z-index: 100;
} }
.site-title { .site-title {
font-weight: 700; font-family: var(--font-display);
font-size: 1.1rem; font-size: var(--text-lg);
font-weight: 400;
letter-spacing: -0.01em;
text-decoration: none; text-decoration: none;
color: inherit; color: var(--color-ink);
line-height: 1;
}
.site-nav {
display: flex;
align-items: center;
gap: var(--space-1);
} }
.site-nav a { .site-nav a {
color: inherit; font-family: var(--font-ui);
font-size: var(--text-sm);
font-weight: 500;
color: var(--color-ink-2);
text-decoration: none; text-decoration: none;
font-size: 0.95rem; padding: var(--space-2) var(--space-3);
padding: 0.5rem; border-radius: var(--radius-sm);
min-height: 44px; min-height: 44px;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
transition: color 0.15s, background 0.15s;
} }
.site-main { .site-nav a:hover { color: var(--color-ink); background: var(--color-paper); }
max-width: 680px; .site-nav a[aria-current="page"] { color: var(--color-accent); font-weight: 600; }
margin: 0 auto;
padding: 1.5rem 1.25rem; @media (max-width: 380px) {
.site-title { font-size: var(--text-md); }
.site-nav a { padding: var(--space-2); font-size: 0.8rem; }
} }
/* Feed */ /* ── Feed ────────────────────────────────────────────────────────────────────── */
.feed { display: flex; flex-direction: column; gap: 2rem; }
.entry-card { .feed { display: flex; flex-direction: column; gap: var(--space-12); }
border-bottom: 1px solid #e5e5e5; .feed-empty { color: var(--color-ink-muted); font-style: italic; }
padding-bottom: 2rem;
}
.entry-date { .entry-card { border-bottom: 1px solid var(--color-border); padding-bottom: var(--space-12); }
.entry-card-inner {
display: block; display: block;
font-size: 0.8rem; text-decoration: none;
color: #666; color: inherit;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 0.4rem;
} }
.entry-card .entry-title { font-size: 1.3rem; margin-bottom: 0.75rem; } /* Card: photo variant */
.entry-card .entry-title a { color: inherit; text-decoration: none; }
.entry-card .entry-title a:hover { text-decoration: underline; }
.entry-thumb { margin-bottom: 0.75rem; } .entry-card-photo {
.entry-thumb img { width: 100%; height: 200px; object-fit: cover; border-radius: 4px; } position: relative;
aspect-ratio: 16 / 9;
border-radius: var(--radius-md);
overflow: hidden;
background: var(--color-border);
margin-bottom: var(--space-5);
}
.entry-excerpt { color: #444; margin-bottom: 0.75rem; } .entry-card-photo img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
transition: transform 0.45s ease;
}
.entry-card-inner:hover .entry-card-photo img { transform: scale(1.04); }
.entry-card-photo-overlay {
position: absolute;
inset: auto 0 0 0;
padding: var(--space-5) var(--space-4) var(--space-3);
background: linear-gradient(to top, rgba(0,0,0,0.58) 0%, transparent 100%);
display: flex;
align-items: flex-end;
gap: var(--space-3);
flex-wrap: wrap;
}
.entry-date-overlay {
font-size: var(--text-xs);
font-weight: 700;
letter-spacing: 0.08em;
color: rgba(255,255,255,0.92);
}
.entry-location-overlay {
font-size: var(--text-xs);
color: rgba(255,255,255,0.85);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 180px;
}
/* Card: text-only variant */
.entry-card-textmeta {
display: flex;
align-items: center;
gap: var(--space-3);
margin-bottom: var(--space-3);
flex-wrap: wrap;
}
.entry-date-plain {
font-size: var(--text-xs);
font-weight: 700;
letter-spacing: 0.07em;
color: var(--color-ink-muted);
}
.entry-location-plain {
font-size: var(--text-xs);
color: var(--color-ink-muted);
}
/* Card body */
.entry-card .entry-title {
font-family: var(--font-display);
font-size: var(--text-xl);
font-weight: 400;
line-height: var(--leading-snug);
color: var(--color-ink);
margin-bottom: var(--space-3);
transition: color 0.15s;
}
.entry-card-inner:hover .entry-title { color: var(--color-accent); }
.entry-excerpt {
font-size: var(--text-base);
line-height: var(--leading-normal);
color: var(--color-ink-2);
margin-bottom: var(--space-3);
}
.entry-read-more { .entry-read-more {
font-size: 0.9rem; font-size: var(--text-sm);
color: #0066cc; font-weight: 500;
text-decoration: none; color: var(--color-accent);
} }
.feed-empty { color: #666; font-style: italic; } /* Location & weather badges (single entry page) */
/* Single entry */ .entry-location {
.entry-header { margin-bottom: 1.5rem; } font-size: var(--text-sm);
.entry-header .entry-date { margin-bottom: 0.5rem; } color: var(--color-ink-2);
.entry .entry-title { font-size: 1.8rem; } display: inline-flex;
.entry-body { margin-bottom: 2rem; } align-items: center;
.entry-body p { margin-bottom: 1em; } gap: var(--space-1);
.entry-body img { max-width: 100%; height: auto; border-radius: 4px; } }
.entry-footer { border-top: 1px solid #e5e5e5; padding-top: 1rem; }
.entry-footer a { color: #0066cc; text-decoration: none; font-size: 0.9rem; }
/* Login form */ .entry-weather {
.login-form { max-width: 400px; margin: 2rem auto; padding: 0 1rem; } font-size: var(--text-sm);
.login-form .form-field { margin-bottom: 1.25rem; } color: var(--color-ink-2);
.login-form .form-label label { display: block; font-size: 0.9rem; font-weight: 600; margin-bottom: 0.4rem; } }
/* ── Single entry ────────────────────────────────────────────────────────────── */
.entry-hero {
width: 100%;
max-height: 480px;
overflow: hidden;
margin-bottom: var(--space-8);
border-radius: var(--radius-md);
}
.entry-hero img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.entry-header { margin-bottom: var(--space-8); }
.entry-header-meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-3);
margin-bottom: var(--space-4);
}
.entry-header .entry-date {
font-size: var(--text-sm);
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--color-ink-muted);
display: inline;
margin-bottom: 0;
}
.entry .entry-title {
font-family: var(--font-display);
font-size: var(--text-2xl);
font-weight: 400;
line-height: var(--leading-snug);
color: var(--color-ink);
margin-bottom: var(--space-4);
}
@media (min-width: 520px) {
.entry .entry-title { font-size: var(--text-3xl); }
}
.entry-title-rule {
height: 1px;
background: var(--color-border);
margin-bottom: var(--space-8);
}
.entry-body { margin-bottom: var(--space-10); }
.entry-body p { margin-bottom: 1.1em; font-size: var(--text-md); line-height: var(--leading-normal); color: var(--color-ink-2); }
.entry-body img { max-width: 100%; height: auto; border-radius: var(--radius-sm); }
.entry-footer { border-top: 1px solid var(--color-border); padding-top: var(--space-5); }
.entry-footer a { color: var(--color-accent); text-decoration: none; font-size: var(--text-sm); font-weight: 500; }
.entry-footer a:hover { color: var(--color-accent-hover); }
/* ── Photo gallery ───────────────────────────────────────────────────────────── */
.entry-gallery {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 3px;
margin-bottom: var(--space-10);
border-radius: var(--radius-md);
overflow: hidden;
}
@media (min-width: 520px) {
.entry-gallery { grid-template-columns: repeat(3, 1fr); }
}
.gallery-thumb {
background: none;
border: none;
padding: 0;
cursor: pointer;
display: block;
aspect-ratio: 1;
overflow: hidden;
}
.gallery-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
transition: opacity 0.15s;
}
.gallery-thumb:hover img,
.gallery-thumb:focus img { opacity: 0.82; }
.gallery-thumb:focus { outline: 2px solid var(--color-accent); outline-offset: 2px; }
/* ── Lightbox ────────────────────────────────────────────────────────────────── */
.lightbox {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.94);
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
}
.lightbox[hidden] { display: none; }
.lightbox-img {
max-width: 92vw;
max-height: 90vh;
object-fit: contain;
border-radius: var(--radius-sm);
display: block;
}
.lightbox-close,
.lightbox-prev,
.lightbox-next {
position: absolute;
background: rgba(255,255,255,0.12);
border: none;
color: #fff;
cursor: pointer;
border-radius: 50%;
width: 44px;
height: 44px;
font-size: 1.4rem;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.15s;
}
.lightbox-close { top: 1rem; right: 1rem; }
.lightbox-prev { left: 0.75rem; top: 50%; transform: translateY(-50%); }
.lightbox-next { right: 0.75rem; top: 50%; transform: translateY(-50%); }
.lightbox-close:hover,
.lightbox-prev:hover,
.lightbox-next:hover { background: rgba(255,255,255,0.26); }
/* ── Map page ────────────────────────────────────────────────────────────────── */
.map-page .site-main { max-width: none; padding: 0; }
.map-container {
height: calc(100vh - var(--site-header-height));
width: 100%;
}
.map-empty {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: var(--color-ink-muted);
font-style: italic;
}
/* ── Stats page ──────────────────────────────────────────────────────────────── */
.stats-heading {
font-family: var(--font-display);
font-size: var(--text-2xl);
font-weight: 400;
margin-bottom: var(--space-8);
color: var(--color-ink);
}
.stats-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--space-4);
margin-bottom: var(--space-8);
}
.stat-block {
background: var(--color-canvas);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: var(--space-6) var(--space-5);
text-align: center;
box-shadow: var(--shadow-sm);
}
.stat-value {
display: block;
font-family: var(--font-display);
font-size: var(--text-3xl);
font-weight: 400;
color: var(--color-accent);
line-height: 1.1;
margin-bottom: var(--space-2);
}
.stat-label {
display: block;
font-size: var(--text-xs);
font-weight: 600;
color: var(--color-ink-muted);
text-transform: uppercase;
letter-spacing: 0.07em;
}
.stats-countries {
font-size: var(--text-sm);
color: var(--color-ink-2);
text-align: center;
line-height: 1.9;
}
.stats-countries-label {
font-weight: 600;
display: block;
margin-bottom: var(--space-2);
color: var(--color-ink);
text-transform: uppercase;
font-size: var(--text-xs);
letter-spacing: 0.07em;
}
.stats-note {
font-size: var(--text-xs);
color: var(--color-ink-muted);
text-align: center;
margin-top: var(--space-6);
}
/* ── Mini-map on tracker feed ────────────────────────────────────────────────── */
.feed-map-wrap {
margin-bottom: var(--space-10);
border-radius: var(--radius-md);
overflow: hidden;
border: 1px solid var(--color-border);
box-shadow: var(--shadow-sm);
}
.feed-map {
height: 240px;
width: 100%;
}
@media (min-width: 520px) {
.feed-map { height: 300px; }
}
.feed-map-link {
display: block;
text-align: right;
font-size: var(--text-xs);
font-weight: 500;
color: var(--color-accent);
text-decoration: none;
padding: var(--space-2) var(--space-4);
background: var(--color-paper);
border-top: 1px solid var(--color-border);
}
.feed-map-link:hover { color: var(--color-accent-hover); }
/* ── Login form ─────────────────────────────────────────────────────────────── */
.login-form { max-width: 400px; margin: var(--space-8) auto; padding: 0 var(--space-4); }
.login-form .form-field { margin-bottom: var(--space-5); }
.login-form .form-label label { display: block; font-size: var(--text-sm); font-weight: 600; margin-bottom: var(--space-2); }
.login-form input[type="text"], .login-form input[type="text"],
.login-form input[type="password"], .login-form input[type="password"],
.login-form input[type="email"] { .login-form input[type="email"] {
width: 100%; box-sizing: border-box; width: 100%;
font-size: 1rem; padding: 0.75rem 1rem; font-family: var(--font-ui);
border: 1px solid #ccc; border-radius: 6px; font-size: var(--text-base);
padding: 0.75rem 1rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-canvas);
color: var(--color-ink);
min-height: 44px; min-height: 44px;
} }
.login-form .form-actions { margin-top: 1.5rem; display: flex; flex-direction: column; gap: 0.75rem; } .login-form input:focus {
.login-form .button { display: block; width: 100%; text-align: center; padding: 0.85rem 1rem; min-height: 44px; border-radius: 6px; font-size: 1rem; cursor: pointer; border: none; } outline: 2px solid var(--color-accent);
.login-form .button.primary { background: #0066cc; color: #fff; } outline-offset: 1px;
.login-form .button.secondary { background: #f0f0f0; color: #333; text-decoration: none; line-height: 44px; padding: 0 1rem; } border-color: var(--color-accent);
.login-form .rememberme { display: flex; align-items: center; gap: 0.5rem; font-size: 0.9rem; } }
.login-form .form-actions { margin-top: var(--space-6); display: flex; flex-direction: column; gap: var(--space-3); }
/* Post form */ .login-form .button { display: block; width: 100%; text-align: center; padding: 0.85rem 1rem; min-height: 44px; border-radius: var(--radius-md); font-size: var(--text-base); font-family: var(--font-ui); font-weight: 600; cursor: pointer; border: none; }
.post-form-wrap h1 { font-size: 1.4rem; margin-bottom: 1.5rem; } .login-form .button.primary { background: var(--color-accent); color: var(--color-accent-on); }
.post-form-wrap .btn-location { .login-form .button.primary:hover { background: var(--color-accent-hover); }
display: block; width: 100%; margin-top: 1rem; .login-form .button.secondary { background: #f0f0f0; color: #333; text-decoration: none; line-height: 44px; padding: 0 1rem; }
padding: 0.85rem 1rem; min-height: 44px; .login-form .rememberme { display: flex; align-items: center; gap: var(--space-2); font-size: var(--text-sm); }
background: #f0f0f0; border: 1px solid #ccc;
border-radius: 6px; font-size: 1rem; cursor: pointer; /* ── Post form ───────────────────────────────────────────────────────────────── */
.post-form-wrap h1 {
font-family: var(--font-display);
font-size: var(--text-xl);
font-weight: 400;
margin-bottom: var(--space-6);
color: var(--color-ink);
}
/* Hide GPS coordinate fields — filled by JS, not user-facing */
.post-form-wrap .form-field:has(input[name="data[lat]"]),
.post-form-wrap .form-field:has(input[name="data[lng]"]) { display: none !important; }
/* Grav form field inputs */
.post-form-wrap .form-field { margin-bottom: var(--space-5); }
.post-form-wrap .form-label label {
display: block;
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-ink);
margin-bottom: var(--space-2);
}
.post-form-wrap input[type="text"],
.post-form-wrap input[type="datetime-local"],
.post-form-wrap textarea {
width: 100%;
font-family: var(--font-ui);
font-size: var(--text-base);
padding: 0.875rem 1rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-canvas);
color: var(--color-ink);
min-height: 44px;
transition: border-color 0.15s;
-webkit-appearance: none;
}
.post-form-wrap input:focus,
.post-form-wrap textarea:focus {
outline: 2px solid var(--color-accent);
outline-offset: 1px;
border-color: var(--color-accent);
}
.post-form-wrap textarea { resize: vertical; min-height: 160px; line-height: var(--leading-normal); }
/* Submit button */
.post-form-wrap .btn-post {
display: block;
width: 100%;
box-sizing: border-box;
padding: 1.1rem 1rem;
min-height: 56px;
background: var(--color-accent);
color: var(--color-accent-on);
border: none;
border-radius: var(--radius-md);
font-family: var(--font-ui);
font-size: var(--text-md);
font-weight: 600;
cursor: pointer;
transition: background 0.15s;
margin-top: var(--space-6);
text-align: center;
-webkit-appearance: none;
appearance: none;
}
.post-form-wrap .btn-post:hover { background: var(--color-accent-hover); }
/* Inline field validation */
.post-form-wrap .field-invalid {
border-color: #c0392b !important;
outline-color: #c0392b !important;
}
.post-form-wrap .field-error {
display: block;
color: #c0392b;
font-size: var(--text-sm);
margin-top: var(--space-1);
}
/* Location / weather action buttons */
.form-action-row {
display: flex;
gap: var(--space-3);
margin-top: var(--space-5);
}
.btn-action {
flex: 1;
padding: 0.75rem var(--space-3);
min-height: 44px;
background: var(--color-canvas);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
font-family: var(--font-ui);
font-size: var(--text-sm);
font-weight: 500;
cursor: pointer;
color: var(--color-ink);
transition: background 0.15s, border-color 0.15s;
}
.btn-action:hover { background: var(--color-paper); border-color: var(--color-accent); }
/* Status feedback */
.form-status {
font-size: var(--text-sm);
color: var(--color-ink-muted);
margin-top: var(--space-2);
min-height: 1.4em;
}
.form-status--ok { color: var(--color-accent); }
.form-status--err { color: #B44A2A; }
/* ── Accessibility & motion ──────────────────────────────────────────────────── */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
} }
.post-form-wrap .location-status { font-size: 0.85rem; color: #666; margin-top: 0.5rem; text-align: center; }
+60
View File
@@ -0,0 +1,60 @@
:root {
/* ── Colors ─────────────────────────────────────────────── */
--color-ink: #17171A;
--color-ink-2: #4A4850;
--color-ink-muted: #9896A0;
--color-paper: #F7F5F2;
--color-canvas: #FFFFFF;
--color-border: #E8E6E3;
--color-border-soft: #F0EDEA;
--color-accent: #1F6B5A;
--color-accent-hover: #185647;
--color-accent-light: #EBF5F2;
--color-accent-on: #FFFFFF;
/* ── Fonts ───────────────────────────────────────────────── */
--font-display: 'DM Serif Display', Georgia, serif;
--font-ui: 'DM Sans', -apple-system, BlinkMacSystemFont, sans-serif;
/* ── Type scale ──────────────────────────────────────────── */
--text-xs: 0.75rem;
--text-sm: 0.875rem;
--text-base: 1rem;
--text-md: 1.125rem;
--text-lg: 1.375rem;
--text-xl: 1.75rem;
--text-2xl: 2.25rem;
--text-3xl: 3rem;
/* ── Leading ─────────────────────────────────────────────── */
--leading-tight: 1.2;
--leading-snug: 1.35;
--leading-normal: 1.65;
/* ── Spacing (4px grid) ──────────────────────────────────── */
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-3: 0.75rem;
--space-4: 1rem;
--space-5: 1.25rem;
--space-6: 1.5rem;
--space-8: 2rem;
--space-10: 2.5rem;
--space-12: 3rem;
--space-16: 4rem;
/* ── Radius ──────────────────────────────────────────────── */
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 12px;
--radius-full: 9999px;
/* ── Shadows ─────────────────────────────────────────────── */
--shadow-sm: 0 1px 3px rgba(0,0,0,0.08);
--shadow-md: 0 4px 12px rgba(0,0,0,0.10);
--shadow-lg: 0 8px 24px rgba(0,0,0,0.14);
/* ── Layout ──────────────────────────────────────────────── */
--content-width: 720px;
--site-header-height: 60px;
}
+113 -4
View File
@@ -1,16 +1,125 @@
{% extends 'default.html.twig' %} {% extends 'default.html.twig' %}
{% block content %} {% block content %}
{% set weather_icons = {
'Sunny': '☀️', 'Partly cloudy': '⛅', 'Cloudy': '☁️',
'Foggy': '🌫️', 'Drizzle': '🌦️', 'Rain': '🌧️',
'Snow': '❄️', 'Thunderstorm': '⛈️'
} %}
{% set hero = null %}
{% if page.header.hero_image and page.media[page.header.hero_image] is defined %}
{% set hero = page.media[page.header.hero_image] %}
{% elseif page.media.images|length > 0 %}
{% set hero = page.media.images|first %}
{% endif %}
<article class="entry"> <article class="entry">
{% if hero %}
<div class="entry-hero">
<img src="{{ hero.cropResize(1440, 720).url }}" alt="{{ page.title }}" loading="eager">
</div>
{% endif %}
<header class="entry-header"> <header class="entry-header">
<div class="entry-header-meta">
<time class="entry-date" datetime="{{ page.date|date('Y-m-d') }}"> <time class="entry-date" datetime="{{ page.date|date('Y-m-d') }}">
{{ page.date|date('l, d F Y') }} {{ page.date|date('l, d F Y') }}
</time> </time>
<h1 class="entry-title">{{ page.title }}</h1> {% if page.header.location_city or page.header.location_country %}
</header> <p class="entry-location">
<div class="entry-body"> {%- set _loc = [] -%}
{{ page.content }} {%- if page.header.location_city -%}{%- set _loc = _loc|merge([page.header.location_city]) -%}{%- endif -%}
{%- if page.header.location_country -%}{%- set _loc = _loc|merge([page.header.location_country]) -%}{%- endif -%}
📍 {{ _loc|join(', ') }}
</p>
{% endif %}
{% if page.header.weather_desc or page.header.weather_temp_c %}
<p class="entry-weather">
{% if page.header.weather_desc %}
{{ weather_icons[page.header.weather_desc] ?? '🌡️' }} {{ page.header.weather_desc }}
{% endif %}
{% if page.header.weather_temp_c %}
· {{ page.header.weather_temp_c|round }}°C
{% endif %}
</p>
{% endif %}
</div> </div>
<h1 class="entry-title">{{ page.title }}</h1>
<div class="entry-title-rule"></div>
</header>
<div class="entry-body">
{{ page.content|raw }}
</div>
{% set images = page.media.images %}
{% if images|length > 0 %}
<div class="entry-gallery" id="entry-gallery">
{% for image in images %}
<button class="gallery-thumb" data-full="{{ image.url }}" data-alt="{{ image.filename }}" aria-label="View {{ image.filename }}">
<img src="{{ image.cropResize(300, 300).url }}" alt="{{ image.filename }}" loading="lazy">
</button>
{% endfor %}
</div>
<div class="lightbox" id="lightbox" role="dialog" aria-modal="true" aria-label="Photo viewer" hidden>
<button class="lightbox-close" id="lb-close" aria-label="Close">✕</button>
<button class="lightbox-prev" id="lb-prev" aria-label="Previous"></button>
<img class="lightbox-img" id="lb-img" src="" alt="">
<button class="lightbox-next" id="lb-next" aria-label="Next"></button>
</div>
<script>
(function() {
var gallery = document.getElementById('entry-gallery');
var lightbox = document.getElementById('lightbox');
var lbImg = document.getElementById('lb-img');
var thumbs = Array.from(gallery.querySelectorAll('.gallery-thumb'));
var current = 0;
function open(index) {
current = index;
var btn = thumbs[index];
lbImg.src = btn.dataset.full;
lbImg.alt = btn.dataset.alt;
lightbox.hidden = false;
document.body.style.overflow = 'hidden';
document.getElementById('lb-close').focus();
}
function close() {
lightbox.hidden = true;
document.body.style.overflow = '';
thumbs[current].focus();
}
function prev() { open((current - 1 + thumbs.length) % thumbs.length); }
function next() { open((current + 1) % thumbs.length); }
thumbs.forEach(function(btn, i) {
btn.addEventListener('click', function() { open(i); });
});
document.getElementById('lb-close').addEventListener('click', close);
document.getElementById('lb-prev').addEventListener('click', prev);
document.getElementById('lb-next').addEventListener('click', next);
lightbox.addEventListener('click', function(e) {
if (e.target === lightbox) close();
});
document.addEventListener('keydown', function(e) {
if (lightbox.hidden) return;
if (e.key === 'Escape') close();
if (e.key === 'ArrowLeft') prev();
if (e.key === 'ArrowRight') next();
});
})();
</script>
{% endif %}
<footer class="entry-footer"> <footer class="entry-footer">
<a href="{{ base_url_absolute }}/tracker">← Back to journal</a> <a href="{{ base_url_absolute }}/tracker">← Back to journal</a>
</footer> </footer>
@@ -0,0 +1,86 @@
{% extends 'partials/base.html.twig' %}
{% block content %}
{% set tracker_page = grav.pages.find('/tracker') %}
{% set all_entries = tracker_page ? tracker_page.children.published() : [] %}
{% set map_entries = [] %}
{% for entry in all_entries %}
{% if entry.header.lat is not empty and entry.header.lng is not empty %}
{% set hero_url = null %}
{% if entry.header.hero_image and entry.media[entry.header.hero_image] is defined %}
{% set hero_url = entry.media[entry.header.hero_image].cropResize(240, 135).url %}
{% elseif entry.media.images|length > 0 %}
{% set hero_url = entry.media.images|first.cropResize(240, 135).url %}
{% endif %}
{% set map_entries = map_entries|merge([{
'lat': entry.header.lat|number_format(6, '.', ''),
'lng': entry.header.lng|number_format(6, '.', ''),
'title': entry.title,
'date': entry.date|date('d M Y'),
'url': entry.url,
'hero': hero_url
}]) %}
{% endif %}
{% endfor %}
<div class="map-container" id="trip-map"></div>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/leaflet@1.9.4/dist/leaflet.min.css">
<script src="https://cdn.jsdelivr.net/npm/leaflet@1.9.4/dist/leaflet.min.js"></script>
<script>
var ENTRIES = {{ map_entries|json_encode|raw }};
var map = L.map('trip-map', { minZoom: 2, maxZoom: 18 });
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
}).addTo(map);
if (ENTRIES.length === 0) {
map.setView([20, 0], 2);
var emptyDiv = document.createElement('div');
emptyDiv.className = 'map-empty';
emptyDiv.textContent = 'No locations yet — entries with GPS will appear here.';
document.getElementById('trip-map').appendChild(emptyDiv);
} else {
var latLngs = ENTRIES.map(function(e) { return [parseFloat(e.lat), parseFloat(e.lng)]; });
// Route polyline
L.polyline(latLngs, { color: '#1F6B5A', weight: 3, opacity: 0.7 }).addTo(map);
// Markers
ENTRIES.forEach(function(entry, i) {
var isLatest = (i === ENTRIES.length - 1);
var size = isLatest ? 18 : 12;
var color = isLatest ? '#155244' : '#1F6B5A';
var icon = L.divIcon({
className: '',
html: '<div style="width:' + size + 'px;height:' + size + 'px;background:' + color + ';border:2px solid #fff;border-radius:50%;box-shadow:0 1px 4px rgba(0,0,0,0.4);' + (isLatest ? 'box-shadow:0 0 0 3px rgba(31,107,90,0.3),0 1px 4px rgba(0,0,0,0.4);' : '') + '"></div>',
iconSize: [size, size],
iconAnchor: [size/2, size/2],
popupAnchor: [0, -(size/2 + 4)]
});
var popupContent = '<div style="font-family:-apple-system,sans-serif;width:180px;">';
if (entry.hero) {
popupContent += '<img src="' + entry.hero + '" style="width:100%;height:100px;object-fit:cover;border-radius:4px;display:block;margin-bottom:8px;" alt="">';
}
popupContent += '<div style="font-size:0.75rem;color:#666;margin-bottom:2px;">📅 ' + entry.date + '</div>';
popupContent += '<div style="font-weight:600;font-size:0.9rem;margin-bottom:8px;">' + entry.title + '</div>';
popupContent += '<a href="' + entry.url + '" style="color:#1F6B5A;font-size:0.85rem;text-decoration:none;">Read entry →</a>';
popupContent += '</div>';
L.marker([parseFloat(entry.lat), parseFloat(entry.lng)], { icon: icon })
.addTo(map)
.bindPopup(popupContent, { maxWidth: 200 });
});
// Fit bounds with padding
var bounds = L.latLngBounds(latLngs);
map.fitBounds(bounds, { padding: [40, 40] });
}
</script>
{% endblock %}
@@ -4,17 +4,26 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% if page.title %}{{ page.title }} | {% endif %}{{ site.title }}</title> <title>{% if page.title %}{{ page.title }} | {% endif %}{{ site.title }}</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,400;0,9..40,500;0,9..40,600;1,9..40,400&family=DM+Serif+Display:ital@0;1&display=swap" rel="stylesheet">
<link rel="stylesheet" href="{{ url('theme://css/tokens.css') }}">
<link rel="stylesheet" href="{{ url('theme://css/style.css') }}"> <link rel="stylesheet" href="{{ url('theme://css/style.css') }}">
{{ assets.css()|raw }}
{{ assets.js()|raw }}
</head> </head>
<body> <body class="{% if page.template == 'map' %}map-page{% endif %}">
<header class="site-header"> <header class="site-header">
<a class="site-title" href="{{ base_url_absolute }}">{{ site.title }}</a> <a class="site-title" href="{{ base_url_absolute }}">into the east</a>
<nav class="site-nav"> <nav class="site-nav" aria-label="Main navigation">
<a href="{{ base_url_absolute }}/tracker">Journal</a> <a href="{{ base_url_absolute }}/tracker"{% if page.url starts with '/tracker' or page.template == 'entry' %} aria-current="page"{% endif %}>Journal</a>
<a href="{{ base_url_absolute }}/map"{% if page.url starts with '/map' %} aria-current="page"{% endif %}>Map</a>
<a href="{{ base_url_absolute }}/stats"{% if page.url starts with '/stats' %} aria-current="page"{% endif %}>Stats</a>
</nav> </nav>
</header> </header>
<main class="site-main"> <main class="site-main">
{% block content %}{% endblock %} {% block content %}{% endblock %}
</main> </main>
{{ assets.js('bottom')|raw }}
</body> </body>
</html> </html>
@@ -2,30 +2,124 @@
{% block content %} {% block content %}
<div class="post-form-wrap"> <div class="post-form-wrap">
<h1>{{ page.title }}</h1> <h1>New Entry</h1>
{% include 'forms/form.html.twig' ignore missing %} {% include 'forms/form.html.twig' ignore missing %}
<button type="button" id="get-location" class="btn-location">Get Current Location</button>
<p id="location-status" class="location-status"></p> <div class="form-action-row">
<button type="button" id="get-location" class="btn-action">📍 Get Location</button>
<button type="button" id="get-weather" class="btn-action">🌤 Get Weather</button>
</div>
<p id="location-status" class="form-status"></p>
<p id="weather-status" class="form-status"></p>
</div> </div>
<script> <script>
// Custom validation — form uses novalidate so we handle it here
(function() {
var REQUIRED = ['title', 'content'];
var form = document.querySelector('form[name="new-entry"]');
if (!form) return;
function clearErrors() {
form.querySelectorAll('.field-error').forEach(function(el) { el.remove(); });
form.querySelectorAll('.field-invalid').forEach(function(el) { el.classList.remove('field-invalid'); });
}
function showError(field, msg) {
field.classList.add('field-invalid');
var err = document.createElement('span');
err.className = 'field-error';
err.textContent = msg;
field.parentNode.insertBefore(err, field.nextSibling);
}
form.addEventListener('submit', function(e) {
clearErrors();
var firstInvalid = null;
REQUIRED.forEach(function(name) {
var field = form.querySelector('[name="data[' + name + ']"]');
if (field && !field.value.trim()) {
showError(field, name.charAt(0).toUpperCase() + name.slice(1) + ' is required.');
if (!firstInvalid) firstInvalid = field;
}
});
if (firstInvalid) {
e.preventDefault();
firstInvalid.focus();
firstInvalid.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
});
}());
</script>
<script>
var WMO_MAP = {
0:'Sunny',1:'Partly cloudy',2:'Partly cloudy',3:'Cloudy',
45:'Foggy',48:'Foggy',
51:'Drizzle',53:'Drizzle',55:'Drizzle',56:'Drizzle',57:'Drizzle',
61:'Rain',63:'Rain',65:'Rain',66:'Rain',67:'Rain',80:'Rain',81:'Rain',82:'Rain',
71:'Snow',73:'Snow',75:'Snow',77:'Snow',85:'Snow',86:'Snow',
95:'Thunderstorm',96:'Thunderstorm',99:'Thunderstorm'
};
function getField(name) {
return document.querySelector('input[name="data[' + name + ']"]');
}
document.getElementById('get-location').addEventListener('click', function() { document.getElementById('get-location').addEventListener('click', function() {
var status = document.getElementById('location-status'); var status = document.getElementById('location-status');
status.className = 'form-status';
status.textContent = 'Getting location…'; status.textContent = 'Getting location…';
if (!navigator.geolocation) { if (!navigator.geolocation) {
status.textContent = 'Geolocation not supported by your browser.'; status.textContent = 'Geolocation not supported.';
return; return;
} }
navigator.geolocation.getCurrentPosition(function(pos) { navigator.geolocation.getCurrentPosition(function(pos) {
var lat = pos.coords.latitude.toFixed(6); var lat = pos.coords.latitude.toFixed(6);
var lng = pos.coords.longitude.toFixed(6); var lng = pos.coords.longitude.toFixed(6);
var latField = document.querySelector('input[name="data[lat]"]'); var latField = getField('lat');
var lngField = document.querySelector('input[name="data[lng]"]'); var lngField = getField('lng');
if (latField) latField.value = lat; if (latField) latField.value = lat;
if (lngField) lngField.value = lng; if (lngField) lngField.value = lng;
status.textContent = 'Location set: ' + lat + ', ' + lng; status.textContent = 'Location captured · ' + lat + ', ' + lng;
status.classList.add('form-status--ok');
}, function(err) { }, function(err) {
status.textContent = 'Could not get location: ' + err.message; status.textContent = 'Could not get location: ' + err.message;
status.classList.add('form-status--err');
});
});
document.getElementById('get-weather').addEventListener('click', function() {
var status = document.getElementById('weather-status');
status.className = 'form-status';
var latField = getField('lat');
var lngField = getField('lng');
var lat = latField ? latField.value.trim() : '';
var lng = lngField ? lngField.value.trim() : '';
if (!lat || !lng) {
status.textContent = 'Get location first, then fetch weather.';
return;
}
status.textContent = 'Fetching weather…';
var url = 'https://api.open-meteo.com/v1/forecast?latitude=' + lat +
'&longitude=' + lng +
'&current=temperature_2m,weather_code&temperature_unit=celsius';
fetch(url)
.then(function(r) { return r.json(); })
.then(function(data) {
var temp = Math.round(data.current.temperature_2m);
var code = data.current.weather_code;
var desc = WMO_MAP[code] || 'Cloudy';
var tempField = getField('weather_temp_c');
var descField = getField('weather_desc');
if (tempField) tempField.value = temp;
if (descField) descField.value = desc;
status.textContent = '✓ Weather set · ' + desc + ' · ' + temp + '°C';
status.classList.add('form-status--ok');
})
.catch(function() {
status.textContent = '✗ Could not fetch weather — enter manually if needed.';
status.classList.add('form-status--err');
}); });
}); });
</script> </script>
@@ -0,0 +1,107 @@
{% extends 'partials/base.html.twig' %}
{% block content %}
{% set tracker_page = grav.pages.find('/tracker') %}
{% set all_entries = tracker_page ? tracker_page.children.published() : [] %}
{# Basic counts #}
{% set entry_count = all_entries|length %}
{# Days on the road — find earliest entry timestamp by iterating #}
{% set days_on_road = 0 %}
{% set first_ts = null %}
{% for entry in all_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 %}
{# Countries — unique, case-insensitive dedup, preserve original casing #}
{% set seen_lower = [] %}
{% set country_display = [] %}
{% for entry in all_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 %}
{# GPS points for distance — collect as JSON for JS computation #}
{% set gps_points = [] %}
{% for entry in all_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 %}
<div class="stats-page">
<h1 class="stats-heading">Trip Statistics</h1>
<div class="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">{{ entry_count }}</span>
<span class="stat-label">{{ entry_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 %}
<div class="stats-countries">
<span class="stats-countries-label">Countries visited</span>
{{ country_display|join(' · ') }}
</div>
{% endif %}
<p class="stats-note">Distance is approximate — straight lines between entry locations.</p>
</div>
<script>
var GPS_POINTS = {{ gps_points|json_encode|raw }};
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 total = 0;
for (var i = 1; i < GPS_POINTS.length; i++) {
total += haversine(
parseFloat(GPS_POINTS[i-1][0]), parseFloat(GPS_POINTS[i-1][1]),
parseFloat(GPS_POINTS[i][0]), parseFloat(GPS_POINTS[i][1])
);
}
var el = document.getElementById('stat-distance');
if (GPS_POINTS.length < 2) {
el.textContent = '—';
} else {
el.textContent = '~' + Math.round(total).toLocaleString();
}
</script>
{% endblock %}
+103 -12
View File
@@ -1,26 +1,117 @@
{% extends 'default.html.twig' %} {% extends 'default.html.twig' %}
{% block content %} {% block content %}
{% set entries = page.collection() %}
{# Collect GPS entries for mini-map #}
{% set map_entries = [] %}
{% for entry in entries %}
{% if entry.header.lat is not empty and entry.header.lng is not empty %}
{% set map_entries = map_entries|merge([{
'lat': entry.header.lat,
'lng': entry.header.lng,
'title': entry.title,
'url': entry.url
}]) %}
{% endif %}
{% endfor %}
{% if map_entries|length > 0 %}
<div class="feed-map-wrap">
<div class="feed-map" id="feed-map"></div>
<a class="feed-map-link" href="{{ base_url_absolute }}/map">View full map →</a>
</div>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/leaflet@1.9.4/dist/leaflet.min.css">
<script src="https://cdn.jsdelivr.net/npm/leaflet@1.9.4/dist/leaflet.min.js"></script>
<script>
var FEED_ENTRIES = {{ map_entries|json_encode|raw }};
var map = L.map('feed-map', { minZoom: 2, maxZoom: 18, zoomControl: true });
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
}).addTo(map);
var latLngs = FEED_ENTRIES.map(function(e) { return [parseFloat(e.lat), parseFloat(e.lng)]; });
if (latLngs.length > 1) {
L.polyline(latLngs, { color: '#1F6B5A', weight: 3, opacity: 0.7 }).addTo(map);
}
FEED_ENTRIES.forEach(function(entry, i) {
var isLatest = (i === FEED_ENTRIES.length - 1);
var size = isLatest ? 16 : 10;
var color = isLatest ? '#155244' : '#1F6B5A';
var icon = L.divIcon({
className: '',
html: '<div style="width:' + size + 'px;height:' + size + 'px;background:' + color + ';border:2px solid #fff;border-radius:50%;box-shadow:0 1px 3px rgba(0,0,0,0.35);cursor:pointer;"></div>',
iconSize: [size, size],
iconAnchor: [size/2, size/2]
});
L.marker([parseFloat(entry.lat), parseFloat(entry.lng)], { icon: icon })
.addTo(map)
.on('click', function() { window.location = entry.url; });
});
if (latLngs.length === 1) {
map.setView(latLngs[0], 10);
} else {
map.fitBounds(L.latLngBounds(latLngs), { padding: [20, 20] });
}
</script>
{% endif %}
<div class="feed"> <div class="feed">
{% set entries = page.children %}
{% if entries|length > 0 %} {% if entries|length > 0 %}
{% for entry in entries %} {% for entry in entries %}
<article class="entry-card"> <article class="entry-card">
<time class="entry-date" datetime="{{ entry.date|date('Y-m-d') }}"> {% set hero = null %}
{{ entry.date|date('d M Y') }} {% if entry.header.hero_image and entry.media[entry.header.hero_image] is defined %}
{% set hero = entry.media[entry.header.hero_image] %}
{% elseif entry.media.images|length > 0 %}
{% set hero = entry.media.images|first %}
{% endif %}
<a class="entry-card-inner" href="{{ entry.url }}">
{% if hero %}
<div class="entry-card-photo">
<img src="{{ hero.cropResize(720, 405).url }}" alt="{{ entry.title }}" loading="lazy">
<div class="entry-card-photo-overlay">
<time class="entry-date-overlay" datetime="{{ entry.date|date('Y-m-d') }}">
{{ entry.date|date('d M Y')|upper }}
</time> </time>
<h2 class="entry-title"> {% if entry.header.location_city or entry.header.location_country %}
<a href="{{ entry.url }}">{{ entry.title }}</a> <span class="entry-location-overlay">
</h2> 📍
{% if entry.header.hero_image %} {% if entry.header.location_city %}{{ entry.header.location_city|slice(0,20) }}{% endif %}
<div class="entry-thumb"> {% if entry.header.location_city and entry.header.location_country %}, {% endif %}
<img src="{{ entry.media[entry.header.hero_image].url }}" alt="{{ entry.title }}"> {% if entry.header.location_country %}{{ entry.header.location_country }}{% endif %}
</span>
{% endif %}
</div>
</div>
{% else %}
<div class="entry-card-textmeta">
<time class="entry-date-plain" datetime="{{ entry.date|date('Y-m-d') }}">
{{ entry.date|date('d M Y')|upper }}
</time>
{% if entry.header.location_city or entry.header.location_country %}
<span class="entry-location-plain">
{%- set _loc = [] -%}
{%- if entry.header.location_city -%}{%- set _loc = _loc|merge([entry.header.location_city]) -%}{%- endif -%}
{%- if entry.header.location_country -%}{%- set _loc = _loc|merge([entry.header.location_country]) -%}{%- endif -%}
📍 {{ _loc|join(', ') }}
</span>
{% endif %}
</div> </div>
{% endif %} {% endif %}
<div class="entry-excerpt">
{{ entry.summary }} <div class="entry-card-body">
<h2 class="entry-title">{{ entry.title }}</h2>
<p class="entry-excerpt">{{ entry.summary|striptags|slice(0, 250)|trim }}</p>
<span class="entry-read-more">Read entry →</span>
</div> </div>
<a class="entry-read-more" href="{{ entry.url }}">Read more →</a> </a>
</article> </article>
{% endfor %} {% endfor %}
{% else %} {% else %}