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:
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
<div class="check"><input type="checkbox" name="case_id" value="{{ case.case_id }}"{% if case.is_closed %} disabled{% endif %}></div>
|
||||
<div class="body">
|
||||
<div class="line1">
|
||||
<a class="case-link" href="/web/cases/{{ case.case_id }}{% if case.is_closed %}?show_closed=1{% endif %}"><span class="time"><time datetime="{{ case.recorded_at_iso }}">{{ case.time_hms_utc }}</time></span> — {% call ol::render(case.oneliner) %}{% if case.has_failed_recording %} — <span class="status-badge fehler">Fehler</span>{% else if case.has_document %} — <span class="status-badge done">ausgewertet</span>{% else if !case.is_closed %} — <span class="status-badge open">offen</span>{% endif %}{% if case.analyzing %} <span class="label analyzing">wird analysiert</span>{% endif %}{% if case.is_closed %} <span class="closed-badge">geschlossen{% match case.days_until_purge %}{% when Some with (d) %} — wird in {{ d }} Tagen entfernt{% when None %}{% endmatch %}</span>{% endif %}</a>
|
||||
<a class="case-link" href="/web/cases/{{ case.case_id }}{% if case.is_closed %}?show_closed=1{% endif %}"><span class="time"><time datetime="{{ case.recorded_at_iso }}">{{ case.time_hms_utc }}</time></span> — {% call ol::render(case.oneliner) %}{% if case.has_failed_recording %} <span class="status-badge fehler">Fehler</span>{% else if case.has_document %} <span class="status-badge done">ausgewertet</span>{% else if !case.is_closed %} <span class="status-badge open">offen</span>{% endif %}{% if case.analyzing %} <span class="label analyzing">wird analysiert</span>{% endif %}{% if case.is_closed %} <span class="closed-badge">geschlossen{% match case.days_until_purge %}{% when Some with (d) %} — wird in {{ d }} Tagen entfernt{% when None %}{% endmatch %}</span>{% endif %}</a>
|
||||
<a class="recordings-link" href="/web/cases/{{ case.case_id }}/recordings{% if case.is_closed %}?show_closed=1{% endif %}">{{ case.recordings_count }} Aufnahmen</a>
|
||||
</div>
|
||||
{% match case.analysis_preview %}{% when Some with (preview) %}<p class="preview">{{ preview }}</p>{% when None %}{% endmatch %}
|
||||
{% match case.analysis_html %}{% when Some with (html) %}<div class="analysis" data-expand><span class="arrow">▸</span><div class="analysis-content">{{ html|safe }}</div></div>{% when None %}{% endmatch %}
|
||||
{% if is_admin %}<div class="uuid admin-only">{{ case.case_id }}</div>{% endif %}
|
||||
</div>
|
||||
<div class="actions">
|
||||
@@ -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 <mark>-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');
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user