# 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.