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 `