From b5c90a1e81a99e3ffe6d6ae900594b15f01a47ef Mon Sep 17 00:00:00 2001 From: Mischa Date: Sun, 21 Jun 2026 16:44:54 +0200 Subject: [PATCH] feat: Phase 4 grouping with entry-break dividers Add group.py route, phase4.html template, and supporting state changes. Photos are shown as a flat stream; clicking divider zones inserts entry-break boundaries that split photos into labelled groups. Labels persist via group_labels dict. Done materialises groups into state.groups and advances to write phase. Co-Authored-By: Claude Sonnet 4.6 --- services/travel-memories/app/__init__.py | 3 +- services/travel-memories/app/routes/group.py | 116 ++++++++++++++++++ services/travel-memories/app/state.py | 3 + .../travel-memories/app/templates/phase4.html | 99 +++++++++++++++ .../tests/fixtures/phase2_state.json | 4 +- .../tests/fixtures/phase3_state.json | 4 +- .../tests/fixtures/phase4_state.json | 4 +- .../tests/fixtures/phase5_state.json | 8 +- .../tests/fixtures/phase6_state.json | 8 +- services/travel-memories/tests/test_phase4.py | 47 +++++++ 10 files changed, 286 insertions(+), 10 deletions(-) create mode 100644 services/travel-memories/app/routes/group.py create mode 100644 services/travel-memories/app/templates/phase4.html create mode 100644 services/travel-memories/tests/test_phase4.py diff --git a/services/travel-memories/app/__init__.py b/services/travel-memories/app/__init__.py index ac4ba97..99e6bd5 100644 --- a/services/travel-memories/app/__init__.py +++ b/services/travel-memories/app/__init__.py @@ -8,13 +8,14 @@ def create_app(state_dir=None, pages_dir=None): app.config["IMMICH_URL"] = os.environ.get("IMMICH_URL", "") app.config["IMMICH_API_KEY"] = os.environ.get("IMMICH_API_KEY", "") - from .routes import albums, triage, proxy, notes, nav, curate + from .routes import albums, triage, proxy, notes, nav, curate, group app.register_blueprint(albums.bp) app.register_blueprint(triage.bp) app.register_blueprint(proxy.bp) app.register_blueprint(notes.bp) app.register_blueprint(nav.bp) app.register_blueprint(curate.bp) + app.register_blueprint(group.bp) @app.get("/health") def health(): diff --git a/services/travel-memories/app/routes/group.py b/services/travel-memories/app/routes/group.py new file mode 100644 index 0000000..c7a5952 --- /dev/null +++ b/services/travel-memories/app/routes/group.py @@ -0,0 +1,116 @@ +import uuid +from flask import Blueprint, current_app, jsonify, redirect, render_template, request +from app.state import Group, 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) + groups, _ = _build_groups(state) + state.groups = [] + for g in groups: + first_photo = g["photos"][0] + state.groups.append(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) + state.groups.append(Group( + id=str(uuid.uuid4()), + photo_ids=[], + entry_type="journal", + body=body.get("text", ""), + )) + 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}) diff --git a/services/travel-memories/app/state.py b/services/travel-memories/app/state.py index c0dc68b..09de90e 100644 --- a/services/travel-memories/app/state.py +++ b/services/travel-memories/app/state.py @@ -21,6 +21,7 @@ class Group: id: str photo_ids: list = field(default_factory=list) entry_type: str = "journal" # journal | story + label: str = "" title: str = "" body: str = "" location_city: str = "" @@ -42,6 +43,8 @@ class TripState: photos: list = field(default_factory=list) groups: list = field(default_factory=list) notes: str = "" + dividers: list = field(default_factory=list) # [{"id": str, "after_order": int}] + group_labels: dict = field(default_factory=dict) # {divider_id: label} def _state_path(album_id: str, app) -> Path: diff --git a/services/travel-memories/app/templates/phase4.html b/services/travel-memories/app/templates/phase4.html new file mode 100644 index 0000000..3a47067 --- /dev/null +++ b/services/travel-memories/app/templates/phase4.html @@ -0,0 +1,99 @@ +{% extends "base.html" %} +{% block content %} +
+
+

Group

+ +
+ +
+ {% for grp in groups %} +
+ {% if grp.label %} +
{{ grp.label }}
+ {% endif %} + {% for photo in grp.photos %} +
+ + {{ photo.local_datetime[11:16] }} + + {{ photo.tag }} + +
+ {% if not loop.last %} +
+
+ +
+ {% endif %} + {% endfor %} +
+ + {% if grp.divider_id %} +
+ + +
+ {% endif %} + + {% if not loop.last and not grp.divider_id %} +
+
+ +
+ {% endif %} + {% endfor %} +
+
+{% endblock %} + +{% block extra_scripts %} + +{% endblock %} diff --git a/services/travel-memories/tests/fixtures/phase2_state.json b/services/travel-memories/tests/fixtures/phase2_state.json index a9cb900..1941a5d 100644 --- a/services/travel-memories/tests/fixtures/phase2_state.json +++ b/services/travel-memories/tests/fixtures/phase2_state.json @@ -14,5 +14,7 @@ "local_datetime": "2023-09-06T10:00:00", "tag": "untagged", "order": 2} ], "groups": [], - "notes": "" + "notes": "", + "dividers": [], + "group_labels": {} } diff --git a/services/travel-memories/tests/fixtures/phase3_state.json b/services/travel-memories/tests/fixtures/phase3_state.json index bc5f632..70da676 100644 --- a/services/travel-memories/tests/fixtures/phase3_state.json +++ b/services/travel-memories/tests/fixtures/phase3_state.json @@ -14,5 +14,7 @@ "local_datetime": "2023-09-06T10:00:00", "tag": "skip", "order": 2} ], "groups": [], - "notes": "" + "notes": "", + "dividers": [], + "group_labels": {} } diff --git a/services/travel-memories/tests/fixtures/phase4_state.json b/services/travel-memories/tests/fixtures/phase4_state.json index e520db3..6851fdc 100644 --- a/services/travel-memories/tests/fixtures/phase4_state.json +++ b/services/travel-memories/tests/fixtures/phase4_state.json @@ -12,5 +12,7 @@ "local_datetime": "2023-09-05T14:30:00", "tag": "story", "order": 1} ], "groups": [], - "notes": "I remember the airport was chaos." + "notes": "I remember the airport was chaos.", + "dividers": [], + "group_labels": {} } diff --git a/services/travel-memories/tests/fixtures/phase5_state.json b/services/travel-memories/tests/fixtures/phase5_state.json index 589f593..8d22623 100644 --- a/services/travel-memories/tests/fixtures/phase5_state.json +++ b/services/travel-memories/tests/fixtures/phase5_state.json @@ -14,16 +14,18 @@ "groups": [ { "id": "g1", "photo_ids": ["asset-1"], "entry_type": "journal", - "title": "", "body": "", "location_city": "", "location_country": "", + "label": "", "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": "", + "label": "", "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." + "notes": "I remember the airport was chaos.", + "dividers": [], + "group_labels": {} } diff --git a/services/travel-memories/tests/fixtures/phase6_state.json b/services/travel-memories/tests/fixtures/phase6_state.json index ad20062..19fc5e6 100644 --- a/services/travel-memories/tests/fixtures/phase6_state.json +++ b/services/travel-memories/tests/fixtures/phase6_state.json @@ -14,18 +14,20 @@ "groups": [ { "id": "g1", "photo_ids": ["asset-1"], "entry_type": "journal", - "title": "Arrival in Almaty", "body": "Chaos at the airport.", + "label": "", "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.", + "label": "", "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": "" + "notes": "", + "dividers": [], + "group_labels": {} } diff --git a/services/travel-memories/tests/test_phase4.py b/services/travel-memories/tests/test_phase4.py new file mode 100644 index 0000000..c7d3e27 --- /dev/null +++ b/services/travel-memories/tests/test_phase4.py @@ -0,0 +1,47 @@ +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**")