feat: Add inline oneliner editing

Introduces inline editing for oneliner text within the case list. This
feature allows users to directly modify oneliner entries in the UI, with
changes being optimistically applied locally and then asynchronously
sent to the server.

Includes:
- New `OnelinerEditState` to manage the editing buffer and focus.
- `SaveResult` struct to communicate the outcome of background save
  operations.
- UI logic to toggle between read and edit modes for oneliner entries.
- Handling of keyboard inputs (Enter, Esc) and button clicks for saving
  or canceling edits.
- Toast banner to display save errors to the user.
- Asynchronous `put_oneliner` calls for background server updates.
- Local storage update for optimistic UI updates.
This commit is contained in:
2026-04-26 16:35:14 +02:00
parent d5e6c334c9
commit d732fdd8ec
3 changed files with 254 additions and 24 deletions
+238 -19
View File
@@ -15,8 +15,9 @@ use std::time::{Duration, Instant};
use doctate_client_core::{
CaseMarker, CaseStore, DEFAULT_MARKER_RETENTION, DEFAULT_ORPHAN_AUDIO_RETENTION, FooterStatus,
PendingUpload, ServerSync, SnapshotCache, SyncConfig, UploadEvent, WorkerSnapshot, WorkerState,
pick_footer_status, run_startup_cleanup, write_sidecar,
PendingUpload, PutOnelinerError, ServerSync, SnapshotCache, SyncConfig, UploadEvent,
WorkerSnapshot, WorkerState, pick_footer_status, put_oneliner, run_startup_cleanup,
write_sidecar,
};
use doctate_common::join_url;
use doctate_common::oneliners::OnelinerState;
@@ -57,6 +58,17 @@ pub struct DoctateApp {
/// still need `case_id` + `recorded_at` when the recorder emits
/// `Finished`. `None` when no flush is in progress.
finalizing: Option<RecordingContext>,
/// Active inline tap-to-edit on a oneliner row. `None` means
/// every row renders in read mode.
oneliner_edit: Option<OnelinerEditState>,
/// Channel for results coming back from background `put_oneliner`
/// spawns. The receiver is drained at the start of every frame.
save_result_tx: mpsc::UnboundedSender<SaveResult>,
save_result_rx: mpsc::UnboundedReceiver<SaveResult>,
/// Toast banner shown until the user dismisses it; populated when
/// a `put_oneliner` returns an error.
last_save_error: Option<String>,
}
/// Enough recording identity to finalize a pending flush or emit an
@@ -67,6 +79,25 @@ struct RecordingContext {
recorded_at: String,
}
/// Per-case oneliner edit buffer. Only one case is in edit mode at a
/// time; clicking another case (or pressing Esc) clears this. The
/// `focus_pending` flag drives a one-shot `request_focus()` on the
/// first render after the edit starts, so the user lands in the
/// TextEdit without a second click.
struct OnelinerEditState {
case_id: Uuid,
draft: String,
focus_pending: bool,
}
/// Result of a background `put_oneliner` request, posted back to the
/// UI thread via mpsc so the next `update()` frame can surface
/// success/failure to the user.
struct SaveResult {
case_id: Uuid,
outcome: Result<doctate_common::oneliners::OnelinerState, PutOnelinerError>,
}
enum UiAction {
SaveConfig,
StartNew,
@@ -74,6 +105,10 @@ enum UiAction {
OpenWeb(Uuid),
Stop,
DismissError,
StartOnelinerEdit { case_id: Uuid, current_text: String },
CancelOnelinerEdit,
SaveOneliner { case_id: Uuid, text: String },
DismissSaveError,
}
impl DoctateApp {
@@ -137,6 +172,8 @@ impl DoctateApp {
(AppState::NotConfigured, None, None, None)
};
let (save_result_tx, save_result_rx) = mpsc::unbounded_channel();
Self {
runtime,
config,
@@ -154,6 +191,10 @@ impl DoctateApp {
worker_rx,
upload_events,
finalizing: None,
oneliner_edit: None,
save_result_tx,
save_result_rx,
last_save_error: None,
}
}
@@ -489,7 +530,7 @@ impl DoctateApp {
}
}
fn render_case_list(&self, ui: &mut egui::Ui) -> Option<UiAction> {
fn render_case_list(&mut self, ui: &mut egui::Ui) -> Option<UiAction> {
let snapshot = self.snapshot_rx.borrow().clone();
// Snapshot is already server-filtered — the server's `window_hours`
// per user (users.toml) is the single source of truth.
@@ -520,24 +561,87 @@ impl DoctateApp {
ui.add_space(4.0);
let time_str = extract_hhmm(&marker.last_activity_at);
let oneliner = match &marker.oneliner {
Some(
OnelinerState::Ready { text, .. } | OnelinerState::Manual { text, .. },
) => text.clone(),
Some(OnelinerState::Empty { .. }) => "".to_owned(),
Some(OnelinerState::Error { .. }) => "".to_owned(),
None => "".to_owned(),
};
let has_pending = pending_ids.contains(&marker.case_id);
let line = if has_pending {
format!("{time_str} · {oneliner}")
let in_edit_mode = self
.oneliner_edit
.as_ref()
.is_some_and(|e| e.case_id == marker.case_id);
if in_edit_mode {
// Inline edit: TextEdit + ✓/✗. Enter saves, Esc cancels.
let edit = self.oneliner_edit.as_mut().unwrap();
ui.horizontal(|ui| {
ui.label(format!("{time_str} ·"));
let response = ui
.add(egui::TextEdit::singleline(&mut edit.draft).desired_width(280.0));
if edit.focus_pending {
response.request_focus();
edit.focus_pending = false;
}
let enter_pressed =
response.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
let save_clicked = ui.button("").clicked();
let cancel_clicked = ui.button("").clicked();
let esc_pressed = ui.input(|i| i.key_pressed(egui::Key::Escape));
if save_clicked || enter_pressed {
action = Some(UiAction::SaveOneliner {
case_id: marker.case_id,
text: edit.draft.clone(),
});
} else if cancel_clicked || esc_pressed {
action = Some(UiAction::CancelOnelinerEdit);
}
});
} else {
format!("{time_str} · {oneliner}")
};
if has_pending {
ui.colored_label(egui::Color32::from_rgb(200, 140, 0), line);
} else {
ui.label(line);
// Read mode: clickable label, plus a green pencil
// for Manual variant so the doctor sees their
// override is the source of truth here.
let oneliner_text = match &marker.oneliner {
Some(
OnelinerState::Ready { text, .. } | OnelinerState::Manual { text, .. },
) => text.clone(),
Some(OnelinerState::Empty { .. }) => "".to_owned(),
Some(OnelinerState::Error { .. }) => "".to_owned(),
None => "".to_owned(),
};
let line = if has_pending {
format!("{time_str} · {oneliner_text}")
} else {
format!("{time_str} · {oneliner_text}")
};
ui.horizontal(|ui| {
let label = if has_pending {
egui::Label::new(
egui::RichText::new(line)
.color(egui::Color32::from_rgb(200, 140, 0)),
)
.sense(egui::Sense::click())
} else {
egui::Label::new(line).sense(egui::Sense::click())
};
let resp = ui.add(label);
if matches!(marker.oneliner, Some(OnelinerState::Manual { .. })) {
ui.colored_label(egui::Color32::from_rgb(0, 110, 0), "");
}
if resp.clicked() {
// Pre-fill the editor with the visible text
// (Ready/Manual). For Empty/Error/None start
// with an empty buffer so the doctor types
// from scratch.
let current = match &marker.oneliner {
Some(
OnelinerState::Ready { text, .. }
| OnelinerState::Manual { text, .. },
) => text.clone(),
_ => String::new(),
};
action = Some(UiAction::StartOnelinerEdit {
case_id: marker.case_id,
current_text: current,
});
}
});
}
ui.horizontal(|ui| {
@@ -623,6 +727,88 @@ impl DoctateApp {
UiAction::OpenWeb(id) => self.on_open_web(id),
UiAction::Stop => self.on_stop(),
UiAction::DismissError => self.state = AppState::Idle,
UiAction::StartOnelinerEdit {
case_id,
current_text,
} => {
self.oneliner_edit = Some(OnelinerEditState {
case_id,
draft: current_text,
focus_pending: true,
});
}
UiAction::CancelOnelinerEdit => {
self.oneliner_edit = None;
}
UiAction::SaveOneliner { case_id, text } => {
self.on_save_oneliner(case_id, text);
}
UiAction::DismissSaveError => {
self.last_save_error = None;
}
}
}
/// Optimistic write + background PUT. Local state is updated
/// immediately so the new text appears on the next frame; the
/// server round-trip runs in a spawned task and reports back via
/// `save_result_tx`.
fn on_save_oneliner(&mut self, case_id: Uuid, text: String) {
let trimmed = text.trim().to_owned();
if trimmed.is_empty() {
// Empty payload would be rejected by the server (400) and
// is also a no-op on the local cache. Treat as cancel.
self.oneliner_edit = None;
return;
}
let local_state = OnelinerState::Manual {
text: trimmed.clone(),
set_at: now_rfc3339(),
};
let store = self.case_store.clone();
let local_clone = local_state.clone();
// Block briefly on the local write — single tokio::fs::write +
// map insert, microseconds in practice. Keeps the UI strictly
// monotonic: by the next render frame the new text is visible.
self.runtime.block_on(async move {
if let Err(e) = store.set_oneliner_local(case_id, local_clone).await {
warn!(case_id = %case_id, error = %e, "local set_oneliner failed");
}
});
self.oneliner_edit = None;
// Background PUT. On failure the next drain_save_results
// surfaces the error; on success no further work is needed
// because the local state is already correct.
let Some(cfg) = self.config.clone() else {
warn!("save_oneliner without config — should not happen");
return;
};
let http = self.http_client.clone();
let tx = self.save_result_tx.clone();
self.runtime.spawn(async move {
let outcome =
put_oneliner(&http, &cfg.server_url, &cfg.api_key, case_id, &trimmed).await;
let _ = tx.send(SaveResult { case_id, outcome });
});
}
fn drain_save_results(&mut self) {
while let Ok(result) = self.save_result_rx.try_recv() {
match result.outcome {
Ok(_) => {
info!(case_id = %result.case_id, "oneliner save succeeded");
}
Err(e) => {
error!(case_id = %result.case_id, error = ?e, "oneliner save failed");
self.last_save_error = Some(format!(
"Oneliner für Fall {} konnte nicht gespeichert werden: {}",
short_case_id(&result.case_id),
user_error_message(&e),
));
}
}
}
}
@@ -660,13 +846,46 @@ fn spawn_worker(
(sync, events_rx, snapshot_rx)
}
/// Short prefix of a `Uuid` for human-readable error messages.
fn short_case_id(id: &Uuid) -> String {
id.to_string().chars().take(8).collect()
}
/// Map a `PutOnelinerError` to a German user-facing one-liner.
fn user_error_message(e: &PutOnelinerError) -> String {
match e {
PutOnelinerError::Terminal { status, .. } => {
format!("Server-Fehler {status} — bitte Konfiguration prüfen.")
}
PutOnelinerError::Transient(_) => "Netzwerk-Fehler — bitte erneut versuchen.".to_string(),
PutOnelinerError::Parse(_) => "Antwort vom Server unverständlich.".to_string(),
}
}
impl eframe::App for DoctateApp {
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
self.drain_recorder_events();
self.drain_upload_events();
self.drain_save_results();
let mut pending_action: Option<UiAction> = None;
// Toast banner for the last save failure. Sits above the
// header so the user can still navigate the rest of the app.
if let Some(msg) = self.last_save_error.clone() {
egui::TopBottomPanel::top("save-error-banner").show(ctx, |ui| {
ui.add_space(4.0);
ui.horizontal(|ui| {
ui.colored_label(egui::Color32::DARK_RED, "");
ui.label(msg);
if ui.button("").clicked() {
pending_action = Some(UiAction::DismissSaveError);
}
});
ui.add_space(4.0);
});
}
egui::TopBottomPanel::top("header").show(ctx, |ui| {
ui.add_space(4.0);
ui.vertical_centered(|ui| {
+4 -1
View File
@@ -1098,7 +1098,10 @@ mod tests {
let path = tmp.path().join(format!("{id}.json"));
let bytes = tokio::fs::read(&path).await.unwrap();
let on_disk: CaseMarker = serde_json::from_slice(&bytes).unwrap();
assert!(matches!(on_disk.oneliner, Some(OnelinerState::Manual { .. })));
assert!(matches!(
on_disk.oneliner,
Some(OnelinerState::Manual { .. })
));
}
#[tokio::test]
+12 -4
View File
@@ -71,7 +71,9 @@ pub async fn put_oneliner(
}
} else if status.as_u16() == 408 || status.as_u16() == 429 || status.is_server_error() {
let body = resp.text().await.unwrap_or_default();
Err(PutOnelinerError::Transient(format!("HTTP {status}: {body}")))
Err(PutOnelinerError::Transient(format!(
"HTTP {status}: {body}"
)))
} else {
let body = resp.text().await.unwrap_or_default();
Err(PutOnelinerError::Terminal {
@@ -115,9 +117,15 @@ mod tests {
let http = reqwest::Client::new();
let case_id = Uuid::new_v4();
let result = put_oneliner(&http, &server.uri(), TEST_API_KEY, case_id, "55 J., Knieschmerz")
.await
.expect("ok");
let result = put_oneliner(
&http,
&server.uri(),
TEST_API_KEY,
case_id,
"55 J., Knieschmerz",
)
.await
.expect("ok");
match result {
OnelinerState::Manual { text, set_at } => {
assert_eq!(text, "55 J., Knieschmerz");