From e0cfd5d512d1f3a46f1b996cb341e042910ba987 Mon Sep 17 00:00:00 2001 From: Brummel Date: Tue, 5 May 2026 16:41:30 +0200 Subject: [PATCH] Refactor: Simplify case status display Introduce a `Badge` enum and a `badge_for` function to consolidate case status logic. This replaces multiple boolean flags (`analyzing`, `has_failed_recording`, `analysis_required`) with a single, more expressive `badge` field. The HTML template has been updated to use this new badge system, removing old status classes and logic. The `scan_user_cases` function no longer needs the `worker_busy` argument as the analysis state is now derived directly from the `auto_trigger` state. This change improves code clarity and maintainability by centralizing status determination and reducing redundant boolean flags. --- server/src/routes/user_web.rs | 135 +++++++++++++++++++++++---------- server/templates/my_cases.html | 11 +-- 2 files changed, 99 insertions(+), 47 deletions(-) diff --git a/server/src/routes/user_web.rs b/server/src/routes/user_web.rs index 45de811..6c3e186 100644 --- a/server/src/routes/user_web.rs +++ b/server/src/routes/user_web.rs @@ -55,6 +55,34 @@ enum OnelinerDisplay { Generating, } +/// The single lifecycle-derived status badge a case can carry in the case list. +/// Mutually exclusive — at most one of these is shown per case. `None` +/// means no badge (successful and current, or pre-pipeline states the +/// doctor doesn't need to be alerted about). +/// +/// Per-recording Whisper failures are *not* surfaced here; they belong +/// to the case-detail view, not the list overview. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Badge { + AnalysisRequired, + Analyzing, + Failed, +} + +/// Map the typed lifecycle state to the single badge shown in the list. +/// Pure function: no I/O, no async, deterministic. Exhaustive match — +/// the compiler refuses to build if a new `CaseAnalysisState` variant +/// is added without a decision here. +fn badge_for(state: &auto_trigger::CaseAnalysisState) -> Option { + use auto_trigger::CaseAnalysisState::*; + match state { + NoRecordings | Pending | Current => None, + Queued => Some(Badge::Analyzing), + Failed => Some(Badge::Failed), + Required { .. } | Idle { .. } => Some(Badge::AnalysisRequired), + } +} + struct UserCaseView { case_id: String, most_recent: String, @@ -75,23 +103,17 @@ struct UserCaseView { /// doctor can tell at a glance which titles they themselves wrote. oneliner_is_manual: bool, recordings_count: usize, - analyzing: bool, has_document: bool, - /// True iff at least one recording is `.m4a.failed` — a *permanent* - /// transcribe failure (ffmpeg remux or Whisper 4xx). Transient Whisper - /// errors never set this flag because they leave the file as plain - /// `.m4a` for the page-load heal to retry. Drives the verdrängende - /// `fehler`-Badge over `offen`/`ausgewertet`. - has_failed_recording: bool, /// True iff the inline "Analysieren"-button should be enabled. - /// Requires: not currently analyzing, all non-failed recordings + /// Requires: no analysis already queued, all non-failed recordings /// transcribed, and an LLM is configured. can_analyze: bool, - /// True iff the typed `CaseAnalysisState` is `Required` or `Idle`, - /// i.e. the latest recording is not yet covered by `document.json`. - /// Drives the per-row "Analyse erforderlich"-badge AND feeds the - /// list-level `required_count` for the bulk "Aktualisieren"-button. - analysis_required: bool, + /// The single lifecycle-derived badge shown for this case in the + /// list. `None` = no badge (NoRecordings/Pending/Current). Drives + /// the per-row badge rendering AND the list-level `required_count` + /// (cases with `Some(AnalysisRequired)`) for the bulk + /// "Aktualisieren"-button. + badge: Option, /// True iff the case carries a `.closed` marker. Drives the muted /// styling, the Close/Reopen button swap, and the purge countdown. is_closed: bool, @@ -650,11 +672,9 @@ pub async fn handle_my_cases( crate::retention::sweep_user_retention(&user_root, &user.slug, &retention, &events_tx).await; let show_closed = query.include_closed(); - let busy = pipeline.analyze_busy.0.load(Ordering::Acquire); let cases = scan_user_cases( &config, &user.slug, - busy, show_closed, retention.auto_delete_days, crate::analyze::backend::any_backend_available(), @@ -665,10 +685,13 @@ pub async fn handle_my_cases( let any_closed = cases.iter().any(|c| c.is_closed); // List-level summary count for the bulk "Aktualisieren"-button. We // intentionally include closed cases here too — the button operates - // on the same population the badge does. If a closed case has - // `analysis_required` it would not actually fire (the bulk handler - // skips closed), but in practice closed cases are `Current` or hidden. - let required_count = cases.iter().filter(|c| c.analysis_required).count(); + // on the same population the badge does. compute_case_view already + // suppresses the AnalysisRequired badge for closed cases, so the + // count and the per-row badge stay in lockstep. + let required_count = cases + .iter() + .filter(|c| c.badge == Some(Badge::AnalysisRequired)) + .count(); // Under `show_closed=false` the case list skips closed entries // entirely — but the per-day `Y/Z Fälle` badge still needs to count // them for `Z`. Do a lightweight second scan (most-recent date per @@ -988,7 +1011,6 @@ pub(crate) async fn locate_closed_case_or_404( async fn scan_user_cases( config: &Config, slug: &str, - worker_busy: bool, include_closed: bool, auto_delete_days: u32, llm_configured: bool, @@ -1013,7 +1035,6 @@ async fn scan_user_cases( case_id, &case_path, llm_configured, - worker_busy, auto_delete_days, now, idle_threshold, @@ -1060,7 +1081,6 @@ async fn compute_case_view( case_id: String, case_path: &Path, llm_configured: bool, - worker_busy: bool, auto_delete_days: u32, now: OffsetDateTime, idle_threshold: std::time::Duration, @@ -1078,7 +1098,6 @@ async fn compute_case_view( .unwrap_or_default(); let recordings_count = recordings.len(); let non_failed_count = recordings.iter().filter(|r| !r.failed).count(); - let has_failed_recording = recordings.iter().any(|r| r.failed); let all_transcribed = non_failed_count > 0 && recordings .iter() @@ -1088,23 +1107,28 @@ async fn compute_case_view( let (oneliner, oneliner_is_manual) = compute_oneliner_display(case_path, &recordings).await; let has_document = any_document_exists(case_path).await; - let analyzing = !has_document && worker_busy && any_analysis_input_exists(case_path).await; - let can_analyze = !analyzing && all_transcribed && llm_configured; let (is_closed, days_until_purge) = closed_state(case_path, auto_delete_days, now).await; + // The typed lifecycle is the single source of truth for both the + // badge and the "is the Analysieren-button enabled?" + // decision: a case in `Queued` (i.e. `analysis_input.json` on disk) + // must not let the user enqueue a duplicate job. let state = auto_trigger::evaluate_state(case_path, idle_threshold, now_systime, boot_time).await; + let analysis_in_flight = matches!(state, auto_trigger::CaseAnalysisState::Queued); // Closed cases must never advertise as "Analyse erforderlich" — the - // whole point of closing a case is to freeze it. The bulk-endpoint - // already skips closed cases in its enqueue loop; this keeps the UI - // classification in lockstep so the badge / required_count match. - let analysis_required = !is_closed - && matches!( - state, - crate::analyze::auto_trigger::CaseAnalysisState::Required { .. } - | crate::analyze::auto_trigger::CaseAnalysisState::Idle { .. } - ); + // whole point of closing a case is to freeze it. Suppress the badge + // in that case so the bulk-endpoint and the per-row UI stay in lockstep. + let badge = if is_closed { + match badge_for(&state) { + Some(Badge::AnalysisRequired) => None, + other => other, + } + } else { + badge_for(&state) + }; + let can_analyze = !analysis_in_flight && all_transcribed && llm_configured; // One extra read per case with a document. Documents are in the KB // range — negligible next to the existing scan_recordings I/O — and @@ -1129,11 +1153,9 @@ async fn compute_case_view( oneliner, oneliner_is_manual, recordings_count, - analyzing, has_document, - has_failed_recording, can_analyze, - analysis_required, + badge, is_closed, days_until_purge, analysis_html, @@ -1257,17 +1279,50 @@ mod tests { oneliner: OnelinerDisplay::Empty, oneliner_is_manual: false, recordings_count: 1, - analyzing: false, has_document: false, - has_failed_recording: false, can_analyze: false, - analysis_required: false, + badge: None, is_closed, days_until_purge: None, analysis_html: None, } } + #[test] + fn badge_mapping_is_exhaustive() { + use auto_trigger::CaseAnalysisState::*; + // No badge for pre-pipeline / in-progress-transcription / done. + assert_eq!(badge_for(&NoRecordings), None); + assert_eq!(badge_for(&Pending), None); + assert_eq!(badge_for(&Current), None); + // Active analysis job persists on disk → badge regardless of worker liveness. + assert_eq!(badge_for(&Queued), Some(Badge::Analyzing)); + // LLM run failed for the current input set. + assert_eq!(badge_for(&Failed), Some(Badge::Failed)); + // Stale-document + idle-window-not-elapsed AND elapsed both ask for action; + // the has_document sub-state does not change the badge. + assert_eq!( + badge_for(&Required { + has_document: false + }), + Some(Badge::AnalysisRequired) + ); + assert_eq!( + badge_for(&Required { has_document: true }), + Some(Badge::AnalysisRequired) + ); + assert_eq!( + badge_for(&Idle { + has_document: false + }), + Some(Badge::AnalysisRequired) + ); + assert_eq!( + badge_for(&Idle { has_document: true }), + Some(Badge::AnalysisRequired) + ); + } + #[test] fn group_by_utc_date_counts_open_only_cases_as_equal() { // Default view: no closed cases at all. open == total. diff --git a/server/templates/my_cases.html b/server/templates/my_cases.html index 69e625f..a889eae 100644 --- a/server/templates/my_cases.html +++ b/server/templates/my_cases.html @@ -20,7 +20,6 @@ section h2 .count { font-weight: normal; font-size: 0.85em; color: #888; } .case-list.has-bulk .case-row { grid-template-columns: auto 1fr auto; } html.admin-view-off .case-list.has-bulk .case-row { grid-template-columns: 1fr auto; } .case-row.done { border-left: 4px solid #7ed321; } -.case-row.open { border-left: 4px solid #4a90e2; } .case-row .check { align-self: start; padding-top: 0.25em; } .case-row .body { min-width: 0; } .case-row .body h3 { margin: 0 0 0.2em 0; font-size: 1em; font-weight: 600; } @@ -45,10 +44,9 @@ html.admin-view-off .case-list.has-bulk .case-row { grid-template-columns: 1fr a .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; margin-left: 0.7em; } -.status-badge.open { background: #4a90e2; } -.status-badge.done { background: #7ed321; } -.status-badge.fehler { background: #e24a4a; } +.status-badge.failed { background: #e24a4a; } .status-badge.required { background: #f5a623; } +.status-badge.analyzing { background: #eee; color: #555; } .required-bar { margin: 1em 0; padding: 0.8em 1em; background: #fff7e6; border: 1px solid #f5a623; border-radius: 4px; display: flex; gap: 0.6em; align-items: center; } .required-bar button { font-size: 0.9em; padding: 0.5em 1em; } @@ -67,7 +65,6 @@ html.admin-view-off .case-list.has-bulk .case-row { grid-template-columns: 1fr a .closed-toggle:hover { text-decoration: underline; } .label { display: inline-block; margin-left: 0.6em; padding: 0.05em 0.5em; border-radius: 999px; font-size: 0.75em; font-weight: bold; vertical-align: middle; } -.label.analyzing { background: #eee; color: #555; } .label.done-doc { background: #7ed321; color: white; } .bulk-bar { margin: 1em 0; padding: 0.8em 1em; background: #f6f6f6; border-radius: 4px; display: flex; gap: 0.6em; flex-wrap: wrap; align-items: center; } @@ -122,11 +119,11 @@ try {

{{ g.label }}{{ g.open_count }}/{{ g.total_count }} Fälle