Add analysis preview to case list

Introduce a `preview_lines` setting in `users.toml` to control the
number of visible lines for the analysis preview in the case list. This
feature extracts the first paragraph of the `document.md` file, strips
markdown formatting, and displays it, capped by the configured
`preview_lines`. The actual line clamping is handled client-side via CSS
`line-clamp`.
This commit is contained in:
2026-04-21 17:25:14 +02:00
parent 26b202ec07
commit 661ea6215e
18 changed files with 179 additions and 1 deletions
+44
View File
@@ -33,6 +33,14 @@ fn default_window_hours() -> u32 {
72
}
/// Default number of analysis-preview lines rendered under each case on
/// the list view when the user entry omits `preview_lines`. Two lines
/// show the first sentence of the LLM output in most cases without
/// dominating the list row.
fn default_preview_lines() -> u32 {
2
}
/// A single user entry from users.toml.
#[derive(Debug, Deserialize)]
pub struct User {
@@ -51,6 +59,14 @@ pub struct User {
/// apply no additional time filter.
#[serde(default = "default_window_hours")]
pub window_hours: u32,
/// Number of analysis-preview lines rendered under each case row on
/// the list view. The preview itself is always the first paragraph of
/// `document.md`; this value only controls how many *visual* lines
/// the browser will show before truncating with `…`. Applied via a
/// CSS `line-clamp` custom property, so a single server value adapts
/// to any viewport width.
#[serde(default = "default_preview_lines")]
pub preview_lines: u32,
}
/// Wrapper for deserializing the [[user]] array from TOML.
@@ -290,6 +306,7 @@ mod tests {
whisper: WhisperUserSettings::default(),
retention: RetentionUserSettings::default(),
window_hours: default_window_hours(),
preview_lines: default_preview_lines(),
}
}
@@ -344,6 +361,33 @@ mod tests {
assert_eq!(r.auto_delete_days, 30);
}
#[test]
fn parses_user_without_preview_lines_defaults_to_2() {
let toml_str = r#"
[[user]]
slug = "a"
api_key = "k1"
web_password = ""
role = "doctor"
"#;
let parsed: UsersFile = toml::from_str(toml_str).unwrap();
assert_eq!(parsed.user[0].preview_lines, 2);
}
#[test]
fn parses_user_with_explicit_preview_lines() {
let toml_str = r#"
[[user]]
slug = "a"
api_key = "k1"
web_password = ""
role = "doctor"
preview_lines = 5
"#;
let parsed: UsersFile = toml::from_str(toml_str).unwrap();
assert_eq!(parsed.user[0].preview_lines, 5);
}
#[test]
fn parses_user_with_partial_retention_block() {
let toml_str = r#"
+113
View File
@@ -90,6 +90,11 @@ 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>,
}
/// Parse a recording filename `YYYY-MM-DDTHH-MM-SSZ.m4a[.failed]` as UTC
@@ -157,6 +162,30 @@ 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,
@@ -179,6 +208,10 @@ struct MyCasesTemplate {
/// to gate the bulk-purge bar so it does not appear on an
/// all-open list rendered with `show_closed=1`.
any_closed: bool,
/// Per-user cap on visible analysis-preview lines. Rendered as a
/// CSS `--preview-lines` custom property on `<html>`; the browser
/// applies it via `line-clamp`. Coming from `users.toml`.
preview_lines: u32,
}
/// Query params for GET /web/cases and /web/cases/{case_id}.
@@ -416,6 +449,12 @@ pub async fn handle_my_cases(
let any_closed = cases.iter().any(|c| c.is_closed);
let groups = group_by_utc_date(cases);
let is_admin = user.is_admin();
let preview_lines = config
.users
.iter()
.find(|u| u.slug == user.slug)
.map(|u| u.preview_lines)
.unwrap_or(2);
MyCasesTemplate {
slug: user.slug,
groups,
@@ -423,6 +462,7 @@ pub async fn handle_my_cases(
is_admin,
show_closed,
any_closed,
preview_lines,
}
.render()
.map(Html)
@@ -759,6 +799,17 @@ async fn compute_case_view(
let (is_closed, days_until_purge) = closed_state(case_path, auto_delete_days, now).await;
// 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 {
read_document(case_path)
.await
.and_then(|md| preview_from_markdown(&md))
} else {
None
};
let time_hms_utc = extract_hhmm_utc(&most_recent);
let recorded_at_iso = recorded_at_iso_of(&most_recent);
Some(UserCaseView {
@@ -774,6 +825,7 @@ async fn compute_case_view(
can_analyze,
is_closed,
days_until_purge,
analysis_preview,
})
}
@@ -858,3 +910,64 @@ async fn compute_oneliner_display(
}
}
}
#[cfg(test)]
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.")
);
}
}