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

488 lines
19 KiB
Markdown
Raw Permalink 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.
# 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`:
```rust
/// 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`):
```html
<div id="doc-body">{{ html|safe }}</div>
</div>
```
Replace it with:
```html
<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):
```html
<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:
```html
<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:
```rust
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):
```html
{% 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):
```html
{% 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`:
```rust
/// 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):
```html
{% 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 %}`):
```html
{% 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).