Refactor: Improve case grouping and counting logic
Introduce `count_closed_by_date` and `most_recent_date_label` to efficiently count closed cases for display purposes when `show_closed` is false. Modify `group_by_utc_date` to accept a `closed_extra` map, allowing it to correctly calculate both open and total case counts per day. Update the `my_cases.html` template to display the new `open_count/total_count` format for case groups.
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::Ordering;
|
||||
@@ -120,7 +121,17 @@ fn utc_date_and_iso_of(filename: &str) -> Option<(Date, String)> {
|
||||
/// grouping is by UTC day, which may drift from the doctor's local-day
|
||||
/// perception around midnight — acceptable tradeoff; full Browser-TZ
|
||||
/// grouping would require a larger client-side restructure.
|
||||
fn group_by_utc_date(cases: Vec<UserCaseView>) -> Vec<DateGroup> {
|
||||
///
|
||||
/// `closed_extra` carries the count of *closed* cases per day-label that
|
||||
/// are NOT present in `cases` (i.e. the default `show_closed=false`
|
||||
/// view). Each group's `total_count` is then `cases_in_group +
|
||||
/// closed_extra[label]`. Under `show_closed=true` the caller passes an
|
||||
/// empty map — closed cases are already in `cases` with `is_closed=true`
|
||||
/// and get counted directly.
|
||||
fn group_by_utc_date(
|
||||
cases: Vec<UserCaseView>,
|
||||
closed_extra: &HashMap<String, usize>,
|
||||
) -> Vec<DateGroup> {
|
||||
let today = OffsetDateTime::now_utc().date();
|
||||
let mut groups: Vec<DateGroup> = Vec::new();
|
||||
for case in cases {
|
||||
@@ -133,12 +144,82 @@ fn group_by_utc_date(cases: Vec<UserCaseView>) -> Vec<DateGroup> {
|
||||
_ => groups.push(DateGroup {
|
||||
label,
|
||||
cases: vec![case],
|
||||
open_count: 0,
|
||||
total_count: 0,
|
||||
}),
|
||||
}
|
||||
}
|
||||
// Second pass: fill in the per-group counts. Doing this after all
|
||||
// cases have landed in their groups keeps the push-logic above
|
||||
// simple and avoids re-counting on every append.
|
||||
for g in &mut groups {
|
||||
g.open_count = g.cases.iter().filter(|c| !c.is_closed).count();
|
||||
g.total_count = g.cases.len() + closed_extra.get(&g.label).copied().unwrap_or(0);
|
||||
}
|
||||
groups
|
||||
}
|
||||
|
||||
/// Count closed cases per UTC-date label for the `show_closed=false`
|
||||
/// listing — the default view where closed cases are hidden from the
|
||||
/// case rows but still contribute to the `Z` in `Y/Z Fälle`.
|
||||
///
|
||||
/// Lightweight on purpose: per closed case we do exactly one `read_dir`
|
||||
/// and pick the lexicographically largest `.m4a[.failed]` filename as
|
||||
/// the most-recent recording, then extract the UTC date. No oneliner,
|
||||
/// document, or flag computation — those aren't rendered for hidden
|
||||
/// cases anyway.
|
||||
async fn count_closed_by_date(user_root: &Path) -> HashMap<String, usize> {
|
||||
let mut out: HashMap<String, usize> = HashMap::new();
|
||||
let Ok(mut entries) = tokio::fs::read_dir(user_root).await else {
|
||||
return out;
|
||||
};
|
||||
while let Ok(Some(entry)) = entries.next_entry().await {
|
||||
let Ok(case_id) = entry.file_name().into_string() else {
|
||||
continue;
|
||||
};
|
||||
if uuid::Uuid::parse_str(&case_id).is_err() {
|
||||
continue;
|
||||
}
|
||||
let case_path = entry.path();
|
||||
if !case_path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
if !crate::paths::is_closed(&case_path).await {
|
||||
continue;
|
||||
}
|
||||
if let Some(label) = most_recent_date_label(&case_path).await {
|
||||
*out.entry(label).or_insert(0) += 1;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Return the UTC-date label (`YYYY-MM-DD`) of the most recent recording
|
||||
/// in `case_path`, or `None` if the directory has no parseable recording
|
||||
/// filenames. Matches the `group_by_utc_date` bucketing rule exactly so
|
||||
/// a closed case counts toward the same day its visible siblings do.
|
||||
async fn most_recent_date_label(case_path: &Path) -> Option<String> {
|
||||
let mut entries = tokio::fs::read_dir(case_path).await.ok()?;
|
||||
let mut best: Option<String> = None;
|
||||
while let Ok(Some(entry)) = entries.next_entry().await {
|
||||
// Non-UTF8 filenames skipped, not fatal — an unparseable entry
|
||||
// must not shadow a valid recording sitting next to it.
|
||||
let Ok(name) = entry.file_name().into_string() else {
|
||||
continue;
|
||||
};
|
||||
// Accept both `.m4a` and `.m4a.failed` — both mark a recording
|
||||
// slot, and failed files still carry a valid timestamp prefix.
|
||||
if !(name.ends_with(".m4a") || name.ends_with(".m4a.failed")) {
|
||||
continue;
|
||||
}
|
||||
if best.as_deref().map(|b| name.as_str() > b).unwrap_or(true) {
|
||||
best = Some(name);
|
||||
}
|
||||
}
|
||||
let name = best?;
|
||||
utc_date_and_iso_of(&name).map(|(d, _)| d.to_string())
|
||||
}
|
||||
|
||||
/// Extract `HH:MM` from a recording filename of the form
|
||||
/// `YYYY-MM-DDTHH-MM-SSZ.m4a[.failed]`. Returns `""` on any mismatch.
|
||||
/// Used only as a no-JS fallback — the canonical value rendered in the
|
||||
@@ -190,6 +271,16 @@ struct DateGroup {
|
||||
/// "Heute", "Gestern", or ISO date "YYYY-MM-DD".
|
||||
label: String,
|
||||
cases: Vec<UserCaseView>,
|
||||
/// Count of *open* (non-closed) cases in this group. Matches the
|
||||
/// number of rows actually rendered in the default view (where
|
||||
/// closed cases are hidden). Rendered as the `Y` in `Y/Z Fälle`.
|
||||
open_count: usize,
|
||||
/// Count of *all* cases whose most-recent recording falls on this
|
||||
/// date — open plus closed, regardless of the show_closed toggle.
|
||||
/// Rendered as the `Z` in `Y/Z Fälle` so doctors can see how many
|
||||
/// cases were actually worked that day even when the list hides
|
||||
/// the closed ones.
|
||||
total_count: usize,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
@@ -447,7 +538,18 @@ pub async fn handle_my_cases(
|
||||
.await;
|
||||
let total = cases.len();
|
||||
let any_closed = cases.iter().any(|c| c.is_closed);
|
||||
let groups = group_by_utc_date(cases);
|
||||
// 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
|
||||
// closed case, no compute_case_view) and feed it into the grouper.
|
||||
// Under `show_closed=true` closed cases are already in `cases`
|
||||
// with their `is_closed` flag, so the map stays empty.
|
||||
let closed_extra = if show_closed {
|
||||
HashMap::new()
|
||||
} else {
|
||||
count_closed_by_date(&user_root).await
|
||||
};
|
||||
let groups = group_by_utc_date(cases, &closed_extra);
|
||||
let is_admin = user.is_admin();
|
||||
let preview_lines = config
|
||||
.users
|
||||
@@ -970,4 +1072,105 @@ mod tests {
|
||||
Some("Patient berichtet über Schmerzen.")
|
||||
);
|
||||
}
|
||||
|
||||
fn mk_view(most_recent: &str, is_closed: bool) -> UserCaseView {
|
||||
UserCaseView {
|
||||
case_id: "00000000-0000-0000-0000-000000000000".into(),
|
||||
most_recent: most_recent.into(),
|
||||
time_hms_utc: String::new(),
|
||||
recorded_at_iso: String::new(),
|
||||
oneliner: OnelinerDisplay::Empty,
|
||||
recordings_count: 1,
|
||||
analyzing: false,
|
||||
has_document: false,
|
||||
has_failed_recording: false,
|
||||
can_analyze: false,
|
||||
is_closed,
|
||||
days_until_purge: None,
|
||||
analysis_preview: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_by_utc_date_counts_open_only_cases_as_equal() {
|
||||
// Default view: no closed cases at all. open == total.
|
||||
let cases = vec![
|
||||
mk_view("2026-04-18T10-00-00Z.m4a", false),
|
||||
mk_view("2026-04-18T09-00-00Z.m4a", false),
|
||||
];
|
||||
let extra = HashMap::new();
|
||||
let groups = group_by_utc_date(cases, &extra);
|
||||
assert_eq!(groups.len(), 1);
|
||||
assert_eq!(groups[0].open_count, 2);
|
||||
assert_eq!(groups[0].total_count, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_by_utc_date_adds_closed_extra_to_total_only() {
|
||||
// show_closed=false path: visible cases are all open, closed ones
|
||||
// come in via the extra-map and inflate `total` without touching
|
||||
// `open`.
|
||||
let cases = vec![mk_view("2026-04-18T10-00-00Z.m4a", false)];
|
||||
let mut extra = HashMap::new();
|
||||
extra.insert("2026-04-18".to_string(), 2);
|
||||
let groups = group_by_utc_date(cases, &extra);
|
||||
assert_eq!(groups[0].open_count, 1);
|
||||
assert_eq!(groups[0].total_count, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_by_utc_date_under_show_closed_distinguishes_open_from_total() {
|
||||
// show_closed=true path: closed cases sit *inside* `cases` with
|
||||
// is_closed=true; the extra-map is empty. `open` must still be
|
||||
// the count of non-closed entries.
|
||||
let cases = vec![
|
||||
mk_view("2026-04-18T10-00-00Z.m4a", false),
|
||||
mk_view("2026-04-18T09-00-00Z.m4a", true),
|
||||
mk_view("2026-04-18T08-00-00Z.m4a", true),
|
||||
];
|
||||
let extra = HashMap::new();
|
||||
let groups = group_by_utc_date(cases, &extra);
|
||||
assert_eq!(groups[0].open_count, 1);
|
||||
assert_eq!(groups[0].total_count, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_by_utc_date_separate_buckets_independent_counts() {
|
||||
// Two calendar days, one gets a closed-extra, the other doesn't.
|
||||
// Cross-talk between buckets would be a bug.
|
||||
let cases = vec![
|
||||
mk_view("2026-04-18T10-00-00Z.m4a", false),
|
||||
mk_view("2026-04-17T10-00-00Z.m4a", false),
|
||||
mk_view("2026-04-17T09-00-00Z.m4a", false),
|
||||
];
|
||||
let mut extra = HashMap::new();
|
||||
extra.insert("2026-04-18".to_string(), 1);
|
||||
let groups = group_by_utc_date(cases, &extra);
|
||||
assert_eq!(groups.len(), 2);
|
||||
let g18 = groups.iter().find(|g| g.label == "2026-04-18").unwrap();
|
||||
let g17 = groups.iter().find(|g| g.label == "2026-04-17").unwrap();
|
||||
assert_eq!((g18.open_count, g18.total_count), (1, 2));
|
||||
assert_eq!((g17.open_count, g17.total_count), (2, 2));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn most_recent_date_label_picks_largest_filename() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let p = dir.path();
|
||||
std::fs::write(p.join("2026-04-17T10-00-00Z.m4a"), b"").unwrap();
|
||||
std::fs::write(p.join("2026-04-18T09-00-00Z.m4a"), b"").unwrap();
|
||||
std::fs::write(p.join("2026-04-18T08-00-00Z.m4a.failed"), b"").unwrap();
|
||||
// Noise files that must be ignored entirely.
|
||||
std::fs::write(p.join("document.md"), b"").unwrap();
|
||||
std::fs::write(p.join("oneliner.json"), b"").unwrap();
|
||||
let label = most_recent_date_label(p).await;
|
||||
assert_eq!(label.as_deref(), Some("2026-04-18"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn most_recent_date_label_returns_none_without_recordings() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::write(dir.path().join("document.md"), b"").unwrap();
|
||||
assert_eq!(most_recent_date_label(dir.path()).await, None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,6 @@ section h2 .count { font-weight: normal; font-size: 0.85em; color: #888; }
|
||||
.case-row .oneliner { color: #333; margin: 0.2em 0 0; }
|
||||
.case-row .line1 { font-size: 1em; margin: 0; }
|
||||
.case-row .line1 .time { font-family: monospace; font-weight: bold; color: #444; }
|
||||
.case-row .line2 { margin: 0.2em 0 0; color: #555; font-size: 0.95em; display: flex; align-items: center; gap: 0.5em; }
|
||||
.case-row .preview { margin: 0.3em 0 0; color: #666; font-size: 0.9em; display: -webkit-box; -webkit-line-clamp: var(--preview-lines); line-clamp: var(--preview-lines); -webkit-box-orient: vertical; overflow: hidden; }
|
||||
.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; }
|
||||
@@ -90,15 +89,14 @@ try {
|
||||
<form method="post" action="/web/cases/bulk">
|
||||
{% for g in groups %}
|
||||
<section>
|
||||
<h2 class="date-group" data-iso="{{ g.label }}"><span class="date-label">{{ g.label }}</span><span class="count">{% if g.cases.len() == 1 %}1 Fall{% else %}{{ g.cases.len() }} Fälle{% endif %}</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">
|
||||
{% for case in g.cases %}
|
||||
<li class="case-row {% if case.is_closed %}closed{% else if case.has_document %}done{% else %}open{% endif %}">
|
||||
<div class="check"><input type="checkbox" name="case_id" value="{{ case.case_id }}"{% if case.is_closed %} disabled{% endif %}></div>
|
||||
<div class="body">
|
||||
<a href="/web/cases/{{ case.case_id }}{% if case.is_closed %}?show_closed=1{% endif %}">
|
||||
<div class="line1"><span class="time"><time datetime="{{ case.recorded_at_iso }}">{{ case.time_hms_utc }}</time></span> — {% call ol::render(case.oneliner) %}</div>
|
||||
<div class="line2">{{ case.recordings_count }} Aufnahmen{% 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.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 %}</div>
|
||||
<div class="line1"><span class="time"><time datetime="{{ case.recorded_at_iso }}">{{ case.time_hms_utc }}</time></span> — {% call ol::render(case.oneliner) %} — {{ case.recordings_count }} Aufnahmen{% 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.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 %}</div>
|
||||
{% match case.analysis_preview %}{% when Some with (preview) %}<p class="preview">{{ preview }}</p>{% when None %}{% endmatch %}
|
||||
{% if is_admin %}<div class="uuid admin-only">{{ case.case_id }}</div>{% endif %}
|
||||
</a>
|
||||
|
||||
Reference in New Issue
Block a user