Files
doctate/docs/specs/0002-copy-close-on-case-page.md
T
Brummel 54a48691d1 audit: cycle copy-close tidy — reconcile spec to shipped structure
Cycle-close audit for the case-page copy+close cycle (spec 0002, plan
0003, feat 0308245). Architect drift review + scripts/check.sh.

Regression gate (scripts/check.sh): exit 0 — 0 failures across 19
stages (common/server/clients-desktop/experiments fmt+clippy+build+test,
wearos assembleDebug+lintDebug+testDebugUnitTest), 74s. The single
`wearos :: lintDebug` WARN is pre-existing and untouched by this
frontend-only cycle; not introduced here, carried forward.

Architect findings and resolution:
- [high] template emits two separate close forms, not the spec's single
  #copy-close-form with a has_document label switch. Resolved as a SPEC
  defect, not a code defect: the spec's single-form snippet was
  infeasible — #doc-body exists only in the has-document match arm, so a
  form anchored after it can never serve the documentless case, and it
  must precede the copy <script> to be bound. The shipped two-form split
  is canonical; spec 0002 §Concrete code shapes + §Data flow reconciled
  to it (this commit).
- [high] the spec's `if (!getElementById("doc-body")) return` guard is
  absent from the shipped handler. Not a defect: the no-document form
  carries no id, so the handler (which binds #copy-close-form) never
  attaches to it — the guard is structurally unnecessary. Spec script
  block + prose updated to state this mechanism.
- [medium] the documentless form's distinctness was unpinned. Added an
  assertion to case_page_empty_case_shows_placeholder that the empty
  case renders no #copy-close-form (it is the bare close form).
- [low] plan counter 0003 vs spec counter 0002: not drift — the naming
  policy is per-directory counters (stable_per_directory_4digit over
  [docs/specs, docs/plans]); independent sequences are expected.
- [note] deferred case-list half of #14 leaves a coherent state
  (refs #14, my_cases.html untouched). No drift.

Recommendation: carry-on. No fix iteration needed; the high items were
spec-vs-code reconciliation (docs) and a test guard, both done here.

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

271 lines
11 KiB
Markdown
Raw 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 — Design Spec
**Date:** 2026-06-01
**Status:** Draft — awaiting user spec review
**Authors:** orchestrator + Claude
## Goal
Give a user finishing a case a single gesture on the case detail page
(`server/templates/case_page.html`): copy the document text to the
clipboard, close the case, and land back on the case list (`/cases`).
Today these are two separate controls — the icon copy button
(`#copy-btn`, above the document) and the trash-can close form (in the
`<h1>` title row). The close already redirects to `/cases` from a case
page (see Data flow), so "back to list" needs no new server work; what
is missing is fusing copy + close into one button.
Scope is **the case page only**. Issue #14 also asks for a per-row
copy+close on the case list (`my_cases.html`); that part is explicitly
deferred — this cycle leaves the list untouched and the issue stays
open (`refs #14`, not `closes`).
## Architecture
Frontend-only. One template file (`server/templates/case_page.html`)
and its inline `<script>`. **No Rust change** — no route, handler, or
struct is touched. The `POST /cases/{case_id}/close` endpoint
(`server/src/routes/case_actions.rs:437`) and its `Referer`-derived
redirect stay exactly as they are; the combined button merely submits
the existing close form after the clipboard write completes.
This keeps every server-side invariant fixed: the `.closed`
discovery-gate semantics (commit `5e86cb5`), the IDOR guard, the CSRF
contract, and the close redirect target are all unchanged because no
server code moves.
## Concrete code shapes
### User-facing artefact (the headline)
What renders directly under the document for an **open** case. For a
case *with* a document the button copies then closes; for an open case
*without* a document it is a bare close (nothing to copy).
**Two physically separate forms, not one with a label switch**
(reconciled to the shipped implementation at audit). `#doc-body` only
exists in the `{% when Some %}` (has-document) arm of the template's
`{% match document_html %}`, so a single form anchored after it could
never serve the documentless case — and it must be emitted *before* the
copy `<script>` so the submit handler can bind it. The two cases
therefore live in their two respective match arms:
In the has-document arm, after `#doc-body` — the combined control,
wired to `copyDocText()`:
```html
<div id="doc-body">{{ html|safe }}</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 %}
```
In the no-document arm — a bare close form. It carries **no** `id`, so
the submit handler below (which binds `#copy-close-form`) never attaches
to it and it submits normally (nothing to copy):
```html
{% 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 %}
```
The inline script, with the two-path clipboard write extracted into a
shared function used by both the icon copy button and the close form:
```js
(() => {
// 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.
// Only the has-document form carries id="copy-close-form", so this
// handler binds only there; the bare no-document "Schließen" form is
// never matched and submits normally (no copy).
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;
}
});
}
})();
```
### Implementation shape (supporting — before → after)
**Remove the H1 trash-can close form** (open-case branch). The
`{% if is_closed %}` reopen branch is untouched.
Before (`server/templates/case_page.html`, the `{% else %}` arm of the
title-row form, ~lines 438458):
```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" ...>
<svg ...><!-- trash icon --></svg>
</button>
</form>
{% endif %}
```
After: the `{% else %}` arm is dropped entirely — for an open case the
title row carries no close affordance (it moves below the document).
The `{% if is_closed %}` reopen arm and the `{% endif %}` remain.
**Refactor the copy IIFE:** the existing anonymous click handler that
inlines the clipboard logic becomes `copyDocText()` (shown above); the
icon button keeps its exact current behaviour by calling it.
## Components
- **H1 title row** — open-case trash-close form removed; closed-case
reopen form unchanged.
- **`doc-footer-actions` block** (new, after `#doc-body`, `!is_closed`
only) — one close form whose button label is `Kopieren + Schließen`
when `has_document`, else `Schließen`; plus a hidden error paragraph.
- **Inline script** — `copyDocText()` shared by the icon copy button
and the close form's submit handler.
No new template variables: `is_closed`, `has_document`, `case_id`, and
`csrf_token` are already in the template's context.
## Data flow
1. User clicks **Kopieren + Schließen**.
2. `submit` event fires → handler `preventDefault()`s.
3. `await copyDocText()` runs the clipboard write inside the gesture.
4. On `true`: `form.submit()``POST /cases/{id}/close` → handler
writes the `.closed` marker and returns `303` to
`resolve_list_return_path(headers)`. The `Referer` is the case page
`/cases/{uuid}`, which that function strips to `/cases`. Browser
lands on the list.
5. On `false`: `#copy-close-error` is unhidden; no submit; case stays
open.
The bare **Schließen** button (open case, no document) lives in a
separate form with no `id`, so the submit handler never binds it; it
submits normally → same close → `/cases`.
## Error handling
- **Copy failure** (both clipboard paths fail — e.g. insecure context
and no `execCommand`): close is aborted, the case stays open, and
`#copy-close-error` is shown. The user never ends up with a closed
(list-hidden) case and an empty clipboard.
- **CSRF**: the new close form carries `{% call csrf::field(csrf_token) %}`,
identical to the form it replaces.
- **Closed case**: neither button renders (`{% if !is_closed %}`);
closing an already-closed case is impossible. The reopen affordance
is the only state-changing control, unchanged.
## Testing strategy
RED→GREEN in `server/tests/case_page_test.rs`, which renders the page
and asserts on the HTML. The JS gesture ordering (copy-before-navigate,
abort-on-failure) is **not** covered by Rust tests — the project has no
headless-browser harness — and is verified manually.
New / extended assertions:
- **Open case with document** renders `id="copy-close-form"`, the text
`Kopieren + Schließen`, and a CSRF token; and **no longer** renders
the H1 trash-can close button (assert its absence so the removal is
pinned).
- **Open case without document** (`case_page_empty_case_shows_placeholder`,
extended): the close form with `action="…/close"` is still present and
its label is `Schließen`.
- **Closed case** renders no `copy-close-form`; the reopen control is
still present.
- **`case_page_document_has_copy_button`** stays green — the icon
copy button survives the refactor.
Whole suite green via `cargo test --manifest-path server/Cargo.toml -j 4`;
`cargo clippy` and `cargo fmt` clean.
## Acceptance criteria
A medical user finishing a case naturally reaches for "put the letter
on the clipboard and be done with this case" in one click — the
`Kopieren + Schließen` button above is that gesture, and is the
empirical evidence for the criterion. It introduces no new failure
class: a failed copy aborts the close (no false sense of a saved
document), CSRF is preserved, and the closed-case discovery-gate and
IDOR guarantees are untouched because no server code changes.
Checklist (the case-page half of issue #14):
- [ ] Case page: one control copies the document to the clipboard,
closes the case, and lands on `/cases`, with the clipboard write
guaranteed to complete before navigation.
- [ ] On copy failure the case is **not** closed and an error is shown.
- [ ] Open case without a document keeps a working close control.
- [ ] The standalone icon copy button (copy without close) still works.
- [ ] The clipboard `execCommand` fallback path still applies.
- [ ] Closed cases render neither button; reopen unchanged.
- [ ] Case list (`my_cases.html`) is untouched this cycle (`refs #14`).