From 4b554f04ff96586c14a48561aca7d3779a2af982 Mon Sep 17 00:00:00 2001 From: Brummel Date: Wed, 15 Apr 2026 19:25:59 +0200 Subject: [PATCH] Refactor case detail and document view Introduce a `DocumentTemplate` struct for rendering the document view, replacing inline HTML. Introduce a `CaseFlags` struct to encapsulate the logic for determining various UI states of a case (e.g., `has_document`, `analyzing`, `can_close`, `llm_missing`). Update `handle_case_detail` to use `compute_flags` for populating the `CaseDetailTemplate`. Update `scan_user_cases` to compute `analyzing` and `has_document` flags for the `UserCaseView`. Add new template logic in `case_detail.html` to display actions based on `CaseFlags`. Add new template logic in `my_cases.html` to display `analyzing` and `done-doc` labels for cases. Create a new `document.html` template. Add a helper function `any_document_exists` to check for the presence of a document file. --- server/src/routes/case_actions.rs | 25 ++++++---- server/src/routes/user_web.rs | 78 ++++++++++++++++++++++++++++++- server/templates/case_detail.html | 18 +++++++ server/templates/document.html | 24 ++++++++++ server/templates/my_cases.html | 7 ++- 5 files changed, 141 insertions(+), 11 deletions(-) create mode 100644 server/templates/document.html diff --git a/server/src/routes/case_actions.rs b/server/src/routes/case_actions.rs index 253d05c..42d0558 100644 --- a/server/src/routes/case_actions.rs +++ b/server/src/routes/case_actions.rs @@ -2,6 +2,7 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::SystemTime; +use askama::Template; use axum::extract::{Path as AxumPath, State}; use axum::response::{Html, Redirect}; use time::format_description::well_known::Rfc3339; @@ -15,6 +16,14 @@ use crate::config::Config; use crate::error::AppError; use crate::routes::user_web::locate_case; +#[derive(Template)] +#[template(path = "document.html")] +struct DocumentTemplate { + case_id: String, + version: u32, + content: String, +} + /// POST /web/cases/{case_id}/close /// /// Persist an `analysis_input_v1.json` for the case and enqueue an analyze @@ -152,14 +161,14 @@ pub async fn handle_document_view( .await .ok_or_else(|| AppError::NotFound("Noch kein Dokument erzeugt".into()))?; - // Minimal inline HTML for step 4; replaced with a proper template in step 7. - let escaped = content - .replace('&', "&") - .replace('<', "<") - .replace('>', ">"); - Ok(Html(format!( - "Dokument v{version}
{escaped}

Zurück

" - ))) + DocumentTemplate { + case_id, + version, + content, + } + .render() + .map(Html) + .map_err(|e| AppError::Internal(format!("Template render failed: {e}"))) } /// Return `(path, mtime)` for every `.m4a` file directly under `case_dir`, diff --git a/server/src/routes/user_web.rs b/server/src/routes/user_web.rs index 8d17033..4da20b8 100644 --- a/server/src/routes/user_web.rs +++ b/server/src/routes/user_web.rs @@ -18,6 +18,8 @@ struct UserCaseView { most_recent: String, oneliner: Option, recordings: Vec, + analyzing: bool, + has_document: bool, } #[derive(Template)] @@ -37,6 +39,64 @@ struct CaseDetailTemplate { status: String, oneliner: Option, recordings: Vec, + can_close: bool, + llm_missing: bool, + analyzing: bool, + has_document: bool, +} + +/// Four mutually exclusive flags derived from filesystem state + config. +/// Priority order (for UI rendering): `has_document`, then `analyzing`, +/// then `can_close`, then `llm_missing`. If none is true, the case is +/// still receiving recordings or has zero transcripts; no action surfaced. +struct CaseFlags { + has_document: bool, + analyzing: bool, + can_close: bool, + llm_missing: bool, +} + +async fn compute_flags( + case_dir: &Path, + status: &str, + recordings: &[RecordingView], + llm_configured: bool, +) -> CaseFlags { + let has_document = any_document_exists(case_dir).await; + let analyzing = !has_document + && tokio::fs::try_exists(case_dir.join("analysis_input_v1.json")) + .await + .unwrap_or(false); + + let non_failed: Vec<&RecordingView> = recordings.iter().filter(|r| !r.failed).collect(); + let all_transcribed = + !non_failed.is_empty() && non_failed.iter().all(|r| r.transcript.is_some()); + + let transcribed_stage = status == "open" && all_transcribed && !has_document && !analyzing; + + CaseFlags { + has_document, + analyzing, + can_close: transcribed_stage && llm_configured, + llm_missing: transcribed_stage && !llm_configured, + } +} + +/// True iff the case directory contains at least one `document_v{N}.md`. +/// We don't care about N here — any version means "Ausgewertet" for the UI. +async fn any_document_exists(case_dir: &Path) -> bool { + let mut entries = match tokio::fs::read_dir(case_dir).await { + Ok(r) => r, + Err(_) => return false, + }; + while let Ok(Some(entry)) = entries.next_entry().await { + let name = entry.file_name(); + let Some(name_str) = name.to_str() else { continue }; + if name_str.starts_with("document_v") && name_str.ends_with(".md") { + return true; + } + } + false } pub async fn handle_my_cases( @@ -80,6 +140,7 @@ pub async fn handle_case_detail( .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()); + let flags = compute_flags(&case_dir, &status, &recordings, config.llm_configured()).await; let case_id_short = case_id.chars().take(8).collect(); CaseDetailTemplate { @@ -89,6 +150,10 @@ pub async fn handle_case_detail( status, oneliner, recordings, + can_close: flags.can_close, + llm_missing: flags.llm_missing, + analyzing: flags.analyzing, + has_document: flags.has_document, } .render() .map(Html) @@ -139,17 +204,26 @@ async fn scan_user_cases(data_path: &Path, slug: &str) -> (Vec, Ve continue; } + let case_path = case_entry.path(); let most_recent = recordings .last() .map(|r| r.filename.clone()) .unwrap_or_default(); let case_id_short = case_id.chars().take(8).collect(); - let oneliner = tokio::fs::read_to_string(case_entry.path().join("oneliner.txt")) + let oneliner = tokio::fs::read_to_string(case_path.join("oneliner.txt")) .await .ok() .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()); + // Only the two flags the list UI needs are computed here — + // `can_close`/`llm_missing` live on the detail page. + let has_document = any_document_exists(&case_path).await; + let analyzing = !has_document + && tokio::fs::try_exists(case_path.join("analysis_input_v1.json")) + .await + .unwrap_or(false); + let view = UserCaseView { case_id, case_id_short, @@ -157,6 +231,8 @@ async fn scan_user_cases(data_path: &Path, slug: &str) -> (Vec, Ve most_recent, oneliner, recordings, + analyzing, + has_document, }; if status == "open" { diff --git a/server/templates/case_detail.html b/server/templates/case_detail.html index 8ca2618..89c9293 100644 --- a/server/templates/case_detail.html +++ b/server/templates/case_detail.html @@ -20,6 +20,12 @@ header form { margin: 0; } .status-badge { display: inline-block; padding: 0.1em 0.6em; border-radius: 999px; font-size: 0.8em; font-weight: bold; color: white; } .status-badge.open { background: #4a90e2; } .status-badge.done { background: #7ed321; } +.actions { margin: 1em 0 1.5em; } +.actions form { margin: 0; display: inline; } +.actions button { font-size: 1em; padding: 0.5em 1em; } +.actions a.doc-link { display: inline-block; padding: 0.5em 1em; background: #7ed321; color: white; text-decoration: none; border-radius: 4px; font-weight: bold; } +.actions .analyzing { color: #888; font-style: italic; } +.actions .llm-missing { color: #a66; font-style: italic; } @@ -36,6 +42,18 @@ header form { margin: 0; } {% endmatch %}
{{ case_id }}
+
+{% if has_document %} +Ergebnis öffnen +{% else if analyzing %} +wird analysiert … +{% else if can_close %} +
+{% else if llm_missing %} +LLM-Analyse nicht konfiguriert +{% endif %} +
+

Aufnahmen ({{ recordings.len() }})

{% for rec in recordings %}
diff --git a/server/templates/document.html b/server/templates/document.html new file mode 100644 index 0000000..0cdf020 --- /dev/null +++ b/server/templates/document.html @@ -0,0 +1,24 @@ + + + + +Doctate — Dokument v{{ version }} + + + +
+ +
+
+

Dokument v{{ version }}

+
{{ case_id }}
+
{{ content }}
+ + diff --git a/server/templates/my_cases.html b/server/templates/my_cases.html index 984b76a..6c5b8b2 100644 --- a/server/templates/my_cases.html +++ b/server/templates/my_cases.html @@ -17,6 +17,9 @@ header form { margin: 0; } section { margin: 1.5em 0; } section h2 { font-size: 1.1em; color: #555; border-bottom: 1px solid #ddd; padding-bottom: 0.2em; } .empty { color: #888; font-style: italic; } +.label { display: inline-block; margin-left: 0.6em; padding: 0.05em 0.5em; border-radius: 999px; font-size: 0.75em; font-weight: bold; } +.label.analyzing { background: #eee; color: #555; } +.label.done-doc { background: #7ed321; color: white; } @@ -32,7 +35,7 @@ section h2 { font-size: 1.1em; color: #555; border-bottom: 1px solid #ddd; paddi {% else %} {% for case in open %} -

{{ case.case_id_short }} — {{ case.recordings.len() }} Aufnahme(n)

+

{{ case.case_id_short }} — {{ case.recordings.len() }} Aufnahme(n){% if case.has_document %}ausgewertet{% else if case.analyzing %}wird analysiert{% endif %}

{% match case.oneliner %} {% when Some with (t) %}
{{ t }}
@@ -51,7 +54,7 @@ section h2 { font-size: 1.1em; color: #555; border-bottom: 1px solid #ddd; paddi {% else %} {% for case in done %}
-

{{ case.case_id_short }} — {{ case.recordings.len() }} Aufnahme(n)

+

{{ case.case_id_short }} — {{ case.recordings.len() }} Aufnahme(n){% if case.has_document %}ausgewertet{% else if case.analyzing %}wird analysiert{% endif %}

{% match case.oneliner %} {% when Some with (t) %}
{{ t }}