Files
doctate/doctate-client-core/src/snapshot_cache.rs
T
Brummel cdfa5ae90e 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.
2026-04-18 11:33:35 +02:00

170 lines
5.8 KiB
Rust

//! On-disk cache of the last successful `/api/oneliners` response.
//!
//! Survives app restarts so the UI has data before the first poll
//! completes. A single JSON file under a caller-chosen path. Atomic
//! writes via temp-file + rename. Read errors on malformed content are
//! treated the same as "no cache" — the next poll will rebuild it.
use std::path::{Path, PathBuf};
use doctate_common::oneliners::OnelinersResponse;
use serde::{Deserialize, Serialize};
use tracing::{debug, warn};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CachedSnapshot {
/// Last ETag we handed the server in `If-None-Match` — raw header
/// value, including the `W/"..."` wrapper.
pub etag: String,
/// RFC3339 UTC; when the client received the response. Feeds the
/// staleness indicator on cold start.
pub fetched_at: String,
pub response: OnelinersResponse,
}
#[derive(Clone)]
pub struct SnapshotCache {
path: PathBuf,
}
impl SnapshotCache {
pub fn new(path: PathBuf) -> Self {
Self { path }
}
pub fn path(&self) -> &Path {
&self.path
}
/// Read and deserialize. `None` on missing file, unreadable file, or
/// parse error — the caller treats all three as "cold start".
pub async fn load(&self) -> Option<CachedSnapshot> {
let bytes = match tokio::fs::read(&self.path).await {
Ok(b) => b,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return None,
Err(e) => {
warn!(path = %self.path.display(), error = %e, "snapshot cache read failed");
return None;
}
};
match serde_json::from_slice::<CachedSnapshot>(&bytes) {
Ok(c) => Some(c),
Err(e) => {
warn!(path = %self.path.display(), error = %e, "snapshot cache malformed — ignoring");
None
}
}
}
/// Persist atomically: write to `{path}.tmp`, then rename.
pub async fn store(&self, cached: &CachedSnapshot) -> std::io::Result<()> {
if let Some(parent) = self.path.parent() {
tokio::fs::create_dir_all(parent).await?;
}
let tmp = self.path.with_extension(tmp_extension(&self.path));
let bytes = serde_json::to_vec(cached)
.map_err(|e| std::io::Error::other(format!("serialize cached snapshot: {e}")))?;
tokio::fs::write(&tmp, bytes).await?;
tokio::fs::rename(&tmp, &self.path).await?;
debug!(path = %self.path.display(), "snapshot cache written");
Ok(())
}
/// Delete the cache file. Idempotent: missing file is success.
pub async fn clear(&self) -> std::io::Result<()> {
match tokio::fs::remove_file(&self.path).await {
Ok(_) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e),
}
}
}
/// Build a `{original_extension}.tmp`-style extension that the rename
/// target never accidentally matches. For `foo.json` → `json.tmp`.
fn tmp_extension(path: &Path) -> String {
match path.extension().and_then(|s| s.to_str()) {
Some(ext) => format!("{ext}.tmp"),
None => "tmp".to_owned(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use doctate_common::oneliners::OnelinerEntry;
use tempfile::TempDir;
fn sample() -> CachedSnapshot {
CachedSnapshot {
etag: "W/\"1729512345-72\"".into(),
fetched_at: "2026-04-18T10:45:00Z".into(),
response: OnelinersResponse {
as_of: "2026-04-18T10:45:00Z".into(),
window_hours: 72,
oneliners: vec![OnelinerEntry {
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()),
}],
},
}
}
#[tokio::test]
async fn store_and_load_roundtrip() {
let tmp = TempDir::new().unwrap();
let cache = SnapshotCache::new(tmp.path().join("cache.json"));
let s = sample();
cache.store(&s).await.unwrap();
let loaded = cache.load().await.expect("cache present");
assert_eq!(loaded.etag, s.etag);
assert_eq!(loaded.response.oneliners.len(), 1);
assert_eq!(loaded.response.oneliners[0].oneliner.as_deref(), Some("Kniegelenk"));
}
#[tokio::test]
async fn load_returns_none_when_missing() {
let tmp = TempDir::new().unwrap();
let cache = SnapshotCache::new(tmp.path().join("missing.json"));
assert!(cache.load().await.is_none());
}
#[tokio::test]
async fn load_returns_none_on_garbage() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("garbage.json");
tokio::fs::write(&path, b"this is not json").await.unwrap();
let cache = SnapshotCache::new(path);
assert!(cache.load().await.is_none());
}
#[tokio::test]
async fn clear_is_idempotent() {
let tmp = TempDir::new().unwrap();
let cache = SnapshotCache::new(tmp.path().join("cache.json"));
cache.clear().await.unwrap();
cache.store(&sample()).await.unwrap();
assert!(cache.load().await.is_some());
cache.clear().await.unwrap();
assert!(cache.load().await.is_none());
// Second clear on already-empty is still Ok.
cache.clear().await.unwrap();
}
#[tokio::test]
async fn store_creates_parent_dirs() {
let tmp = TempDir::new().unwrap();
let deep = tmp.path().join("a/b/c/cache.json");
let cache = SnapshotCache::new(deep.clone());
cache.store(&sample()).await.unwrap();
assert!(deep.exists());
}
}