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.
This commit is contained in:
2026-04-15 19:25:59 +02:00
parent aed4cd59d3
commit 4b554f04ff
5 changed files with 141 additions and 11 deletions
+17 -8
View File
@@ -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('<', "&lt;")
.replace('>', "&gt;");
Ok(Html(format!(
"<!doctype html><html><head><meta charset=\"utf-8\"><title>Dokument v{version}</title></head><body><pre>{escaped}</pre><p><a href=\"/web/cases/{case_id}\">Zurück</a></p></body></html>"
)))
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`,
+77 -1
View File
@@ -18,6 +18,8 @@ struct UserCaseView {
most_recent: String,
oneliner: Option<String>,
recordings: Vec<RecordingView>,
analyzing: bool,
has_document: bool,
}
#[derive(Template)]
@@ -37,6 +39,64 @@ struct CaseDetailTemplate {
status: String,
oneliner: Option<String>,
recordings: Vec<RecordingView>,
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<UserCaseView>, 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<UserCaseView>, Ve
most_recent,
oneliner,
recordings,
analyzing,
has_document,
};
if status == "open" {