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

11 KiB
Raw Blame History

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():

<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):

{% 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:

(() => {
    // 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):

{% 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 scriptcopyDocText() 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).