Refactor analysis display to full HTML

Replaces `analysis_preview` with `analysis_html` to display the full
markdown-rendered document content instead of just a plain-text preview.
This allows for richer content presentation and enables client-side
expansion for detailed views.

The HTML template has been updated to include a new `.analysis`
container that handles the expand/collapse functionality using
JavaScript. This approach avoids server-side truncation and relies on
CSS for presentation.
This commit is contained in:
2026-04-21 18:21:04 +02:00
parent 87a04c4b27
commit d13bd5307e
2 changed files with 37 additions and 93 deletions
+12 -89
View File
@@ -91,11 +91,12 @@ struct UserCaseView {
/// "geschlossen"). Clamped at 0 — cases eligible for purge this
/// sweep render as "0" briefly.
days_until_purge: Option<u32>,
/// 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<String>,
/// 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<String>,
}
/// 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<String> {
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,
}
}