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**")