Refactor upload and polling logic

This commit consolidates the upload and polling functionalities into a
single `ServerSync` worker. The `oneliner_poller` and `uploader` modules
have been removed, and their responsibilities are now handled by
`server_sync`.

The `ServerSync` worker manages both uploading pending recordings and
polling the `/api/oneliners` endpoint. Uploads take precedence, and
polling only occurs when the pending upload directory is empty. The
worker now uses a unified HTTP client and a single `tokio::select!` loop
for managing these tasks.

Key changes include:
- Removal of `oneliner_poller.rs` and `uploader.rs`.
- Introduction of `server_sync.rs` and `upload.rs` in
  `doctate-client-core`.
- `ServerSync` now handles upload events (`UploadEvent`) and provides a
  `WorkerSnapshot` for UI feedback.
- Upload logic has been refactored to handle transient and terminal
  errors more robustly, including file deletion for terminal failures.
- Polling logic is integrated into the `ServerSync` loop, utilizing the
  existing `SnapshotCache` and `CaseStore`.
- The `App` component in `client-desktop` has been updated to use
  `ServerSync` instead of the separate poller and uploader.
- Documentation comments have been updated to reflect the new structure.
This commit is contained in:
2026-04-18 13:24:38 +02:00
parent cab71e6a26
commit 305d739f56
10 changed files with 1473 additions and 1169 deletions
+158 -15
View File
@@ -1,8 +1,14 @@
//! UI-visible application state. Transitions are driven by user actions
//! (buttons) and async events from the recorder + uploader channels.
//! UI-visible application state and the footer status derivation.
//!
//! The state machine here only reflects **user intent** — "ready to
//! record", "recording in progress", "config error, please fix".
//! Background activity (upload queue, poll cycle, finalize flush) lives
//! in a separate observable layer (`FooterStatus`) derived via a pure
//! function so it can be table-tested without egui.
use std::time::Instant;
use std::time::Duration;
use doctate_client_core::server_sync::WorkerState;
use uuid::Uuid;
#[derive(Debug)]
@@ -15,16 +21,7 @@ pub enum AppState {
Recording {
case_id: Uuid,
recorded_at: String,
started_at: Instant,
},
/// User pressed Stop; waiting for ffmpeg to finalize the MOOV atom.
FinalizingRecording {
case_id: Uuid,
recorded_at: String,
},
/// File handed to uploader; waiting for server ACK.
Uploading {
case_id: Uuid,
started_at: std::time::Instant,
},
/// Terminal error; user must dismiss or fix config.
Error {
@@ -38,9 +35,155 @@ impl AppState {
Self::NotConfigured => "Konfiguration fehlt",
Self::Idle => "Bereit",
Self::Recording { .. } => "Aufnahme läuft",
Self::FinalizingRecording { .. } => "Speichere…",
Self::Uploading { .. } => "Upload läuft",
Self::Error { .. } => "Fehler",
}
}
}
/// Derived observable rendered in the footer. Priority order encoded
/// in `pick_footer_status` — active work beats connectivity indicators.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FooterStatus {
/// ffmpeg is flushing the MOOV atom. Spinner.
Finalizing,
/// Worker is uploading. Spinner. `count` = pending-dir depth.
Uploading { count: usize },
/// Worker is polling `/api/oneliners`. Spinner.
Synchronizing,
/// Never got a successful request yet — cold start or bad config.
NeverConnected,
/// Last success older than threshold. Red text.
Offline,
/// Last success fresh. Green text. The default quiet state.
Online,
}
/// Decide which footer status to show. Pure function — no side effects,
/// fully table-testable. Inputs chosen to match what `DoctateApp` can
/// observe cheaply per egui frame.
pub fn pick_footer_status(
finalizing: bool,
worker_state: WorkerState,
queue_len: usize,
last_success_age: Option<Duration>,
offline_threshold: Duration,
) -> FooterStatus {
if finalizing {
return FooterStatus::Finalizing;
}
if queue_len > 0 || worker_state == WorkerState::Uploading {
return FooterStatus::Uploading {
count: queue_len.max(1),
};
}
if worker_state == WorkerState::Polling {
return FooterStatus::Synchronizing;
}
match last_success_age {
None => FooterStatus::NeverConnected,
Some(age) if age > offline_threshold => FooterStatus::Offline,
Some(_) => FooterStatus::Online,
}
}
#[cfg(test)]
mod tests {
use super::*;
const THRESHOLD: Duration = Duration::from_secs(30);
#[test]
fn finalizing_wins_over_everything() {
let s = pick_footer_status(
true,
WorkerState::Uploading,
5,
Some(Duration::from_millis(1)),
THRESHOLD,
);
assert_eq!(s, FooterStatus::Finalizing);
}
#[test]
fn uploading_when_queue_non_empty() {
let s = pick_footer_status(
false,
WorkerState::Uploading,
3,
Some(Duration::from_millis(1)),
THRESHOLD,
);
assert_eq!(s, FooterStatus::Uploading { count: 3 });
}
#[test]
fn uploading_reports_at_least_one_even_if_scan_raced() {
// Edge case: WorkerState says Uploading, but scan returned 0
// (just finished a file and the next scan hasn't happened yet).
// We still want to show "Uploading" to avoid a flicker back to
// Online right before the next file starts.
let s = pick_footer_status(
false,
WorkerState::Uploading,
0,
Some(Duration::from_millis(1)),
THRESHOLD,
);
assert_eq!(s, FooterStatus::Uploading { count: 1 });
}
#[test]
fn synchronizing_when_polling_and_no_queue() {
let s = pick_footer_status(
false,
WorkerState::Polling,
0,
Some(Duration::from_millis(1)),
THRESHOLD,
);
assert_eq!(s, FooterStatus::Synchronizing);
}
#[test]
fn never_connected_without_last_success() {
let s = pick_footer_status(false, WorkerState::Idle, 0, None, THRESHOLD);
assert_eq!(s, FooterStatus::NeverConnected);
}
#[test]
fn offline_when_last_success_is_stale() {
let s = pick_footer_status(
false,
WorkerState::Idle,
0,
Some(Duration::from_secs(120)),
THRESHOLD,
);
assert_eq!(s, FooterStatus::Offline);
}
#[test]
fn online_when_last_success_is_fresh() {
let s = pick_footer_status(
false,
WorkerState::Idle,
0,
Some(Duration::from_secs(5)),
THRESHOLD,
);
assert_eq!(s, FooterStatus::Online);
}
#[test]
fn offline_threshold_is_inclusive_of_equal_age_as_online() {
// Exactly at threshold: still online (strict ">" in logic).
let s = pick_footer_status(
false,
WorkerState::Idle,
0,
Some(THRESHOLD),
THRESHOLD,
);
assert_eq!(s, FooterStatus::Online);
}
}