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.
This commit is contained in:
2026-05-05 16:41:30 +02:00
parent 16ef0bbe78
commit e0cfd5d512
2 changed files with 99 additions and 47 deletions
+95 -40
View File
@@ -55,6 +55,34 @@ enum OnelinerDisplay {
Generating, 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<Badge> {
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 { struct UserCaseView {
case_id: String, case_id: String,
most_recent: String, most_recent: String,
@@ -75,23 +103,17 @@ struct UserCaseView {
/// doctor can tell at a glance which titles they themselves wrote. /// doctor can tell at a glance which titles they themselves wrote.
oneliner_is_manual: bool, oneliner_is_manual: bool,
recordings_count: usize, recordings_count: usize,
analyzing: bool,
has_document: 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. /// 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. /// transcribed, and an LLM is configured.
can_analyze: bool, can_analyze: bool,
/// True iff the typed `CaseAnalysisState` is `Required` or `Idle`, /// The single lifecycle-derived badge shown for this case in the
/// i.e. the latest recording is not yet covered by `document.json`. /// list. `None` = no badge (NoRecordings/Pending/Current). Drives
/// Drives the per-row "Analyse erforderlich"-badge AND feeds the /// the per-row badge rendering AND the list-level `required_count`
/// list-level `required_count` for the bulk "Aktualisieren"-button. /// (cases with `Some(AnalysisRequired)`) for the bulk
analysis_required: bool, /// "Aktualisieren"-button.
badge: Option<Badge>,
/// True iff the case carries a `.closed` marker. Drives the muted /// True iff the case carries a `.closed` marker. Drives the muted
/// styling, the Close/Reopen button swap, and the purge countdown. /// styling, the Close/Reopen button swap, and the purge countdown.
is_closed: bool, 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; crate::retention::sweep_user_retention(&user_root, &user.slug, &retention, &events_tx).await;
let show_closed = query.include_closed(); let show_closed = query.include_closed();
let busy = pipeline.analyze_busy.0.load(Ordering::Acquire);
let cases = scan_user_cases( let cases = scan_user_cases(
&config, &config,
&user.slug, &user.slug,
busy,
show_closed, show_closed,
retention.auto_delete_days, retention.auto_delete_days,
crate::analyze::backend::any_backend_available(), 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); let any_closed = cases.iter().any(|c| c.is_closed);
// List-level summary count for the bulk "Aktualisieren"-button. We // List-level summary count for the bulk "Aktualisieren"-button. We
// intentionally include closed cases here too — the button operates // intentionally include closed cases here too — the button operates
// on the same population the badge does. If a closed case has // on the same population the badge does. compute_case_view already
// `analysis_required` it would not actually fire (the bulk handler // suppresses the AnalysisRequired badge for closed cases, so the
// skips closed), but in practice closed cases are `Current` or hidden. // count and the per-row badge stay in lockstep.
let required_count = cases.iter().filter(|c| c.analysis_required).count(); let required_count = cases
.iter()
.filter(|c| c.badge == Some(Badge::AnalysisRequired))
.count();
// Under `show_closed=false` the case list skips closed entries // Under `show_closed=false` the case list skips closed entries
// entirely — but the per-day `Y/Z Fälle` badge still needs to count // 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 // 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( async fn scan_user_cases(
config: &Config, config: &Config,
slug: &str, slug: &str,
worker_busy: bool,
include_closed: bool, include_closed: bool,
auto_delete_days: u32, auto_delete_days: u32,
llm_configured: bool, llm_configured: bool,
@@ -1013,7 +1035,6 @@ async fn scan_user_cases(
case_id, case_id,
&case_path, &case_path,
llm_configured, llm_configured,
worker_busy,
auto_delete_days, auto_delete_days,
now, now,
idle_threshold, idle_threshold,
@@ -1060,7 +1081,6 @@ async fn compute_case_view(
case_id: String, case_id: String,
case_path: &Path, case_path: &Path,
llm_configured: bool, llm_configured: bool,
worker_busy: bool,
auto_delete_days: u32, auto_delete_days: u32,
now: OffsetDateTime, now: OffsetDateTime,
idle_threshold: std::time::Duration, idle_threshold: std::time::Duration,
@@ -1078,7 +1098,6 @@ async fn compute_case_view(
.unwrap_or_default(); .unwrap_or_default();
let recordings_count = recordings.len(); let recordings_count = recordings.len();
let non_failed_count = recordings.iter().filter(|r| !r.failed).count(); 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 let all_transcribed = non_failed_count > 0
&& recordings && recordings
.iter() .iter()
@@ -1088,23 +1107,28 @@ async fn compute_case_view(
let (oneliner, oneliner_is_manual) = compute_oneliner_display(case_path, &recordings).await; let (oneliner, oneliner_is_manual) = compute_oneliner_display(case_path, &recordings).await;
let has_document = any_document_exists(case_path).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; 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 = let state =
auto_trigger::evaluate_state(case_path, idle_threshold, now_systime, boot_time).await; 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 // Closed cases must never advertise as "Analyse erforderlich" — the
// whole point of closing a case is to freeze it. The bulk-endpoint // whole point of closing a case is to freeze it. Suppress the badge
// already skips closed cases in its enqueue loop; this keeps the UI // in that case so the bulk-endpoint and the per-row UI stay in lockstep.
// classification in lockstep so the badge / required_count match. let badge = if is_closed {
let analysis_required = !is_closed match badge_for(&state) {
&& matches!( Some(Badge::AnalysisRequired) => None,
state, other => other,
crate::analyze::auto_trigger::CaseAnalysisState::Required { .. } }
| crate::analyze::auto_trigger::CaseAnalysisState::Idle { .. } } 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 // One extra read per case with a document. Documents are in the KB
// range — negligible next to the existing scan_recordings I/O — and // range — negligible next to the existing scan_recordings I/O — and
@@ -1129,11 +1153,9 @@ async fn compute_case_view(
oneliner, oneliner,
oneliner_is_manual, oneliner_is_manual,
recordings_count, recordings_count,
analyzing,
has_document, has_document,
has_failed_recording,
can_analyze, can_analyze,
analysis_required, badge,
is_closed, is_closed,
days_until_purge, days_until_purge,
analysis_html, analysis_html,
@@ -1257,17 +1279,50 @@ mod tests {
oneliner: OnelinerDisplay::Empty, oneliner: OnelinerDisplay::Empty,
oneliner_is_manual: false, oneliner_is_manual: false,
recordings_count: 1, recordings_count: 1,
analyzing: false,
has_document: false, has_document: false,
has_failed_recording: false,
can_analyze: false, can_analyze: false,
analysis_required: false, badge: None,
is_closed, is_closed,
days_until_purge: None, days_until_purge: None,
analysis_html: 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] #[test]
fn group_by_utc_date_counts_open_only_cases_as_equal() { fn group_by_utc_date_counts_open_only_cases_as_equal() {
// Default view: no closed cases at all. open == total. // Default view: no closed cases at all. open == total.
+4 -7
View File
@@ -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; } .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; } 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.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 .check { align-self: start; padding-top: 0.25em; }
.case-row .body { min-width: 0; } .case-row .body { min-width: 0; }
.case-row .body h3 { margin: 0 0 0.2em 0; font-size: 1em; font-weight: 600; } .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 .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; } .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 { 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.failed { background: #e24a4a; }
.status-badge.done { background: #7ed321; }
.status-badge.fehler { background: #e24a4a; }
.status-badge.required { background: #f5a623; } .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 { 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; } .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; } .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 { 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; } .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; } .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 {
<h2 class="date-group" data-iso="{{ g.label }}"><span class="date-label">{{ g.label }}</span><span class="count">{{ g.open_count }}/{{ g.total_count }} Fälle</span></h2> <h2 class="date-group" data-iso="{{ g.label }}"><span class="date-label">{{ g.label }}</span><span class="count">{{ g.open_count }}/{{ g.total_count }} Fälle</span></h2>
<ul class="case-list{% if is_admin %} has-bulk{% endif %}"> <ul class="case-list{% if is_admin %} has-bulk{% endif %}">
{% for case in g.cases %} {% for case in g.cases %}
<li class="case-row {% if case.is_closed %}closed{% else if case.has_document %}done{% else %}open{% endif %}"> <li class="case-row {% if case.is_closed %}closed{% else if case.has_document %}done{% endif %}">
{% if is_admin %}<div class="check admin-only"><input type="checkbox" name="case_id" value="{{ case.case_id }}"{% if case.is_closed %} disabled{% endif %}></div>{% endif %} {% if is_admin %}<div class="check admin-only"><input type="checkbox" name="case_id" value="{{ case.case_id }}"{% if case.is_closed %} disabled{% endif %}></div>{% endif %}
<div class="body"> <div class="body">
<div class="line1"> <div class="line1">
<a class="case-link" href="/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.oneliner_is_manual %} <span class="manual-marker" title="manuell bearbeitet" aria-label="manuell bearbeitet">👤</span>{% endif %}{% 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.analysis_required && !case.analyzing && !case.is_closed %} <span class="status-badge required">Analyse erforderlich</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="/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.oneliner_is_manual %} <span class="manual-marker" title="manuell bearbeitet" aria-label="manuell bearbeitet">👤</span>{% endif %}{% match case.badge %}{% when Some with (Badge::Failed) %} <span class="status-badge failed">Fehler</span>{% when Some with (Badge::Analyzing) %} <span class="status-badge analyzing">wird analysiert</span>{% when Some with (Badge::AnalysisRequired) %} <span class="status-badge required">Analyse erforderlich</span>{% when None %}{% endmatch %}{% 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="/cases/{{ case.case_id }}/recordings{% if case.is_closed %}?show_closed=1{% endif %}">{{ case.recordings_count }} Aufnahmen</a> <a class="recordings-link" href="/cases/{{ case.case_id }}/recordings{% if case.is_closed %}?show_closed=1{% endif %}">{{ case.recordings_count }} Aufnahmen</a>
</div> </div>
{% 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 %} {% 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 %}