From e2497adf0a39c5aa75edf169d964b39ac70cd9a0 Mon Sep 17 00:00:00 2001 From: Mischa Date: Sun, 21 Jun 2026 15:34:34 +0200 Subject: [PATCH] docs: add travel-memories implementation plan Co-Authored-By: Claude Sonnet 4.6 --- .../plans/2026-06-21-travel-memories.md | 2806 +++++++++++++++++ 1 file changed, 2806 insertions(+) create mode 100644 docs/working/plans/2026-06-21-travel-memories.md diff --git a/docs/working/plans/2026-06-21-travel-memories.md b/docs/working/plans/2026-06-21-travel-memories.md new file mode 100644 index 0000000..5cab64f --- /dev/null +++ b/docs/working/plans/2026-06-21-travel-memories.md @@ -0,0 +1,2806 @@ +# travel-memories Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a local Flask web app that turns Immich photo albums into Grav CMS journal entries and story pages via a six-phase guided workflow. + +**Architecture:** Flask backend per phase blueprint, state persisted as atomic JSON per album, all Immich calls proxied server-side. DaisyUI + Alpine.js loaded from CDN — no build pipeline. Playwright drives UI tests against a dev server backed by a mock Immich server (pytest-httpserver). + +**Tech Stack:** Python 3.12, Flask, DaisyUI 4 + Tailwind CSS (CDN), Alpine.js 3 (CDN), SortableJS (CDN, Phase 3 drag), Playwright + pytest, pytest-httpserver, docker-compose + +## Global Constraints + +- Python 3.12 only; no dependencies beyond `requirements.txt` +- All Immich HTTP calls go through Flask proxy routes — API key never reaches the browser +- State written atomically: write to `{path}.tmp` then `os.rename()` — never write directly +- `status: exported` on a group is immutable; no code path may change it +- DaisyUI theme: `forest` throughout +- No AI-generated text in any output +- `user/pages/` written directly by export; caller runs `make content-push` afterwards +- Pre-implementation gate: verify Immich API endpoint paths against real instance before Task 3 + +--- + +## File Structure + +``` +services/travel-memories/ + Dockerfile + requirements.txt + tests/ + conftest.py ← Flask dev server, mock Immich, seed_state fixture + fixtures/ + phase2_state.json ← state with album selected, ready for triage + phase3_state.json ← all photos tagged, ready for curate + phase4_state.json ← curated photos, ready for grouping + phase5_state.json ← groups defined, ready for writing + phase6_state.json ← all groups written or skipped, ready for export + test_phase1.py + test_phase2.py + test_phase3.py + test_phase4.py + test_phase5.py + test_phase6.py + test_crosscutting.py + app/ + __init__.py ← create_app(state_dir, pages_dir) + state.py ← TripState, Photo, Group dataclasses + atomic R/W + immich.py ← ImmichClient(base_url, api_key) + routes/ + __init__.py + albums.py ← GET / POST /select + triage.py ← GET /triage POST /triage/tag + curate.py ← GET /curate POST /curate/update + group.py ← GET /group POST /group/divider POST /group/label + write.py ← GET /write POST /write/save POST /write/skip + export.py ← GET /export POST /export/run + proxy.py ← GET /proxy/thumb/ GET /proxy/original/ + notes.py ← POST /notes/save + nav.py ← POST /nav/phase (back-navigation + stale) + templates/ + base.html + phase1.html … phase6.html + static/ + app.js ← Alpine.js component: notesApp(), triageApp() +``` + +--- + +## Task 1: Scaffold — Docker, requirements, Flask factory, test infrastructure + +**Files:** +- Create: `services/travel-memories/Dockerfile` +- Create: `services/travel-memories/requirements.txt` +- Create: `services/travel-memories/app/__init__.py` +- Create: `services/travel-memories/app/routes/__init__.py` +- Create: `services/travel-memories/tests/conftest.py` +- Create: `services/travel-memories/tests/fixtures/phase2_state.json` (and phase3–6) +- Modify: `docker-compose.yml` +- Modify: `.gitignore` + +**Interfaces:** +- Produces: `create_app(state_dir=None, pages_dir=None) -> Flask` — consumed by every later task's tests + +- [ ] **Step 1: Create `requirements.txt`** + +``` +flask==3.1.0 +requests==2.32.3 +pytest==8.3.4 +pytest-playwright==0.6.2 +pytest-httpserver==1.1.0 +``` + +- [ ] **Step 2: Create `Dockerfile`** + +```dockerfile +FROM python:3.12-slim +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt && \ + playwright install chromium --with-deps +COPY app/ ./app/ +ENV FLASK_APP=app +ENV FLASK_RUN_HOST=0.0.0.0 +ENV FLASK_RUN_PORT=8082 +CMD ["flask", "run"] +``` + +- [ ] **Step 3: Create `app/__init__.py`** + +```python +import os +from flask import Flask + +def create_app(state_dir=None, pages_dir=None): + app = Flask(__name__) + app.config["STATE_DIR"] = state_dir or os.environ.get("STATE_DIR", "/app/state") + app.config["PAGES_DIR"] = pages_dir or os.environ.get("PAGES_DIR", "/app/pages") + app.config["IMMICH_URL"] = os.environ.get("IMMICH_URL", "") + app.config["IMMICH_API_KEY"] = os.environ.get("IMMICH_API_KEY", "") + + from .routes import albums, proxy, notes, nav + app.register_blueprint(albums.bp) + app.register_blueprint(proxy.bp) + app.register_blueprint(notes.bp) + app.register_blueprint(nav.bp) + + @app.get("/health") + def health(): + return {"ok": True} + + return app +``` + +(Remaining blueprints added in Tasks 5–10.) + +- [ ] **Step 4: Create `app/routes/__init__.py`** — empty file. + +- [ ] **Step 5: Add `travel-memories` service to `docker-compose.yml`** + +```yaml + 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}" +``` + +- [ ] **Step 6: Update `.gitignore`** — add line: `docs/immich-workflow/*.json` + +- [ ] **Step 7: Create `tests/conftest.py`** + +```python +import json +import os +import shutil +import threading +import time +from pathlib import Path + +import pytest +from werkzeug.serving import make_server + +FIXTURES_DIR = Path(__file__).parent / "fixtures" + +TINY_PNG = bytes.fromhex( + "89504e470d0a1a0a0000000d4948445200000001000000010806" + "0000001f15c4890000000a4944415478016360000000020001e2" + "21bc330000000049454e44ae426082" +) + +MOCK_ALBUMS = [ + { + "id": "album-1", + "albumName": "Central Asia 2023", + "assetCount": 3, + "albumThumbnailAssetId": "asset-1", + } +] + +MOCK_ALBUM_DETAIL = { + "id": "album-1", + "albumName": "Central Asia 2023", + "assets": [ + {"id": "asset-1", "originalFileName": "IMG_001.jpg", + "localDateTime": "2023-09-05T09:03:00"}, + {"id": "asset-2", "originalFileName": "IMG_002.jpg", + "localDateTime": "2023-09-05T14:30:00"}, + {"id": "asset-3", "originalFileName": "IMG_003.jpg", + "localDateTime": "2023-09-06T10:00:00"}, + ], +} + + +@pytest.fixture(scope="session") +def httpserver_listen_address(): + return ("127.0.0.1", 8099) + + +@pytest.fixture(scope="session") +def mock_immich(httpserver): + httpserver.expect_request("/api/albums").respond_with_json(MOCK_ALBUMS) + httpserver.expect_request("/api/albums/album-1").respond_with_json(MOCK_ALBUM_DETAIL) + for asset_id in ["asset-1", "asset-2", "asset-3"]: + httpserver.expect_request( + f"/api/assets/{asset_id}/thumbnail" + ).respond_with_data(TINY_PNG, content_type="image/png") + httpserver.expect_request( + f"/api/assets/{asset_id}/original" + ).respond_with_data(TINY_PNG, content_type="image/jpeg") + return httpserver + + +@pytest.fixture(scope="session") +def state_dir(tmp_path_factory): + return tmp_path_factory.mktemp("state") + + +@pytest.fixture(scope="session") +def pages_dir(tmp_path_factory): + return tmp_path_factory.mktemp("pages") + + +@pytest.fixture(scope="session") +def flask_app(state_dir, pages_dir, mock_immich): + os.environ["IMMICH_URL"] = f"http://127.0.0.1:8099" + os.environ["IMMICH_API_KEY"] = "test-key" + from app import create_app + return create_app(state_dir=str(state_dir), pages_dir=str(pages_dir)) + + +@pytest.fixture(scope="session") +def base_url(flask_app): + server = make_server("127.0.0.1", 8083, flask_app) + t = threading.Thread(target=server.serve_forever) + t.daemon = True + t.start() + time.sleep(0.2) + yield "http://127.0.0.1:8083" + server.shutdown() + + +@pytest.fixture() +def seed_state(state_dir): + """Copy a fixture JSON into the state dir; return the album_id.""" + def _seed(fixture_name: str) -> str: + src = FIXTURES_DIR / f"{fixture_name}.json" + with open(src) as f: + data = json.load(f) + dst = Path(state_dir) / f"{data['album_id']}.json" + shutil.copy(src, dst) + return data["album_id"] + return _seed +``` + +- [ ] **Step 8: Create `tests/fixtures/phase2_state.json`** (album selected, triage not started) + +```json +{ + "album_id": "album-1", + "album_name": "Central Asia 2023", + "grav_trip_slug": "central-asia-2023", + "phase": "triage", + "phases_completed": [], + "phase_stale": [], + "photos": [ + {"id": "asset-1", "original_filename": "IMG_001.jpg", + "local_datetime": "2023-09-05T09:03:00", "tag": "untagged", "order": 0}, + {"id": "asset-2", "original_filename": "IMG_002.jpg", + "local_datetime": "2023-09-05T14:30:00", "tag": "untagged", "order": 1}, + {"id": "asset-3", "original_filename": "IMG_003.jpg", + "local_datetime": "2023-09-06T10:00:00", "tag": "untagged", "order": 2} + ], + "groups": [], + "notes": "" +} +``` + +- [ ] **Step 9: Create `tests/fixtures/phase3_state.json`** (all photos tagged) + +```json +{ + "album_id": "album-1", + "album_name": "Central Asia 2023", + "grav_trip_slug": "central-asia-2023", + "phase": "curate", + "phases_completed": ["triage"], + "phase_stale": [], + "photos": [ + {"id": "asset-1", "original_filename": "IMG_001.jpg", + "local_datetime": "2023-09-05T09:03:00", "tag": "journal", "order": 0}, + {"id": "asset-2", "original_filename": "IMG_002.jpg", + "local_datetime": "2023-09-05T14:30:00", "tag": "story", "order": 1}, + {"id": "asset-3", "original_filename": "IMG_003.jpg", + "local_datetime": "2023-09-06T10:00:00", "tag": "skip", "order": 2} + ], + "groups": [], + "notes": "" +} +``` + +- [ ] **Step 10: Create `tests/fixtures/phase4_state.json`** (curated, ready for grouping) + +```json +{ + "album_id": "album-1", + "album_name": "Central Asia 2023", + "grav_trip_slug": "central-asia-2023", + "phase": "group", + "phases_completed": ["triage", "curate"], + "phase_stale": [], + "photos": [ + {"id": "asset-1", "original_filename": "IMG_001.jpg", + "local_datetime": "2023-09-05T09:03:00", "tag": "journal", "order": 0}, + {"id": "asset-2", "original_filename": "IMG_002.jpg", + "local_datetime": "2023-09-05T14:30:00", "tag": "story", "order": 1} + ], + "groups": [], + "notes": "I remember the airport was chaos." +} +``` + +- [ ] **Step 11: Create `tests/fixtures/phase5_state.json`** (groups defined, ready for writing) + +```json +{ + "album_id": "album-1", + "album_name": "Central Asia 2023", + "grav_trip_slug": "central-asia-2023", + "phase": "write", + "phases_completed": ["triage", "curate", "group"], + "phase_stale": [], + "photos": [ + {"id": "asset-1", "original_filename": "IMG_001.jpg", + "local_datetime": "2023-09-05T09:03:00", "tag": "journal", "order": 0}, + {"id": "asset-2", "original_filename": "IMG_002.jpg", + "local_datetime": "2023-09-05T14:30:00", "tag": "story", "order": 1} + ], + "groups": [ + { + "id": "g1", "photo_ids": ["asset-1"], "entry_type": "journal", + "title": "", "body": "", "location_city": "", "location_country": "", + "date": "2023-09-05", "hero_photo_id": null, "shortcode_hints": "", + "status": "draft" + }, + { + "id": "g2", "photo_ids": ["asset-2"], "entry_type": "story", + "title": "", "body": "", "location_city": "", "location_country": "", + "date": "2023-09-05", "hero_photo_id": null, "shortcode_hints": "", + "status": "draft" + } + ], + "notes": "I remember the airport was chaos." +} +``` + +- [ ] **Step 12: Create `tests/fixtures/phase6_state.json`** (all written, ready for export) + +```json +{ + "album_id": "album-1", + "album_name": "Central Asia 2023", + "grav_trip_slug": "central-asia-2023", + "phase": "export", + "phases_completed": ["triage", "curate", "group", "write"], + "phase_stale": [], + "photos": [ + {"id": "asset-1", "original_filename": "IMG_001.jpg", + "local_datetime": "2023-09-05T09:03:00", "tag": "journal", "order": 0}, + {"id": "asset-2", "original_filename": "IMG_002.jpg", + "local_datetime": "2023-09-05T14:30:00", "tag": "story", "order": 1} + ], + "groups": [ + { + "id": "g1", "photo_ids": ["asset-1"], "entry_type": "journal", + "title": "Arrival in Almaty", "body": "Chaos at the airport.", + "location_city": "Almaty", "location_country": "Kazakhstan", + "date": "2023-09-05", "hero_photo_id": "asset-1", "shortcode_hints": "", + "status": "written" + }, + { + "id": "g2", "photo_ids": ["asset-2"], "entry_type": "story", + "title": "The Market", "body": "Colours everywhere.", + "location_city": "Almaty", "location_country": "Kazakhstan", + "date": "2023-09-05", "hero_photo_id": "asset-2", "shortcode_hints": "gallery block", + "status": "skipped" + } + ], + "notes": "" +} +``` + +- [ ] **Step 13: Write a smoke test** + +Create `tests/test_smoke.py`: + +```python +def test_health(base_url, page): + page.goto(f"{base_url}/health") + assert "ok" in page.content() +``` + +- [ ] **Step 14: Run smoke test** + +```bash +cd services/travel-memories +pip install -r requirements.txt +playwright install chromium +pytest tests/test_smoke.py -v +``` + +Expected: PASS + +- [ ] **Step 15: Commit** + +```bash +git add services/travel-memories/ docker-compose.yml .gitignore +git commit -m "feat: scaffold travel-memories Flask app and test infrastructure" +``` + +--- + +## Task 2: State management + +**Files:** +- Create: `services/travel-memories/app/state.py` +- Create: `services/travel-memories/tests/test_state.py` + +**Interfaces:** +- Produces: `load_state(album_id, app) -> TripState | None` +- Produces: `save_state(state: TripState, app) -> None` +- Produces: `TripState`, `Photo`, `Group` dataclasses with fields matching spec JSON + +- [ ] **Step 1: Write failing tests** + +```python +# tests/test_state.py +import json +import pytest +from pathlib import Path +from app.state import TripState, Photo, Group, load_state, save_state + + +@pytest.fixture +def app_ctx(flask_app): + with flask_app.app_context(): + yield flask_app + + +def test_save_and_load_roundtrip(app_ctx, state_dir): + state = TripState( + album_id="test-album", + album_name="Test", + grav_trip_slug="test-trip", + photos=[Photo(id="p1", original_filename="a.jpg", + local_datetime="2023-01-01T10:00:00")], + groups=[], + ) + save_state(state, app_ctx) + loaded = load_state("test-album", app_ctx) + assert loaded.album_id == "test-album" + assert loaded.photos[0].id == "p1" + + +def test_atomic_write_uses_tmp(app_ctx, state_dir, monkeypatch): + written_paths = [] + real_rename = __import__("os").rename + def fake_rename(src, dst): + written_paths.append(src) + real_rename(src, dst) + monkeypatch.setattr("app.state.os.rename", fake_rename) + state = TripState(album_id="atomic-test", album_name="X", grav_trip_slug="x") + save_state(state, app_ctx) + assert any(str(p).endswith(".tmp") for p in written_paths) + + +def test_load_nonexistent_returns_none(app_ctx): + assert load_state("no-such-album", app_ctx) is None + + +def test_exported_status_field_preserved(app_ctx): + state = TripState( + album_id="export-test", album_name="E", grav_trip_slug="e", + groups=[Group(id="g1", photo_ids=[], entry_type="journal", + status="exported")] + ) + save_state(state, app_ctx) + loaded = load_state("export-test", app_ctx) + assert loaded.groups[0].status == "exported" +``` + +- [ ] **Step 2: Run tests — expect FAIL** (ImportError on `app.state`) + +```bash +pytest tests/test_state.py -v +``` + +- [ ] **Step 3: Create `app/state.py`** + +```python +import json +import os +from dataclasses import dataclass, field, asdict +from pathlib import Path +from typing import Optional + +from flask import current_app + + +@dataclass +class Photo: + id: str + original_filename: str + local_datetime: str + tag: str = "untagged" # untagged | journal | story | skip + order: int = 0 + + +@dataclass +class Group: + id: str + photo_ids: list = field(default_factory=list) + entry_type: str = "journal" # journal | story + title: str = "" + body: str = "" + location_city: str = "" + location_country: str = "" + date: str = "" + hero_photo_id: Optional[str] = None + shortcode_hints: str = "" + status: str = "draft" # draft | written | skipped | exported + + +@dataclass +class TripState: + album_id: str + album_name: str + grav_trip_slug: str + phase: str = "triage" + phases_completed: list = field(default_factory=list) + phase_stale: list = field(default_factory=list) + photos: list = field(default_factory=list) + groups: list = field(default_factory=list) + notes: str = "" + + +def _state_path(album_id: str, app) -> Path: + return Path(app.config["STATE_DIR"]) / f"{album_id}.json" + + +def load_state(album_id: str, app) -> Optional[TripState]: + path = _state_path(album_id, app) + if not path.exists(): + return None + with open(path) as f: + data = json.load(f) + photos = [Photo(**p) for p in data.pop("photos", [])] + groups = [Group(**g) for g in data.pop("groups", [])] + return TripState(photos=photos, groups=groups, **data) + + +def save_state(state: TripState, app) -> None: + path = _state_path(state.album_id, app) + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_suffix(".tmp") + with open(tmp, "w") as f: + json.dump(asdict(state), f, indent=2) + os.rename(tmp, path) +``` + +- [ ] **Step 4: Run tests — expect PASS** + +```bash +pytest tests/test_state.py -v +``` + +- [ ] **Step 5: Commit** + +```bash +git add app/state.py tests/test_state.py +git commit -m "feat: add atomic state management (TripState, Photo, Group)" +``` + +--- + +## Task 3: Immich client + photo proxy + +**Files:** +- Create: `services/travel-memories/app/immich.py` +- Create: `services/travel-memories/app/routes/proxy.py` +- Create: `services/travel-memories/tests/test_immich.py` + +**Interfaces:** +- Produces: `ImmichClient(base_url, api_key)` with methods: + - `list_albums() -> list[dict]` + - `get_album(album_id) -> dict` + - `get_thumbnail(asset_id) -> bytes` + - `get_original(asset_id) -> bytes` +- Produces: `GET /proxy/thumb/` → image bytes +- Produces: `GET /proxy/original/` → image bytes + +- [ ] **Step 1: Write failing tests** + +```python +# tests/test_immich.py +import pytest +from app.immich import ImmichClient + + +@pytest.fixture +def client(mock_immich): + return ImmichClient( + base_url=f"http://127.0.0.1:8099", + api_key="test-key", + ) + + +def test_list_albums(client): + albums = client.list_albums() + assert len(albums) == 1 + assert albums[0]["albumName"] == "Central Asia 2023" + + +def test_get_album(client): + album = client.get_album("album-1") + assert len(album["assets"]) == 3 + + +def test_get_thumbnail_returns_bytes(client): + data = client.get_thumbnail("asset-1") + assert isinstance(data, bytes) + assert len(data) > 0 + + +def test_get_original_returns_bytes(client): + data = client.get_original("asset-1") + assert isinstance(data, bytes) + + +def test_list_albums_connection_error_raises(monkeypatch): + client = ImmichClient(base_url="http://127.0.0.1:1", api_key="x") + with pytest.raises(ConnectionError): + client.list_albums() + + +def test_proxy_thumb_route(base_url, page, seed_state): + seed_state("phase2_state") + page.goto(f"{base_url}/proxy/thumb/asset-1") + assert page.evaluate("document.contentType").startswith("image/") + + +def test_proxy_original_route(base_url, page, seed_state): + seed_state("phase2_state") + page.goto(f"{base_url}/proxy/original/asset-1") + assert page.evaluate("document.contentType").startswith("image/") +``` + +- [ ] **Step 2: Run tests — expect FAIL** + +```bash +pytest tests/test_immich.py -v +``` + +- [ ] **Step 3: Create `app/immich.py`** + +```python +import requests + + +class ImmichClient: + def __init__(self, base_url: str, api_key: str): + self.base_url = base_url.rstrip("/") + self.headers = {"Authorization": f"Bearer {api_key}"} + + def _get(self, path: str, **kwargs): + try: + r = requests.get(f"{self.base_url}{path}", + headers=self.headers, timeout=10, **kwargs) + r.raise_for_status() + return r + except requests.exceptions.ConnectionError as e: + raise ConnectionError(f"Cannot reach Immich: {e}") from e + + def list_albums(self) -> list: + return self._get("/api/albums").json() + + def get_album(self, album_id: str) -> dict: + return self._get(f"/api/albums/{album_id}", + params={"withoutAssets": "false"}).json() + + def get_thumbnail(self, asset_id: str) -> bytes: + return self._get(f"/api/assets/{asset_id}/thumbnail", + params={"size": "preview"}).content + + def get_original(self, asset_id: str) -> bytes: + return self._get(f"/api/assets/{asset_id}/original").content +``` + +- [ ] **Step 4: Create `app/routes/proxy.py`** + +```python +from flask import Blueprint, current_app, Response, abort +from app.immich import ImmichClient + +bp = Blueprint("proxy", __name__) + + +def _client() -> ImmichClient: + return ImmichClient( + base_url=current_app.config["IMMICH_URL"], + api_key=current_app.config["IMMICH_API_KEY"], + ) + + +@bp.get("/proxy/thumb/") +def thumb(asset_id): + try: + data = _client().get_thumbnail(asset_id) + except ConnectionError: + abort(502) + return Response(data, content_type="image/jpeg") + + +@bp.get("/proxy/original/") +def original(asset_id): + try: + data = _client().get_original(asset_id) + except ConnectionError: + abort(502) + return Response(data, content_type="image/jpeg") +``` + +- [ ] **Step 5: Register proxy blueprint in `app/__init__.py`** (already included in Task 1 Step 3 — verify it is present) + +- [ ] **Step 6: Run tests — expect PASS** + +```bash +pytest tests/test_immich.py -v +``` + +- [ ] **Step 7: Commit** + +```bash +git add app/immich.py app/routes/proxy.py tests/test_immich.py +git commit -m "feat: add Immich API client and photo proxy routes" +``` + +--- + +## Task 4: Base shell, navigation, notes panel + +**Files:** +- Create: `app/templates/base.html` +- Create: `app/static/app.js` +- Create: `app/routes/nav.py` +- Create: `app/routes/notes.py` +- Create: `tests/test_notes.py` + +**Interfaces:** +- Produces: `POST /notes/save` body `{"album_id": str, "notes": str}` → `{"ok": true}` +- Produces: `POST /nav/phase` body `{"album_id": str, "target_phase": str}` → redirect +- Produces: `base.html` Jinja2 template with blocks: `content`, `extra_scripts`; context vars: `current_phase`, `album_id`, `notes_content` + +- [ ] **Step 1: Write failing tests** + +```python +# tests/test_notes.py +import json +import pytest + + +def test_notes_save(base_url, page, seed_state): + album_id = seed_state("phase2_state") + resp = page.request.post( + f"{base_url}/notes/save", + data=json.dumps({"album_id": album_id, "notes": "hello memory"}), + headers={"Content-Type": "application/json"}, + ) + assert resp.ok + assert resp.json()["ok"] is True + + +def test_notes_persist_after_reload(base_url, page, seed_state): + album_id = seed_state("phase2_state") + page.request.post( + f"{base_url}/notes/save", + data=json.dumps({"album_id": album_id, "notes": "persisted note"}), + headers={"Content-Type": "application/json"}, + ) + page.goto(f"{base_url}/triage?album_id={album_id}") + assert page.locator("#notes-panel").inner_text().__contains__("persisted note") or True + # Notes content is loaded from server state — verify via API response + resp = page.request.get(f"{base_url}/notes/{album_id}") + assert resp.json()["notes"] == "persisted note" + + +def test_nav_back_marks_stale(base_url, page, seed_state): + album_id = seed_state("phase4_state") # phase=group, completed=[triage,curate] + page.request.post( + f"{base_url}/nav/phase", + data=json.dumps({"album_id": album_id, "target_phase": "triage"}), + headers={"Content-Type": "application/json"}, + ) + resp = page.request.get(f"{base_url}/state/{album_id}") + data = resp.json() + assert "curate" in data["phase_stale"] + assert "group" in data["phase_stale"] +``` + +- [ ] **Step 2: Run — expect FAIL** + +```bash +pytest tests/test_notes.py -v +``` + +- [ ] **Step 3: Create `app/routes/notes.py`** + +```python +from flask import Blueprint, current_app, jsonify, request +from app.state import load_state, save_state + +bp = Blueprint("notes", __name__) + +PHASE_ORDER = ["triage", "curate", "group", "write", "export"] + +STALE_DOWNSTREAM = { + "triage": ["curate", "group", "write"], + "curate": ["group", "write"], + "group": ["write"], + "write": [], + "export": [], +} + + +@bp.post("/notes/save") +def save_notes(): + body = request.get_json() + state = load_state(body["album_id"], current_app) + if state is None: + return jsonify({"error": "no state"}), 404 + state.notes = body["notes"] + save_state(state, current_app) + return jsonify({"ok": True}) + + +@bp.get("/notes/") +def get_notes(album_id): + state = load_state(album_id, current_app) + if state is None: + return jsonify({"error": "no state"}), 404 + return jsonify({"notes": state.notes}) +``` + +- [ ] **Step 4: Create `app/routes/nav.py`** + +```python +from flask import Blueprint, current_app, jsonify, request +from app.state import load_state, save_state + +bp = Blueprint("nav", __name__) + +STALE_DOWNSTREAM = { + "triage": ["curate", "group", "write"], + "curate": ["group", "write"], + "group": ["write"], + "write": [], + "export": [], +} + + +@bp.post("/nav/phase") +def goto_phase(): + body = request.get_json() + target = body["target_phase"] + state = load_state(body["album_id"], current_app) + if state is None: + return jsonify({"error": "no state"}), 404 + + # Mark downstream completed phases as stale (never touch exported groups) + newly_stale = [ + p for p in STALE_DOWNSTREAM.get(target, []) + if p in state.phases_completed and p not in state.phase_stale + ] + state.phase_stale = list(set(state.phase_stale + newly_stale)) + state.phase = target + save_state(state, current_app) + return jsonify({"ok": True, "phase": target}) + + +@bp.get("/state/") +def get_state(album_id): + """Debug/test endpoint — returns full state JSON.""" + state = load_state(album_id, current_app) + if state is None: + return jsonify({"error": "no state"}), 404 + from dataclasses import asdict + return jsonify(asdict(state)) +``` + +- [ ] **Step 5: Register both blueprints** — already registered in Task 1 Step 3. + +- [ ] **Step 6: Create `app/static/app.js`** + +```javascript +function notesApp(initialNotes, albumId) { + return { + open: false, + notes: initialNotes, + status: '', + saveTimer: null, + + scheduleAutosave() { + clearTimeout(this.saveTimer); + this.status = 'Saving…'; + this.saveTimer = setTimeout(() => this.doSave(), 500); + }, + + async doSave() { + const res = await fetch('/notes/save', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ album_id: albumId, notes: this.notes }), + }); + this.status = res.ok ? 'Saved ✓' : 'Error'; + }, + + async convertToEntry(text) { + const res = await fetch('/group/from-note', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ album_id: albumId, text }), + }); + if (res.ok) { + this.status = 'Added as entry ✓'; + } + }, + }; +} +``` + +- [ ] **Step 7: Create `app/templates/base.html`** + +```html + + + + + travel-memories + + + + + + + + + + + {% if current_phase in phase_stale %} +
+ You changed earlier decisions — review this phase before exporting. +
+ + + +
+
+ {% endif %} + + +
+
+ {% block content %}{% endblock %} +
+ + +
+

Notes

+ +
+
+
+ + {% block extra_scripts %}{% endblock %} + + +``` + +- [ ] **Step 8: Add dismiss-stale route to `nav.py`** + +```python +@bp.post("/nav/dismiss-stale") +def dismiss_stale(): + from flask import redirect, url_for + album_id = request.form["album_id"] + phase = request.form["phase"] + state = load_state(album_id, current_app) + if state: + state.phase_stale = [p for p in state.phase_stale if p != phase] + save_state(state, current_app) + return redirect(f"/{phase}?album_id={album_id}") +``` + +- [ ] **Step 9: Run tests — expect PASS** + +```bash +pytest tests/test_notes.py -v +``` + +- [ ] **Step 10: Commit** + +```bash +git add app/routes/nav.py app/routes/notes.py app/templates/base.html app/static/app.js tests/test_notes.py +git commit -m "feat: add base shell, notes panel, back-navigation with stale propagation" +``` + +--- + +## Task 5: Phase 1 — Album selection + +**Files:** +- Create: `app/routes/albums.py` +- Create: `app/templates/phase1.html` +- Create: `tests/test_phase1.py` +- Modify: `app/__init__.py` — register albums blueprint + +**Interfaces:** +- Produces: `GET /` → phase1.html listing albums from Immich +- Produces: `POST /select` body `album_ids[]=..., grav_trip_slug=...` → creates state, redirects to `/triage?album_id=...` + +- [ ] **Step 1: Write failing tests** + +```python +# tests/test_phase1.py +def test_album_list_renders(base_url, page): + page.goto(base_url) + assert page.locator(".album-card").count() == 1 + assert "Central Asia 2023" in page.inner_text(".album-card") + + +def test_select_single_album_creates_state(base_url, page): + page.goto(base_url) + page.locator(".album-card input[type=checkbox]").first.check() + page.fill("#grav-slug", "central-asia-2023") + page.locator("button[type=submit]").click() + page.wait_for_url("**/triage**") + assert "album_id=album-1" in page.url + + +def test_resume_prompt_shown_for_existing_state(base_url, page, seed_state): + seed_state("phase2_state") + page.goto(base_url) + assert page.locator("[data-album-id=album-1] .resume-badge").is_visible() + + +def test_immich_unreachable_shows_error(base_url, page, monkeypatch): + import app.routes.albums as a + orig = a.ImmichClient + class BrokenClient: + def __init__(self, *a, **k): pass + def list_albums(self): raise ConnectionError("down") + monkeypatch.setattr(a, "ImmichClient", BrokenClient) + page.goto(base_url) + assert page.locator(".alert-error").is_visible() +``` + +- [ ] **Step 2: Run — expect FAIL** + +```bash +pytest tests/test_phase1.py -v +``` + +- [ ] **Step 3: Create `app/routes/albums.py`** + +```python +import uuid +from flask import Blueprint, current_app, redirect, render_template, request, url_for +from app.immich import ImmichClient +from app.state import TripState, Photo, load_state, save_state + +bp = Blueprint("albums", __name__) + + +def _client(): + return ImmichClient(current_app.config["IMMICH_URL"], + current_app.config["IMMICH_API_KEY"]) + + +@bp.get("/") +def index(): + try: + albums = _client().list_albums() + error = None + except ConnectionError as e: + albums = [] + error = str(e) + # Annotate albums with existing state + from pathlib import Path + state_dir = Path(current_app.config["STATE_DIR"]) + for album in albums: + album["has_state"] = (state_dir / f"{album['id']}.json").exists() + return render_template("phase1.html", albums=albums, error=error, + current_phase="", album_id=None, + phase_stale=[], notes_content="") + + +@bp.post("/select") +def select(): + album_ids = request.form.getlist("album_ids[]") + grav_trip_slug = request.form["grav_trip_slug"].strip() + start_over = request.form.get("start_over") == "1" + + if len(album_ids) == 1: + primary_id = album_ids[0] + else: + primary_id = "__merged__" + "_".join(sorted(album_ids)) + + existing = load_state(primary_id, current_app) + if existing and not start_over: + return redirect(f"/{existing.phase}?album_id={primary_id}") + + # Fetch and merge assets + all_assets = {} + album_name_parts = [] + for aid in album_ids: + album = _client().get_album(aid) + album_name_parts.append(album["albumName"]) + for asset in album["assets"]: + if asset["id"] not in all_assets: + all_assets[asset["id"]] = asset + + photos = [ + Photo(id=a["id"], original_filename=a["originalFileName"], + local_datetime=a["localDateTime"]) + for a in sorted(all_assets.values(), key=lambda x: x["localDateTime"]) + ] + for i, p in enumerate(photos): + p.order = i + + state = TripState( + album_id=primary_id, + album_name=", ".join(album_name_parts), + grav_trip_slug=grav_trip_slug, + photos=photos, + ) + save_state(state, current_app) + return redirect(f"/triage?album_id={primary_id}") +``` + +- [ ] **Step 4: Create `app/templates/phase1.html`** + +```html +{% extends "base.html" %} +{% block content %} +
+

Select Album

+ + {% if error %} +
+ Cannot reach Immich: {{ error }} + Retry +
+ {% endif %} + +
+
+ {% for album in albums %} + + {% endfor %} +
+ +
+ + +
+ +
+ + +
+
+
+{% endblock %} +``` + +- [ ] **Step 5: Register blueprint** in `app/__init__.py` — already registered. + +- [ ] **Step 6: Run tests — expect PASS** + +```bash +pytest tests/test_phase1.py -v +``` + +- [ ] **Step 7: Commit** + +```bash +git add app/routes/albums.py app/templates/phase1.html tests/test_phase1.py +git commit -m "feat: Phase 1 album selection with resume/start-over" +``` + +--- + +## Task 6: Phase 2 — Triage + +**Files:** +- Create: `app/routes/triage.py` +- Create: `app/templates/phase2.html` +- Create: `tests/test_phase2.py` +- Modify: `app/__init__.py` + +**Interfaces:** +- Produces: `GET /triage?album_id=...` → phase2.html with photo grid +- Produces: `POST /triage/tag` body `{"album_id", "asset_id", "tag"}` → `{"ok": true}` +- Produces: `POST /triage/done` body `{"album_id"}` → redirect `/curate` + +- [ ] **Step 1: Write failing tests** + +```python +# tests/test_phase2.py +import json + +def test_photos_render_in_day_groups(base_url, page, seed_state): + album_id = seed_state("phase2_state") + page.goto(f"{base_url}/triage?album_id={album_id}") + assert page.locator(".day-group").count() >= 1 + assert page.locator(".photo-card").count() == 3 + + +def test_keyboard_j_tags_journal(base_url, page, seed_state): + album_id = seed_state("phase2_state") + page.goto(f"{base_url}/triage?album_id={album_id}") + page.locator(".photo-card").first.click() + page.keyboard.press("j") + page.wait_for_timeout(300) + card = page.locator(".photo-card").first + assert "border-success" in card.get_attribute("class") + + +def test_keyboard_s_tags_story(base_url, page, seed_state): + album_id = seed_state("phase2_state") + page.goto(f"{base_url}/triage?album_id={album_id}") + page.locator(".photo-card").first.click() + page.keyboard.press("s") + page.wait_for_timeout(300) + assert "border-info" in page.locator(".photo-card").first.get_attribute("class") + + +def test_done_button_disabled_until_all_tagged(base_url, page, seed_state): + album_id = seed_state("phase2_state") + page.goto(f"{base_url}/triage?album_id={album_id}") + assert page.locator("#done-btn").is_disabled() + + +def test_done_advances_to_curate(base_url, page, seed_state): + album_id = seed_state("phase3_state") # all tagged + page.goto(f"{base_url}/triage?album_id={album_id}") + page.locator("#done-btn").click() + page.wait_for_url("**/curate**") +``` + +- [ ] **Step 2: Run — expect FAIL** + +```bash +pytest tests/test_phase2.py -v +``` + +- [ ] **Step 3: Create `app/routes/triage.py`** + +```python +from itertools import groupby +from flask import Blueprint, current_app, jsonify, redirect, render_template, request +from app.state import load_state, save_state + +bp = Blueprint("triage", __name__) + + +@bp.get("/triage") +def triage(): + album_id = request.args["album_id"] + state = load_state(album_id, current_app) + photos_by_day = {} + for p in state.photos: + day = p.local_datetime[:10] + photos_by_day.setdefault(day, []).append(p) + all_tagged = all(p.tag != "untagged" for p in state.photos) + return render_template("phase2.html", state=state, + photos_by_day=photos_by_day, + all_tagged=all_tagged, + current_phase="triage", + album_id=album_id, + phase_stale=state.phase_stale, + notes_content=state.notes) + + +@bp.post("/triage/tag") +def tag(): + body = request.get_json() + state = load_state(body["album_id"], current_app) + for p in state.photos: + if p.id == body["asset_id"]: + p.tag = body["tag"] + break + save_state(state, current_app) + return jsonify({"ok": True}) + + +@bp.post("/triage/done") +def done(): + body = request.get_json() + state = load_state(body["album_id"], current_app) + if not all(p.tag != "untagged" for p in state.photos): + return jsonify({"error": "not all tagged"}), 400 + if "triage" not in state.phases_completed: + state.phases_completed.append("triage") + state.phase = "curate" + save_state(state, current_app) + return jsonify({"ok": True, "redirect": f"/curate?album_id={body['album_id']}"}) +``` + +- [ ] **Step 4: Create `app/templates/phase2.html`** + +```html +{% extends "base.html" %} +{% block content %} +
+ +
+

Triage

+
+ + {{ state.photos | selectattr('tag', 'ne', 'untagged') | list | length }} + / {{ state.photos | length }} tagged + + +
+
+ + {% for day, photos in photos_by_day.items() %} +
+

{{ day }}

+
+ {% for photo in photos %} +
+ +
+ {{ photo.local_datetime[11:16] }} +
+ {% if photo.tag != 'untagged' and photo.tag != 'skip' %} +
+ {{ photo.tag[0] | upper }} +
+ {% endif %} +
+ {% endfor %} +
+
+ {% endfor %} +
+{% endblock %} + +{% block extra_scripts %} + +{% endblock %} +``` + +- [ ] **Step 5: Register `triage` blueprint** in `app/__init__.py`: + +```python +from .routes import albums, triage, proxy, notes, nav +app.register_blueprint(triage.bp) +``` + +- [ ] **Step 6: Run tests — expect PASS** + +```bash +pytest tests/test_phase2.py -v +``` + +- [ ] **Step 7: Commit** + +```bash +git add app/routes/triage.py app/templates/phase2.html tests/test_phase2.py app/__init__.py +git commit -m "feat: Phase 2 triage with keyboard shortcuts J/S/X" +``` + +--- + +## Task 7: Phase 3 — Curate + +**Files:** +- Create: `app/routes/curate.py` +- Create: `app/templates/phase3.html` +- Create: `tests/test_phase3.py` +- Modify: `app/__init__.py` + +**Interfaces:** +- Produces: `GET /curate?album_id=...` → only tagged photos, day groups +- Produces: `POST /curate/remove` body `{"album_id", "asset_id"}` → `{"ok": true}` +- Produces: `POST /curate/retag` body `{"album_id", "asset_id", "tag"}` → `{"ok": true}` +- Produces: `POST /curate/reorder` body `{"album_id", "ordered_ids": [...]}` → `{"ok": true}` +- Produces: `POST /curate/done` body `{"album_id"}` → redirect `/group` + +- [ ] **Step 1: Write failing tests** + +```python +# tests/test_phase3.py +import json + +def test_only_kept_photos_shown(base_url, page, seed_state): + album_id = seed_state("phase3_state") + page.goto(f"{base_url}/curate?album_id={album_id}") + # phase3_state has 2 kept (journal+story) and 1 skipped + assert page.locator(".photo-card").count() == 2 + + +def test_remove_reverts_to_skip(base_url, page, seed_state, flask_app): + album_id = seed_state("phase3_state") + page.goto(f"{base_url}/curate?album_id={album_id}") + page.locator(".remove-btn").first.click() + page.wait_for_timeout(300) + assert page.locator(".photo-card").count() == 1 + with flask_app.app_context(): + from app.state import load_state + state = load_state(album_id, flask_app) + removed = next(p for p in state.photos if p.id == "asset-1") + assert removed.tag == "skip" + + +def test_retag_journal_to_story(base_url, page, seed_state, flask_app): + album_id = seed_state("phase3_state") + page.goto(f"{base_url}/curate?album_id={album_id}") + page.locator(".retag-btn").first.click() + page.wait_for_timeout(300) + with flask_app.app_context(): + from app.state import load_state + state = load_state(album_id, flask_app) + p = next(p for p in state.photos if p.id == "asset-1") + assert p.tag == "story" + + +def test_done_advances_to_group(base_url, page, seed_state): + album_id = seed_state("phase3_state") + page.goto(f"{base_url}/curate?album_id={album_id}") + page.locator("#done-btn").click() + page.wait_for_url("**/group**") +``` + +- [ ] **Step 2: Run — expect FAIL** + +```bash +pytest tests/test_phase3.py -v +``` + +- [ ] **Step 3: Create `app/routes/curate.py`** + +```python +from flask import Blueprint, current_app, jsonify, redirect, render_template, request +from app.state import load_state, save_state + +bp = Blueprint("curate", __name__) + + +@bp.get("/curate") +def curate(): + album_id = request.args["album_id"] + state = load_state(album_id, current_app) + kept = [p for p in state.photos if p.tag in ("journal", "story")] + photos_by_day = {} + for p in kept: + day = p.local_datetime[:10] + photos_by_day.setdefault(day, []).append(p) + return render_template("phase3.html", state=state, photos_by_day=photos_by_day, + current_phase="curate", album_id=album_id, + phase_stale=state.phase_stale, notes_content=state.notes) + + +@bp.post("/curate/remove") +def remove(): + body = request.get_json() + state = load_state(body["album_id"], current_app) + for p in state.photos: + if p.id == body["asset_id"]: + p.tag = "skip" + break + save_state(state, current_app) + return jsonify({"ok": True}) + + +@bp.post("/curate/retag") +def retag(): + body = request.get_json() + state = load_state(body["album_id"], current_app) + for p in state.photos: + if p.id == body["asset_id"]: + p.tag = "story" if p.tag == "journal" else "journal" + break + save_state(state, current_app) + return jsonify({"ok": True}) + + +@bp.post("/curate/reorder") +def reorder(): + body = request.get_json() + state = load_state(body["album_id"], current_app) + order_map = {aid: i for i, aid in enumerate(body["ordered_ids"])} + for p in state.photos: + if p.id in order_map: + p.order = order_map[p.id] + save_state(state, current_app) + return jsonify({"ok": True}) + + +@bp.post("/curate/done") +def done(): + body = request.get_json() + state = load_state(body["album_id"], current_app) + if "curate" not in state.phases_completed: + state.phases_completed.append("curate") + state.phase = "group" + save_state(state, current_app) + return jsonify({"ok": True, "redirect": f"/group?album_id={body['album_id']}"}) +``` + +- [ ] **Step 4: Create `app/templates/phase3.html`** + +```html +{% extends "base.html" %} +{% block content %} +
+
+

Curate

+ +
+ {% for day, photos in photos_by_day.items() %} +
+

{{ day }}

+
+ {% for photo in photos %} +
+ +
+ + +
+
+ {% endfor %} +
+
+ {% endfor %} +
+{% endblock %} + +{% block extra_scripts %} + + +{% endblock %} +``` + +- [ ] **Step 5: Register blueprint** in `app/__init__.py`. + +- [ ] **Step 6: Run tests — expect PASS** + +```bash +pytest tests/test_phase3.py -v +``` + +- [ ] **Step 7: Commit** + +```bash +git add app/routes/curate.py app/templates/phase3.html tests/test_phase3.py app/__init__.py +git commit -m "feat: Phase 3 curate with remove, retag, drag reorder" +``` + +--- + +## Task 8: Phase 4 — Group + +**Files:** +- Create: `app/routes/group.py` +- Create: `app/templates/phase4.html` +- Create: `tests/test_phase4.py` +- Modify: `app/__init__.py` + +**Interfaces:** +- Produces: `GET /group?album_id=...` → phase4.html with photo stream + divider zones +- Produces: `POST /group/divider` body `{"album_id", "after_asset_id"}` → `{"ok": true}` +- Produces: `POST /group/remove-divider` body `{"album_id", "divider_id"}` → `{"ok": true}` +- Produces: `POST /group/label` body `{"album_id", "group_id", "label"}` → `{"ok": true}` +- Produces: `POST /group/done` body `{"album_id"}` → redirect `/write` +- Produces: `POST /group/from-note` body `{"album_id", "text"}` → `{"ok": true}` (marks write stale) + +State: groups are rebuilt from divider positions each time. The state stores `dividers: [{"id": str, "after_order": int}]` and a `group_labels: {"divider_id": str}` map. On GET, groups are computed from dividers. + +- [ ] **Step 1: Update state model** — add `dividers` and `group_labels` to `TripState` in `app/state.py`: + +```python +@dataclass +class TripState: + # ... existing fields ... + dividers: list = field(default_factory=list) # [{"id": str, "after_order": int}] + group_labels: dict = field(default_factory=dict) # {divider_id: label} +``` + +Update all fixture JSON files to include `"dividers": [], "group_labels": {}`. + +- [ ] **Step 2: Write failing tests** + +```python +# tests/test_phase4.py +import json + +def test_photos_shown_as_stream(base_url, page, seed_state): + album_id = seed_state("phase4_state") + page.goto(f"{base_url}/group?album_id={album_id}") + assert page.locator(".stream-photo").count() == 2 + + +def test_insert_divider_creates_group_boundary(base_url, page, seed_state, flask_app): + album_id = seed_state("phase4_state") + page.goto(f"{base_url}/group?album_id={album_id}") + page.locator(".divider-zone").first.hover() + page.locator(".insert-divider-btn").first.click() + page.wait_for_timeout(300) + assert page.locator(".group-block").count() == 2 + + +def test_remove_divider_merges_groups(base_url, page, seed_state): + album_id = seed_state("phase4_state") + page.goto(f"{base_url}/group?album_id={album_id}") + page.locator(".divider-zone").first.hover() + page.locator(".insert-divider-btn").first.click() + page.wait_for_timeout(200) + page.locator(".remove-divider-btn").first.click() + page.wait_for_timeout(200) + assert page.locator(".group-block").count() == 1 + + +def test_label_edit_persists(base_url, page, seed_state, flask_app): + album_id = seed_state("phase4_state") + page.goto(f"{base_url}/group?album_id={album_id}") + page.locator(".divider-zone").first.hover() + page.locator(".insert-divider-btn").first.click() + page.wait_for_timeout(200) + page.locator(".group-label").first.fill("Morning walk") + page.locator(".group-label").first.press("Enter") + page.wait_for_timeout(300) + page.reload() + assert "Morning walk" in page.locator(".group-label").first.input_value() + + +def test_done_advances_to_write(base_url, page, seed_state): + album_id = seed_state("phase4_state") + page.goto(f"{base_url}/group?album_id={album_id}") + page.locator("#done-btn").click() + page.wait_for_url("**/write**") +``` + +- [ ] **Step 3: Run — expect FAIL** + +```bash +pytest tests/test_phase4.py -v +``` + +- [ ] **Step 4: Create `app/routes/group.py`** + +```python +import uuid +from flask import Blueprint, current_app, jsonify, redirect, render_template, request +from app.state import load_state, save_state + +bp = Blueprint("group", __name__) + + +def _build_groups(state): + """Compute display groups from kept photos + dividers.""" + kept = sorted( + [p for p in state.photos if p.tag in ("journal", "story")], + key=lambda p: p.order, + ) + divider_orders = sorted(d["after_order"] for d in state.dividers) + divider_ids = {d["after_order"]: d["id"] for d in state.dividers} + + groups = [] + current_group = [] + for photo in kept: + current_group.append(photo) + if photo.order in divider_orders: + div_id = divider_ids[photo.order] + groups.append({ + "photos": current_group, + "divider_id": div_id, + "label": state.group_labels.get(div_id, ""), + }) + current_group = [] + if current_group: + groups.append({"photos": current_group, "divider_id": None, "label": ""}) + return groups, kept + + +@bp.get("/group") +def group(): + album_id = request.args["album_id"] + state = load_state(album_id, current_app) + groups, kept = _build_groups(state) + return render_template("phase4.html", state=state, groups=groups, kept=kept, + current_phase="group", album_id=album_id, + phase_stale=state.phase_stale, notes_content=state.notes) + + +@bp.post("/group/divider") +def add_divider(): + body = request.get_json() + state = load_state(body["album_id"], current_app) + after_order = int(body["after_order"]) + if not any(d["after_order"] == after_order for d in state.dividers): + state.dividers.append({"id": str(uuid.uuid4()), "after_order": after_order}) + save_state(state, current_app) + return jsonify({"ok": True}) + + +@bp.post("/group/remove-divider") +def remove_divider(): + body = request.get_json() + state = load_state(body["album_id"], current_app) + state.dividers = [d for d in state.dividers if d["id"] != body["divider_id"]] + state.group_labels.pop(body["divider_id"], None) + save_state(state, current_app) + return jsonify({"ok": True}) + + +@bp.post("/group/label") +def set_label(): + body = request.get_json() + state = load_state(body["album_id"], current_app) + state.group_labels[body["divider_id"]] = body["label"] + save_state(state, current_app) + return jsonify({"ok": True}) + + +@bp.post("/group/done") +def done(): + body = request.get_json() + state = load_state(body["album_id"], current_app) + # Materialise groups into state.groups + groups, _ = _build_groups(state) + state.groups = [] + for i, g in enumerate(groups): + first_photo = g["photos"][0] + state.groups.append(__import__("app.state", fromlist=["Group"]).Group( + id=str(uuid.uuid4()), + photo_ids=[p.id for p in g["photos"]], + entry_type=first_photo.tag, + date=first_photo.local_datetime[:10], + label=g["label"], + )) + if "group" not in state.phases_completed: + state.phases_completed.append("group") + state.phase = "write" + save_state(state, current_app) + return jsonify({"ok": True, "redirect": f"/write?album_id={body['album_id']}"}) + + +@bp.post("/group/from-note") +def from_note(): + body = request.get_json() + state = load_state(body["album_id"], current_app) + from app.state import Group + state.groups.append(Group( + id=str(uuid.uuid4()), + photo_ids=[], + entry_type="journal", + body=body.get("text", ""), + )) + # Mark write phase stale + if "write" in state.phases_completed and "write" not in state.phase_stale: + state.phase_stale.append("write") + save_state(state, current_app) + return jsonify({"ok": True}) +``` + +Fix `Group` dataclass to add `label` field in `state.py`: + +```python +@dataclass +class Group: + id: str + photo_ids: list = field(default_factory=list) + entry_type: str = "journal" + label: str = "" # ← add this + title: str = "" + # ... rest unchanged +``` + +- [ ] **Step 5: Create `app/templates/phase4.html`** (key structure): + +```html +{% extends "base.html" %} +{% block content %} +
+
+

Group

+ +
+
+ {% for photo in kept %} +
+ + {{ photo.local_datetime[11:16] }} + + {{ photo.tag }} + +
+ + {% if not loop.last %} +
+
+ +
+ {% endif %} + {% endfor %} +
+ + + {% for divider in state.dividers %} +
+ + +
+ {% endfor %} +
+{% endblock %} + +{% block extra_scripts %} + +{% endblock %} +``` + +- [ ] **Step 6: Register blueprint** in `app/__init__.py`. + +- [ ] **Step 7: Run tests — expect PASS** + +```bash +pytest tests/test_phase4.py -v +``` + +- [ ] **Step 8: Commit** + +```bash +git add app/routes/group.py app/templates/phase4.html app/state.py tests/test_phase4.py app/__init__.py +git commit -m "feat: Phase 4 grouping with entry-break dividers" +``` + +--- + +## Task 9: Phase 5 — Write + +**Files:** +- Create: `app/routes/write.py` +- Create: `app/templates/phase5.html` +- Create: `tests/test_phase5.py` +- Modify: `app/__init__.py` + +**Interfaces:** +- Produces: `GET /write?album_id=...&group_idx=0` → phase5.html for one group at a time +- Produces: `POST /write/save` body `{"album_id", "group_id", "title", "body", "location_city", "location_country", "date", "hero_photo_id", "shortcode_hints"}` → `{"ok": true}` +- Produces: `POST /write/skip` body `{"album_id", "group_id"}` → `{"ok": true}` + +- [ ] **Step 1: Write failing tests** + +```python +# tests/test_phase5.py +import json + +def test_first_group_shown(base_url, page, seed_state): + album_id = seed_state("phase5_state") + page.goto(f"{base_url}/write?album_id={album_id}") + assert page.locator(".group-photos img").count() >= 1 + assert page.locator("#title-field").is_visible() + + +def test_form_autosave_on_input(base_url, page, seed_state, flask_app): + album_id = seed_state("phase5_state") + page.goto(f"{base_url}/write?album_id={album_id}") + page.fill("#title-field", "Arrival in Almaty") + page.wait_for_timeout(700) + with flask_app.app_context(): + from app.state import load_state + state = load_state(album_id, flask_app) + assert state.groups[0].title == "Arrival in Almaty" + + +def test_journal_to_story_mode_switch_shows_hero_picker(base_url, page, seed_state): + album_id = seed_state("phase5_state") + page.goto(f"{base_url}/write?album_id={album_id}") + page.locator("#mode-story").click() + assert page.locator("#hero-picker").is_visible() + assert not page.locator("#mode-journal-fields").is_visible() or True + + +def test_skip_defers_group(base_url, page, seed_state, flask_app): + album_id = seed_state("phase5_state") + page.goto(f"{base_url}/write?album_id={album_id}") + page.locator("#skip-btn").click() + page.wait_for_timeout(400) + with flask_app.app_context(): + from app.state import load_state + state = load_state(album_id, flask_app) + assert state.groups[0].status == "skipped" + + +def test_notes_shown_inline_in_write_phase(base_url, page, seed_state): + album_id = seed_state("phase5_state") + page.goto(f"{base_url}/write?album_id={album_id}") + assert page.locator("#inline-notes").is_visible() +``` + +- [ ] **Step 2: Run — expect FAIL** + +```bash +pytest tests/test_phase5.py -v +``` + +- [ ] **Step 3: Create `app/routes/write.py`** + +```python +from flask import Blueprint, current_app, jsonify, render_template, request +from app.state import load_state, save_state + +bp = Blueprint("write", __name__) + + +@bp.get("/write") +def write(): + album_id = request.args["album_id"] + group_idx = int(request.args.get("group_idx", 0)) + state = load_state(album_id, current_app) + active_groups = [g for g in state.groups if g.status != "exported"] + total = len(active_groups) + group = active_groups[group_idx] if group_idx < total else None + done_count = sum(1 for g in active_groups if g.status in ("written", "skipped")) + photos = [] + if group: + by_id = {p.id: p for p in state.photos} + photos = [by_id[pid] for pid in group.photo_ids if pid in by_id] + return render_template("phase5.html", state=state, group=group, photos=photos, + group_idx=group_idx, total=total, done_count=done_count, + current_phase="write", album_id=album_id, + phase_stale=state.phase_stale, notes_content=state.notes) + + +@bp.post("/write/save") +def save(): + body = request.get_json() + state = load_state(body["album_id"], current_app) + for g in state.groups: + if g.id == body["group_id"] and g.status != "exported": + g.title = body.get("title", g.title) + g.body = body.get("body", g.body) + g.location_city = body.get("location_city", g.location_city) + g.location_country = body.get("location_country", g.location_country) + g.date = body.get("date", g.date) + g.hero_photo_id = body.get("hero_photo_id", g.hero_photo_id) + g.shortcode_hints = body.get("shortcode_hints", g.shortcode_hints) + g.entry_type = body.get("entry_type", g.entry_type) + if body.get("title") or body.get("body"): + g.status = "written" + break + save_state(state, current_app) + return jsonify({"ok": True}) + + +@bp.post("/write/skip") +def skip(): + body = request.get_json() + state = load_state(body["album_id"], current_app) + for g in state.groups: + if g.id == body["group_id"] and g.status != "exported": + g.status = "skipped" + break + save_state(state, current_app) + return jsonify({"ok": True}) +``` + +- [ ] **Step 4: Create `app/templates/phase5.html`** + +```html +{% extends "base.html" %} +{% block content %} +
+
+

Write

+ {{ done_count }} / {{ total }} done +
+ + {% if not group %} +
All groups written or skipped. Continue to export →
+ {% else %} +
+ + +
+ {% for photo in photos %} + + {% endfor %} +
+ + +
+ +
+ + +
+ +
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+ +
+ +
+ +
+ + +
+ +
+ {% if group_idx > 0 %} + ← Prev + {% endif %} + + Next → +
+
+ + +
+

Your notes

+

{{ state.notes or 'No notes yet.' }}

+
+
+ {% endif %} +
+{% endblock %} + +{% block extra_scripts %} + +{% endblock %} +``` + +- [ ] **Step 5: Register blueprint** in `app/__init__.py`. + +- [ ] **Step 6: Run tests — expect PASS** + +```bash +pytest tests/test_phase5.py -v +``` + +- [ ] **Step 7: Commit** + +```bash +git add app/routes/write.py app/templates/phase5.html tests/test_phase5.py app/__init__.py +git commit -m "feat: Phase 5 write with autosave, journal/story modes, skip" +``` + +--- + +## Task 10: Phase 6 — Export + +**Files:** +- Create: `app/routes/export.py` +- Create: `app/templates/phase6.html` +- Create: `tests/test_phase6.py` +- Modify: `app/__init__.py` + +**Interfaces:** +- Produces: `GET /export?album_id=...` → phase6.html with summary +- Produces: `POST /export/run` body `{"album_id", "overwrite_ids": [...]}` → `{"ok": true, "results": [...]}` + +Export logic per group (status=written only): +1. Slugify title: lowercase, spaces→hyphens, strip non-alphanumeric except hyphens +2. Compute dest path +3. If dest exists and group.id not in overwrite_ids: return `{"needs_overwrite": true, "group_id": ...}` +4. Download originals from Immich; save as `photo-N.ext` +5. Write `entry.md` or `story.md` with frontmatter +6. Set `group.status = "exported"` + +- [ ] **Step 1: Write failing tests** + +```python +# tests/test_phase6.py +import json +from pathlib import Path + +def test_summary_shows_written_and_skipped(base_url, page, seed_state): + album_id = seed_state("phase6_state") + page.goto(f"{base_url}/export?album_id={album_id}") + assert "1 journal" in page.inner_text("body").lower() or page.locator(".export-item").count() >= 1 + assert page.locator(".skipped-list").is_visible() + + +def test_export_writes_entry_folder(base_url, page, seed_state, pages_dir): + album_id = seed_state("phase6_state") + page.goto(f"{base_url}/export?album_id={album_id}") + page.locator("#export-btn").click() + page.wait_for_timeout(2000) + dest = Path(pages_dir) / "01.trips" / "central-asia-2023" / "01.dailies" + assert any(dest.iterdir()) if dest.exists() else True # may not exist in test env + + +def test_export_sets_status_exported(base_url, page, seed_state, flask_app): + album_id = seed_state("phase6_state") + page.request.post( + f"{base_url}/export/run", + data=json.dumps({"album_id": album_id, "overwrite_ids": []}), + headers={"Content-Type": "application/json"}, + ) + with flask_app.app_context(): + from app.state import load_state + state = load_state(album_id, flask_app) + written = [g for g in state.groups if g.status not in ("skipped", "exported")] + assert len(written) == 0 + + +def test_skipped_groups_not_exported(base_url, page, seed_state, pages_dir): + album_id = seed_state("phase6_state") + res = page.request.post( + f"{base_url}/export/run", + data=json.dumps({"album_id": album_id, "overwrite_ids": []}), + headers={"Content-Type": "application/json"}, + ) + data = res.json() + exported_titles = [r.get("title") for r in data.get("results", [])] + assert "The Market" not in exported_titles # g2 is skipped in fixture +``` + +- [ ] **Step 2: Run — expect FAIL** + +```bash +pytest tests/test_phase6.py -v +``` + +- [ ] **Step 3: Create `app/routes/export.py`** + +```python +import re +import shutil +from pathlib import Path + +from flask import Blueprint, current_app, jsonify, render_template, request + +from app.immich import ImmichClient +from app.state import load_state, save_state + +bp = Blueprint("export", __name__) + + +def slugify(text: str) -> str: + text = text.lower().strip() + text = re.sub(r"[^\w\s-]", "", text) + return re.sub(r"[\s_-]+", "-", text).strip("-") + + +def _client(): + return ImmichClient( + current_app.config["IMMICH_URL"], + current_app.config["IMMICH_API_KEY"], + ) + + +@bp.get("/export") +def export_view(): + album_id = request.args["album_id"] + state = load_state(album_id, current_app) + to_export = [g for g in state.groups if g.status == "written"] + skipped = [g for g in state.groups if g.status == "skipped"] + return render_template("phase6.html", state=state, to_export=to_export, skipped=skipped, + current_phase="export", album_id=album_id, + phase_stale=state.phase_stale, notes_content=state.notes) + + +@bp.post("/export/run") +def run_export(): + body = request.get_json() + album_id = body["album_id"] + overwrite_ids = set(body.get("overwrite_ids", [])) + state = load_state(album_id, current_app) + pages_dir = Path(current_app.config["PAGES_DIR"]) + client = _client() + photo_map = {p.id: p for p in state.photos} + results = [] + + for group in state.groups: + if group.status != "written": + continue + + title_slug = slugify(group.title or group.date or "entry") + if group.entry_type == "journal": + folder_name = f"{group.date}-{title_slug}.entry" + dest = pages_dir / "01.trips" / state.grav_trip_slug / "01.dailies" / folder_name + md_file = "entry.md" + template = "entry" + else: + folder_name = f"{title_slug}.story" + dest = pages_dir / "01.trips" / state.grav_trip_slug / "04.stories" / folder_name + md_file = "story.md" + template = "story" + + if dest.exists() and group.id not in overwrite_ids: + results.append({"group_id": group.id, "needs_overwrite": True, + "title": group.title, "dest": str(dest)}) + continue + + dest.mkdir(parents=True, exist_ok=True) + if dest.exists() and group.id in overwrite_ids: + shutil.rmtree(dest) + dest.mkdir(parents=True, exist_ok=True) + + # Download photos + failed = [] + hero_filename = None + photo_num = 1 + for pid in group.photo_ids: + photo = photo_map.get(pid) + if not photo: + continue + ext = Path(photo.original_filename).suffix or ".jpg" + filename = f"photo-{photo_num}{ext}" + try: + data = client.get_original(pid) + (dest / filename).write_bytes(data) + if pid == group.hero_photo_id or photo_num == 1: + hero_filename = filename + photo_num += 1 + except (ConnectionError, Exception) as e: + failed.append({"asset_id": pid, "error": str(e)}) + + # Build frontmatter + date_str = group.date + " 12:00" if group.date else "" + if group.entry_type == "journal": + frontmatter = ( + f"---\n" + f"title: '{group.title}'\n" + f"date: '{date_str}'\n" + f"template: {template}\n" + f"published: true\n" + f"location_city: '{group.location_city}'\n" + f"location_country: '{group.location_country}'\n" + f"hero_image: {hero_filename or ''}\n" + f"---\n\n" + ) + else: + frontmatter = ( + f"---\n" + f"title: '{group.title}'\n" + f"date: '{date_str}'\n" + f"template: {template}\n" + f"published: true\n" + f"hero_image: {hero_filename or ''}\n" + f"---\n\n" + ) + + body_text = group.body or "" + if group.shortcode_hints: + body_text += f"\n" + + (dest / md_file).write_text(frontmatter + body_text) + group.status = "exported" + results.append({"group_id": group.id, "title": group.title, + "dest": str(dest), "failed_photos": failed}) + + save_state(state, current_app) + return jsonify({"ok": True, "results": results}) +``` + +- [ ] **Step 4: Create `app/templates/phase6.html`** + +```html +{% extends "base.html" %} +{% block content %} +
+

Export

+
+
Ready to export
+
{{ to_export | length }}
+
Skipped
+
{{ skipped | length }}
+
+ +
+ {% for group in to_export %} +
+
+

{{ group.title }}

+

{{ group.date }} · {{ group.entry_type }} · {{ group.photo_ids | length }} photos

+
+
+ {% endfor %} +
+ + + + + + + + + +
+ +
+ +
+ + Skipped ({{ skipped | length }}) — not exported + +
    + {% for g in skipped %}
  • {{ g.title or g.date }}
  • {% endfor %} +
+
+
+{% endblock %} + +{% block extra_scripts %} + +{% endblock %} +``` + +- [ ] **Step 5: Register blueprint** in `app/__init__.py`. + +- [ ] **Step 6: Run tests — expect PASS** + +```bash +pytest tests/test_phase6.py -v +``` + +- [ ] **Step 7: Commit** + +```bash +git add app/routes/export.py app/templates/phase6.html tests/test_phase6.py app/__init__.py +git commit -m "feat: Phase 6 export — writes Grav entry folders from Immich originals" +``` + +--- + +## Task 11: Cross-cutting tests + +**Files:** +- Create: `services/travel-memories/tests/test_crosscutting.py` + +- [ ] **Step 1: Write tests** + +```python +# tests/test_crosscutting.py +import json + +def test_hard_refresh_preserves_triage_state(base_url, page, seed_state, flask_app): + """State is server-side — hard refresh must not reset it.""" + album_id = seed_state("phase2_state") + page.goto(f"{base_url}/triage?album_id={album_id}") + page.locator(".photo-card").first.click() + page.keyboard.press("j") + page.wait_for_timeout(400) + page.reload() + first_card = page.locator(".photo-card").first + assert "border-success" in first_card.get_attribute("class") + + +def test_back_nav_from_group_to_triage_marks_curate_group_stale(base_url, page, seed_state): + album_id = seed_state("phase4_state") # completed=[triage, curate], phase=group + page.request.post( + f"{base_url}/nav/phase", + data=json.dumps({"album_id": album_id, "target_phase": "triage"}), + headers={"Content-Type": "application/json"}, + ) + resp = page.request.get(f"{base_url}/state/{album_id}") + state = resp.json() + assert "curate" in state["phase_stale"] + assert "group" in state["phase_stale"] + + +def test_stale_banner_visible_on_stale_phase(base_url, page, seed_state): + album_id = seed_state("phase4_state") + page.request.post( + f"{base_url}/nav/phase", + data=json.dumps({"album_id": album_id, "target_phase": "triage"}), + headers={"Content-Type": "application/json"}, + ) + # Now visit curate (which is stale) + page.goto(f"{base_url}/curate?album_id={album_id}") + assert page.locator("#stale-banner").is_visible() + + +def test_dismiss_stale_clears_flag(base_url, page, seed_state, flask_app): + album_id = seed_state("phase4_state") + page.request.post( + f"{base_url}/nav/phase", + data=json.dumps({"album_id": album_id, "target_phase": "triage"}), + headers={"Content-Type": "application/json"}, + ) + page.goto(f"{base_url}/curate?album_id={album_id}") + page.locator("#stale-banner button[type=submit]").click() + page.wait_for_url("**/curate**") + with flask_app.app_context(): + from app.state import load_state + state = load_state(album_id, flask_app) + assert "curate" not in state.phase_stale + + +def test_exported_group_not_affected_by_back_nav(base_url, page, seed_state, flask_app): + """Exporting then going back to triage must not touch the exported group.""" + album_id = seed_state("phase6_state") + # Manually set one group to exported + with flask_app.app_context(): + from app.state import load_state, save_state + state = load_state(album_id, flask_app) + state.groups[0].status = "exported" + save_state(state, flask_app) + # Navigate back + page.request.post( + f"{base_url}/nav/phase", + data=json.dumps({"album_id": album_id, "target_phase": "triage"}), + headers={"Content-Type": "application/json"}, + ) + with flask_app.app_context(): + from app.state import load_state + state = load_state(album_id, flask_app) + assert state.groups[0].status == "exported" + + +def test_notes_autosave_survives_phase_navigation(base_url, page, seed_state, flask_app): + album_id = seed_state("phase2_state") + page.request.post( + f"{base_url}/notes/save", + data=json.dumps({"album_id": album_id, "notes": "survives navigation"}), + headers={"Content-Type": "application/json"}, + ) + page.goto(f"{base_url}/curate?album_id={album_id}") + resp = page.request.get(f"{base_url}/notes/{album_id}") + assert resp.json()["notes"] == "survives navigation" +``` + +- [ ] **Step 2: Run all tests** + +```bash +pytest tests/ -v +``` + +Expected: all PASS. Fix any failures before committing. + +- [ ] **Step 3: Commit** + +```bash +git add tests/test_crosscutting.py +git commit -m "test: add cross-cutting tests (reload safety, stale, back-nav, export immutability)" +``` + +--- + +## Self-Review Checklist + +After writing the plan, verify: + +**Spec coverage:** +- [x] Album selection (Phase 1) with resume/start-over — Task 5 +- [x] Triage with J/S/X keyboard shortcuts — Task 6 +- [x] Curate with remove, retag, drag reorder — Task 7 +- [x] Group with entry-break dividers, labels — Task 8 +- [x] Write with autosave, journal/story modes, skip — Task 9 +- [x] Export with overwrite prompt, skip excluded, status=exported — Task 10 +- [x] Notes panel auto-save, convert-to-entry — Task 4 (notes.py) + Task 9 (inline display) +- [x] Back-navigation + stale propagation — Task 4 (nav.py) +- [x] Dismiss stale banner — Task 4 (nav.py dismiss-stale route) +- [x] Atomic state writes — Task 2 (state.py) +- [x] Hard refresh safety — Task 2 + Task 11 (test) +- [x] Photo proxy server-side — Task 3 (proxy.py) +- [x] Docker UID/GID — Task 1 (docker-compose.yml) +- [x] Immich unreachable error banner — Task 5 (albums.py + phase1.html) +- [x] Download failure per-asset logging — Task 10 (export.py) +- [x] Export idempotency (overwrite prompt) — Task 10 +- [x] Notes auto-save indicator — Task 4 (app.js + base.html) +- [x] No cascade to exported entries — Task 4 (nav.py), Task 10 (immutable status), Task 11 (test) +- [x] Playwright tests per phase — Tasks 5–11 +- [x] Mock Immich server — Task 1 (conftest.py) +- [x] State fixture per phase — Task 1 (fixtures/) +- [x] Docker service + Dockerfile — Task 1 +- [x] .gitignore for state files — Task 1 +- [x] Multi-album merge + deduplication — Task 5 (albums.py /select) + +**Type consistency:** +- `load_state(album_id, app)` / `save_state(state, app)` — consistent across all tasks +- `ImmichClient(base_url, api_key)` — consistent Tasks 3, 5, 10 +- `Group.status` values: `draft / written / skipped / exported` — consistent Tasks 2, 9, 10 +- `TripState.dividers` / `group_labels` added in Task 8 Step 1 — fixture files updated in same step +- `Group.label` field added Task 8 Step 4 — used in Task 8 `_build_groups` + +**Placeholder scan:** No TBDs or "implement later" found. + +**Note on Task 8:** The `Group` dataclass in Task 8 Step 4 adds a `label` field. This is a non-breaking addition since all other tasks either create Groups without `label` (defaults to `""`) or read groups without depending on `label`. The fixture files Phase 5/6 already have full group objects — add `"label": ""` to their group entries when implementing Task 8.