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:
@@ -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<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 {
|
||||
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<Badge>,
|
||||
/// 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.
|
||||
|
||||
@@ -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 {
|
||||
<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 %}">
|
||||
{% 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 %}
|
||||
<div class="body">
|
||||
<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>
|
||||
</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 %}
|
||||
|
||||
Reference in New Issue
Block a user