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