Tracker ordering fix + March–April fixture entries #1
@@ -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}¤t=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
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user