Files
doctate/clients/desktop/src/app.rs
T
Brummel a142725a8a fix(desktop): keep recordings on hard upload errors, reap ghost markers
Mirror of the 2026-05-05 Wear-OS fix: port the canonical client sync
spec (project_client_sync_semantics.md) into the Rust desktop client.

Before this change, server_sync::run_loop had a `UploadOutcome::Terminal`
branch that deleted the m4a + sidecar pair on any non-2xx + non-5xx
response (401, 413, 415, parse drift, audio read failures) and never
called mark_synced — so the marker stayed `synced_to_server=false` as a
non-removable ghost in the desktop UI. Same shape as the Wear-OS bug.

Changes:
- `UploadOutcome` reduced to two variants (`Succeeded` | `Transient`).
  The Terminal arm in `run_loop` is gone; `delete_pair` only fires from
  the success path.
- `post_upload` now classifies every failure mode as `Transient`:
  4xx incl. 401, parse-ack drift on 200, file-read errors, multipart
  build errors, and network errors. Backoff retries forever.
- New `CaseStore::cleanup_orphaned_unsynced(pending_case_ids, cutoff)`:
  removes markers that are unsynced AND have no waiting upload pair AND
  are older than a small grace window. Self-heals pre-spec ghosts.
- `run_startup_cleanup` gains a third sweep that calls
  `cleanup_orphaned_unsynced` with `DEFAULT_ORPHAN_MARKER_GRACE = 5 min`,
  matching the Wear-OS StartupCleanup contract.
- Tests inverted/added: `post_upload_transient_on_401`,
  `post_upload_transient_on_2xx_unparseable_body`,
  `former_terminal_401_keeps_pair_and_retries`, four new
  `cleanup_orphaned_unsynced_*` cases, plus `sweeps_orphan_unsynced_marker`
  and `keeps_unsynced_marker_with_pending_pair` integration tests.

Followup (out of scope): `UploadEvent::Failed.will_retry` can now only
be `true` and is vestigial. Removing it touches every Failed-event
consumer in app.rs/UI; left for a separate cleanup commit.
2026-05-05 10:21:33 +02:00

947 lines
34 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The egui application: config panel, record/stop controls, case list.
//!
//! State transitions are driven by:
//! - user clicks (new/continue/stop/open/save-config/dismiss-error)
//! - recorder events (ffmpeg finalizing → RecorderEvent::Finished)
//! - upload events (terminal failure → AppState::Error)
//!
//! Connectivity and upload-progress indicators live in the footer; they
//! never block the main state machine. See
//! `berpr-fe-den-plan-lively-journal.md` for the design rationale.
use std::path::PathBuf;
use std::sync::Arc;
use std::time::{Duration, Instant};
use crate::case_store::{CaseMarker, CaseStore};
use crate::case_update::{PutOnelinerError, put_oneliner};
use crate::footer_status::{FooterStatus, pick_footer_status};
use crate::server_sync::{ServerSync, SyncConfig, WorkerSnapshot, WorkerState};
use crate::snapshot_cache::SnapshotCache;
use crate::startup::{
DEFAULT_MARKER_RETENTION, DEFAULT_ORPHAN_AUDIO_RETENTION, DEFAULT_ORPHAN_MARKER_GRACE,
run_startup_cleanup,
};
use crate::upload::{PendingUpload, UploadEvent, write_sidecar};
use doctate_common::join_url;
use doctate_common::oneliners::OnelinerState;
use doctate_common::timestamp::{extract_hhmm, now_rfc3339, recorded_at_to_filename_stem};
use eframe::egui;
use tokio::runtime::Runtime;
use tokio::sync::{mpsc, watch};
use tracing::{error, info, warn};
use uuid::Uuid;
use crate::config::Config;
use crate::recorder::{Recorder, RecorderEvent};
use crate::state::AppState;
pub struct DoctateApp {
runtime: Arc<Runtime>,
config: Option<Config>,
pending_dir: PathBuf,
state: AppState,
server_url_input: String,
api_key_input: String,
http_client: Arc<reqwest::Client>,
case_store: Arc<CaseStore>,
snapshot_rx: watch::Receiver<Arc<Vec<CaseMarker>>>,
snapshot_cache: Arc<SnapshotCache>,
recorder: Option<Recorder>,
recorder_events: Option<mpsc::UnboundedReceiver<RecorderEvent>>,
server_sync: Option<ServerSync>,
worker_rx: Option<watch::Receiver<WorkerSnapshot>>,
upload_events: Option<mpsc::UnboundedReceiver<UploadEvent>>,
/// Context for an in-flight ffmpeg flush: we left `AppState::Recording`
/// for `AppState::Idle` on Stop (so the UI stays responsive), but we
/// 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
/// upload event once the recorder signals `Finished`.
#[derive(Debug, Clone)]
struct RecordingContext {
case_id: Uuid,
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,
Continue(Uuid),
OpenWeb(Uuid),
Stop,
DismissError,
StartOnelinerEdit { case_id: Uuid, current_text: String },
CancelOnelinerEdit,
SaveOneliner { case_id: Uuid, text: String },
DismissSaveError,
}
impl DoctateApp {
pub fn new(
runtime: Arc<Runtime>,
pending_dir: PathBuf,
cases_dir: PathBuf,
snapshot_cache_path: PathBuf,
) -> Self {
let config = match crate::config_path::load() {
Ok(Some(c)) => Some(c),
Ok(None) => None,
Err(e) => {
warn!(error = %e, "config load failed");
None
}
};
let server_url_input = config
.as_ref()
.map(|c| c.server_url.clone())
.unwrap_or_default();
let api_key_input = config
.as_ref()
.map(|c| c.api_key.clone())
.unwrap_or_default();
let (store, snapshot_rx) = runtime
.block_on(async { CaseStore::open(cases_dir).await })
.expect("open case store");
let case_store = Arc::new(store);
let snapshot_cache = Arc::new(SnapshotCache::new(snapshot_cache_path));
let http_client = Arc::new(reqwest::Client::new());
// Startup cleanup — one-shot, before the worker spawns.
{
let store = case_store.clone();
let pending = pending_dir.clone();
runtime.block_on(async move {
run_startup_cleanup(
&store,
&pending,
DEFAULT_MARKER_RETENTION,
DEFAULT_ORPHAN_AUDIO_RETENTION,
DEFAULT_ORPHAN_MARKER_GRACE,
)
.await;
});
}
let (state, server_sync, worker_rx, upload_events) = if let Some(cfg) = &config {
let (sync, events, rx) = spawn_worker(
&runtime,
cfg.clone(),
pending_dir.clone(),
http_client.clone(),
case_store.clone(),
snapshot_cache.clone(),
);
(AppState::Idle, Some(sync), Some(rx), Some(events))
} else {
(AppState::NotConfigured, None, None, None)
};
let (save_result_tx, save_result_rx) = mpsc::unbounded_channel();
Self {
runtime,
config,
pending_dir,
state,
server_url_input,
api_key_input,
http_client,
case_store,
snapshot_rx,
snapshot_cache,
recorder: None,
recorder_events: None,
server_sync,
worker_rx,
upload_events,
finalizing: None,
oneliner_edit: None,
save_result_tx,
save_result_rx,
last_save_error: None,
}
}
fn on_save_config(&mut self) {
let cfg = Config {
server_url: self.server_url_input.trim().to_string(),
api_key: self.api_key_input.trim().to_string(),
oneliner_poll_interval_seconds: self
.config
.as_ref()
.and_then(|c| c.oneliner_poll_interval_seconds),
};
if let Err(e) = crate::config_path::save(&cfg) {
error!(error = %e, "config save failed");
self.state = AppState::Error {
message: format!("Speichern: {e}"),
};
return;
}
info!("config saved");
let store = self.case_store.clone();
let cache = self.snapshot_cache.clone();
self.runtime.block_on(async move {
if let Err(e) = store.clear_all().await {
warn!(error = %e, "case store clear failed on config change");
}
if let Err(e) = cache.clear().await {
warn!(error = %e, "snapshot cache clear failed on config change");
}
});
// Drop old worker explicitly so its abort fires before new spawn.
self.server_sync = None;
self.worker_rx = None;
self.upload_events = None;
let (sync, events, rx) = spawn_worker(
&self.runtime,
cfg.clone(),
self.pending_dir.clone(),
self.http_client.clone(),
self.case_store.clone(),
self.snapshot_cache.clone(),
);
self.server_sync = Some(sync);
self.worker_rx = Some(rx);
self.upload_events = Some(events);
self.config = Some(cfg);
self.state = AppState::Idle;
self.finalizing = None;
}
fn on_start_new(&mut self) {
self.start_recording_for(Uuid::new_v4(), /* is_continue */ false);
}
fn on_continue(&mut self, case_id: Uuid) {
self.start_recording_for(case_id, /* is_continue */ true);
}
fn start_recording_for(&mut self, case_id: Uuid, is_continue: bool) {
// Two-part guard: user-visible state AND no lingering recorder
// from a previous stop that is still flushing.
if !matches!(self.state, AppState::Idle) || self.recorder.is_some() {
return;
}
if let Err(e) = std::fs::create_dir_all(&self.pending_dir) {
self.state = AppState::Error {
message: format!("Pending-Verzeichnis: {e}"),
};
return;
}
let recorded_at = now_rfc3339();
let stem = format!("{}_{}", case_id, recorded_at_to_filename_stem(&recorded_at));
let output_path = self.pending_dir.join(format!("{stem}.m4a"));
// Persist the case marker before recording so the UI list
// reflects the new case immediately.
let store = self.case_store.clone();
let now = time::OffsetDateTime::now_utc();
self.runtime.block_on(async move {
let res = if is_continue {
store.mark_activity(case_id, now).await.map(|_| ())
} else {
store.create_local(case_id, now).await.map(|_| ())
};
if let Err(e) = res {
warn!(error = %e, "case store write failed");
}
});
let _guard = self.runtime.enter();
match Recorder::start(output_path) {
Ok((recorder, events)) => {
self.recorder = Some(recorder);
self.recorder_events = Some(events);
self.state = AppState::Recording {
case_id,
recorded_at,
started_at: Instant::now(),
};
}
Err(e) => {
self.state = AppState::Error {
message: format!("Aufnahme starten: {e}"),
};
}
}
}
fn on_stop(&mut self) {
let AppState::Recording {
case_id,
recorded_at,
..
} = &self.state
else {
return;
};
let case_id = *case_id;
let recorded_at = recorded_at.clone();
if let Some(rec) = self.recorder.take() {
rec.stop();
}
// UI is ready for the next recording immediately — the finalizing
// ffmpeg flush shows up as a footer spinner, not a blocking state.
self.finalizing = Some(RecordingContext {
case_id,
recorded_at,
});
self.state = AppState::Idle;
}
fn on_open_web(&self, case_id: Uuid) {
let Some(cfg) = &self.config else {
return;
};
let server_url = cfg.server_url.clone();
let api_key = cfg.api_key.clone();
let http = self.http_client.clone();
let return_to = format!("/cases/{case_id}");
let fallback_url = join_url(&server_url, &return_to);
// Fire-and-forget on the runtime. Blocking the egui UI thread on
// an HTTP round-trip would freeze the window for ~100500ms.
self.runtime.spawn(async move {
let url =
match crate::magic_link::build_magic_url(&http, &server_url, &api_key, &return_to)
.await
{
Ok(u) => u,
Err(e) => {
warn!(error = %e, "magic-link request failed; falling back to plain URL");
fallback_url
}
};
if let Err(e) = webbrowser::open(&url) {
warn!(url = %url, error = %e, "open browser failed");
}
});
}
fn drain_recorder_events(&mut self) {
let Some(rx) = self.recorder_events.as_mut() else {
return;
};
let mut events = Vec::new();
while let Ok(event) = rx.try_recv() {
events.push(event);
}
for event in events {
match event {
RecorderEvent::Started => {}
RecorderEvent::Finished { output } => {
self.handle_recording_finished(output);
}
RecorderEvent::Failed { error: err } => {
error!(error = %err, "recorder failed");
self.finalizing = None;
self.state = AppState::Error {
message: format!("Aufnahme: {err}"),
};
self.recorder_events = None;
self.recorder = None;
}
}
}
}
fn handle_recording_finished(&mut self, output: PathBuf) {
// Prefer the explicit finalizing context (Stop-flow). Fall back
// to AppState::Recording (recorder terminated before Stop — rare
// but possible).
let ctx = if let Some(ctx) = self.finalizing.take() {
ctx
} else if let AppState::Recording {
case_id,
recorded_at,
..
} = &self.state
{
let ctx = RecordingContext {
case_id: *case_id,
recorded_at: recorded_at.clone(),
};
self.state = AppState::Idle;
ctx
} else {
warn!(state = ?self.state, "recorder finished in unexpected state");
self.recorder_events = None;
self.recorder = None;
return;
};
let RecordingContext {
case_id,
recorded_at,
} = ctx;
let store = self.case_store.clone();
let now = time::OffsetDateTime::now_utc();
self.runtime.block_on(async move {
if let Err(e) = store.mark_activity(case_id, now).await {
warn!(error = %e, "mark_activity on recording finished failed");
}
});
let upload = PendingUpload {
case_id,
recorded_at,
file: output,
};
if let Err(e) = write_sidecar(&upload) {
self.state = AppState::Error {
message: format!("Sidecar schreiben: {e}"),
};
self.recorder_events = None;
self.recorder = None;
return;
}
// Kick the worker so it picks up the fresh file without waiting
// for the next poll-tick.
if let Some(sync) = &self.server_sync {
sync.notify();
} else {
warn!("no server_sync active — upload stays on disk until next launch");
}
self.recorder_events = None;
self.recorder = None;
}
fn drain_upload_events(&mut self) {
let Some(rx) = self.upload_events.as_mut() else {
return;
};
let mut events = Vec::new();
while let Ok(event) = rx.try_recv() {
events.push(event);
}
for event in events {
match event {
UploadEvent::Started(case_id) => {
info!(case_id = %case_id, "upload attempt started");
}
UploadEvent::Succeeded { case_id, status } => {
info!(case_id = %case_id, status = ?status, "upload succeeded");
}
UploadEvent::Failed {
case_id,
reason,
will_retry,
} => {
warn!(case_id = %case_id, reason = %reason, will_retry, "upload failed");
if !will_retry {
// Terminal = operator action needed. Block UI.
self.state = AppState::Error { message: reason };
}
}
}
}
}
fn render_controls(&mut self, ui: &mut egui::Ui) -> Option<UiAction> {
match &self.state {
AppState::NotConfigured => {
ui.label("Server URL:");
ui.add(egui::TextEdit::singleline(&mut self.server_url_input).desired_width(280.0));
ui.add_space(4.0);
ui.label("API Key:");
ui.add(
egui::TextEdit::singleline(&mut self.api_key_input)
.desired_width(280.0)
.password(true),
);
ui.add_space(8.0);
if ui.button("Speichern").clicked() {
return Some(UiAction::SaveConfig);
}
None
}
AppState::Idle => {
if ui.button("● Neu").clicked() {
return Some(UiAction::StartNew);
}
None
}
AppState::Recording { started_at, .. } => {
let elapsed = started_at.elapsed();
ui.label(format!(
"● REC {:02}:{:02}",
elapsed.as_secs() / 60,
elapsed.as_secs() % 60
));
ui.add_space(4.0);
if ui.button("■ Stop").clicked() {
return Some(UiAction::Stop);
}
None
}
AppState::Error { message } => {
ui.colored_label(egui::Color32::RED, message);
ui.add_space(8.0);
if ui.button("OK").clicked() {
return Some(UiAction::DismissError);
}
None
}
}
}
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.
let visible: Vec<&CaseMarker> = snapshot.iter().collect();
if visible.is_empty() {
ui.add_space(8.0);
ui.colored_label(egui::Color32::GRAY, "Noch keine Fälle heute.");
return None;
}
// Pending-upload lookup for the badge. Cheap clone (Arc) from the
// worker's last published snapshot; empty set if no worker yet.
let pending_ids = self
.worker_rx
.as_ref()
.map(|rx| rx.borrow().pending_case_ids.clone())
.unwrap_or_default();
let mut action = None;
// Ready-for-new means the user-intent state is Idle AND no ffmpeg
// instance is still flushing. Upload-queue depth does NOT gate.
let idle = matches!(self.state, AppState::Idle) && self.recorder.is_none();
egui::ScrollArea::vertical().show(ui, |ui| {
for marker in visible {
ui.separator();
ui.add_space(4.0);
let time_str = extract_hhmm(&marker.last_activity_at);
let has_pending = pending_ids.contains(&marker.case_id);
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 {
// 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 { .. })) {
// Person-glyph marks doctor-authored titles.
// Replaces an earlier pencil icon: the pencil
// suggested an edit affordance, but on this row
// the marker is informational only — clicking
// the title text already opens the editor.
ui.colored_label(egui::Color32::GRAY, "👤")
.on_hover_text("manuell bearbeitet");
}
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| {
if ui
.add_enabled(idle, egui::Button::new("▶ Fortsetzen"))
.clicked()
{
action = Some(UiAction::Continue(marker.case_id));
}
if ui.button("🌐 Öffnen").clicked() {
action = Some(UiAction::OpenWeb(marker.case_id));
}
});
ui.add_space(4.0);
}
});
action
}
fn render_footer_status(&self, ui: &mut egui::Ui) {
let Some(worker_rx) = &self.worker_rx else {
ui.colored_label(egui::Color32::GRAY, "—");
return;
};
let snap = worker_rx.borrow().clone();
let last_success_age = snap.last_success.map(|i| i.elapsed());
let last_failure_age = snap.last_failure.map(|i| i.elapsed());
let threshold = self
.config
.as_ref()
.map(|c| std::cmp::max(3 * c.poll_interval(), Duration::from_secs(30)))
.unwrap_or(Duration::from_secs(30));
let status = pick_footer_status(
self.finalizing.is_some(),
snap.state,
snap.queue_len,
last_success_age,
last_failure_age,
threshold,
);
match status {
FooterStatus::Finalizing => {
ui.horizontal(|ui| {
ui.spinner();
ui.label("Speichere Aufnahme…");
});
}
FooterStatus::Uploading { count } => {
ui.horizontal(|ui| {
ui.spinner();
if count <= 1 {
ui.label("Uploading…");
} else {
ui.label(format!("Uploading ({count} offen)…"));
}
});
}
FooterStatus::Synchronizing => {
ui.horizontal(|ui| {
ui.spinner();
ui.label("Synchronisiere…");
});
}
FooterStatus::NeverConnected => {
ui.colored_label(egui::Color32::GRAY, "⚪ noch keine Verbindung");
}
FooterStatus::Offline => {
ui.colored_label(egui::Color32::DARK_RED, "🔴 Offline");
}
FooterStatus::Online => {
ui.colored_label(egui::Color32::DARK_GREEN, "🟢 Online");
}
}
}
fn handle_action(&mut self, action: UiAction) {
match action {
UiAction::SaveConfig => self.on_save_config(),
UiAction::StartNew => self.on_start_new(),
UiAction::Continue(id) => self.on_continue(id),
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),
));
}
}
}
}
fn worker_is_active(&self) -> bool {
let Some(rx) = &self.worker_rx else {
return false;
};
let snap = rx.borrow();
snap.state != WorkerState::Idle || snap.queue_len > 0
}
}
fn spawn_worker(
runtime: &Runtime,
config: Config,
pending_dir: PathBuf,
http: Arc<reqwest::Client>,
case_store: Arc<CaseStore>,
snapshot_cache: Arc<SnapshotCache>,
) -> (
ServerSync,
mpsc::UnboundedReceiver<UploadEvent>,
watch::Receiver<WorkerSnapshot>,
) {
let _guard = runtime.enter();
let poll_interval = config.poll_interval();
let sync_config = SyncConfig {
server_url: config.server_url,
api_key: config.api_key,
poll_interval,
};
let (sync, events_rx) =
ServerSync::spawn(http, sync_config, case_store, snapshot_cache, pending_dir);
let snapshot_rx = sync.snapshot();
(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| {
ui.heading("doctate");
ui.label(self.state.label());
});
ui.add_space(4.0);
});
egui::TopBottomPanel::bottom("footer").show(ctx, |ui| {
ui.add_space(4.0);
ui.vertical_centered(|ui| {
self.render_footer_status(ui);
});
ui.add_space(4.0);
});
egui::CentralPanel::default().show(ctx, |ui| {
ui.vertical_centered(|ui| {
ui.add_space(8.0);
if let Some(a) = self.render_controls(ui) {
pending_action = Some(a);
}
ui.add_space(8.0);
});
if !matches!(self.state, AppState::NotConfigured)
&& let Some(a) = self.render_case_list(ui)
{
pending_action = Some(a);
}
});
if let Some(action) = pending_action {
self.handle_action(action);
}
// Repaint cadence: fast while visibly active (spinner, REC-timer),
// slow (1 Hz) in the quiet steady state.
let active_spinner = self.finalizing.is_some() || self.worker_is_active();
let interval = match self.state {
AppState::Recording { .. } => Duration::from_millis(250),
_ if active_spinner => Duration::from_millis(100),
_ => Duration::from_secs(1),
};
ctx.request_repaint_after(interval);
}
}