Files
intotheeast-com/docs/working/plans/2026-06-21-travel-memories.md
T
2026-06-21 15:34:34 +02:00

2807 lines
91 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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/<id> GET /proxy/original/<id>
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 phase36)
- 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 510.)
- [ ] **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/<asset_id>` → image bytes
- Produces: `GET /proxy/original/<asset_id>` → 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/<asset_id>")
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/<asset_id>")
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/<album_id>")
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/<album_id>")
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
<!DOCTYPE html>
<html data-theme="forest" lang="en">
<head>
<meta charset="UTF-8">
<title>travel-memories</title>
<link href="https://cdn.jsdelivr.net/npm/daisyui@4/dist/full.min.css" rel="stylesheet">
<script src="https://cdn.tailwindcss.com"></script>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3/dist/cdn.min.js"></script>
</head>
<body class="min-h-screen bg-base-200">
<!-- Navbar -->
<div class="navbar bg-base-100 shadow-sm sticky top-0 z-40">
<div class="navbar-start px-4 font-bold text-lg">travel-memories</div>
<div class="navbar-center">
<ul class="steps">
{% set phases = [('','Album'),('triage','Triage'),('curate','Curate'),('group','Group'),('write','Write'),('export','Export')] %}
{% for key, label in phases %}
<li class="step {% if current_phase == key %}step-primary{% endif %}
{% if key in phase_stale %}step-warning{% endif %}">
{% if album_id %}
<a hx-post="/nav/phase" hx-vals='{"album_id":"{{ album_id }}","target_phase":"{{ key }}"}' href="/{{ key }}{% if album_id %}?album_id={{ album_id }}{% endif %}">{{ label }}</a>
{% else %}{{ label }}{% endif %}
</li>
{% endfor %}
</ul>
</div>
<div class="navbar-end px-4">
{% if album_id %}
<button class="btn btn-ghost btn-sm" @click="open = !open" x-data>📝 Notes</button>
{% endif %}
</div>
</div>
<!-- Stale warning -->
{% if current_phase in phase_stale %}
<div class="alert alert-warning rounded-none" id="stale-banner">
<span>You changed earlier decisions — review this phase before exporting.</span>
<form method="post" action="/nav/dismiss-stale">
<input type="hidden" name="album_id" value="{{ album_id }}">
<input type="hidden" name="phase" value="{{ current_phase }}">
<button class="btn btn-xs">Dismiss</button>
</form>
</div>
{% endif %}
<!-- Body with notes drawer -->
<div class="flex relative" x-data="notesApp('{{ notes_content | e }}', '{{ album_id }}')">
<div class="flex-1 min-w-0 transition-all" :class="open ? 'mr-80' : ''">
{% block content %}{% endblock %}
</div>
<!-- Notes panel -->
<div class="fixed right-0 top-16 h-[calc(100vh-4rem)] w-80 bg-base-100 shadow-2xl p-4 flex flex-col transition-transform z-30"
:class="open ? 'translate-x-0' : 'translate-x-full'" id="notes-panel">
<h3 class="font-bold text-base mb-2">Notes</h3>
<textarea class="textarea textarea-bordered flex-1 resize-none text-sm"
x-model="notes"
@input="scheduleAutosave()"
placeholder="Jot down memories at any time…"></textarea>
<div class="text-xs text-right mt-1 opacity-60" x-text="status"></div>
</div>
</div>
{% block extra_scripts %}{% endblock %}
</body>
</html>
```
- [ ] **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 %}
<div class="p-6 max-w-5xl mx-auto">
<h1 class="text-2xl font-bold mb-4">Select Album</h1>
{% if error %}
<div class="alert alert-error mb-4">
<span>Cannot reach Immich: {{ error }}</span>
<a href="/" class="btn btn-sm">Retry</a>
</div>
{% endif %}
<form method="post" action="/select">
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 mb-6">
{% for album in albums %}
<label class="album-card card bg-base-100 shadow cursor-pointer hover:shadow-lg transition"
data-album-id="{{ album.id }}">
<figure class="h-40 overflow-hidden">
<img src="/proxy/thumb/{{ album.albumThumbnailAssetId }}"
class="w-full h-full object-cover" alt="">
</figure>
<div class="card-body p-4">
<div class="flex items-start gap-2">
<input type="checkbox" name="album_ids[]" value="{{ album.id }}"
class="checkbox checkbox-primary mt-1">
<div>
<p class="font-semibold">{{ album.albumName }}</p>
<p class="text-sm opacity-60">{{ album.assetCount }} photos</p>
{% if album.has_state %}
<span class="resume-badge badge badge-warning badge-sm mt-1">In progress</span>
{% endif %}
</div>
</div>
</div>
</label>
{% endfor %}
</div>
<div class="form-control mb-4 max-w-xs">
<label class="label"><span class="label-text">Grav trip slug</span></label>
<input id="grav-slug" type="text" name="grav_trip_slug" required
placeholder="central-asia-2023" class="input input-bordered">
</div>
<div class="flex gap-2">
<button type="submit" class="btn btn-primary">Start →</button>
<button type="submit" name="start_over" value="1" class="btn btn-ghost btn-sm">
Start over (discard progress)
</button>
</div>
</form>
</div>
{% 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 %}
<div class="p-4 max-w-6xl mx-auto" x-data="triageApp('{{ album_id }}')"
@keydown.j.window="tagFocused('journal')"
@keydown.s.window="tagFocused('story')"
@keydown.x.window="tagFocused('skip')"
@keydown.space.prevent.window="tagFocused('skip')">
<div class="flex items-center justify-between mb-4">
<h1 class="text-xl font-bold">Triage</h1>
<div class="flex items-center gap-3">
<span class="text-sm opacity-60" id="tagged-count">
{{ state.photos | selectattr('tag', 'ne', 'untagged') | list | length }}
/ {{ state.photos | length }} tagged
</span>
<button id="done-btn"
class="btn btn-primary btn-sm"
{% if not all_tagged %}disabled{% endif %}
@click="done()">
Done triaging →
</button>
</div>
</div>
{% for day, photos in photos_by_day.items() %}
<div class="day-group mb-6">
<h2 class="sticky top-16 z-20 bg-base-200 py-1 text-sm font-semibold opacity-70">{{ day }}</h2>
<div class="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 gap-2 mt-2">
{% for photo in photos %}
<div class="photo-card relative cursor-pointer rounded-lg overflow-hidden border-4
{% if photo.tag == 'journal' %}border-success
{% elif photo.tag == 'story' %}border-info
{% elif photo.tag == 'skip' %}border-base-300 opacity-40
{% else %}border-transparent{% endif %}"
data-asset-id="{{ photo.id }}"
data-tag="{{ photo.tag }}"
tabindex="0"
@click="select($el)"
@focus="select($el)">
<img src="/proxy/thumb/{{ photo.id }}"
class="w-full aspect-square object-cover" loading="lazy" alt="">
<div class="absolute bottom-0 left-0 right-0 text-[10px] text-white bg-black/40 px-1">
{{ photo.local_datetime[11:16] }}
</div>
{% if photo.tag != 'untagged' and photo.tag != 'skip' %}
<div class="absolute top-1 right-1 badge badge-xs
{% if photo.tag == 'journal' %}badge-success{% else %}badge-info{% endif %}">
{{ photo.tag[0] | upper }}
</div>
{% endif %}
</div>
{% endfor %}
</div>
</div>
{% endfor %}
</div>
{% endblock %}
{% block extra_scripts %}
<script>
function triageApp(albumId) {
return {
focused: null,
select(el) {
this.focused = el;
},
async tagFocused(tag) {
const el = this.focused || document.querySelector('.photo-card');
if (!el) return;
const assetId = el.dataset.assetId;
await fetch('/triage/tag', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ album_id: albumId, asset_id: assetId, tag }),
});
el.dataset.tag = tag;
el.className = el.className.replace(/border-\S+/, '');
const cls = tag === 'journal' ? 'border-success'
: tag === 'story' ? 'border-info'
: 'border-base-300 opacity-40';
el.classList.add(...cls.split(' '));
this.updateCount();
},
updateCount() {
const total = document.querySelectorAll('.photo-card').length;
const tagged = document.querySelectorAll('.photo-card:not([data-tag=untagged])').length;
document.getElementById('tagged-count').textContent = `${tagged} / ${total} tagged`;
document.getElementById('done-btn').disabled = tagged < total;
},
async done() {
const res = await fetch('/triage/done', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ album_id: albumId }),
});
const data = await res.json();
if (data.redirect) window.location = data.redirect;
},
};
}
</script>
{% 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 %}
<div class="p-4 max-w-6xl mx-auto">
<div class="flex items-center justify-between mb-4">
<h1 class="text-xl font-bold">Curate</h1>
<button id="done-btn" class="btn btn-primary btn-sm" @click="done()">
Curate done →
</button>
</div>
{% for day, photos in photos_by_day.items() %}
<div class="day-group mb-6">
<h2 class="sticky top-16 bg-base-200 py-1 text-sm font-semibold opacity-70">{{ day }}</h2>
<div class="flex flex-wrap gap-2 mt-2" id="day-{{ day }}"
x-data x-init="Sortable.create($el, {onEnd: e => reorder('{{ album_id }}', e.to)})">
{% for photo in photos %}
<div class="photo-card relative w-32 h-32 rounded-lg overflow-hidden border-4
{% if photo.tag == 'story' %}border-info{% else %}border-success{% endif %}"
data-asset-id="{{ photo.id }}">
<img src="/proxy/thumb/{{ photo.id }}" class="w-full h-full object-cover" alt="">
<div class="absolute top-1 left-1 flex gap-1">
<button class="retag-btn btn btn-xs btn-ghost bg-black/40 text-white"
@click.stop="retag('{{ album_id }}', '{{ photo.id }}', $el.closest('.photo-card'))">
{% if photo.tag == 'journal' %}→S{% else %}→J{% endif %}
</button>
<button class="remove-btn btn btn-xs btn-ghost bg-black/40 text-white"
@click.stop="remove('{{ album_id }}', '{{ photo.id }}', $el.closest('.photo-card'))">
</button>
</div>
</div>
{% endfor %}
</div>
</div>
{% endfor %}
</div>
{% endblock %}
{% block extra_scripts %}
<script src="https://cdn.jsdelivr.net/npm/sortablejs@1/Sortable.min.js"></script>
<script>
async function remove(albumId, assetId, el) {
await fetch('/curate/remove', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({album_id: albumId, asset_id: assetId})
});
el.remove();
}
async function retag(albumId, assetId, el) {
await fetch('/curate/retag', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({album_id: albumId, asset_id: assetId})
});
el.classList.toggle('border-info');
el.classList.toggle('border-success');
}
async function reorder(albumId, container) {
const ids = [...container.querySelectorAll('.photo-card')].map(e => e.dataset.assetId);
await fetch('/curate/reorder', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({album_id: albumId, ordered_ids: ids})
});
}
async function done() {
const albumId = new URLSearchParams(location.search).get('album_id');
const res = await fetch('/curate/done', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({album_id: albumId})
});
const data = await res.json();
if (data.redirect) window.location = data.redirect;
}
</script>
{% 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 %}
<div class="p-4 max-w-3xl mx-auto" x-data="groupApp('{{ album_id }}')">
<div class="flex items-center justify-between mb-4">
<h1 class="text-xl font-bold">Group</h1>
<button id="done-btn" class="btn btn-primary btn-sm" @click="done()">Grouping done →</button>
</div>
<div class="space-y-1">
{% for photo in kept %}
<div class="stream-photo flex items-center gap-3 bg-base-100 rounded p-1"
data-order="{{ photo.order }}">
<img src="/proxy/thumb/{{ photo.id }}" class="w-16 h-16 object-cover rounded">
<span class="text-xs opacity-60">{{ photo.local_datetime[11:16] }}</span>
<span class="badge badge-xs {% if photo.tag == 'story' %}badge-info{% else %}badge-success{% endif %}">
{{ photo.tag }}
</span>
</div>
<!-- Divider zone (shown between photos) -->
{% if not loop.last %}
<div class="divider-zone group relative h-4 flex items-center cursor-pointer"
data-after-order="{{ photo.order }}">
<div class="absolute inset-x-0 h-0.5 bg-base-300 group-hover:bg-primary transition"></div>
<button class="insert-divider-btn absolute left-1/2 -translate-x-1/2 btn btn-xs btn-primary opacity-0 group-hover:opacity-100 transition z-10"
@click="addDivider({{ photo.order }})">✂ cut here</button>
</div>
{% endif %}
{% endfor %}
</div>
<!-- Rendered dividers (shown as labelled separators) -->
{% for divider in state.dividers %}
<div class="group-block divider my-4" data-divider-id="{{ divider.id }}">
<input class="group-label input input-sm input-bordered w-48"
value="{{ state.group_labels.get(divider.id, '') }}"
placeholder="Label this entry…"
@change="setLabel('{{ divider.id }}', $el.value)"
@keydown.enter="$el.blur()">
<button class="remove-divider-btn btn btn-xs btn-ghost opacity-60"
@click="removeDivider('{{ divider.id }}')"></button>
</div>
{% endfor %}
</div>
{% endblock %}
{% block extra_scripts %}
<script>
function groupApp(albumId) {
return {
async addDivider(afterOrder) {
await fetch('/group/divider', {method:'POST',headers:{'Content-Type':'application/json'},
body: JSON.stringify({album_id: albumId, after_order: afterOrder})});
window.location.reload();
},
async removeDivider(dividerId) {
await fetch('/group/remove-divider', {method:'POST',headers:{'Content-Type':'application/json'},
body: JSON.stringify({album_id: albumId, divider_id: dividerId})});
window.location.reload();
},
async setLabel(dividerId, label) {
await fetch('/group/label', {method:'POST',headers:{'Content-Type':'application/json'},
body: JSON.stringify({album_id: albumId, divider_id: dividerId, label})});
},
async done() {
const res = await fetch('/group/done', {method:'POST',headers:{'Content-Type':'application/json'},
body: JSON.stringify({album_id: albumId})});
const data = await res.json();
if (data.redirect) window.location = data.redirect;
},
};
}
</script>
{% 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 %}
<div class="p-4 max-w-6xl mx-auto">
<div class="flex items-center justify-between mb-4">
<h1 class="text-xl font-bold">Write</h1>
<span class="text-sm opacity-60">{{ done_count }} / {{ total }} done</span>
</div>
{% if not group %}
<div class="alert alert-success">All groups written or skipped. <a href="/export?album_id={{ album_id }}" class="link">Continue to export →</a></div>
{% else %}
<div class="flex gap-4" x-data="writeApp('{{ album_id }}', '{{ group.id }}', '{{ group.entry_type }}')">
<!-- Photos panel -->
<div class="group-photos w-64 flex-shrink-0 space-y-2 overflow-y-auto max-h-[80vh]">
{% for photo in photos %}
<img src="/proxy/thumb/{{ photo.id }}"
class="w-full rounded cursor-pointer border-4 transition"
:class="heroId === '{{ photo.id }}' ? 'border-primary' : 'border-transparent'"
@click="setHero('{{ photo.id }}')"
alt="">
{% endfor %}
</div>
<!-- Form -->
<div class="flex-1 space-y-4">
<!-- Mode switch -->
<div class="tabs">
<button id="mode-journal" class="tab tab-bordered" :class="mode==='journal'?'tab-active':''"
@click="mode='journal'">Journal</button>
<button id="mode-story" class="tab tab-bordered" :class="mode==='story'?'tab-active':''"
@click="mode='story'">Story</button>
</div>
<div class="form-control">
<label class="label text-sm">Title</label>
<input id="title-field" type="text" class="input input-bordered" x-model="form.title"
@input.debounce.500ms="autosave()">
</div>
<div class="grid grid-cols-2 gap-2">
<div class="form-control">
<label class="label text-sm">City</label>
<input type="text" class="input input-bordered input-sm" x-model="form.location_city"
@input.debounce.500ms="autosave()">
</div>
<div class="form-control">
<label class="label text-sm">Country</label>
<input type="text" class="input input-bordered input-sm" x-model="form.location_country"
@input.debounce.500ms="autosave()">
</div>
</div>
<div class="form-control">
<label class="label text-sm">Body</label>
<textarea class="textarea textarea-bordered h-40" x-model="form.body"
@input.debounce.500ms="autosave()"></textarea>
</div>
<div id="hero-picker" x-show="mode==='story'" class="form-control">
<label class="label text-sm">Hero photo selected: <span x-text="heroId || 'none'"></span></label>
</div>
<div x-show="mode==='story'" class="form-control">
<label class="label text-sm">Shortcode hints</label>
<input type="text" class="input input-bordered input-sm" x-model="form.shortcode_hints"
@input.debounce.500ms="autosave()" placeholder="e.g. gallery block, pull quote">
</div>
<div class="flex gap-2">
{% if group_idx > 0 %}
<a href="/write?album_id={{ album_id }}&group_idx={{ group_idx - 1 }}" class="btn btn-ghost btn-sm">← Prev</a>
{% endif %}
<button id="skip-btn" class="btn btn-ghost btn-sm" @click="skip()">Skip for now</button>
<a href="/write?album_id={{ album_id }}&group_idx={{ group_idx + 1 }}" class="btn btn-primary btn-sm ml-auto">Next →</a>
</div>
</div>
<!-- Inline notes -->
<div id="inline-notes" class="w-64 flex-shrink-0 bg-base-100 rounded p-3">
<h3 class="font-semibold text-sm mb-2">Your notes</h3>
<p class="text-xs opacity-70 whitespace-pre-wrap">{{ state.notes or 'No notes yet.' }}</p>
</div>
</div>
{% endif %}
</div>
{% endblock %}
{% block extra_scripts %}
<script>
function writeApp(albumId, groupId, initialMode) {
return {
mode: initialMode,
heroId: null,
form: {
title: document.getElementById('title-field')?.value || '',
body: '', location_city: '', location_country: '',
shortcode_hints: '',
},
setHero(id) { this.heroId = id; this.autosave(); },
async autosave() {
await fetch('/write/save', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({
album_id: albumId, group_id: groupId, entry_type: this.mode,
hero_photo_id: this.heroId, ...this.form,
}),
});
},
async skip() {
await fetch('/write/skip', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({album_id: albumId, group_id: groupId}),
});
window.location.reload();
},
};
}
</script>
{% 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<!-- shortcode hints: {group.shortcode_hints} -->"
(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 %}
<div class="p-6 max-w-3xl mx-auto" x-data="exportApp('{{ album_id }}')">
<h1 class="text-2xl font-bold mb-4">Export</h1>
<div class="stats shadow mb-6">
<div class="stat"><div class="stat-title">Ready to export</div>
<div class="stat-value text-primary">{{ to_export | length }}</div></div>
<div class="stat"><div class="stat-title">Skipped</div>
<div class="stat-value opacity-40">{{ skipped | length }}</div></div>
</div>
<div class="space-y-2 mb-6">
{% for group in to_export %}
<div class="export-item card card-compact bg-base-100 shadow">
<div class="card-body">
<p class="font-semibold">{{ group.title }}</p>
<p class="text-xs opacity-60">{{ group.date }} · {{ group.entry_type }} · {{ group.photo_ids | length }} photos</p>
</div>
</div>
{% endfor %}
</div>
<button id="export-btn" class="btn btn-primary" @click="runExport()">
Export {{ to_export | length }} entries
</button>
<!-- Overwrite confirmation modal -->
<dialog id="overwrite-modal" class="modal">
<div class="modal-box">
<h3 class="font-bold">Destination exists</h3>
<p x-text="overwriteMsg" class="py-2 text-sm"></p>
<div class="modal-action">
<button class="btn btn-warning btn-sm" @click="confirmOverwrite()">Overwrite</button>
<button class="btn btn-ghost btn-sm" @click="skipOverwrite()">Skip this entry</button>
</div>
</div>
</dialog>
<!-- Results -->
<div x-show="results.length > 0" class="mt-6 space-y-1">
<template x-for="r in results" :key="r.group_id">
<div class="text-sm" :class="r.needs_overwrite ? 'text-warning' : 'text-success'">
<span x-text="r.needs_overwrite ? '⚠ ' + r.title + ' — exists' : '✓ ' + r.title"></span>
<template x-if="r.failed_photos && r.failed_photos.length">
<span class="text-error ml-2" x-text="`(${r.failed_photos.length} photo(s) failed)`"></span>
</template>
</div>
</template>
</div>
<details class="mt-6">
<summary class="cursor-pointer text-sm opacity-60 skipped-list">
Skipped ({{ skipped | length }}) — not exported
</summary>
<ul class="mt-2 space-y-1 text-sm opacity-60">
{% for g in skipped %}<li>{{ g.title or g.date }}</li>{% endfor %}
</ul>
</details>
</div>
{% endblock %}
{% block extra_scripts %}
<script>
function exportApp(albumId) {
return {
results: [],
pendingOverwrites: [],
currentOverwrite: null,
overwriteMsg: '',
confirmedIds: [],
async runExport(extraOverwrites = []) {
const res = await fetch('/export/run', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({album_id: albumId, overwrite_ids: [...this.confirmedIds, ...extraOverwrites]}),
});
const data = await res.json();
const needsOverwrite = data.results.filter(r => r.needs_overwrite);
const done = data.results.filter(r => !r.needs_overwrite);
this.results.push(...done);
if (needsOverwrite.length > 0) {
this.pendingOverwrites = needsOverwrite;
this.showNextOverwrite();
}
},
showNextOverwrite() {
if (this.pendingOverwrites.length === 0) return;
this.currentOverwrite = this.pendingOverwrites.shift();
this.overwriteMsg = `"${this.currentOverwrite.title}" already exists at ${this.currentOverwrite.dest}`;
document.getElementById('overwrite-modal').showModal();
},
confirmOverwrite() {
document.getElementById('overwrite-modal').close();
this.confirmedIds.push(this.currentOverwrite.group_id);
this.runExport();
},
skipOverwrite() {
document.getElementById('overwrite-modal').close();
this.results.push({...this.currentOverwrite, skipped: true});
this.showNextOverwrite();
},
};
}
</script>
{% 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 511
- [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.