Files
doctate/docs/plans/0003-copy-close-on-case-page.md
Brummel 9efa052b6d plan: copy-close on case page
Bite-sized implementation plan for spec 0002 (case-page half of #14):
add a gated "Kopieren + Schließen" control below the document (Task 1,
with a shared copyDocText() refactor), a bare "Schließen" for
documentless open cases (Task 2), then remove the H1 trash-can close
(Task 3), and a whole-suite + clippy + fmt gate (Task 4). Ordered so
each task leaves the suite green.

refs #14
2026-06-01 14:29:01 +02:00

19 KiB
Raw Permalink Blame History

Copy + Close on the Case Page — Implementation Plan

Parent spec: docs/specs/0002-copy-close-on-case-page.md

For agentic workers: REQUIRED SUB-SKILL: use the implement skill to run this plan. Steps use - [ ] checkboxes for tracking.

Goal: Replace the case page's icon-only trash-can close with a text "Kopieren + Schließen" control below the document that copies the document text then closes the case (landing on /cases), keeping a bare "Schließen" control for documentless open cases.

Architecture: Frontend-only. One Askama template (server/templates/case_page.html) and its inline <script>; no Rust route/handler/struct change — the existing POST /cases/{id}/close already strips the case-page Referer to /cases. Tests render the page and assert on the HTML (server/tests/case_page_test.rs).

Tech Stack: Askama template + vanilla inline JS; Rust integration tests via tower::ServiceExt::oneshot.

Task ordering note (load-bearing): the new close affordances (Tasks 1 + 2) are added before the H1 trash-can is removed (Task 3). Removing the trash-can first would leave case_page_empty_case_shows_placeholder (asserts a close action on the empty case) RED until a later task; this ordering keeps the whole suite green at every task boundary.

Template control-flow recap (from recon): the document area is {% match document_html %}. The {% when Some with (html) %} arm holds #doc-content, #copy-btn, #doc-body, and the copy <script> — here has_document is always true. The {% when None %} arm (no document) nests {% match analysis_failed %} (failure-banner vs. analyzing/empty/status). The H1 title row has a separate {% if is_closed %} reopen / {% else %} close (trash-can) / {% endif %}.

Parse gate (self-review §9): the project profile declares no spec_validation parsers, and the inlined bodies are Askama/HTML (no configured parser) plus Rust test code (caught by the implement compile gate). Parse-the-bytes gate is a documented no-op here.

Files this plan creates or modifies:

  • Modify: server/templates/case_page.html — H1 close arm removed; two new doc-footer-actions close forms; copy <script> refactored.
  • Test: server/tests/case_page_test.rs — two new tests + one extended.

Task 1: Gated "Kopieren + Schließen" control + copy-script refactor

Files:

  • Modify: server/templates/case_page.html (after #doc-body ~625; script ~626661)

  • Test: server/tests/case_page_test.rs

  • Step 1: Write the failing test (and a closed-case guard)

Append both functions at the end of server/tests/case_page_test.rs:

/// Property: an open case WITH a document renders the combined
/// "Kopieren + Schließen" control below the document — a single form
/// that POSTs the close action and carries the CSRF token.
#[tokio::test]
async fn case_page_open_document_has_copy_close_button() {
    let cfg = TestConfig::new()
        .with_label("cp-copyclose")
        .with_user(test_user("dr_a"))
        .with_llm("http://unused")
        .build();
    let case_id = "11111111-1111-1111-1111-111111111111";
    let case_dir = seed_case(&cfg.data_path, "dr_a", case_id);
    write_document_for_test(&case_dir, "fertiger Brief", "1970-01-01T00:00:00Z");

    let app = doctate_server::create_router(cfg);
    let cookie = login_dr_a(&app).await;

    let resp = app
        .oneshot(get_with_cookie(paths::case_detail(case_id), &cookie))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let body = body_string(resp).await;

    assert!(
        body.contains(r#"id="copy-close-form""#),
        "combined copy-close form missing"
    );
    assert!(
        body.contains("Kopieren + Schließen"),
        "copy-close button label missing"
    );
    // The combined form must POST the close action...
    let form_start = body
        .find(r#"id="copy-close-form""#)
        .expect("copy-close form present");
    let rest = &body[form_start..];
    let form_end = rest.find("</form>").expect("copy-close form closes");
    let form_html = &rest[..form_end];
    assert!(
        form_html.contains(&format!(r#"action="{}""#, paths::case_close(case_id))),
        "copy-close form must POST the close action"
    );
    // ...and carry the CSRF token inside that same form.
    assert!(
        form_html.contains(r#"name="csrf_token""#),
        "copy-close form must carry the CSRF token"
    );
}

/// Guard: a CLOSED case (even with a document, rendered read-only) shows
/// no copy-close control — closing an already-closed case is impossible.
/// The reopen affordance is the only state-changing control.
#[tokio::test]
async fn case_page_closed_document_hides_copy_close() {
    let cfg = TestConfig::new()
        .with_label("cp-closed-copyclose")
        .with_user(test_user("dr_a"))
        .with_llm("http://unused")
        .build();
    let case_id = "11111111-1111-1111-1111-111111111111";
    let case_dir = seed_case(&cfg.data_path, "dr_a", case_id);
    write_document_for_test(&case_dir, "fertiger Brief", "1970-01-01T00:00:00Z");
    write_closed_marker(&case_dir, "2026-04-15T12:00:00Z");

    let app = doctate_server::create_router(cfg);
    let cookie = login_dr_a(&app).await;

    let resp = app
        .oneshot(get_with_cookie(paths::case_detail(case_id), &cookie))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let body = body_string(resp).await;

    assert!(
        !body.contains(r#"id="copy-close-form""#),
        "closed case must not expose the copy-close control"
    );
    assert!(
        body.contains(&format!(r#"action="{}""#, paths::case_reopen(case_id))),
        "closed case must still offer reopen"
    );
}
  • Step 2: Run tests to verify the RED driver fails

Run: cargo test --manifest-path server/Cargo.toml -j 4 case_page_open_document_has_copy_close_button Expected: FAIL with "combined copy-close form missing" (the form does not exist yet). The crate still compiles — case_page_closed_document_hides_copy_close only asserts absence, so it compiles and would pass; it becomes load-bearing once Step 3 adds the gated form, and is verified green in Step 5.

  • Step 3: Insert the gated copy-close form below the document

In server/templates/case_page.html, find this exact block (the #doc-body line and the </div> closing #doc-content):

            <div id="doc-body">{{ html|safe }}</div>
        </div>

Replace it with:

            <div id="doc-body">{{ html|safe }}</div>
        </div>
        {% if !is_closed %}
        <div class="doc-footer-actions">
            <form id="copy-close-form" method="post" action="/cases/{{ case_id }}/close">
                {% call csrf::field(csrf_token) %}
                <button type="submit" class="copy-close-btn">Kopieren + Schließen</button>
            </form>
            <p id="copy-close-error" class="copy-error" role="alert" hidden>
                Kopieren fehlgeschlagen — Fall nicht geschlossen.
            </p>
        </div>
        {% endif %}
  • Step 4: Refactor the copy <script> to share the clipboard write and wire the close form

In the same file, find this exact <script> IIFE (immediately after the block edited in Step 3):

        <script>
            (() => {
                const btn = document.getElementById("copy-btn");
                if (!btn) return;
                btn.hidden = false;
                btn.addEventListener("click", async () => {
                    const text = document.getElementById("doc-body").innerText;
                    let ok = false;
                    try {
                        if (navigator.clipboard && window.isSecureContext) {
                            await navigator.clipboard.writeText(text);
                            ok = true;
                        }
                    } catch (_) {
                        /* fall through */
                    }
                    if (!ok) {
                        const ta = document.createElement("textarea");
                        ta.value = text;
                        ta.setAttribute("readonly", "");
                        ta.style.position = "fixed";
                        ta.style.top = "-1000px";
                        document.body.appendChild(ta);
                        ta.select();
                        try {
                            ok = document.execCommand("copy");
                        } catch (_) {}
                        ta.remove();
                    }
                    if (ok) {
                        btn.classList.add("copied");
                        setTimeout(() => btn.classList.remove("copied"), 1500);
                    }
                });
            })();
        </script>

Replace it with:

        <script>
            (() => {
                // Shared two-path clipboard write: async Clipboard API in
                // secure contexts, textarea + execCommand fallback otherwise.
                // Returns whether the copy actually succeeded so callers can
                // gate the follow-up close on it.
                async function copyDocText() {
                    const body = document.getElementById("doc-body");
                    if (!body) return false; // no document → nothing to copy
                    const text = body.innerText;
                    let ok = false;
                    try {
                        if (navigator.clipboard && window.isSecureContext) {
                            await navigator.clipboard.writeText(text);
                            ok = true;
                        }
                    } catch (_) {
                        /* fall through to execCommand */
                    }
                    if (!ok) {
                        const ta = document.createElement("textarea");
                        ta.value = text;
                        ta.setAttribute("readonly", "");
                        ta.style.position = "fixed";
                        ta.style.top = "-1000px";
                        document.body.appendChild(ta);
                        ta.select();
                        try {
                            ok = document.execCommand("copy");
                        } catch (_) {}
                        ta.remove();
                    }
                    return ok;
                }

                const copyBtn = document.getElementById("copy-btn");
                if (copyBtn) {
                    copyBtn.hidden = false;
                    copyBtn.addEventListener("click", async () => {
                        if (await copyDocText()) {
                            copyBtn.classList.add("copied");
                            setTimeout(() => copyBtn.classList.remove("copied"), 1500);
                        }
                    });
                }

                // Combined "copy then close": the clipboard write must finish
                // inside the user-gesture context before the form navigates,
                // and a failed copy aborts the close so the user never loses a
                // document they believed was on the clipboard.
                const closeForm = document.getElementById("copy-close-form");
                if (closeForm) {
                    closeForm.addEventListener("submit", async (e) => {
                        e.preventDefault();
                        if (await copyDocText()) {
                            closeForm.submit(); // close → 303 → /cases
                        } else {
                            document.getElementById("copy-close-error").hidden = false;
                        }
                    });
                }
            })();
        </script>
  • Step 5: Run tests to verify they pass

Run: cargo test --manifest-path server/Cargo.toml -j 4 --test case_page_test Expected: the whole case_page_test target PASSes — in particular the new case_page_open_document_has_copy_close_button (form present on open+doc), case_page_closed_document_hides_copy_close (absent on closed), and the pre-existing case_page_document_has_copy_button (icon #copy-btn + #doc-content survive the refactor).


Task 2: Bare "Schließen" control for documentless open cases

Files:

  • Modify: server/templates/case_page.html (the {% when None %} arm of {% match document_html %}, ~line 746)

  • Test: server/tests/case_page_test.rs:270 (case_page_empty_case_shows_placeholder, extended)

  • Step 1: Extend the empty-case test with the label assertion

In server/tests/case_page_test.rs, in case_page_empty_case_shows_placeholder, after the existing close-form assertion (the block ending at the "close form missing on empty case" message), add:

    assert!(
        body.contains(r#">Schließen</button>"#),
        "documentless open case must offer a bare Schließen button"
    );
  • Step 2: Run test to verify it fails

Run: cargo test --manifest-path server/Cargo.toml -j 4 case_page_empty_case_shows_placeholder Expected: FAIL with "documentless open case must offer a bare Schließen button" (today the empty case has only the icon trash-can in the H1, no text >Schließen</button>).

  • Step 3: Insert the bare close form in the no-document branch

In server/templates/case_page.html, find this exact line (the two {% endmatch %} that close the inner analysis_failed match and the outer document_html match):

        {% endif %} {% endmatch %} {% endmatch %}

Replace it with (split: bare form lands inside the document_html None arm, after the inner match resolves, before the outer match closes — so it renders for both the failure-banner and the placeholder/analyzing sub-cases of a documentless open case):

        {% endif %} {% endmatch %}
        {% if !is_closed %}
        <div class="doc-footer-actions">
            <form method="post" action="/cases/{{ case_id }}/close">
                {% call csrf::field(csrf_token) %}
                <button type="submit" class="copy-close-btn">Schließen</button>
            </form>
        </div>
        {% endif %}
        {% endmatch %}
  • Step 4: Run test to verify it passes

Run: cargo test --manifest-path server/Cargo.toml -j 4 case_page_empty_case_shows_placeholder Expected: PASS (placeholder text, the close action, and the bare >Schließen</button> are all present).


Task 3: Remove the H1 trash-can close button (open-case arm)

Files:

  • Modify: server/templates/case_page.html:439473 (the {% else %} close arm of the H1 title-row form)

  • Test: server/tests/case_page_test.rs

  • Step 1: Write the failing test

Append at the end of server/tests/case_page_test.rs:

/// Property: the icon-only trash-can close button in the H1 title row is
/// gone — closing now happens via the text control below the document.
/// (The closed-case reopen affordance in the same slot is untouched.)
#[tokio::test]
async fn case_page_open_case_has_no_trashcan_close() {
    let cfg = TestConfig::new()
        .with_label("cp-no-trash")
        .with_user(test_user("dr_a"))
        .with_llm("http://unused")
        .build();
    let case_id = "11111111-1111-1111-1111-111111111111";
    let case_dir = seed_case(&cfg.data_path, "dr_a", case_id);
    write_document_for_test(&case_dir, "fertiger Brief", "1970-01-01T00:00:00Z");

    let app = doctate_server::create_router(cfg);
    let cookie = login_dr_a(&app).await;

    let resp = app
        .oneshot(get_with_cookie(paths::case_detail(case_id), &cookie))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let body = body_string(resp).await;

    assert!(
        !body.contains(r#"title="Fall schließen""#),
        "the H1 trash-can close button must be gone"
    );
}
  • Step 2: Run test to verify it fails

Run: cargo test --manifest-path server/Cargo.toml -j 4 case_page_open_case_has_no_trashcan_close Expected: FAIL with "the H1 trash-can close button must be gone" (the title-row close form still carries title="Fall schließen").

  • Step 3: Delete the H1 close arm

In server/templates/case_page.html, find this exact block (the {% else %} close arm and the {% endif %} that closes the H1 reopen/close conditional):

            {% else %}
            <form
                class="delete-form"
                method="post"
                action="/cases/{{ case_id }}/close"
            >
                {% call csrf::field(csrf_token) %}
                <button
                    type="submit"
                    class="delete-btn"
                    title="Fall schließen"
                    aria-label="Fall schließen"
                >
                    <svg
                        xmlns="http://www.w3.org/2000/svg"
                        viewBox="0 0 24 24"
                        width="18"
                        height="18"
                        fill="none"
                        stroke="currentColor"
                        stroke-width="2"
                        stroke-linecap="round"
                        stroke-linejoin="round"
                    >
                        <path d="M3 6h18"></path>
                        <path
                            d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"
                        ></path>
                        <path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
                        <line x1="10" x2="10" y1="11" y2="17"></line>
                        <line x1="14" x2="14" y1="11" y2="17"></line>
                    </svg>
                </button>
            </form>
            {% endif %}

Replace it with (drop the entire {% else %} arm; keep {% endif %}):

            {% endif %}
  • Step 4: Run test to verify it passes

Run: cargo test --manifest-path server/Cargo.toml -j 4 case_page_open_case_has_no_trashcan_close Expected: PASS (no title="Fall schließen" on the open case page).

  • Step 5: Run the full case-page + close-redirect suites to confirm no regression

Run: cargo test --manifest-path server/Cargo.toml -j 4 --test case_page_test Expected: PASS — in particular case_page_empty_case_shows_placeholder (close action now served by the Task 2 bare form), case_page_renders_for_closed_case_without_show_closed, and case_page_hides_mutation_actions_when_closed (H1 reopen arm untouched) all stay green.


Task 4: Whole-suite + lint/format gate

Files: (none — verification only)

  • Step 1: Run the full server test suite

Run: cargo test --manifest-path server/Cargo.toml -j 4 Expected: PASS (no failures, no regressions).

  • Step 2: Clippy

Run: cargo clippy --manifest-path server/Cargo.toml --all-targets Expected: no warnings (CLAUDE.md: "cargo clippy hat immer Recht").

  • Step 3: Format

Run: cargo fmt --manifest-path server/Cargo.toml Expected: no diff (or only the newly added test code reflowed).