From cdfa5ae90ef68933b8131ab999a29e5b48161935 Mon Sep 17 00:00:00 2001 From: Brummel Date: Sat, 18 Apr 2026 11:33:35 +0200 Subject: [PATCH] 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. --- CLAUDE.md | 1 + doctate-client-core/src/case_store.rs | 62 +++++++++++++++++- doctate-client-core/src/oneliner_poller.rs | 1 + doctate-client-core/src/snapshot_cache.rs | 1 + doctate-common/src/oneliners.rs | 15 ++++- doctate-common/src/timestamp.rs | 27 ++++++-- server/src/main.rs | 21 +++++- server/src/routes/oneliners.rs | 47 +++++++++---- server/src/routes/user_web.rs | 76 ++++++++++++---------- server/templates/my_cases.html | 37 ++++++++++- server/tests/oneliners_api_test.rs | 54 +++++++++++++++ 11 files changed, 286 insertions(+), 56 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 87d4feb..7b85738 100755 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,6 +12,7 @@ ## 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. * 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` + lokales `downcast_ref` gegenüber Deep Copies via `Rc::new(obj.clone())`. diff --git a/doctate-client-core/src/case_store.rs b/doctate-client-core/src/case_store.rs index cdbf862..be205e2 100644 --- a/doctate-client-core/src/case_store.rs +++ b/doctate-client-core/src/case_store.rs @@ -144,6 +144,12 @@ impl CaseStore { /// snapshot: insert (with `synced_to_server = true`) or update the /// local marker (oneliner + last_activity_at from server timestamps, /// 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( &self, snapshot: &OnelinersResponse, @@ -155,8 +161,9 @@ impl CaseStore { continue; }; let server_activity = entry - .updated_at + .last_recording_at .clone() + .or_else(|| entry.updated_at.clone()) .unwrap_or_else(|| entry.created_at.clone()); let updated_marker = match state.get(&case_id) { @@ -308,6 +315,7 @@ mod tests { case_id: case_id.to_string(), oneliner: oneliner.map(str::to_owned), created_at: created.to_owned(), + last_recording_at: Some(created.to_owned()), updated_at: Some(created.to_owned()), } } @@ -486,6 +494,58 @@ mod tests { 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] async fn sorted_newest_first() { let tmp = TempDir::new().unwrap(); diff --git a/doctate-client-core/src/oneliner_poller.rs b/doctate-client-core/src/oneliner_poller.rs index eb4956e..bf4e44c 100644 --- a/doctate-client-core/src/oneliner_poller.rs +++ b/doctate-client-core/src/oneliner_poller.rs @@ -282,6 +282,7 @@ mod tests { case_id: case_id.into(), oneliner: oneliner.map(str::to_owned), 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()), }], } diff --git a/doctate-client-core/src/snapshot_cache.rs b/doctate-client-core/src/snapshot_cache.rs index 508e8e1..ef0800f 100644 --- a/doctate-client-core/src/snapshot_cache.rs +++ b/doctate-client-core/src/snapshot_cache.rs @@ -106,6 +106,7 @@ mod tests { case_id: "550e8400-e29b-41d4-a716-446655440000".into(), oneliner: Some("Kniegelenk".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()), }], }, diff --git a/doctate-common/src/oneliners.rs b/doctate-common/src/oneliners.rs index 83d2e55..067f339 100644 --- a/doctate-common/src/oneliners.rs +++ b/doctate-common/src/oneliners.rs @@ -21,12 +21,23 @@ pub struct OnelinersResponse { pub oneliners: Vec, } -/// One case summary. `oneliner` is `None` while the case is still -/// transcribing / generating; `updated_at` is `None` for the same reason. +/// One case summary. +/// +/// - `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)] pub struct OnelinerEntry { pub case_id: String, pub oneliner: Option, pub created_at: String, + pub last_recording_at: Option, pub updated_at: Option, } diff --git a/doctate-common/src/timestamp.rs b/doctate-common/src/timestamp.rs index 889da7b..1f5b385 100644 --- a/doctate-common/src/timestamp.rs +++ b/doctate-common/src/timestamp.rs @@ -1,12 +1,16 @@ use time::OffsetDateTime; use time::format_description::well_known::Rfc3339; -/// Current UTC time as an RFC3339 string (e.g. `2026-04-15T10:32:00.123Z`). -/// -/// Formatting an `OffsetDateTime` as RFC3339 is infallible — every required -/// component is always present — so we panic on error rather than propagate. +/// Current UTC time as an RFC3339 string with second precision, e.g. +/// `2026-04-15T10:32:00Z`. Subsecond fractions are deliberately stripped +/// so every caller produces the same filename shape — the server's +/// 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 { OffsetDateTime::now_utc() + .replace_nanosecond(0) + .expect("0 is always a valid nanosecond value") .format(&Rfc3339) .expect("RFC3339 formatting of OffsetDateTime is infallible") } @@ -69,4 +73,19 @@ mod tests { assert!(now.starts_with("20"), "unexpected year prefix: {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 `