From 75ede79d26340284fb2bcad24c0fe379b4db4b51 Mon Sep 17 00:00:00 2001 From: Brummel Date: Tue, 21 Apr 2026 17:41:35 +0200 Subject: [PATCH] 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. --- server/src/routes/user_web.rs | 207 ++++++++++++++++++++++++++++++++- server/templates/my_cases.html | 6 +- 2 files changed, 207 insertions(+), 6 deletions(-) diff --git a/server/src/routes/user_web.rs b/server/src/routes/user_web.rs index 9ef0953..eabc9ac 100644 --- a/server/src/routes/user_web.rs +++ b/server/src/routes/user_web.rs @@ -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) -> Vec { +/// +/// `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, + closed_extra: &HashMap, +) -> Vec { let today = OffsetDateTime::now_utc().date(); let mut groups: Vec = Vec::new(); for case in cases { @@ -133,12 +144,82 @@ fn group_by_utc_date(cases: Vec) -> Vec { _ => 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 { + let mut out: HashMap = 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 { + let mut entries = tokio::fs::read_dir(case_path).await.ok()?; + let mut best: Option = 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, + /// 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); + } } diff --git a/server/templates/my_cases.html b/server/templates/my_cases.html index aabdaa2..06e9ed4 100644 --- a/server/templates/my_cases.html +++ b/server/templates/my_cases.html @@ -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 {
{% for g in groups %}
-

{{ g.label }}{% if g.cases.len() == 1 %}1 Fall{% else %}{{ g.cases.len() }} Fälle{% endif %}

+

{{ g.label }}{{ g.open_count }}/{{ g.total_count }} Fälle