Files
intotheeast-com/docs/working/specs/2026-06-21-travel-memories-design.md
T
2026-06-21 15:05:09 +02:00

13 KiB

travel-memories — Design Spec

Date: 2026-06-21 Status: Draft

Overview

travel-memories is a personal local web app for turning Immich photo albums into Grav CMS journal entries and story pages. It runs in Docker alongside the existing Grav dev environment, connects to an Immich instance on the local network, and guides the user through a six-phase workflow: select album → triage photos → curate selection → group into entries → write content → export to Grav. Progress is saved continuously so work can be paused and resumed at any stage.


Scope

What this covers

  • Immich album browsing and photo selection (read-only access to Immich)
  • A structured six-phase workflow with pause/resume at any phase
  • Journal entries and story drafts output as Grav-compatible entry folders
  • Persistent state with hard-refresh safety
  • Back-navigation with stale warnings (no auto-deletion of downstream work)
  • A notes panel for capturing memories at any stage throughout the workflow
  • Playwright UI test suite covering all phases

What this does not cover

  • AI-assisted writing, title generation, or typo correction (post-export luxury step, separate tool)
  • Writing to Grav via the API — export writes files directly; make content-push handles sync as usual
  • Multi-user support
  • Mobile layout (desktop tool; tablet usable)
  • Any modification of Immich albums or assets

Architecture

Docker

New service added to docker-compose.yml:

travel-memories:
  build: ./services/travel-memories
  ports:
    - "8082:8082"
  volumes:
    - ./docs/immich-workflow:/app/state
    - ./user/pages:/app/pages
  env_file: .env
  user: "${UID}:${GID}"

UID and GID must be set in the shell environment (or in a .env fragment) so the container user matches the host user — this prevents permission errors when writing into user/pages/.

Service source layout

services/travel-memories/
  Dockerfile
  requirements.txt
  app/
    __init__.py       ← Flask app factory
    routes/
      albums.py       ← Phase 1: album listing + selection
      triage.py       ← Phase 2
      curate.py       ← Phase 3
      group.py        ← Phase 4
      write.py        ← Phase 5
      export.py       ← Phase 6
      proxy.py        ← Immich photo proxy (thumbs + originals)
      notes.py        ← Notes panel save endpoint
    state.py          ← Atomic JSON read/write helpers
    immich.py         ← Immich API client
    templates/
      base.html       ← DaisyUI shell, notes panel, nav
      phase1.html
      phase2.html
      phase3.html
      phase4.html
      phase5.html
      phase6.html
    static/
      app.js          ← Alpine.js component definitions

Tech stack

  • Backend: Python 3.12 + Flask
  • Frontend: Tailwind CSS + DaisyUI + Alpine.js — all loaded from CDN, no build pipeline
  • DaisyUI theme: forest (closest to the blog's teal palette)
  • State: One JSON file per album at /app/state/{album-id}.json (maps to docs/immich-workflow/ on host)
  • Photo serving: All Immich requests proxied through Flask — the Immich API key is never sent to the browser

Immich API

All requests use Authorization: Bearer {IMMICH_API_KEY} and base URL from IMMICH_URL env var.

Purpose Method Endpoint
List albums GET /api/albums
Get album with assets GET /api/albums/{id}?withoutAssets=false
Get asset metadata GET /api/assets/{id}
Get thumbnail GET /api/assets/{id}/thumbnail?size=preview
Download original GET /api/assets/{id}/original

Pre-implementation gate: verify these endpoint paths against the running Immich instance before writing any code. Immich API paths have changed between versions.


State model

One JSON file per album. Written atomically: always written to {file}.tmp then os.rename() to prevent corruption from crashes.

{
  "album_id": "abc123",
  "album_name": "Central Asia 2023",
  "grav_trip_slug": "central-asia-2023",
  "created_at": "2026-06-21T10:00:00",
  "updated_at": "2026-06-21T14:32:00",
  "phase": "group",
  "phase_stale": ["write"],
  "photos": [
    {
      "id": "asset-uuid",
      "original_filename": "IMG_1234.jpg",
      "local_datetime": "2023-09-05T14:32:00",
      "tag": "journal",
      "order": 3
    }
  ],
  "groups": [
    {
      "id": "g1",
      "photo_ids": ["asset-uuid", "asset-uuid-2"],
      "entry_type": "journal",
      "title": "",
      "body": "",
      "location_city": "",
      "location_country": "",
      "date": "2023-09-05",
      "hero_photo_id": null,
      "status": "draft"
    }
  ],
  "notes": "Arrived in Almaty — chaos at the airport. Lost one bag. The smell of the market..."
}

status values for a group: draft, written, skipped, exported. exported is immutable — never modified by upstream phase changes.


Phases

Phase 1 — Album selection

App fetches album list from Immich on load and displays them as cards (cover thumbnail, name, photo count). User selects one or more albums. Multi-select merges all assets into one workspace, deduplicated by asset ID. After selection the app fetches all asset metadata, initialises the state file, and advances to Phase 2.

If the state file for this album already exists, the app offers to resume or start over. "Start over" deletes the existing state file and reinitialises from the album metadata — exported entry files on disk are not touched.

Phase 2 — Triage

Photos displayed in a responsive grid, ordered by localDateTime, with sticky day-group headers. Each photo shows its thumbnail, date, and time.

Click or keyboard to tag:

  • J → journal (green border)
  • S → story (blue border)
  • X or space → skip (dimmed)

All photos must be tagged before "Done triaging" is enabled. Progress shown as a counter ("847 / 847 tagged").

Phase 3 — Curate

Shows only journal- and story-tagged photos, in chronological order with day-group headers. Actions per photo: remove (revert to skip), swap tag (journal ↔ story). Drag to reorder within a day group. "Curate done" completes the phase.

Phase 4 — Group

All kept photos shown as a flat chronological stream. User inserts entry-break dividers between photos to define group boundaries. Each resulting segment is one entry. Groups get an auto-suggested working label (2023-09-05 · Journal, editable). Split a group by adding a divider; merge by removing one. "Grouping done" completes the phase.

Phase 5 — Write

One group at a time; progress indicator ("3 of 12 groups done"). The notes panel is shown inline alongside the form for this phase.

Layout: photos (scrollable) on the left, form on the right.

Journal mode fields: Title, Date (pre-filled from first photo), City, Country, Body (plain text).

Story mode adds: Hero photo picker (click to select from group photos), Shortcode hints (optional free-text field for notes like "gallery block here" — written to the story body as an HTML comment block at export, not processed as actual shortcodes).

"Skip for now" defers a group; it can be revisited. Every form change auto-saves to state (debounced 500ms). Phase complete when all groups are written or explicitly skipped.

Phase 6 — Export

Summary view: N journal entries, M stories ready to export. Skipped groups shown in a collapsible list (not exported).

"Export" downloads full-resolution originals from Immich and writes Grav-compatible folders under the mounted user/pages/ volume.

Output paths:

  • Journal: user/pages/01.trips/{slug}/01.dailies/{YYYY-MM-DD}-{title-slug}.entry/
  • Story: user/pages/01.trips/{slug}/04.stories/{title-slug}.story/

Entry file structure (journal example):

2023-09-05-arrival-in-almaty.entry/
  entry.md          ← frontmatter + body
  photo-1.jpg
  photo-2.jpg

Frontmatter written (journal):

title: 'Arrival in Almaty'
date: '2023-09-05 14:32'
template: entry
published: true
location_city: 'Almaty'
location_country: 'Kazakhstan'
hero_image: photo-1.jpg

Frontmatter written (story):

title: 'The Silk Road Begins'
date: '2023-09-05 14:32'
template: story
published: true
hero_image: photo-1.jpg

If a destination folder already exists, a per-entry overwrite prompt is shown before writing. After export, group status is set to exported in state. Exported entries are never touched by subsequent upstream changes.


Notes panel

A persistent drawer on the right side of every phase. Free-text, auto-saved (debounced 500ms) with a "Saved ✓" / "Saving…" indicator always visible.

In Phase 5 (Write), the notes panel content is shown inline next to the active group's form as a memory aid.

A "Convert to entry" action on any selected note text promotes it to a new group appended to the Phase 4 grouping and marks Phase 5 as stale. The user stays on the current phase — no automatic navigation.


Back-navigation

A phase nav bar is always visible. Clicking an earlier phase is always allowed.

Going back to Phase N marks all completed phases above N as stale. Stale phases show a yellow warning banner: "You changed earlier decisions — review this phase before exporting."

Stale does not delete content. The user re-confirms by redoing the phase or by dismissing the banner explicitly (which clears the stale flag without redoing the work).

Stale propagation:

Navigate back to Marks stale
Phase 2 (triage) Phases 3, 4, 5
Phase 3 (curate) Phases 4, 5
Phase 4 (group) Phase 5
Phase 5 (write) Nothing (export is always fresh)

Exported entries (status: exported) are never affected by stale propagation.


Robustness constraints

  1. Atomic writes — state always written via os.rename() from a .tmp file; crash during write cannot corrupt existing state
  2. Reload safety — all authoritative state is server-side JSON; a hard browser refresh re-fetches from disk, nothing is lost
  3. Photo proxy — all Immich asset requests route through /proxy/thumb/{id} and /proxy/original/{id} on the Flask backend; the API key never reaches the browser
  4. Docker UID/GIDuser: "${UID}:${GID}" in docker-compose ensures container writes are owned by the host user; without this, export writes fail or produce root-owned files
  5. Immich unreachable — album list and photo grid show an error banner with a retry button; the app does not crash or show a Python traceback
  6. Download failure on export — failed asset downloads are logged per-asset; export continues for remaining assets; post-export summary lists any failed assets
  7. Export idempotency — if a destination folder already exists, a per-entry overwrite prompt is shown; no silent overwrites
  8. Notes auto-save — debounced 500ms; "Saved ✓" indicator always visible; no save button needed
  9. No cascade to exported entriesstatus: exported is immutable in state; removing photos upstream or going back never modifies exported entry files or their state record

Test strategy

Playwright (Python API). Tests run against the containerised app. A lightweight mock Immich server (Flask or pytest-httpserver) serves pre-canned fixture responses — no real Immich instance required.

One state fixture JSON per phase so individual phase tests do not require clicking through all earlier phases.

Coverage per phase:

Phase Tests
1 — Album selection Album list renders; single select initialises state; multi-select merges and deduplicates; resume prompt shown for existing state
2 — Triage Photos render in day groups; J/S/X keyboard shortcuts apply correct tag; completion gate requires all photos tagged
3 — Curate Only tagged photos shown; remove reverts to skip; drag reorder updates order in state
4 — Group Divider inserts create new group; divider removal merges groups; label edit persists
5 — Write Form auto-saves on change; notes panel saves; journal/story mode switch changes visible fields; skip-for-now defers group
6 — Export Overwrite prompt shown for existing folder; skipped groups excluded; exported status set in state after write
Cross-cutting Hard refresh at each phase preserves state; back-nav stale banner appears; stale banner dismissal clears flag

New environment variables

Add to .env:

IMMICH_URL=http://<nas-ip>:2283
IMMICH_API_KEY=<your-key>

File map

Path Action
services/travel-memories/ Create — Flask app source
services/travel-memories/Dockerfile Create
services/travel-memories/requirements.txt Create
docker-compose.yml Update — add travel-memories service
docs/immich-workflow/ Create — state files per album (host-mounted); add docs/immich-workflow/*.json to .gitignore
.env Update — add IMMICH_URL and IMMICH_API_KEY