diff --git a/Cargo.lock b/Cargo.lock index 5738665..490ea7d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1177,6 +1177,7 @@ name = "doctate-desktop" version = "0.1.0" dependencies = [ "directories", + "doctate-client-core", "doctate-common", "eframe", "libc", @@ -1185,11 +1186,13 @@ dependencies = [ "serde_json", "tempfile", "thiserror 1.0.69", + "time", "tokio", "toml", "tracing", "tracing-subscriber", "uuid", + "webbrowser", "which", "wiremock", ] diff --git a/client-desktop/src/app.rs b/client-desktop/src/app.rs index a453740..0c75119 100644 --- a/client-desktop/src/app.rs +++ b/client-desktop/src/app.rs @@ -1,21 +1,24 @@ -//! The egui application: config panel, record/stop buttons, state -//! transitions driven by recorder + uploader channels. +//! The egui application: config panel, record/stop controls, case list, +//! state transitions driven by recorder + uploader + poller channels. use std::path::PathBuf; use std::sync::Arc; use std::time::{Duration, Instant}; +use doctate_client_core::{ + CaseMarker, CaseStore, OnelinerPoller, PollerConfig, SnapshotCache, +}; use doctate_common::timestamp::{now_rfc3339, recorded_at_to_filename_stem}; use eframe::egui; use tokio::runtime::Runtime; -use tokio::sync::mpsc; +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; -use crate::uploader::{PendingUpload, UploadEvent, Uploader, write_sidecar}; +use crate::uploader::{write_sidecar, PendingUpload, UploadEvent, Uploader}; pub struct DoctateApp { runtime: Arc, @@ -26,22 +29,36 @@ pub struct DoctateApp { server_url_input: String, api_key_input: String, + http_client: Arc, + case_store: Arc, + snapshot_rx: watch::Receiver>>, + snapshot_cache: Arc, + recorder: Option, recorder_events: Option>, uploader: Option, upload_events: Option>, + + poller: Option, } enum UiAction { SaveConfig, StartNew, + Continue(Uuid), + OpenWeb(Uuid), Stop, DismissError, } impl DoctateApp { - pub fn new(runtime: Arc, pending_dir: PathBuf) -> Self { + pub fn new( + runtime: Arc, + pending_dir: PathBuf, + cases_dir: PathBuf, + snapshot_cache_path: PathBuf, + ) -> Self { let config = match Config::load() { Ok(Some(c)) => Some(c), Ok(None) => None, @@ -60,11 +77,27 @@ impl DoctateApp { .map(|c| c.api_key.clone()) .unwrap_or_default(); - let (state, uploader, upload_events) = if let Some(cfg) = &config { - let (up, rx) = spawn_uploader(&runtime, cfg.clone(), pending_dir.clone()); - (AppState::Idle, Some(up), Some(rx)) + // Open the case store before any background task — the store + // owns the watch::Sender that drives the UI list. + 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()); + + let (state, uploader, upload_events, poller) = if let Some(cfg) = &config { + let (up, rx, pol) = spawn_background( + &runtime, + cfg.clone(), + pending_dir.clone(), + http_client.clone(), + case_store.clone(), + snapshot_cache.clone(), + ); + (AppState::Idle, Some(up), Some(rx), Some(pol)) } else { - (AppState::NotConfigured, None, None) + (AppState::NotConfigured, None, None, None) }; Self { @@ -74,10 +107,15 @@ impl DoctateApp { state, server_url_input, api_key_input, + http_client, + case_store, + snapshot_rx, + snapshot_cache, recorder: None, recorder_events: None, uploader, upload_events, + poller, } } @@ -85,26 +123,70 @@ impl DoctateApp { 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), + oneliner_window_hours: self + .config + .as_ref() + .and_then(|c| c.oneliner_window_hours), }; - match cfg.save() { - Ok(()) => { - info!("config saved"); - let (up, rx) = spawn_uploader(&self.runtime, cfg.clone(), self.pending_dir.clone()); - self.uploader = Some(up); - self.upload_events = Some(rx); - self.config = Some(cfg); - self.state = AppState::Idle; - } - Err(e) => { - error!(error = %e, "config save failed"); - self.state = AppState::Error { - message: format!("Speichern: {e}"), - }; - } + if let Err(e) = cfg.save() { + error!(error = %e, "config save failed"); + self.state = AppState::Error { + message: format!("Speichern: {e}"), + }; + return; } + info!("config saved"); + + // New API key = potentially different user. Wipe local state so + // we don't leak old user's cases into the new user's view. + 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 background tasks explicitly so their AbortHandle / + // channel-close fires before the new ones spawn. + self.poller = None; + self.uploader = None; + self.upload_events = None; + + let (up, rx, pol) = spawn_background( + &self.runtime, + cfg.clone(), + self.pending_dir.clone(), + self.http_client.clone(), + self.case_store.clone(), + self.snapshot_cache.clone(), + ); + self.uploader = Some(up); + self.upload_events = Some(rx); + self.poller = Some(pol); + self.config = Some(cfg); + self.state = AppState::Idle; } 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) { + if !matches!(self.state, AppState::Idle) { + return; + } if let Err(e) = std::fs::create_dir_all(&self.pending_dir) { self.state = AppState::Error { message: format!("Pending-Verzeichnis: {e}"), @@ -112,11 +194,26 @@ impl DoctateApp { return; } - let case_id = Uuid::new_v4(); 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 (even if the recorder fails + // to start, the marker stays — harmless and reproducible). + 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)) => { @@ -156,6 +253,20 @@ impl DoctateApp { }; } + fn on_open_web(&self, case_id: Uuid) { + let Some(cfg) = &self.config else { + return; + }; + let url = format!( + "{}/web/cases/{}", + cfg.server_url.trim_end_matches('/'), + case_id + ); + 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; @@ -201,6 +312,15 @@ impl DoctateApp { } }; + // Bump activity on the marker so the case rises in the list. + 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, @@ -271,7 +391,7 @@ impl DoctateApp { } } - fn render_body(&mut self, ui: &mut egui::Ui) -> Option { + fn render_controls(&mut self, ui: &mut egui::Ui) -> Option { match &self.state { AppState::NotConfigured => { ui.label("Server URL:"); @@ -331,23 +451,142 @@ impl DoctateApp { } } + fn render_case_list(&self, ui: &mut egui::Ui) -> Option { + let snapshot = self.snapshot_rx.borrow().clone(); + let window = self + .config + .as_ref() + .map(Config::window_hours) + .unwrap_or(72); + let cutoff = time::OffsetDateTime::now_utc() - time::Duration::hours(window as i64); + + let visible: Vec<&CaseMarker> = snapshot + .iter() + .filter(|m| parse_rfc3339(&m.last_activity_at).is_none_or(|t| t >= cutoff)) + .collect(); + + if visible.is_empty() { + ui.add_space(8.0); + ui.colored_label(egui::Color32::GRAY, "Noch keine Fälle heute."); + return None; + } + + let mut action = None; + let idle = matches!(self.state, AppState::Idle); + + 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 oneliner = marker + .oneliner + .as_deref() + .map(str::to_owned) + .unwrap_or_else(|| "⏳".to_owned()); + ui.label(format!("{time_str} · {oneliner}")); + + 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_staleness(&self, ui: &mut egui::Ui) { + let Some(poller) = &self.poller else { + return; + }; + let Some(last) = poller.last_success() else { + ui.colored_label(egui::Color32::GRAY, "⚪ noch keine Verbindung"); + return; + }; + let age = last.elapsed(); + let secs = age.as_secs(); + let (color, text) = if secs < 30 { + (egui::Color32::DARK_GREEN, format!("🟢 zuletzt: vor {secs} s")) + } else if secs < 120 { + ( + egui::Color32::from_rgb(200, 140, 0), + format!("🟡 zuletzt: vor {secs} s"), + ) + } else { + ( + egui::Color32::DARK_RED, + format!("🔴 zuletzt: vor {} s", secs), + ) + }; + ui.colored_label(color, text); + } + 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, } } } -fn spawn_uploader( +fn spawn_background( runtime: &Runtime, config: Config, pending_dir: PathBuf, -) -> (Uploader, mpsc::UnboundedReceiver) { + http: Arc, + case_store: Arc, + snapshot_cache: Arc, +) -> (Uploader, mpsc::UnboundedReceiver, OnelinerPoller) { let _guard = runtime.enter(); - Uploader::spawn(config, pending_dir) + let poll_interval = config.poll_interval(); + let window_hours = config.window_hours(); + let (uploader, rx) = Uploader::spawn(config.clone(), pending_dir); + let poller = OnelinerPoller::spawn( + http, + PollerConfig { + server_url: config.server_url, + api_key: config.api_key, + poll_interval, + window_hours, + }, + case_store, + snapshot_cache, + ); + (uploader, rx, poller) +} + +fn parse_rfc3339(s: &str) -> Option { + time::OffsetDateTime::parse(s, &time::format_description::well_known::Rfc3339).ok() +} + +/// Extract `HH:MM` from an RFC3339 UTC timestamp. Returns the raw string +/// truncated to the first 16 chars as a fallback on parse failure — the +/// user still gets something readable. +fn extract_hhmm(ts: &str) -> String { + match parse_rfc3339(ts) { + Some(t) => { + let local = t + .to_offset( + time::UtcOffset::current_local_offset() + .unwrap_or(time::UtcOffset::UTC), + ); + format!("{:02}:{:02}", local.hour(), local.minute()) + } + None => ts.chars().take(16).collect(), + } } impl eframe::App for DoctateApp { @@ -355,27 +594,53 @@ impl eframe::App for DoctateApp { self.drain_recorder_events(); self.drain_upload_events(); + let mut pending_action: Option = None; + + 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_staleness(ui); + }); + ui.add_space(4.0); + }); + egui::CentralPanel::default().show(ctx, |ui| { ui.vertical_centered(|ui| { ui.add_space(8.0); - ui.heading("doctate"); - ui.add_space(4.0); - ui.label(self.state.label()); - ui.add_space(12.0); - - let action = self.render_body(ui); - if let Some(action) = action { - self.handle_action(action); + if let Some(a) = self.render_controls(ui) { + pending_action = Some(a); } + ui.add_space(8.0); }); + // Case list fills the remaining space. + 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 periodically so the staleness indicator + watch + // snapshots reach the UI without a user event. match self.state { AppState::Recording { .. } => ctx.request_repaint_after(Duration::from_millis(250)), AppState::FinalizingRecording { .. } | AppState::Uploading { .. } => { ctx.request_repaint_after(Duration::from_millis(100)); } - _ => {} + _ => ctx.request_repaint_after(Duration::from_secs(1)), } } } diff --git a/client-desktop/src/main.rs b/client-desktop/src/main.rs index c0e622b..93a0650 100644 --- a/client-desktop/src/main.rs +++ b/client-desktop/src/main.rs @@ -12,7 +12,7 @@ use tracing::info; use tracing_subscriber::EnvFilter; use crate::app::DoctateApp; -use crate::paths::pending_dir; +use crate::paths::{cases_dir, pending_dir, snapshot_cache_path}; fn main() -> eframe::Result { tracing_subscriber::fmt() @@ -39,11 +39,26 @@ fn main() -> eframe::Result { std::process::exit(1); } }; + let cases = match cases_dir() { + Ok(p) => p, + Err(e) => { + eprintln!("cannot determine cases directory: {e}"); + std::process::exit(1); + } + }; + let cache_path = match snapshot_cache_path() { + Ok(p) => p, + Err(e) => { + eprintln!("cannot determine snapshot cache path: {e}"); + std::process::exit(1); + } + }; let options = eframe::NativeOptions { viewport: egui::ViewportBuilder::default() - .with_inner_size([320.0, 280.0]) - .with_resizable(false), + .with_inner_size([400.0, 560.0]) + .with_min_inner_size([320.0, 400.0]) + .with_resizable(true), ..Default::default() }; @@ -51,7 +66,7 @@ fn main() -> eframe::Result { "doctate", options, Box::new(move |_cc| { - let app = DoctateApp::new(runtime, pending); + let app = DoctateApp::new(runtime, pending, cases, cache_path); Ok(Box::new(app) as Box) }), ) diff --git a/client-desktop/src/uploader.rs b/client-desktop/src/uploader.rs index 75e48da..8129fa4 100644 --- a/client-desktop/src/uploader.rs +++ b/client-desktop/src/uploader.rs @@ -330,6 +330,8 @@ mod tests { let config = Config { server_url: server.uri(), api_key: "test-key".into(), + oneliner_poll_interval_seconds: None, + oneliner_window_hours: None, }; let client = reqwest::Client::new(); @@ -370,6 +372,8 @@ mod tests { let config = Config { server_url: server.uri(), api_key: "test-key".into(), + oneliner_poll_interval_seconds: None, + oneliner_window_hours: None, }; let client = reqwest::Client::new(); @@ -400,6 +404,8 @@ mod tests { let config = Config { server_url: server.uri(), api_key: "bad-key".into(), + oneliner_poll_interval_seconds: None, + oneliner_window_hours: None, }; let client = reqwest::Client::new();