From a6a2b31c43d19aae21d4959164fe5a652a956268 Mon Sep 17 00:00:00 2001 From: Mischa Date: Sun, 21 Jun 2026 16:26:53 +0200 Subject: [PATCH] feat: Phase 2 triage with keyboard shortcuts J/S/X Implement /triage GET/POST routes in triage.py blueprint; render phase2.html with day-grouped photo grid, Alpine.js keyboard tagging (J=journal, S=story, X/Space=skip), and done-button gated on all-tagged. Remove stub from albums.py; register triage.bp in __init__.py. Co-Authored-By: Claude Sonnet 4.6 --- services/travel-memories/app/__init__.py | 3 +- services/travel-memories/app/routes/albums.py | 19 --- services/travel-memories/app/routes/triage.py | 51 ++++++++ .../travel-memories/app/templates/phase2.html | 113 ++++++++++++++++++ services/travel-memories/tests/test_phase2.py | 40 +++++++ 5 files changed, 206 insertions(+), 20 deletions(-) create mode 100644 services/travel-memories/app/routes/triage.py create mode 100644 services/travel-memories/app/templates/phase2.html create mode 100644 services/travel-memories/tests/test_phase2.py diff --git a/services/travel-memories/app/__init__.py b/services/travel-memories/app/__init__.py index b4344aa..f2b9fd4 100644 --- a/services/travel-memories/app/__init__.py +++ b/services/travel-memories/app/__init__.py @@ -8,8 +8,9 @@ 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, proxy, notes, nav + from .routes import albums, triage, proxy, notes, nav 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) diff --git a/services/travel-memories/app/routes/albums.py b/services/travel-memories/app/routes/albums.py index 1119470..f82e554 100644 --- a/services/travel-memories/app/routes/albums.py +++ b/services/travel-memories/app/routes/albums.py @@ -70,22 +70,3 @@ def select(): save_state(state, current_app) return redirect(f"/triage?album_id={primary_id}") - -# TODO(task-6): replace this stub with the real triage route -@bp.get("/triage") -def triage(): - album_id = request.args.get("album_id", "") - notes_content = "" - phase_stale = [] - if album_id: - state = load_state(album_id, current_app) - if state: - notes_content = state.notes - phase_stale = state.phase_stale - return render_template( - "base.html", - current_phase="triage", - album_id=album_id, - notes_content=notes_content, - phase_stale=phase_stale, - ) diff --git a/services/travel-memories/app/routes/triage.py b/services/travel-memories/app/routes/triage.py new file mode 100644 index 0000000..ab96664 --- /dev/null +++ b/services/travel-memories/app/routes/triage.py @@ -0,0 +1,51 @@ +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) + tagged_count = sum(1 for p in state.photos if p.tag != "untagged") + return jsonify({"ok": True, "tagged_count": tagged_count, "total": len(state.photos)}) + + +@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']}"}) diff --git a/services/travel-memories/app/templates/phase2.html b/services/travel-memories/app/templates/phase2.html new file mode 100644 index 0000000..b4f0f23 --- /dev/null +++ b/services/travel-memories/app/templates/phase2.html @@ -0,0 +1,113 @@ +{% extends "base.html" %} +{% block content %} +
+ +
+

Triage

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

{{ day }}

+
+ {% for photo in photos %} +
+ +
+ {{ photo.local_datetime[11:16] }} +
+ {% if photo.tag != 'untagged' and photo.tag != 'skip' %} +
+ {{ photo.tag[0] | upper }} +
+ {% endif %} +
+ {% endfor %} +
+
+ {% endfor %} +
+{% endblock %} + +{% block extra_scripts %} + +{% endblock %} diff --git a/services/travel-memories/tests/test_phase2.py b/services/travel-memories/tests/test_phase2.py new file mode 100644 index 0000000..8a0ac2c --- /dev/null +++ b/services/travel-memories/tests/test_phase2.py @@ -0,0 +1,40 @@ +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**")