Refactor oneliner storage to use enum

The oneliner is now stored as `OnelinerState` enum which can represent
three states: `Ready`, `Empty` or `Error`. This allows the client to
differentiate between a case with no medical content and a case where
the oneliner generation failed.

The `OnelinerState` enum is serialized to JSON and stored in
`oneliner.json` file. The `OnelinerEntry` struct has been updated to
reflect this change.

The client-desktop application has been updated to handle the new
`OnelinerState` enum and display appropriate UI elements for each state.

The server-side code has also been updated to read and write the
`OnelinerState` enum, and to handle the new file format.
The `reset_case_artefacts` function in
`server/src/routes/case_actions.rs` has been updated to remove
`oneliner.txt` and create `oneliner.json` instead.

The tests have been updated to reflect these changes.
This commit is contained in:
2026-04-20 12:37:48 +02:00
parent 224ee60363
commit d65720c671
15 changed files with 440 additions and 125 deletions
+29 -12
View File
@@ -22,7 +22,7 @@ use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use doctate_common::oneliners::OnelinersResponse;
use doctate_common::oneliners::{OnelinerState, OnelinersResponse};
use serde::{Deserialize, Serialize};
use thiserror::Error;
use time::format_description::well_known::Rfc3339;
@@ -48,9 +48,12 @@ pub struct CaseMarker {
/// (`= false`) markers guard unsent uploads and are never touched by
/// either — losing one would silently erase a patient recording.
pub synced_to_server: bool,
/// `None` while transcription / oneliner generation is pending; set
/// from the server snapshot when available.
pub oneliner: Option<String>,
/// Persisted oneliner state mirrored from the server snapshot.
/// `None` while no state file has been written server-side yet (fresh
/// case, nothing transcribed). The variant distinguishes usable text
/// from "no medical content" and "generation failed" — clients render
/// each case as they see fit.
pub oneliner: Option<OnelinerState>,
}
#[derive(Debug, Error)]
@@ -443,10 +446,24 @@ mod tests {
OffsetDateTime::parse(s, &Rfc3339).unwrap()
}
fn ready(text: &str) -> OnelinerState {
OnelinerState::Ready {
text: text.to_owned(),
generated_at: "2026-04-18T10:00:00Z".into(),
}
}
fn text_of(o: &Option<OnelinerState>) -> Option<&str> {
match o {
Some(OnelinerState::Ready { text, .. }) => Some(text.as_str()),
_ => None,
}
}
fn server_entry(case_id: Uuid, oneliner: Option<&str>, created: &str) -> OnelinerEntry {
OnelinerEntry {
case_id: case_id.to_string(),
oneliner: oneliner.map(str::to_owned),
oneliner: oneliner.map(ready),
created_at: created.to_owned(),
last_recording_at: Some(created.to_owned()),
updated_at: Some(created.to_owned()),
@@ -535,7 +552,7 @@ mod tests {
let list = store.list().await;
assert_eq!(list.len(), 1);
assert_eq!(list[0].case_id, id);
assert_eq!(list[0].oneliner.as_deref(), Some("Kniegelenk re."));
assert_eq!(text_of(&list[0].oneliner), Some("Kniegelenk re."));
assert!(list[0].synced_to_server);
}
@@ -559,7 +576,7 @@ mod tests {
let list = store.list().await;
assert_eq!(list.len(), 1);
assert!(list[0].synced_to_server);
assert_eq!(list[0].oneliner.as_deref(), Some("Hypertonie"));
assert_eq!(text_of(&list[0].oneliner), Some("Hypertonie"));
}
#[tokio::test]
@@ -604,7 +621,7 @@ mod tests {
let list = store.list().await;
assert_eq!(list.len(), 1);
assert!(list[0].synced_to_server);
assert_eq!(list[0].oneliner.as_deref(), Some("old"));
assert_eq!(text_of(&list[0].oneliner), Some("old"));
}
#[tokio::test]
@@ -670,7 +687,7 @@ mod tests {
// oneliner regenerated just now in a recovery scan.
let entry = OnelinerEntry {
case_id: id.to_string(),
oneliner: Some("x".into()),
oneliner: Some(ready("x")),
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()),
@@ -694,7 +711,7 @@ mod tests {
let id = Uuid::new_v4();
let entry = OnelinerEntry {
case_id: id.to_string(),
oneliner: Some("x".into()),
oneliner: Some(ready("x")),
created_at: "2026-04-17T09:00:00Z".into(),
last_recording_at: None,
updated_at: Some("2026-04-18T10:00:00Z".into()),
@@ -724,7 +741,7 @@ mod tests {
store
.merge_server_snapshot(&server_snapshot(vec![OnelinerEntry {
case_id: old_synced.to_string(),
oneliner: Some("old".into()),
oneliner: Some(ready("old")),
created_at: "2026-04-14T10:00:00Z".into(),
last_recording_at: Some("2026-04-14T10:00:00Z".into()),
updated_at: Some("2026-04-14T10:00:00Z".into()),
@@ -740,7 +757,7 @@ mod tests {
store
.merge_server_snapshot(&server_snapshot(vec![OnelinerEntry {
case_id: young_synced.to_string(),
oneliner: Some("young".into()),
oneliner: Some(ready("young")),
created_at: "2026-04-18T10:00:00Z".into(),
last_recording_at: Some("2026-04-18T10:00:00Z".into()),
updated_at: Some("2026-04-18T10:00:00Z".into()),
+6 -1
View File
@@ -515,12 +515,17 @@ mod tests {
}
fn oneliner_body(case_id: &str, oneliner: Option<&str>) -> OnelinersResponse {
use doctate_common::oneliners::OnelinerState;
let state = oneliner.map(|t| OnelinerState::Ready {
text: t.to_owned(),
generated_at: "2026-04-18T10:00:00Z".into(),
});
OnelinersResponse {
as_of: "2026-04-18T10:00:00Z".into(),
window_hours: 72,
oneliners: vec![OnelinerEntry {
case_id: case_id.into(),
oneliner: oneliner.map(str::to_owned),
oneliner: state,
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()),
+10 -6
View File
@@ -92,7 +92,7 @@ fn tmp_extension(path: &Path) -> String {
#[cfg(test)]
mod tests {
use super::*;
use doctate_common::oneliners::OnelinerEntry;
use doctate_common::oneliners::{OnelinerEntry, OnelinerState};
use tempfile::TempDir;
fn sample() -> CachedSnapshot {
@@ -104,7 +104,10 @@ mod tests {
window_hours: 72,
oneliners: vec![OnelinerEntry {
case_id: "550e8400-e29b-41d4-a716-446655440000".into(),
oneliner: Some("Kniegelenk".into()),
oneliner: Some(OnelinerState::Ready {
text: "Kniegelenk".into(),
generated_at: "2026-04-18T10:30: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()),
@@ -123,10 +126,11 @@ mod tests {
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")
);
let text = match &loaded.response.oneliners[0].oneliner {
Some(OnelinerState::Ready { text, .. }) => Some(text.as_str()),
_ => None,
};
assert_eq!(text, Some("Kniegelenk"));
}
#[tokio::test]