diff --git a/server/src/routes/user_web.rs b/server/src/routes/user_web.rs index eabc9ac..4c58c41 100644 --- a/server/src/routes/user_web.rs +++ b/server/src/routes/user_web.rs @@ -91,11 +91,12 @@ struct UserCaseView { /// "geschlossen"). Clamped at 0 — cases eligible for purge this /// sweep render as "0" briefly. days_until_purge: Option, - /// First paragraph of `document.md` as plain text (markdown - /// formatting stripped). `None` when the case has no document yet. - /// The browser CSS-clamps this to the user's configured number of - /// lines; no server-side line counting happens. - analysis_preview: Option, + /// Full `document.md` rendered through the same `md_to_html` + /// pipeline as the case-detail page. `None` when the case has no + /// document yet. The browser clamps this to `preview_lines` in the + /// collapsed state and shows it whole when expanded — single + /// rendering, two CSS layouts, no server-side truncation. + analysis_html: Option, } /// Parse a recording filename `YYYY-MM-DDTHH-MM-SSZ.m4a[.failed]` as UTC @@ -243,30 +244,6 @@ fn recorded_at_iso_of(filename: &str) -> String { .unwrap_or_default() } -/// Extract the first paragraph of a `document.md` body as plain text. -/// -/// A "paragraph" here is the first non-empty block separated by a blank -/// line (`\n\n`). Markdown decoration characters (`#`, `*`, `_`, `` ` ``, -/// `=`, `>`) are stripped — the preview is meant to appear in a cramped -/// list row where bold/italic/heading styling would only add noise. -/// Returns `None` for input that ends up blank after stripping. -/// -/// The visible line count is NOT enforced here: that is browser-side via -/// CSS `line-clamp`. Server logic stays viewport-agnostic. -fn preview_from_markdown(md: &str) -> Option { - let first = md.split("\n\n").map(str::trim).find(|p| !p.is_empty())?; - let plain: String = first - .chars() - .filter(|c| !matches!(c, '#' | '*' | '_' | '`' | '=' | '>')) - .collect(); - let trimmed = plain.trim(); - if trimmed.is_empty() { - None - } else { - Some(trimmed.to_string()) - } -} - struct DateGroup { /// "Heute", "Gestern", or ISO date "YYYY-MM-DD". label: String, @@ -904,10 +881,12 @@ async fn compute_case_view( // One extra read per case with a document. Documents are in the KB // range — negligible next to the existing scan_recordings I/O — and // we skip the read entirely when the stat above said no document. - let analysis_preview = if has_document { + // Rendered once, regardless of expand state: the browser handles + // clamp-vs-expand purely through CSS toggling. + let analysis_html = if has_document { read_document(case_path) .await - .and_then(|md| preview_from_markdown(&md)) + .map(|md| crate::analyze::render::md_to_html(&md)) } else { None }; @@ -927,7 +906,7 @@ async fn compute_case_view( can_analyze, is_closed, days_until_purge, - analysis_preview, + analysis_html, }) } @@ -1017,62 +996,6 @@ async fn compute_oneliner_display( mod tests { use super::*; - #[test] - fn preview_from_markdown_takes_first_paragraph() { - let md = "Erster Absatz mit Text.\n\nZweiter Absatz, irrelevant."; - assert_eq!( - preview_from_markdown(md).as_deref(), - Some("Erster Absatz mit Text.") - ); - } - - #[test] - fn preview_from_markdown_strips_formatting_chars() { - let md = "# Überschrift\n\n**Patient** klagt über _Knie_ mit ==Schmerz==."; - // Both the heading and the inline formatting survive only as plain text. - assert_eq!(preview_from_markdown(md).as_deref(), Some("Überschrift")); - } - - #[test] - fn preview_from_markdown_skips_leading_blank_paragraphs() { - let md = "\n\n\n\nErster echter Absatz."; - assert_eq!( - preview_from_markdown(md).as_deref(), - Some("Erster echter Absatz.") - ); - } - - #[test] - fn preview_from_markdown_returns_none_for_blank_input() { - assert_eq!(preview_from_markdown(""), None); - assert_eq!(preview_from_markdown(" \n\n "), None); - } - - #[test] - fn preview_from_markdown_returns_none_when_only_formatting() { - // An all-formatting "paragraph" (no actual letters/numbers after - // strip) should collapse to None rather than an empty preview row. - assert_eq!(preview_from_markdown("=== ## **"), None); - } - - #[test] - fn preview_from_markdown_strips_inline_formatting_but_keeps_words() { - let md = "**Fett** und _kursiv_ und `code` in einer Zeile."; - assert_eq!( - preview_from_markdown(md).as_deref(), - Some("Fett und kursiv und code in einer Zeile.") - ); - } - - #[test] - fn preview_from_markdown_drops_blockquote_marker() { - let md = "> Patient berichtet über Schmerzen."; - assert_eq!( - preview_from_markdown(md).as_deref(), - Some("Patient berichtet über Schmerzen.") - ); - } - fn mk_view(most_recent: &str, is_closed: bool) -> UserCaseView { UserCaseView { case_id: "00000000-0000-0000-0000-000000000000".into(), @@ -1087,7 +1010,7 @@ mod tests { can_analyze: false, is_closed, days_until_purge: None, - analysis_preview: None, + analysis_html: None, } } diff --git a/server/templates/my_cases.html b/server/templates/my_cases.html index e43b486..498f3a9 100644 --- a/server/templates/my_cases.html +++ b/server/templates/my_cases.html @@ -29,9 +29,18 @@ section h2 .count { font-weight: normal; font-size: 0.85em; color: #888; } .case-row .case-link:hover { text-decoration: underline; } .case-row .recordings-link { color: #4a90e2; text-decoration: none; font-size: 0.9em; white-space: nowrap; } .case-row .recordings-link:hover { text-decoration: underline; } -.case-row .preview { margin: 0.3em 0 0; color: #666; font-size: 0.9em; display: -webkit-box; -webkit-line-clamp: var(--preview-lines); line-clamp: var(--preview-lines); -webkit-box-orient: vertical; overflow: hidden; } +.case-row .analysis { margin-top: 0.55em; padding-top: 0.45em; border-top: 1px solid #e5e5e5; display: flex; align-items: flex-start; gap: 0.4em; cursor: pointer; } +.case-row .analysis .arrow { flex-shrink: 0; color: #888; line-height: 1.2; transition: transform 0.15s ease; } +.case-row .analysis.open .arrow { transform: rotate(90deg); } +.case-row .analysis-content { color: #555; font-size: 0.9em; min-width: 0; flex: 1 1 auto; display: -webkit-box; -webkit-line-clamp: var(--preview-lines); line-clamp: var(--preview-lines); -webkit-box-orient: vertical; overflow: hidden; } +.case-row .analysis-content > :first-child { margin-top: 0; } +.case-row .analysis-content > :last-child { margin-bottom: 0; } +.case-row .analysis-content p { display: inline; margin: 0; } +.case-row .analysis-content mark { background: #fff3a0; padding: 0 0.15em; border-radius: 2px; } +.case-row .analysis.open .analysis-content { display: block; overflow: visible; } +.case-row .analysis.open .analysis-content p { display: block; margin: 0 0 0.5em; } .case-row .uuid { color: #999; font-family: monospace; font-size: 0.7em; margin-top: 0.2em; } -.status-badge { display: inline-block; padding: 0.05em 0.5em; border-radius: 999px; font-size: 0.75em; font-weight: bold; color: white; } +.status-badge { display: inline-block; padding: 0.05em 0.5em; border-radius: 999px; font-size: 0.75em; font-weight: bold; color: white; margin-left: 0.7em; } .status-badge.open { background: #4a90e2; } .status-badge.done { background: #7ed321; } .status-badge.fehler { background: #e24a4a; } @@ -98,10 +107,10 @@ try {
@@ -212,6 +221,18 @@ try { window.addEventListener('pagehide', () => es.close()); es.addEventListener('case', () => scheduleReload()); })(); + +// Expandable analysis: click anywhere on .analysis toggles the `open` +// class. Links/buttons inside the analysis are preserved — a click on +// a -wrapped term shouldn't accidentally expand (no such case +// today, but the guard is free). Delegation keeps one listener across +// all case rows, no per-row wiring. +document.addEventListener('click', (e) => { + const exp = e.target.closest('[data-expand]'); + if (!exp) return; + if (e.target.closest('a, button, input')) return; + exp.classList.toggle('open'); +});