Refactor timestamp handling for activity sorting

Introduce `last_recording_at` to `OnelinerEntry` and use it as the
primary sort key for cases. This ensures that cases with recent
dictation activity are prioritized, even if their initial creation date
is older.

The logic for determining a case's activity has been updated to consider
the most recent `.m4a` file's modification time (`last_recording_at`),
falling back to `updated_at` or `created_at` if necessary.

This change also refactors the timestamp formatting and handling within
the web interface to correctly display and sort cases based on their
actual last activity time, improving user experience and data relevance.
The `now_rfc3339` function is updated to strip sub-second precision for
consistent filename generation.
This commit is contained in:
2026-04-18 11:33:35 +02:00
parent 7637596598
commit cdfa5ae90e
11 changed files with 286 additions and 56 deletions
+1
View File
@@ -12,6 +12,7 @@
## Rust ## Rust
* Ich bevorzuge Rust, vor allem für Business-Logik. (Das heißt nicht, dass z.B. JS in HTML böse ist!)
* Halte dich and Best Practices und Rust-Style-Guidelines. * Halte dich and Best Practices und Rust-Style-Guidelines.
* Ich bin sehr erfahren mit Delphi und verstehe auch funktionale Sprachen. Rust ist neu für mich. Erkläre alles, was Rust von Delphi unterscheidet. * Ich bin sehr erfahren mit Delphi und verstehe auch funktionale Sprachen. Rust ist neu für mich. Erkläre alles, was Rust von Delphi unterscheidet.
* Performance: Bevorzuge `Rc<dyn Trait>` + lokales `downcast_ref` gegenüber Deep Copies via `Rc::new(obj.clone())`. * Performance: Bevorzuge `Rc<dyn Trait>` + lokales `downcast_ref` gegenüber Deep Copies via `Rc::new(obj.clone())`.
+61 -1
View File
@@ -144,6 +144,12 @@ impl CaseStore {
/// snapshot: insert (with `synced_to_server = true`) or update the /// snapshot: insert (with `synced_to_server = true`) or update the
/// local marker (oneliner + last_activity_at from server timestamps, /// local marker (oneliner + last_activity_at from server timestamps,
/// taking the newer of local vs. server). Never deletes. /// taking the newer of local vs. server). Never deletes.
///
/// The `last_activity_at` seed from the server prefers
/// `last_recording_at` (most recent m4a mtime — the authoritative
/// "when was this case last worked on?") over `updated_at` (oneliner
/// regeneration time, which collapses to the recovery-scan moment
/// after a restart). Falls back to `created_at` if neither is set.
pub async fn merge_server_snapshot( pub async fn merge_server_snapshot(
&self, &self,
snapshot: &OnelinersResponse, snapshot: &OnelinersResponse,
@@ -155,8 +161,9 @@ impl CaseStore {
continue; continue;
}; };
let server_activity = entry let server_activity = entry
.updated_at .last_recording_at
.clone() .clone()
.or_else(|| entry.updated_at.clone())
.unwrap_or_else(|| entry.created_at.clone()); .unwrap_or_else(|| entry.created_at.clone());
let updated_marker = match state.get(&case_id) { let updated_marker = match state.get(&case_id) {
@@ -308,6 +315,7 @@ mod tests {
case_id: case_id.to_string(), case_id: case_id.to_string(),
oneliner: oneliner.map(str::to_owned), oneliner: oneliner.map(str::to_owned),
created_at: created.to_owned(), created_at: created.to_owned(),
last_recording_at: Some(created.to_owned()),
updated_at: Some(created.to_owned()), updated_at: Some(created.to_owned()),
} }
} }
@@ -486,6 +494,58 @@ mod tests {
assert_eq!(count, 0); assert_eq!(count, 0);
} }
/// The client must seed `last_activity_at` from `last_recording_at`
/// (the authoritative dictation-activity timestamp), not from
/// `updated_at` (oneliner regeneration time, which collapses to a
/// narrow window after a startup recovery scan and breaks ordering).
#[tokio::test]
async fn merge_prefers_last_recording_over_updated_at() {
let tmp = TempDir::new().unwrap();
let (store, _rx) = CaseStore::open(tmp.path().to_owned()).await.unwrap();
let id = Uuid::new_v4();
// Server: case created yesterday, last dictation 30 min ago,
// oneliner regenerated just now in a recovery scan.
let entry = OnelinerEntry {
case_id: id.to_string(),
oneliner: Some("x".into()),
created_at: "2026-04-17T09:00:00Z".into(),
last_recording_at: Some("2026-04-18T09:30:00Z".into()),
updated_at: Some("2026-04-18T10:00:00Z".into()),
};
store
.merge_server_snapshot(&server_snapshot(vec![entry]))
.await
.unwrap();
let list = store.list().await;
assert_eq!(list[0].last_activity_at, "2026-04-18T09:30:00Z");
}
/// Backward-compat: if the server response omits `last_recording_at`
/// (older server or future schema shift), the client falls back to
/// `updated_at` then `created_at`.
#[tokio::test]
async fn merge_falls_back_to_updated_at_when_last_recording_missing() {
let tmp = TempDir::new().unwrap();
let (store, _rx) = CaseStore::open(tmp.path().to_owned()).await.unwrap();
let id = Uuid::new_v4();
let entry = OnelinerEntry {
case_id: id.to_string(),
oneliner: Some("x".into()),
created_at: "2026-04-17T09:00:00Z".into(),
last_recording_at: None,
updated_at: Some("2026-04-18T10:00:00Z".into()),
};
store
.merge_server_snapshot(&server_snapshot(vec![entry]))
.await
.unwrap();
let list = store.list().await;
assert_eq!(list[0].last_activity_at, "2026-04-18T10:00:00Z");
}
#[tokio::test] #[tokio::test]
async fn sorted_newest_first() { async fn sorted_newest_first() {
let tmp = TempDir::new().unwrap(); let tmp = TempDir::new().unwrap();
@@ -282,6 +282,7 @@ mod tests {
case_id: case_id.into(), case_id: case_id.into(),
oneliner: oneliner.map(str::to_owned), oneliner: oneliner.map(str::to_owned),
created_at: "2026-04-18T09:30:00Z".into(), created_at: "2026-04-18T09:30:00Z".into(),
last_recording_at: Some("2026-04-18T09:45:00Z".into()),
updated_at: Some("2026-04-18T09:45:00Z".into()), updated_at: Some("2026-04-18T09:45:00Z".into()),
}], }],
} }
@@ -106,6 +106,7 @@ mod tests {
case_id: "550e8400-e29b-41d4-a716-446655440000".into(), case_id: "550e8400-e29b-41d4-a716-446655440000".into(),
oneliner: Some("Kniegelenk".into()), oneliner: Some("Kniegelenk".into()),
created_at: "2026-04-18T10:00:00Z".into(), created_at: "2026-04-18T10:00:00Z".into(),
last_recording_at: Some("2026-04-18T10:30:00Z".into()),
updated_at: Some("2026-04-18T10:30:00Z".into()), updated_at: Some("2026-04-18T10:30:00Z".into()),
}], }],
}, },
+13 -2
View File
@@ -21,12 +21,23 @@ pub struct OnelinersResponse {
pub oneliners: Vec<OnelinerEntry>, pub oneliners: Vec<OnelinerEntry>,
} }
/// One case summary. `oneliner` is `None` while the case is still /// One case summary.
/// transcribing / generating; `updated_at` is `None` for the same reason. ///
/// - `created_at` — earliest `.m4a` mtime in the case (first dictation
/// in this case).
/// - `last_recording_at` — latest `.m4a` mtime (most recent dictation,
/// including addenda). This is the **primary sort key** for clients:
/// "when did the doctor last work on this case?". Absent only if the
/// case has no recordings at all (shouldn't happen in normal flow).
/// - `updated_at` — `oneliner.txt` mtime, i.e. when the oneliner text
/// was last regenerated. Diagnostic only — not for sort or display.
/// - `oneliner` — the text itself; `None` while transcription / LLM
/// generation is still pending.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OnelinerEntry { pub struct OnelinerEntry {
pub case_id: String, pub case_id: String,
pub oneliner: Option<String>, pub oneliner: Option<String>,
pub created_at: String, pub created_at: String,
pub last_recording_at: Option<String>,
pub updated_at: Option<String>, pub updated_at: Option<String>,
} }
+23 -4
View File
@@ -1,12 +1,16 @@
use time::OffsetDateTime; use time::OffsetDateTime;
use time::format_description::well_known::Rfc3339; use time::format_description::well_known::Rfc3339;
/// Current UTC time as an RFC3339 string (e.g. `2026-04-15T10:32:00.123Z`). /// Current UTC time as an RFC3339 string with second precision, e.g.
/// /// `2026-04-15T10:32:00Z`. Subsecond fractions are deliberately stripped
/// Formatting an `OffsetDateTime` as RFC3339 is infallible — every required /// so every caller produces the same filename shape — the server's
/// component is always present — so we panic on error rather than propagate. /// filename parser expects exactly `YYYY-MM-DDTHH-MM-SSZ` after the
/// `:` → `-` substitution, and dictation events don't need finer
/// resolution than one second.
pub fn now_rfc3339() -> String { pub fn now_rfc3339() -> String {
OffsetDateTime::now_utc() OffsetDateTime::now_utc()
.replace_nanosecond(0)
.expect("0 is always a valid nanosecond value")
.format(&Rfc3339) .format(&Rfc3339)
.expect("RFC3339 formatting of OffsetDateTime is infallible") .expect("RFC3339 formatting of OffsetDateTime is infallible")
} }
@@ -69,4 +73,19 @@ mod tests {
assert!(now.starts_with("20"), "unexpected year prefix: {now}"); assert!(now.starts_with("20"), "unexpected year prefix: {now}");
assert!(now.contains('T'), "missing T separator: {now}"); assert!(now.contains('T'), "missing T separator: {now}");
} }
/// Regression guard: subsecond precision must be stripped. A single
/// nanosecond leak would feed the server filename parser
/// `YYYY-MM-DDTHH-MM-SS.nnnnnnnnnZ.m4a`, which it treats as
/// malformed — the resulting `<time datetime>` is empty, breaking
/// browser-side TZ rendering.
#[test]
fn now_rfc3339_has_no_subsecond_component() {
let now = now_rfc3339();
assert!(
!now.contains('.'),
"now_rfc3339 must be second-granular, got {now}"
);
assert!(now.ends_with('Z'), "expected UTC 'Z' suffix, got {now}");
}
} }
+19 -2
View File
@@ -183,6 +183,23 @@ async fn main() {
.layer(TraceLayer::new_for_http()) .layer(TraceLayer::new_for_http())
.layer(RequestBodyLimitLayer::new(50 * 1024 * 1024)); // 50 MB .layer(RequestBodyLimitLayer::new(50 * 1024 * 1024)); // 50 MB
let listener = TcpListener::bind(&addr).await.unwrap(); let listener = match TcpListener::bind(&addr).await {
axum::serve(listener, app).await.unwrap(); Ok(l) => l,
Err(e) if e.kind() == std::io::ErrorKind::AddrInUse => {
eprintln!(
"Port {} is already in use. Is another doctate-server instance running?",
config.server_port
);
std::process::exit(1);
}
Err(e) => {
eprintln!("Failed to bind to {addr}: {e}");
std::process::exit(1);
}
};
if let Err(e) = axum::serve(listener, app).await {
eprintln!("Server runtime error: {e}");
std::process::exit(1);
}
} }
+36 -11
View File
@@ -91,9 +91,10 @@ fn not_modified(etag: &str) -> Response {
response response
} }
/// Scan `<user_data_dir>/<case_id>/` for cases whose earliest recording /// Scan `<user_data_dir>/<case_id>/` for cases whose latest recording
/// sits inside the `[since, now]` window. Returns entries sorted by /// sits inside the `[since, now]` window. Returns entries sorted by
/// `created_at` descending (newest first). Soft-deleted cases and /// `last_recording_at` descending (most recently active first); cases
/// without recordings fall back to `created_at`. Soft-deleted cases and
/// non-UUID directories are filtered out. /// non-UUID directories are filtered out.
async fn enumerate_recent_oneliners( async fn enumerate_recent_oneliners(
user_data_dir: &Path, user_data_dir: &Path,
@@ -118,11 +119,15 @@ async fn enumerate_recent_oneliners(
continue; continue;
} }
let Some(created_mtime) = earliest_m4a_mtime(&case_dir).await else { let Some((earliest, latest)) = m4a_mtime_range(&case_dir).await else {
continue; continue;
}; };
let created_at = OffsetDateTime::from(created_mtime); let created_at = OffsetDateTime::from(earliest);
if created_at < since { let last_recording_at = OffsetDateTime::from(latest);
// Window check on the *latest* recording: a case that got a
// follow-up dictation today must show up even if it was first
// created years ago.
if last_recording_at < since {
continue; continue;
} }
@@ -131,25 +136,37 @@ async fn enumerate_recent_oneliners(
warn!(case = %case_dir.display(), "format created_at failed"); warn!(case = %case_dir.display(), "format created_at failed");
continue; continue;
}; };
let last_recording_at_str = last_recording_at.format(&Rfc3339).ok();
let updated_at_str = updated_at.and_then(|t| t.format(&Rfc3339).ok()); let updated_at_str = updated_at.and_then(|t| t.format(&Rfc3339).ok());
entries.push(OnelinerEntry { entries.push(OnelinerEntry {
case_id: case_id.to_owned(), case_id: case_id.to_owned(),
oneliner: oneliner_text, oneliner: oneliner_text,
created_at: created_at_str, created_at: created_at_str,
last_recording_at: last_recording_at_str,
updated_at: updated_at_str, updated_at: updated_at_str,
}); });
} }
entries.sort_by(|a, b| b.created_at.cmp(&a.created_at)); // Primary sort: last_recording_at desc (most recent dictation on top).
// Fallback for entries without recordings (shouldn't happen today):
// use created_at. Strings sort in the same order as RFC3339 UTC
// timestamps because they are lexicographically monotonic.
entries.sort_by(|a, b| {
let a_key = a.last_recording_at.as_deref().unwrap_or(&a.created_at);
let b_key = b.last_recording_at.as_deref().unwrap_or(&b.created_at);
b_key.cmp(a_key)
});
entries entries
} }
/// Return the earliest `.m4a` mtime in `case_dir`. `.m4a.failed` counts — /// Return `(earliest, latest)` `.m4a` mtime pair in `case_dir`.
/// a failed recording still marks the birth of the case. Returns None on /// `.m4a.failed` entries count — a failed recording still marks activity.
/// read errors or empty directories. /// `None` on empty directory or read error. A single recording yields
async fn earliest_m4a_mtime(case_dir: &Path) -> Option<SystemTime> { /// `(mtime, mtime)`.
async fn m4a_mtime_range(case_dir: &Path) -> Option<(SystemTime, SystemTime)> {
let mut files = tokio::fs::read_dir(case_dir).await.ok()?; let mut files = tokio::fs::read_dir(case_dir).await.ok()?;
let mut earliest: Option<SystemTime> = None; let mut earliest: Option<SystemTime> = None;
let mut latest: Option<SystemTime> = None;
while let Ok(Some(entry)) = files.next_entry().await { while let Ok(Some(entry)) = files.next_entry().await {
let path: PathBuf = entry.path(); let path: PathBuf = entry.path();
let Some(name) = path.file_name().and_then(|s| s.to_str()) else { let Some(name) = path.file_name().and_then(|s| s.to_str()) else {
@@ -169,8 +186,16 @@ async fn earliest_m4a_mtime(case_dir: &Path) -> Option<SystemTime> {
Some(e) if mtime < e => Some(mtime), Some(e) if mtime < e => Some(mtime),
other => other, other => other,
}; };
latest = match latest {
None => Some(mtime),
Some(l) if mtime > l => Some(mtime),
other => other,
};
}
match (earliest, latest) {
(Some(e), Some(l)) => Some((e, l)),
_ => None,
} }
earliest
} }
/// Read `oneliner.txt` (trimmed, non-empty) plus its mtime. Missing file /// Read `oneliner.txt` (trimmed, non-empty) plus its mtime. Missing file
+42 -34
View File
@@ -7,7 +7,7 @@ use axum::extract::{Path as AxumPath, State};
use axum::response::Html; use axum::response::Html;
use tracing::{info, warn}; use tracing::{info, warn};
use time::{format_description::well_known::Rfc3339, Date, OffsetDateTime, UtcOffset}; use time::{format_description::well_known::Rfc3339, Date, OffsetDateTime};
use crate::analyze::{recovery as analyze_recovery, AnalyzeSender, ANALYSIS_INPUT_FILE, DOCUMENT_FILE}; use crate::analyze::{recovery as analyze_recovery, AnalyzeSender, ANALYSIS_INPUT_FILE, DOCUMENT_FILE};
use crate::auth::AuthenticatedWebUser; use crate::auth::AuthenticatedWebUser;
@@ -20,9 +20,15 @@ use crate::{AnalyzeBusy, TranscribeBusy};
struct UserCaseView { struct UserCaseView {
case_id: String, case_id: String,
most_recent: String, most_recent: String,
/// HH:MM extracted from `most_recent` filename (e.g. "10:32"), /// HH:MM of the most recent recording in UTC — no-JS fallback.
/// empty string if filename does not match the expected timestamp form. /// Browser script replaces the displayed text with the browser-local
time_hms: String, /// equivalent via the parallel `recorded_at_iso` field.
time_hms_utc: String,
/// Full RFC3339 UTC timestamp of the most recent recording. Fed into
/// the HTML `<time datetime="...">` attribute so browser JS can
/// render it in the doctor's actual timezone (which may differ from
/// the server's, e.g. server on Bahamas, doctor in Germany).
recorded_at_iso: String,
oneliner: Option<String>, oneliner: Option<String>,
recordings_count: usize, recordings_count: usize,
analyzing: bool, analyzing: bool,
@@ -33,9 +39,9 @@ struct UserCaseView {
can_analyze: bool, can_analyze: bool,
} }
/// Parse a recording filename `YYYY-MM-DDTHH-MM-SSZ.m4a[.failed]` and return /// Parse a recording filename `YYYY-MM-DDTHH-MM-SSZ.m4a[.failed]` as UTC
/// the date in the given offset. Returns `None` on any mismatch. /// and return `(Date, Rfc3339String)`. None on malformed input.
fn local_date_of(filename: &str, offset: UtcOffset) -> Option<Date> { fn utc_date_and_iso_of(filename: &str) -> Option<(Date, String)> {
let s = filename.get(..20)?; let s = filename.get(..20)?;
if s.as_bytes().get(19) != Some(&b'Z') { if s.as_bytes().get(19) != Some(&b'Z') {
return None; return None;
@@ -47,33 +53,23 @@ fn local_date_of(filename: &str, offset: UtcOffset) -> Option<Date> {
buf[16] = b':'; buf[16] = b':';
let fixed = std::str::from_utf8(&buf).ok()?; let fixed = std::str::from_utf8(&buf).ok()?;
let dt = OffsetDateTime::parse(fixed, &Rfc3339).ok()?; let dt = OffsetDateTime::parse(fixed, &Rfc3339).ok()?;
Some(dt.to_offset(offset).date()) Some((dt.date(), fixed.to_owned()))
} }
/// Best-effort local offset. Falls back to UTC if the runtime can't resolve /// Group cases (already sorted newest-first) into UTC-date buckets. The
/// it (e.g. multi-threaded tokio on Unix without `TZ` set). Near-midnight /// label is the raw ISO date string (e.g. `2026-04-18`); browser JS
/// labels may drift in that case — acceptable tradeoff vs. caching at startup. /// re-labels the current/previous UTC date to "Heute"/"Gestern". Note:
fn local_offset_or_utc() -> UtcOffset { /// grouping is by UTC day, which may drift from the doctor's local-day
UtcOffset::current_local_offset().unwrap_or(UtcOffset::UTC) /// 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> {
/// Group cases (already sorted newest-first) into date buckets. Label is let today = OffsetDateTime::now_utc().date();
/// "Heute" / "Gestern" / ISO date.
fn group_by_local_date(cases: Vec<UserCaseView>) -> Vec<DateGroup> {
let offset = local_offset_or_utc();
let today = OffsetDateTime::now_utc().to_offset(offset).date();
let yesterday = today.previous_day();
let mut groups: Vec<DateGroup> = Vec::new(); let mut groups: Vec<DateGroup> = Vec::new();
for case in cases { for case in cases {
let d = local_date_of(&case.most_recent, offset).unwrap_or(today); let d = utc_date_and_iso_of(&case.most_recent)
let label = if d == today { .map(|(d, _)| d)
"Heute".to_string() .unwrap_or(today);
} else if Some(d) == yesterday { let label = d.to_string();
"Gestern".to_string()
} else {
d.to_string()
};
match groups.last_mut() { match groups.last_mut() {
Some(g) if g.label == label => g.cases.push(case), Some(g) if g.label == label => g.cases.push(case),
_ => groups.push(DateGroup { _ => groups.push(DateGroup {
@@ -87,7 +83,9 @@ fn group_by_local_date(cases: Vec<UserCaseView>) -> Vec<DateGroup> {
/// Extract `HH:MM` from a recording filename of the form /// Extract `HH:MM` from a recording filename of the form
/// `YYYY-MM-DDTHH-MM-SSZ.m4a[.failed]`. Returns `""` on any mismatch. /// `YYYY-MM-DDTHH-MM-SSZ.m4a[.failed]`. Returns `""` on any mismatch.
fn extract_hhmm(filename: &str) -> String { /// Used only as a no-JS fallback — the canonical value rendered in the
/// browser comes from the `<time datetime>`-based script.
fn extract_hhmm_utc(filename: &str) -> String {
let (Some(h), Some(m)) = (filename.get(11..13), filename.get(14..16)) else { let (Some(h), Some(m)) = (filename.get(11..13), filename.get(14..16)) else {
return String::new(); return String::new();
}; };
@@ -98,6 +96,14 @@ fn extract_hhmm(filename: &str) -> String {
} }
} }
/// Build an RFC3339 UTC timestamp string from a recording filename.
/// Returns empty string on malformed input.
fn recorded_at_iso_of(filename: &str) -> String {
utc_date_and_iso_of(filename)
.map(|(_, iso)| iso)
.unwrap_or_default()
}
struct DateGroup { struct DateGroup {
/// "Heute", "Gestern", or ISO date "YYYY-MM-DD". /// "Heute", "Gestern", or ISO date "YYYY-MM-DD".
label: String, label: String,
@@ -232,7 +238,7 @@ pub async fn handle_my_cases(
let busy = analyze_busy.0.load(Ordering::Acquire); let busy = analyze_busy.0.load(Ordering::Acquire);
let cases = scan_user_cases(&config, &user.slug, busy).await; let cases = scan_user_cases(&config, &user.slug, busy).await;
let total = cases.len(); let total = cases.len();
let groups = group_by_local_date(cases); let groups = group_by_utc_date(cases);
let undo_count = crate::routes::case_actions::summarize_latest_batch(&user_root) let undo_count = crate::routes::case_actions::summarize_latest_batch(&user_root)
.await .await
.map(|(_, n)| n) .map(|(_, n)| n)
@@ -387,11 +393,13 @@ async fn scan_user_cases(config: &Config, slug: &str, worker_busy: bool) -> Vec<
!has_document && worker_busy && any_analysis_input_exists(&case_path).await; !has_document && worker_busy && any_analysis_input_exists(&case_path).await;
let can_analyze = !analyzing && all_transcribed && llm_configured; let can_analyze = !analyzing && all_transcribed && llm_configured;
let time_hms = extract_hhmm(&most_recent); let time_hms_utc = extract_hhmm_utc(&most_recent);
let recorded_at_iso = recorded_at_iso_of(&most_recent);
cases.push(UserCaseView { cases.push(UserCaseView {
case_id, case_id,
most_recent, most_recent,
time_hms, time_hms_utc,
recorded_at_iso,
oneliner, oneliner,
recordings_count, recordings_count,
analyzing, analyzing,
+35 -2
View File
@@ -61,14 +61,14 @@ section h2 { font-size: 1.1em; color: #555; border-bottom: 1px solid #ddd; paddi
<form method="post" action="/web/cases/bulk"> <form method="post" action="/web/cases/bulk">
{% for g in groups %} {% for g in groups %}
<section> <section>
<h2>{{ g.label }} ({{ g.cases.len() }})</h2> <h2 class="date-group" data-iso="{{ g.label }}">{{ g.label }} ({{ g.cases.len() }})</h2>
<ul class="case-list"> <ul class="case-list">
{% for case in g.cases %} {% for case in g.cases %}
<li class="case-row {% if case.has_document %}done{% else %}open{% endif %}"> <li class="case-row {% if case.has_document %}done{% else %}open{% endif %}">
<div class="check"><input type="checkbox" name="case_id" value="{{ case.case_id }}"></div> <div class="check"><input type="checkbox" name="case_id" value="{{ case.case_id }}"></div>
<div class="body"> <div class="body">
{% if case.has_document %}<a href="/web/cases/{{ case.case_id }}/document">{% else %}<a href="/web/cases/{{ case.case_id }}">{% endif %} {% if case.has_document %}<a href="/web/cases/{{ case.case_id }}/document">{% else %}<a href="/web/cases/{{ case.case_id }}">{% endif %}
<div class="line1"><span class="time">{{ case.time_hms }}</span>{% match case.oneliner %}{% when Some with (t) %} — {{ t }}{% when None %} — <span class="empty">kein Oneliner</span>{% endmatch %}</div> <div class="line1"><span class="time"><time datetime="{{ case.recorded_at_iso }}">{{ case.time_hms_utc }}</time></span>{% match case.oneliner %}{% when Some with (t) %} — {{ t }}{% when None %} — <span class="empty">kein Oneliner</span>{% endmatch %}</div>
<div class="line2">{{ case.recordings_count }} Aufnahmen{% if case.has_document %} — <span class="status-badge done">ausgewertet</span>{% else %} — <span class="status-badge open">offen</span>{% endif %}{% if case.analyzing %} <span class="label analyzing">wird analysiert</span>{% endif %}</div> <div class="line2">{{ case.recordings_count }} Aufnahmen{% if case.has_document %} — <span class="status-badge done">ausgewertet</span>{% else %} — <span class="status-badge open">offen</span>{% endif %}{% if case.analyzing %} <span class="label analyzing">wird analysiert</span>{% endif %}</div>
{% if is_admin %}<div class="uuid">{{ case.case_id }}</div>{% endif %} {% if is_admin %}<div class="uuid">{{ case.case_id }}</div>{% endif %}
</a> </a>
@@ -92,5 +92,38 @@ section h2 { font-size: 1.1em; color: #555; border-bottom: 1px solid #ddd; paddi
</div> </div>
</form> </form>
{% endif %} {% endif %}
<script>
// Render all UTC timestamps in the browser's local timezone, and relabel
// date-group headings to Heute/Gestern based on the browser's current
// UTC date. Server-side renders only UTC so timezone logic belongs here.
(function () {
document.querySelectorAll('time[datetime]').forEach(function (el) {
var d = new Date(el.dateTime);
if (!isNaN(d.getTime())) {
el.textContent = d.toLocaleTimeString(undefined, {
hour: '2-digit',
minute: '2-digit',
hour12: false,
});
}
});
var todayISO = new Date().toISOString().slice(0, 10);
var yesterday = new Date();
yesterday.setUTCDate(yesterday.getUTCDate() - 1);
var yesterdayISO = yesterday.toISOString().slice(0, 10);
document.querySelectorAll('h2.date-group').forEach(function (h) {
var iso = h.dataset.iso;
if (!iso) return;
var suffix = h.textContent.replace(iso, '').trim();
var label;
if (iso === todayISO) label = 'Heute';
else if (iso === yesterdayISO) label = 'Gestern';
else label = iso;
h.textContent = label + ' ' + suffix;
});
})();
</script>
</body> </body>
</html> </html>
+54
View File
@@ -363,3 +363,57 @@ async fn multiple_cases_sorted_newest_first() {
let _ = std::fs::remove_dir_all(&dp); let _ = std::fs::remove_dir_all(&dp);
} }
/// Regression guard: if a case was first created long ago but got a
/// follow-up recording today, it must sort ahead of a case whose single
/// (older) recording happens to be newer than the first one. Sorting
/// must use `last_recording_at`, not `created_at`.
#[tokio::test]
async fn sorts_by_last_recording_not_created() {
let (config, dp) = test_config();
// Case A: first recording 20h ago (old created_at), second 1 min ago
// (new last_recording_at).
let case_a = "550e8400-e29b-41d4-a716-000000000020";
let dir_a = dp.join(TEST_SLUG).join(case_a);
tokio::fs::create_dir_all(&dir_a).await.unwrap();
let old_m4a = dir_a.join("2026-04-17T12-00-00Z.m4a");
tokio::fs::write(&old_m4a, b"x").await.unwrap();
filetime::set_file_mtime(
&old_m4a,
FileTime::from_system_time(SystemTime::now() - Duration::from_secs(20 * 3600)),
)
.unwrap();
let fresh_m4a = dir_a.join("2026-04-18T07-59-00Z.m4a");
tokio::fs::write(&fresh_m4a, b"x").await.unwrap();
filetime::set_file_mtime(
&fresh_m4a,
FileTime::from_system_time(SystemTime::now() - Duration::from_secs(60)),
)
.unwrap();
tokio::fs::write(dir_a.join("oneliner.txt"), "A (fresh addendum)")
.await
.unwrap();
// Case B: single recording 10h ago — created_at newer than A's, but
// last_recording_at older than A's fresh addendum.
seed_case(
&dp,
"550e8400-e29b-41d4-a716-000000000021",
Duration::from_secs(10 * 3600),
Some("B (single, middle-aged)"),
)
.await;
let app = doctate_server::create_router(config);
let body = body_json(
app.oneshot(get("/api/oneliners?hours=48")).await.unwrap(),
)
.await;
let arr = body["oneliners"].as_array().unwrap();
assert_eq!(arr.len(), 2);
assert_eq!(arr[0]["case_id"], case_a, "A must sort first");
assert!(arr[0]["last_recording_at"].is_string());
let _ = std::fs::remove_dir_all(&dp);
}