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.
|
||||
|
||||
Reference in New Issue
Block a user