diff --git a/client-desktop/src/app.rs b/client-desktop/src/app.rs index 3685ebd..56b721b 100644 --- a/client-desktop/src/app.rs +++ b/client-desktop/src/app.rs @@ -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, + + /// Active inline tap-to-edit on a oneliner row. `None` means + /// every row renders in read mode. + oneliner_edit: Option, + /// 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, + save_result_rx: mpsc::UnboundedReceiver, + /// Toast banner shown until the user dismisses it; populated when + /// a `put_oneliner` returns an error. + last_save_error: Option, } /// 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, +} + 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 { + fn render_case_list(&mut self, ui: &mut egui::Ui) -> Option { 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 = 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| { diff --git a/doctate-client-core/src/case_store.rs b/doctate-client-core/src/case_store.rs index 5c81e1c..de622ec 100644 --- a/doctate-client-core/src/case_store.rs +++ b/doctate-client-core/src/case_store.rs @@ -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] diff --git a/doctate-client-core/src/case_update.rs b/doctate-client-core/src/case_update.rs index 950a209..42efd98 100644 --- a/doctate-client-core/src/case_update.rs +++ b/doctate-client-core/src/case_update.rs @@ -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");