From 46647d26eb2098e7a6965c0bc1087b30fc8e14da Mon Sep 17 00:00:00 2001 From: Andre Hugo Date: Wed, 8 Jul 2026 08:53:11 -0700 Subject: [PATCH 01/20] docs: high-priority improvements design spec (4 items) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #2 Conflict feedback toasts — dirtySince flag + toast on remote change #3 latest.json release order — dedicated manifest job after all assets #1 SSE realtime chip sync — broadcast channel + SSE endpoint + push client #4 Async encryption — background encrypt queue with reader-coordination flag --- ...07-08-high-priority-improvements-design.md | 163 ++++++++++++++++++ 1 file changed, 163 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-08-high-priority-improvements-design.md diff --git a/docs/superpowers/specs/2026-07-08-high-priority-improvements-design.md b/docs/superpowers/specs/2026-07-08-high-priority-improvements-design.md new file mode 100644 index 00000000..e1683611 --- /dev/null +++ b/docs/superpowers/specs/2026-07-08-high-priority-improvements-design.md @@ -0,0 +1,163 @@ +# High-Priority Improvements — Design Spec + +**Date:** 2026-07-08 +**Status:** Approved (all design sections) +**Scope:** 4 independent improvements, implemented sequentially + +## Overview + +Four high-priority improvements identified during a review session. Each is an independent subsystem with its own design, implemented in risk order: #2 (toasts) → #3 (release fix) → #1 (SSE) → #4 (async encryption). + +--- + +## Item #2: Conflict Feedback Toasts + +### Problem +When the 30s poll detects that another machine reordered/changed chips, `refreshChips` silently overwrites local state. If the user just reordered locally, their change is clobbered with no indication. + +### Approach +Track local mutations via a `dirtySince` timestamp. When the poll fires: +- **No local mutation in the last 5 seconds** → silent update (normal idle case) +- **Local mutation happened recently** → show a toast: "Condition chips updated from another machine" (auto-dismiss, info-level) + +### Details +- `dirtySince` is a `$state` in `ConditionChips.svelte`, set on every local add/remove/reorder, cleared after 5s via `setTimeout` +- The toast uses the existing `toasts.add()` API with `autoDismiss: true` +- No action button needed — chips already updated via LWW, this is just awareness +- The toast is shown at most once per detected change (not on every poll tick) + +### Files +- `src/lib/components/ConditionChips.svelte` — add `dirtySince` state, set it in add/remove/reorder handlers, check it in `refreshChips` +- `src/lib/stores/toasts.svelte.ts` — already has `add()` API, no changes needed + +--- + +## Item #3: latest.json Release Order Fix + +### Problem +The release workflow runs 3 parallel matrix jobs (Linux, Windows, macOS). Each uses `tauri-action` which auto-generates `latest.json` independently. The first job to finish publishes a partial manifest — clients see a new version but can't download their platform's binary yet. + +### Approach +1. Set `updaterJson: false` on the `tauri-action` step to suppress per-job `latest.json` generation +2. Add a `manifest` job with `needs: release` (same pattern as the existing `prune` job) that runs after all platform builds complete +3. The manifest job generates a consolidated `latest.json` with all platform entries and uploads it to the release + +### Manifest generation +A small inline Node.js script reads the release assets via `gh` CLI, finds the platform-specific assets + their `.sig` files, and constructs the JSON matching Tauri's expected format: + +```json +{ + "version": "0.29.0", + "notes": "...", + "pub_date": "2026-07-08T...", + "platforms": { + "linux-x86_64": { "signature": "...", "url": "..." }, + "windows-x86_64": { "signature": "...", "url": "..." }, + "darwin-aarch64": { "signature": "...", "url": "..." } + } +} +``` + +### Files +- `.github/workflows/release.yml` — add `updaterJson: false`, add `manifest` job + +### Why this eliminates the race +`latest.json` only appears after every platform binary + signature is uploaded. Clients never see a partial manifest. The updater store's friendly error message (from v0.26.2) remains as a safety net. + +--- + +## Item #1: SSE Realtime Chip Sync + +### Problem +The 30s poll means up to 30 seconds of latency between machine A reordering and machine B seeing it. + +### Approach +Add SSE (Server-Sent Events) push from the vocab API server. The client subscribes to a stream and re-pulls immediately when notified. + +### Server side (`sharing_vocab_api.rs`) +- New endpoint: `GET /v1/condition-chips/events` — returns `text/event-stream` +- Add `tokio::sync::broadcast::Sender<()>` to `ApiState` +- When `condition_chips_sync_handler` completes a merge, call `sender.send(())` to notify subscribers +- The SSE handler receives from the broadcast channel and sends `data: changed\n\n` +- Client doesn't need chip data in the SSE event — just a "re-pull" signal + +### Client side (`conditions_remote.rs` + background task) +- New `subscribe_events()` method on `ConditionsRemote` using `reqwest`'s `resp.bytes_stream()` for SSE line parsing +- A long-lived `tokio::spawn` task reads the stream and emits Tauri event `condition-chips-changed` +- Frontend listens via `listen('condition-chips-changed', ...)` and calls `refreshChips()` immediately + +### Connection lifecycle +- The SSE subscription starts when the condition chip component mounts (same time as the initial pull + poll). It only connects if sync is enabled AND paired. +- On unpair/disconnect: the streaming response errors out naturally, the task logs and enters backoff +- On network drop: reqwest stream errors → task logs, waits 5s (exponential backoff up to 30s), reconnects +- On component destroy: the task is cancelled (drop the JoinHandle) +- **Keep the 30s poll as safety net** — if SSE works, the poll is a no-op (no changes detected). Belt and suspenders. + +### Why SSE over WebSocket +SSE is simpler: unidirectional server→client, plain HTTP, no framing protocol. Chip sync only needs server→client push; the client already pushes via HTTP POST. + +### Files +- `src-tauri/src/sharing_vocab_api.rs` — add broadcast channel to ApiState, SSE endpoint, trigger on sync +- `src-tauri/src/conditions_remote.rs` — add `subscribe_events()` streaming method +- `src-tauri/src/commands/conditions.rs` — add command to start/stop SSE subscription +- `src/lib/components/ConditionChips.svelte` — listen for `condition-chips-changed` event + +--- + +## Item #4: Async Encryption (Non-Blocking Stop) + +### Problem +`stop_recording` blocks for 1-2 seconds while encrypting the WAV (AES-256-GCM + fsync on a 30-50MB file). The user can't start a new recording until it returns. + +### Critical constraint +The transcription pipeline reads the WAV immediately after `stop_recording` returns. If encryption is mid-rename, the reader sees a half-written file ("no RIFF tag found"). The current await exists to prevent this race. + +### Approach — background encryption with reader-coordination flag +1. `stop_recording` saves the WAV, inserts the recording row with `encryption_pending = true`, spawns background encryption task, and returns immediately (~50ms) +2. Background task calls `encrypt_file_in_place`, then updates the row: `encryption_pending = false` +3. The reader (`open_recording_wav`) already handles both encrypted and plaintext files — it checks `FE1` magic and branches accordingly + +### Why this is safe +Encryption is atomic at the filesystem level (rename is atomic on all platforms). The reader checks for `FE1` magic: +- Before rename: file is plaintext → reader gets `NotEncrypted` → reads plaintext ✓ +- After rename: file is ciphertext → reader decrypts ✓ +- No window where reader sees a corrupted file — rename either completed or it didn't + +### New DB column (migration m012) +`encryption_pending BOOLEAN NOT NULL DEFAULT 0` on the `recordings` table. Tracks encryption state independently from the recording's processing `status`. + +### Background task reliability +If the app crashes between insert and encryption, the recording stays plaintext with `encryption_pending = true`. On next app launch, a sweep checks for `encryption_pending = true` rows and encrypts them (same pattern as `fail_stuck_processing`). + +### Files +- `crates/db/src/migrations/m012_encryption_pending.rs` — add column +- `crates/db/src/migrations/mod.rs` — register m012 +- `crates/db/src/recordings.rs` — add `set_encryption_pending` method +- `src-tauri/src/commands/audio.rs` — change `stop_recording` to spawn background encryption instead of awaiting +- `src-tauri/src/lib.rs` or `src-tauri/src/state.rs` — add encryption sweep on startup + +### Implementation order +This item is done LAST (after #2, #3, #1) because it touches the most sensitive code path (audio recording + PHI encryption). + +--- + +## Testing Strategy + +### Item #2 (toasts) +- Unit test: `dirtySince` is set on add/remove/reorder, cleared after 5s +- Frontend test: toast appears when poll detects change while dirty, no toast when idle + +### Item #3 (release) +- Verify the manifest job produces valid JSON with all 3 platforms +- Manual: trigger a release tag, verify `latest.json` appears only after all assets + +### Item #1 (SSE) +- Server unit test: broadcast sender triggers on sync handler +- Client integration test: SSE stream receives "changed" event after a sync +- Connection lifecycle: test reconnection after simulated network drop + +### Item #4 (async encryption) +- DB migration test: m012 adds column without data loss +- Reader test: `open_recording_wav` correctly reads both `encryption_pending=true` (plaintext) and `encryption_pending=false` (ciphertext) files +- Integration test: `stop_recording` returns immediately, background task encrypts, reader can transcribe +- Sweep test: on startup, `encryption_pending=true` rows get encrypted From 2cc94bad92b839568d1b35ea655654562e176db4 Mon Sep 17 00:00:00 2001 From: Andre Hugo Date: Wed, 8 Jul 2026 08:57:08 -0700 Subject: [PATCH 02/20] docs: implementation plans for 4 high-priority improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #2 Conflict toasts — dirtySince flag + toast on remote change (1 task) #3 Release manifest — updaterJson:false + dedicated manifest job (1 task) #1 SSE chip sync — broadcast + SSE endpoint + streaming client (3 tasks) #4 Async encryption — background encrypt + startup sweep (3 tasks) --- .../plans/2026-07-08-item1-sse-chip-sync.md | 404 ++++++++++++++++++ .../plans/2026-07-08-item2-conflict-toasts.md | 149 +++++++ .../2026-07-08-item3-release-manifest.md | 175 ++++++++ .../2026-07-08-item4-async-encryption.md | 275 ++++++++++++ 4 files changed, 1003 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-08-item1-sse-chip-sync.md create mode 100644 docs/superpowers/plans/2026-07-08-item2-conflict-toasts.md create mode 100644 docs/superpowers/plans/2026-07-08-item3-release-manifest.md create mode 100644 docs/superpowers/plans/2026-07-08-item4-async-encryption.md diff --git a/docs/superpowers/plans/2026-07-08-item1-sse-chip-sync.md b/docs/superpowers/plans/2026-07-08-item1-sse-chip-sync.md new file mode 100644 index 00000000..bbf160c0 --- /dev/null +++ b/docs/superpowers/plans/2026-07-08-item1-sse-chip-sync.md @@ -0,0 +1,404 @@ +# Item #1: SSE Realtime Chip Sync — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans. Steps use checkbox (`- [ ]`) syntax. + +**Goal:** Replace the 30s poll with push-based SSE so chip changes appear within seconds across machines. + +**Architecture:** Add a `tokio::sync::broadcast` channel to the server's `ApiState`. When the sync handler completes a merge, broadcast a notification. Add an SSE endpoint that streams these notifications. The client subscribes via a long-lived streaming reqwest connection and emits a Tauri event the frontend listens for. Keep the 30s poll as a safety net. + +**Tech Stack:** Axum 0.8 SSE, reqwest streaming, tokio broadcast, Tauri events. + +**Spec:** `docs/superpowers/specs/2026-07-08-high-priority-improvements-design.md` (Item #1) + +--- + +## File Structure + +### Modified files + +| File | Change | +|------|--------| +| `src-tauri/src/sharing_vocab_api.rs` | Add broadcast channel to ApiState, SSE endpoint, trigger on sync | +| `src-tauri/src/conditions_remote.rs` | Add `subscribe_events()` streaming method | +| `src-tauri/src/commands/conditions.rs` | Add `subscribe_condition_chips` command | +| `src-tauri/src/lib.rs` | Register new command | +| `src/lib/components/ConditionChips.svelte` | Listen for `condition-chips-changed` event | + +--- + +## Task 1: Add broadcast channel to server ApiState + SSE endpoint + +**Files:** +- Modify: `src-tauri/src/sharing_vocab_api.rs` + +- [ ] **Step 1: Read the current file** + +Read `src-tauri/src/sharing_vocab_api.rs` fully. Find: +- The `ApiState` struct definition +- The `spawn` function that builds the Router +- The `condition_chips_sync_handler` function +- The imports section + +- [ ] **Step 2: Add broadcast channel to ApiState** + +Add a broadcast sender to `ApiState`. The channel carries `()` — just a "something changed" signal, no data: + +```rust +use tokio::sync::broadcast; + +#[derive(Clone)] +pub struct ApiState { + db: Arc, + tokens: Arc, + chips_changed_tx: broadcast::Sender<()>, +} +``` + +In the `spawn` function, create the channel before building `ApiState`: + +```rust +let (chips_changed_tx, _) = broadcast::channel::<()>(16); +let state = ApiState { + db: Arc::clone(&db), + tokens: Arc::clone(&tokens), + chips_changed_tx: chips_changed_tx.clone(), +}; +``` + +- [ ] **Step 3: Trigger broadcast in sync handler** + +In `condition_chips_sync_handler`, after the merge succeeds and before returning, trigger the broadcast: + +```rust + // Notify SSE subscribers that chips changed. + let _ = state.chips_changed_tx.send(()); +``` + +This is best-effort — `send` returns Err if there are no receivers, which is fine (no subscribers = no notification needed). + +- [ ] **Step 4: Add the SSE endpoint** + +Add a new route to the Router (alongside the other condition-chips routes): + +```rust + .route("/v1/condition-chips/events", get(condition_chips_events_handler)) +``` + +Add the handler: + +```rust +use axum::response::sse::{Event, Sse}; +use futures_util::Stream; + +/// GET /v1/condition-chips/events — SSE stream of chip-change notifications. +/// +/// Sends a "changed" event whenever the sync handler completes a merge. +/// The client uses this as a "re-pull" signal — it calls list_condition_chips +/// on each event. +async fn condition_chips_events_handler( + AxumState(state): AxumState, + headers: HeaderMap, +) -> Result>>, StatusCode> { + let _ = authorize(&state, &headers)?; + let mut rx = state.chips_changed_tx.subscribe(); + + let stream = async_stream::stream! { + // Send an initial event so the client knows the connection is live. + yield Ok(Event::data("connected")); + loop { + match rx.recv().await { + Ok(()) => yield Ok(Event::data("changed")), + Err(broadcast::error::RecvError::Lagged(_)) => continue, + Err(broadcast::error::RecvError::Closed) => break, + } + } + }; + + Ok(Sse::new(stream)) +} +``` + +**IMPORTANT:** This uses `async_stream` and `futures_util`. Check if they're already dependencies. If not, add them to `src-tauri/Cargo.toml`: +- `async-stream` or `async-stream` — check workspace deps +- `futures-util` — likely already available + +Read `src-tauri/Cargo.toml` to check. The `axum` SSE support is built-in (no extra feature flag needed for 0.8). + +- [ ] **Step 5: Build to verify** + +Run: `cargo build -p rust-medical-assistant 2>&1 | tail -20` +Expected: compiles. Fix any missing imports or dependencies. + +- [ ] **Step 6: Commit** + +```bash +git add src-tauri/src/sharing_vocab_api.rs src-tauri/Cargo.toml +git commit -m "feat(sharing): SSE endpoint for condition chip change notifications" +``` + +--- + +## Task 2: Add SSE subscription client + Tauri command + +**Files:** +- Modify: `src-tauri/src/conditions_remote.rs` +- Modify: `src-tauri/src/commands/conditions.rs` +- Modify: `src-tauri/src/lib.rs` + +- [ ] **Step 1: Add subscribe_events to ConditionsRemote** + +Read `src-tauri/src/conditions_remote.rs` fully. Add a new method that opens a streaming GET and yields "changed" signals: + +```rust +use futures_util::StreamExt; + +/// Subscribe to SSE change notifications. Returns a stream of () items, +/// each representing a "chips changed" signal from the server. +/// +/// The caller should call `list_condition_chips` (pull + merge) on each +/// item to stay in sync. +pub async fn subscribe_events( + &self, +) -> AppResult> { + let url = format!( + "{}/v1/condition-chips/events", + self.base_url().ok_or_else(|| { + AppError::Other("no vocab base URL for conditions remote".into()) + })? + ); + + let resp = self + .client + .get(&url) + .timeout(Duration::from_secs(300)) // long timeout for SSE + .bearer_auth(&self.bearer) + .send() + .await + .map_err(|e| AppError::Other(format!("SSE connect: {e}")))?; + + if !resp.status().is_success() { + return Err(AppError::Other(format!( + "SSE connect failed: {}", + resp.status() + ))); + } + + // Parse SSE lines from the response body stream. + let byte_stream = resp.bytes_stream(); + let event_stream = byte_stream + .filter_map(|chunk| async move { + match chunk { + Ok(bytes) => { + let text = String::from_utf8_lossy(&bytes); + // SSE events are "data: \n\n" + for line in text.lines() { + if line.starts_with("data: changed") { + return Some(()); + } + } + None + } + Err(_) => None, + } + }); + + Ok(event_stream) +} +``` + +**Note:** The SSE parsing is intentionally simple — we only look for `data: changed` lines. The `data: connected` initial event is ignored (it's just a connection acknowledgment). + +- [ ] **Step 2: Add the subscribe command** + +In `src-tauri/src/commands/conditions.rs`, add a command that starts the SSE subscription and emits a Tauri event on each notification: + +```rust +/// Start listening for condition chip change notifications from the server. +/// Emits a `condition-chips-changed` Tauri event whenever the server notifies +/// of a change. The frontend calls this when sync is enabled + paired. +#[tauri::command] +#[instrument(skip(app, state), name = "conditions::subscribe")] +pub async fn subscribe_condition_chips( + app: tauri::AppHandle, + state: tauri::State<'_, AppState>, +) -> AppResult<()> { + // Only subscribe if sync is enabled + paired. + let Some((conn, bearer)) = paired_conditions_target(&state) else { + return Ok(()); + }; + let Some(remote) = crate::conditions_remote::ConditionsRemote::from( + &conn, + Some(bearer), + state.http_client.clone(), + ) else { + return Ok(()); + }; + + // Clone the connection data for the spawned task (avoids borrow issues). + // The remote borrows conn — we need to move conn into the task. + // Actually, ConditionsRemote borrows &PairedConnection which can't be moved + // into a 'static task. We need to reconstruct inside the task. + let http_client = state.http_client.clone(); + let bearer = bearer; + let conn = conn; + + tokio::spawn(async move { + let mut backoff = Duration::from_secs(5); + loop { + let remote = match crate::conditions_remote::ConditionsRemote::from( + &conn, + Some(bearer.clone()), + http_client.clone(), + ) { + Some(r) => r, + None => { + tracing::warn!("SSE subscription: remote unavailable, retrying"); + tokio::time::sleep(backoff).await; + backoff = (backoff * 2).min(Duration::from_secs(30)); + continue; + } + }; + + match remote.subscribe_events().await { + Ok(mut stream) => { + tracing::info!("SSE subscription connected"); + backoff = Duration::from_secs(5); // reset backoff on success + while let Some(()) = stream.next().await { + // Emit Tauri event — frontend listens for this. + let _ = app.emit("condition-chips-changed", ()); + } + tracing::info!("SSE stream ended, reconnecting"); + } + Err(e) => { + tracing::warn!(error = %e, "SSE subscription failed, reconnecting"); + } + } + tokio::time::sleep(backoff).await; + backoff = (backoff * 2).min(Duration::from_secs(30)); + } + }); + + Ok(()) +} +``` + +**IMPORTANT:** This command spawns an infinite reconnection loop. The task runs forever (until the app exits). The `app.emit("condition-chips-changed", ())` is what the frontend listens for. + +Add necessary imports at the top of `conditions.rs`: +```rust +use std::time::Duration; +use futures_util::StreamExt; +use tauri::Emitter; +``` + +- [ ] **Step 3: Register the command** + +In `src-tauri/src/lib.rs`, add to `generate_handler!`: +```rust + commands::conditions::subscribe_condition_chips, +``` + +- [ ] **Step 4: Build to verify** + +Run: `cargo build -p rust-medical-assistant 2>&1 | tail -20` +Expected: compiles. Fix any issues. + +- [ ] **Step 5: Commit** + +```bash +git add src-tauri/src/conditions_remote.rs src-tauri/src/commands/conditions.rs src-tauri/src/lib.rs src-tauri/Cargo.toml +git commit -m "feat(sync): SSE client + subscribe command for realtime chip sync" +``` + +--- + +## Task 3: Frontend — listen for SSE events + call subscribe on mount + +**Files:** +- Modify: `src/lib/components/ConditionChips.svelte` + +- [ ] **Step 1: Add Tauri event listener** + +In `ConditionChips.svelte`, import the Tauri event listener: + +```typescript + import { listen } from '@tauri-apps/api/event'; +``` + +In `onMount`, after starting the poll, also start the SSE subscription and listen for the event: + +```typescript + onMount(async () => { + await refreshChips(); + pollHandle = setInterval(refreshChips, 30_000); + + // Listen for SSE push notifications (realtime sync). + unlistenSSE = await listen('condition-chips-changed', () => { + refreshChips(); + }); + + // Start the SSE subscription on the backend (no-op if not paired). + try { + await invoke('subscribe_condition_chips'); + } catch (e) { + console.error('Failed to start chip sync subscription:', e); + } + }); +``` + +Add the state + cleanup: + +```typescript + let unlistenSSE: (() => void) | null = null; +``` + +In `onDestroy`: +```typescript + onDestroy(() => { + if (pollHandle) clearInterval(pollHandle); + if (dirtyTimer) clearTimeout(dirtyTimer); + if (unlistenSSE) unlistenSSE(); + }); +``` + +Add the `invoke` import if not already present: +```typescript + import { invoke } from '@tauri-apps/api/core'; +``` + +- [ ] **Step 2: Run type check** + +Run: `npm run check` +Expected: 0 errors. + +- [ ] **Step 3: Run lint + tests** + +Run: `npx eslint src/lib/components/ConditionChips.svelte` +Run: `npx vitest run src/lib/components/ConditionChips.test.ts` +Expected: 0 errors, all tests pass. + +- [ ] **Step 4: Commit** + +```bash +git add src/lib/components/ConditionChips.svelte +git commit -m "feat(frontend): listen for SSE chip change events for realtime sync" +``` + +--- + +## Self-Review + +### Spec coverage +- ✅ Broadcast channel in ApiState — Task 1 +- ✅ SSE endpoint — Task 1 +- ✅ Trigger on sync handler — Task 1 +- ✅ Client subscribe_events() — Task 2 +- ✅ Tauri command + reconnection loop — Task 2 +- ✅ Frontend listen + subscribe on mount — Task 3 +- ✅ Keep 30s poll as safety net — Task 3 (pollHandle unchanged) + +### Known caveats +1. `async_stream` and `futures_util` may need to be added to Cargo.toml — check in Task 1 Step 4. +2. The `PairedConnection` borrow issue in the spawned task — the command reconstructs `ConditionsRemote` inside the loop to avoid lifetime issues. +3. The SSE stream parsing is simple line-matching — doesn't handle multi-line SSE data fields, but our events are single-line (`data: changed`). +4. The `subscribe_condition_chips` command is fire-and-forget — it spawns a forever-loop task. There's no explicit stop mechanism beyond app exit. This is acceptable since the task is cheap and the poll is the safety net. diff --git a/docs/superpowers/plans/2026-07-08-item2-conflict-toasts.md b/docs/superpowers/plans/2026-07-08-item2-conflict-toasts.md new file mode 100644 index 00000000..7243441e --- /dev/null +++ b/docs/superpowers/plans/2026-07-08-item2-conflict-toasts.md @@ -0,0 +1,149 @@ +# Item #2: Conflict Feedback Toasts — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans. Steps use checkbox (`- [ ]`) syntax. + +**Goal:** Show a toast when another machine changes condition chips while the user has local pending changes. + +**Architecture:** Track a `dirtySince` timestamp in `ConditionChips.svelte`. Set it on local mutations (add/remove/reorder). When the poll detects a remote change AND `dirtySince` is within 5 seconds, show an info toast. Clear `dirtySince` after 5s. + +**Tech Stack:** Svelte 5 runes, existing `toasts` store. + +**Spec:** `docs/superpowers/specs/2026-07-08-high-priority-improvements-design.md` (Item #2) + +--- + +## Task 1: Add dirtySince tracking + toast on remote change + +**Files:** +- Modify: `src/lib/components/ConditionChips.svelte` + +- [ ] **Step 1: Read the current component** + +Read `src/lib/components/ConditionChips.svelte` fully. Find: +- The `refreshChips` function (the poll callback) +- The `addNewCondition`, `removeCondition`, and `handleDrop` functions (local mutations) +- The imports at the top + +- [ ] **Step 2: Add imports + dirtySince state** + +In the ` + +
+ + + +
+ +{#if sharingOn} +

Stop sharing first (in the panel below) before switching modes.

+{/if} + + +``` + +- [ ] **Step 3: Create ConditionChipSync.svelte** + +Extract the sync toggle: + +```svelte + + +{#if visible} + +{/if} +``` + +- [ ] **Step 4: Rewrite Sharing.svelte as thin orchestrator** + +Replace Sharing.svelte with: + +```svelte + + + + + +``` + +**IMPORTANT:** The `onStatusChange` callback replaces the direct invoke calls that were in Sharing.svelte. The ServerWizard/ServerStatus components previously used an `ondone`/`onstopped` callback prop for refresh — check if those are still needed and wire them through the modes component. Read the original Sharing.svelte to verify the callback wiring for ServerWizard and ServerStatus. + +- [ ] **Step 5: Run type check + lint + tests** + +Run: `npm run check` +Run: `npx eslint src/lib/components/settings/Sharing.svelte src/lib/components/settings/sharing/SharingModes.svelte src/lib/components/settings/sharing/ConditionChipSync.svelte` +Expected: 0 errors. + +- [ ] **Step 6: Commit** + +```bash +git add src/lib/components/settings/Sharing.svelte src/lib/components/settings/sharing/SharingModes.svelte src/lib/components/settings/sharing/ConditionChipSync.svelte +git commit -m "refactor: split Sharing.svelte into SharingModes + ConditionChipSync sections + +Follows the General.svelte split pattern. Parent is now a thin orchestrator +(~25 lines) that routes sub-components based on mode." +``` + +--- + +## Self-Review + +### Spec coverage +- ✅ #5 join_err helper — Task 1 (revised to avoid medical-core dep change) +- ✅ #6 Round-trip test — Task 1 (4 test scenarios) +- ✅ #7 Dependabot triage — Tasks 1-2 (close + ignore + try-merge) +- ✅ #8 Sharing split — Task 1 (2 new components + thin parent) + +### Type consistency +- `join_err(e: tokio::task::JoinError) -> AppError` — consistent signature +- `SharingModes` callbacks: `onModeChange(mode)` + `onStatusChange(sharingOn, pairedTo)` — must match parent's usage +- `ConditionChipSync` prop: `visible: boolean` — matches parent's `sharingOn || pairedTo !== null` + +### Known caveats +1. #5: The `join_err` helper avoids adding tokio to medical-core. Some command files may import `AppError` directly and need `use crate::commands::join_err;` added. +2. #8: ServerWizard/ServerStatus previously had `ondone`/`onstopped` callbacks for refresh — these need to be wired through SharingModes. Verify the original Sharing.svelte callback wiring. +3. #7: sha2 and rodio bumps may not compile — have a close-with-comment plan ready. From 1574764a1bb215ccd7e7a2f60174ff8e1c675162 Mon Sep 17 00:00:00 2001 From: Andre Hugo Date: Wed, 8 Jul 2026 10:24:32 -0700 Subject: [PATCH 05/20] docs: low-priority improvements spec + plans (items #9-#10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #9 Conditional chip poll — gate 30s interval behind sync_condition_chips #10 Keyboard shortcuts — Space (record/stop) + Cmd+Enter (generate SOAP) --- .../plans/2026-07-08-low-priority-items.md | 191 ++++++++++++++++++ ...-07-08-low-priority-improvements-design.md | 42 ++++ 2 files changed, 233 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-08-low-priority-items.md create mode 100644 docs/superpowers/specs/2026-07-08-low-priority-improvements-design.md diff --git a/docs/superpowers/plans/2026-07-08-low-priority-items.md b/docs/superpowers/plans/2026-07-08-low-priority-items.md new file mode 100644 index 00000000..a5157a1c --- /dev/null +++ b/docs/superpowers/plans/2026-07-08-low-priority-items.md @@ -0,0 +1,191 @@ +# Low-Priority Improvements — Implementation Plans + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans. + +**Spec:** `docs/superpowers/specs/2026-07-08-low-priority-improvements-design.md` + +--- + +# Item #9: Conditional Chip Poll + +## Task 1: Gate the poll behind sync_condition_chips + +**Files:** +- Modify: `src/lib/components/ConditionChips.svelte` + +- [ ] **Step 1: Read the current poll setup** + +Read `src/lib/components/ConditionChips.svelte`. Find the `onMount` block with `setInterval` and the `pollHandle` + `onDestroy` declarations. + +- [ ] **Step 2: Add settings import** + +Add to the script imports: +```typescript + import { settings } from '../stores/settings.svelte'; +``` + +- [ ] **Step 3: Replace unconditional poll with $effect** + +Replace the `pollHandle = setInterval(refreshChips, 30_000)` line in `onMount` with an `$effect` that watches the sync setting: + +```typescript + // Only poll when sync is enabled — avoids pointless DB reads for users + // who haven't opted into chip sync (the default). + $effect(() => { + if (settings.state.sync_condition_chips) { + pollHandle = setInterval(refreshChips, 30_000); + return () => { if (pollHandle) clearInterval(pollHandle); }; + } + }); +``` + +Remove the `setInterval` line from `onMount`. Keep the initial `refreshChips()` call in `onMount` (chips load regardless of sync). + +The `$effect` automatically starts/stops the interval when `sync_condition_chips` changes. The cleanup function clears the interval when the setting is toggled off or the component is destroyed. + +Remove the `onDestroy` pollHandle cleanup since the `$effect` handles it now. Keep `onDestroy` for `dirtyTimer` and `unlistenSSE` if they exist. + +- [ ] **Step 4: Run type check + lint + tests** + +Run: `npm run check` +Run: `npx eslint src/lib/components/ConditionChips.svelte` +Run: `npx vitest run src/lib/components/ConditionChips.test.ts` +Expected: 0 errors, all tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/components/ConditionChips.svelte +git commit -m "perf: only poll condition chips when sync is enabled + +The 30s poll now only runs when sync_condition_chips is true. When sync +is off (the default), no interval runs — saves a pointless DB read every +30s for every user who hasn't opted into chip sync." +``` + +--- + +# Item #10: Keyboard Shortcuts + +## Task 1: Add Space (record/stop) + Cmd+Enter (generate SOAP) shortcuts + +**Files:** +- Modify: `src/lib/pages/RecordTab.svelte` + +- [ ] **Step 1: Read the current RecordTab** + +Read `src/lib/pages/RecordTab.svelte`. Find: +- The record/stop button and its click handler +- The generate SOAP button and its handler +- The `audio` store usage (audio.state.state for recording/idle/stopped) +- The onMount/onDestroy or existing lifecycle hooks + +- [ ] **Step 2: Add keyboard handler** + +In the script section, add a keydown handler: + +```typescript + function handleGlobalKeydown(e: KeyboardEvent) { + // Don't fire when typing in an input, textarea, or contenteditable. + const target = e.target as HTMLElement; + if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) { + return; + } + + // Space — toggle record/stop + if (e.code === 'Space') { + e.preventDefault(); + if (audio.state.state === 'recording') { + audio.stop(); + } else if (audio.state.state === 'idle' || audio.state.state === 'stopped') { + audio.start(); + } + return; + } + + // Cmd+Enter (Mac) or Ctrl+Enter — generate SOAP + if ((e.metaKey || e.ctrlKey) && e.code === 'Enter') { + e.preventDefault(); + // Only generate if there's a selected recording and we're not already generating + if (audio.state.lastRecordingId && /* check not already generating */) { + // Call the generate handler — read the actual function name from RecordTab + } + } + } +``` + +**IMPORTANT:** Read the actual RecordTab to find the correct function names for starting/stopping recording and generating SOAP. The `audio.start()` and `audio.stop()` are on the audio store, but the generate function may be a local function like `handleGenerate` or `handleRegenerateSoap`. Also check if there's a `generating` state flag to prevent double-generate. + +- [ ] **Step 3: Register the listener on mount** + +```typescript + onMount(() => { + window.addEventListener('keydown', handleGlobalKeydown); + }); + + onDestroy(() => { + window.removeEventListener('keydown', handleGlobalKeydown); + }); +``` + +If the component already has onMount/onDestroy, add the listener to the existing hooks. + +- [ ] **Step 4: Add discoverability hints** + +Find the record/stop button in the markup. Add a `title` attribute and a small hint: + +For the record button, add to its title: +```svelte +title="Record (Space)" +``` + +For the generate button, add: +```svelte +title="Generate SOAP (Cmd+Enter)" +``` + +Optionally, add a tiny hint text below the record button: +```svelte +Space to record +``` + +With CSS: +```css +.shortcut-hint { + font-size: 9px; + color: var(--text-muted, #666); + opacity: 0.6; +} +``` + +- [ ] **Step 5: Run type check + lint** + +Run: `npm run check` +Run: `npx eslint src/lib/pages/RecordTab.svelte` +Expected: 0 errors. + +- [ ] **Step 6: Commit** + +```bash +git add src/lib/pages/RecordTab.svelte +git commit -m "feat(ui): keyboard shortcuts — Space for record/stop, Cmd+Enter for SOAP + +Space toggles recording (push-to-talk pattern). Cmd+Enter generates SOAP +for the selected recording. Both are guarded against firing while typing +in inputs. Hints added to button titles." +``` + +--- + +## Self-Review + +### Spec coverage +- ✅ #9 Conditional poll — gate behind sync_condition_chips via $effect +- ✅ #10 Shortcuts — Space (record/stop) + Cmd+Enter (generate SOAP) +- ✅ Input guard — check tagName before firing +- ✅ Discoverability — title attributes + hint text + +### Known caveats +1. #10: The exact function names for generate SOAP must be read from RecordTab — the plan says to read the file first. +2. #10: Space key may conflict with scroll behavior — `e.preventDefault()` handles this. +3. #9: The `$effect` must be at the top level of the script, not inside onMount — Svelte 5 requirement. diff --git a/docs/superpowers/specs/2026-07-08-low-priority-improvements-design.md b/docs/superpowers/specs/2026-07-08-low-priority-improvements-design.md new file mode 100644 index 00000000..7298ac69 --- /dev/null +++ b/docs/superpowers/specs/2026-07-08-low-priority-improvements-design.md @@ -0,0 +1,42 @@ +# Low-Priority Improvements — Design Spec + +**Date:** 2026-07-08 +**Status:** Approved +**Scope:** 2 independent improvements + +--- + +## Item #9: Conditional Chip Poll + +### Problem +The 30s poll calls `refreshChips()` unconditionally — even when sync is off and the machine isn't paired. Re-reads the local DB every 30s for no reason. + +### Approach +Gate the `setInterval` behind the `sync_condition_chips` setting. Only poll when sync is enabled. Use an `$effect` that watches the setting and starts/stops the interval accordingly. When sync is off (the default), no poll runs at all. + +### Details +- Replace the unconditional `setInterval` in `onMount` with an `$effect` that depends on `settings.state.sync_condition_chips` +- When the setting is true: start the 30s interval +- When the setting is false: clear the interval +- The initial `listConditionChips()` call in `onMount` stays unconditional (chips need to load regardless of sync) + +--- + +## Item #10: Keyboard Shortcuts + +### Problem +No keyboard shortcuts for common actions. Clinicians working fast would benefit from push-to-talk and quick-generate. + +### Approach +Add a global keyboard handler on the Record tab: +- **Space** — toggle record/stop (push-to-talk pattern). Only fires when not typing in an input/textarea. +- **Cmd+Enter** — generate SOAP for the selected recording. + +Guard against firing when the user is typing in an input, textarea, or contenteditable element (check `e.target.tagName`). + +### Discoverability +- Add a small hint near the record button: "⌨ Space" in muted text +- Add `title` attributes to buttons mentioning the shortcut + +### Files +- `src/lib/pages/RecordTab.svelte` — add keydown listener + hints From 0cf57a671cb8ef1ddd2362530a4897e917bd719e Mon Sep 17 00:00:00 2001 From: Andre Hugo Date: Wed, 8 Jul 2026 10:31:13 -0700 Subject: [PATCH 06/20] perf: only poll condition chips when sync is enabled --- src/lib/components/ConditionChips.svelte | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/lib/components/ConditionChips.svelte b/src/lib/components/ConditionChips.svelte index c263ac0c..8cc83e27 100644 --- a/src/lib/components/ConditionChips.svelte +++ b/src/lib/components/ConditionChips.svelte @@ -7,6 +7,7 @@ reorderConditionChips, } from '../api/conditions'; import type { ConditionChip } from '../api/conditions'; + import { settings } from '../stores/settings.svelte'; let { onAdd }: { onAdd: (condition: string) => void } = $props(); @@ -63,10 +64,15 @@ onMount(async () => { await refreshChips(); - // Poll every 30s so changes from other machines (via server sync) appear - // without requiring an app restart. When sync is off or unpaired, this - // just re-reads the local DB — negligible cost. - pollHandle = setInterval(refreshChips, 30_000); + }); + + // Only poll when sync is enabled — avoids pointless DB reads for users + // who haven't opted into chip sync (the default). + $effect(() => { + if (settings.state.sync_condition_chips) { + pollHandle = setInterval(refreshChips, 30_000); + return () => { if (pollHandle) clearInterval(pollHandle); }; + } }); async function refreshChips() { From 49afcc989020f13b15668854a6934625e054bd1b Mon Sep 17 00:00:00 2001 From: Andre Hugo Date: Wed, 8 Jul 2026 10:34:53 -0700 Subject: [PATCH 07/20] =?UTF-8?q?feat(ui):=20keyboard=20shortcuts=20?= =?UTF-8?q?=E2=80=94=20Space=20for=20record/stop,=20Cmd+Enter=20for=20SOAP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/components/RecordingHeader.svelte | 8 ++-- src/lib/pages/RecordTab.svelte | 44 +++++++++++++++++++++- src/lib/pages/record/PipelineStatus.svelte | 2 +- 3 files changed, 48 insertions(+), 6 deletions(-) diff --git a/src/lib/components/RecordingHeader.svelte b/src/lib/components/RecordingHeader.svelte index 85ebbc56..288410ad 100644 --- a/src/lib/components/RecordingHeader.svelte +++ b/src/lib/components/RecordingHeader.svelte @@ -54,14 +54,14 @@
{#if audio.state.state === 'idle'} - {:else if audio.state.state === 'recording'} - - {:else if audio.state.state === 'stopped'} - {/if} diff --git a/src/lib/pages/RecordTab.svelte b/src/lib/pages/RecordTab.svelte index 3f63355a..a42b69e9 100644 --- a/src/lib/pages/RecordTab.svelte +++ b/src/lib/pages/RecordTab.svelte @@ -15,7 +15,7 @@ import PatientContextSidebar from './record/PatientContextSidebar.svelte'; import ResizeHandle from './record/ResizeHandle.svelte'; import { open } from '@tauri-apps/plugin-dialog'; - import { onMount } from 'svelte'; + import { onMount, onDestroy } from 'svelte'; import { contextTemplates } from '../stores/contextTemplates.svelte'; import { toasts } from '../stores/toasts.svelte'; import { rsvp } from '../stores/rsvp.svelte'; @@ -86,6 +86,11 @@ onMount(() => { contextTemplates.load(); + window.addEventListener('keydown', handleGlobalKeydown); + }); + + onDestroy(() => { + window.removeEventListener('keydown', handleGlobalKeydown); }); // Import flow state @@ -315,6 +320,43 @@ toasts.error(`Failed to open speed reader: ${e}`); } } + + // Global keyboard shortcuts for the Record tab. + // Space — toggle record/stop + // Cmd+Enter — generate (regenerate) SOAP for the selected recording + // Both bail out when the user is typing in an input, textarea, or + // contenteditable element so the shortcuts don't hijack normal text entry. + function handleGlobalKeydown(e: KeyboardEvent) { + const target = e.target as HTMLElement | null; + if ( + target && + (target.tagName === 'INPUT' || + target.tagName === 'TEXTAREA' || + target.isContentEditable) + ) { + return; + } + + // Space — toggle record/stop. + // recording -> stop; idle/stopped/paused -> start a fresh recording. + if (e.code === 'Space') { + e.preventDefault(); + if (audio.state.state === 'recording') { + handleStopRecording(); + } else { + handleStartRecording(); + } + return; + } + + // Cmd+Enter (Mac) or Ctrl+Enter — regenerate the SOAP note for the + // selected recording. handleRegenerateSoap already no-ops when there's + // no recording or a regeneration is in flight. + if ((e.metaKey || e.ctrlKey) && e.code === 'Enter') { + e.preventDefault(); + handleRegenerateSoap(); + } + }
diff --git a/src/lib/pages/record/PipelineStatus.svelte b/src/lib/pages/record/PipelineStatus.svelte index 257813ac..3de95d4e 100644 --- a/src/lib/pages/record/PipelineStatus.svelte +++ b/src/lib/pages/record/PipelineStatus.svelte @@ -128,7 +128,7 @@ class="btn-secondary" onclick={onRegenerate} disabled={regenerating} - title="Regenerate the SOAP note using the current Patient Context (keeps the transcript)" + title="Generate SOAP (Cmd+Enter) — regenerate using the current Patient Context (keeps the transcript)" > {#if regenerating}{/if} {regenerating ? 'Regenerating…' : 'Regenerate'} From d115f2cae29e7ba225e43f898b2d5e17df182050 Mon Sep 17 00:00:00 2001 From: Andre Hugo Date: Wed, 8 Jul 2026 10:41:12 -0700 Subject: [PATCH 08/20] refactor: extract join_err helper to eliminate spawn_blocking boilerplate --- src-tauri/src/commands/audio.rs | 4 ++-- src-tauri/src/commands/conditions.rs | 16 +++++++-------- src-tauri/src/commands/generation/helpers.rs | 4 ++-- src-tauri/src/commands/letter_audiences.rs | 6 +++--- src-tauri/src/commands/mod.rs | 6 ++++++ src-tauri/src/commands/recordings_edit.rs | 2 +- src-tauri/src/commands/support.rs | 2 +- src-tauri/src/commands/training_corpus.rs | 6 +++--- src-tauri/src/commands/transcription/inner.rs | 2 +- src-tauri/src/commands/user_dictionary.rs | 6 +++--- src-tauri/src/commands/vocabulary.rs | 20 +++++++++---------- 11 files changed, 40 insertions(+), 34 deletions(-) diff --git a/src-tauri/src/commands/audio.rs b/src-tauri/src/commands/audio.rs index a0e643be..1d2d2abf 100644 --- a/src-tauri/src/commands/audio.rs +++ b/src-tauri/src/commands/audio.rs @@ -497,12 +497,12 @@ pub async fn check_recording_audio_levels( RecordingsRepo::get_by_id(&conn, &uuid).map_err(AppError::from) }) .await - .map_err(|e| AppError::Other(format!("Task join error: {e}")))??; + .map_err(crate::commands::join_err)??; let wav_path = recording.audio_path.clone(); let levels = tokio::task::spawn_blocking(move || compute_audio_levels(&wav_path)) .await - .map_err(|e| AppError::Other(format!("Task join error: {e}")))??; + .map_err(crate::commands::join_err)??; if levels.is_silent { warn!( diff --git a/src-tauri/src/commands/conditions.rs b/src-tauri/src/commands/conditions.rs index 8721245d..8c3ba2a3 100644 --- a/src-tauri/src/commands/conditions.rs +++ b/src-tauri/src/commands/conditions.rs @@ -86,7 +86,7 @@ pub async fn list_condition_chips( .map_err(AppError::from) }) .await - .map_err(|e| AppError::Other(format!("Task join error: {e}")))?; + .map_err(crate::commands::join_err)?; } Err(e) => { tracing::warn!(error = %e, "conditions remote list failed, using local"); @@ -102,7 +102,7 @@ pub async fn list_condition_chips( medical_db::condition_chips::ConditionChipsRepo::list_active(&conn).map_err(AppError::from) }) .await - .map_err(|e| AppError::Other(format!("Task join error: {e}")))? + .map_err(crate::commands::join_err)? } /// Add a condition chip. @@ -126,7 +126,7 @@ pub async fn add_condition_chip( .map_err(AppError::from) }) .await - .map_err(|e| AppError::Other(format!("Task join error: {e}")))??; + .map_err(crate::commands::join_err)??; // 2. Best-effort background sync push (non-blocking). The owned // `PairedConnection` is moved into the task and the `ConditionsRemote` @@ -182,7 +182,7 @@ pub async fn remove_condition_chip( .map_err(AppError::from) }) .await - .map_err(|e| AppError::Other(format!("Task join error: {e}")))??; + .map_err(crate::commands::join_err)??; // 2. Best-effort background sync — push ALL chips (including tombstones) // so the server records the deletion. The owned `PairedConnection` is @@ -257,7 +257,7 @@ pub async fn sync_condition_chips_cmd( medical_db::condition_chips::ConditionChipsRepo::list_all(&conn).map_err(AppError::from) }) .await - .map_err(|e| AppError::Other(format!("Task join error: {e}")))??; + .map_err(crate::commands::join_err)??; if let Some((conn, bearer)) = paired_conditions_target(&state) && let Some(remote) = crate::conditions_remote::ConditionsRemote::from( @@ -274,7 +274,7 @@ pub async fn sync_condition_chips_cmd( .map_err(AppError::from) }) .await - .map_err(|e| AppError::Other(format!("Task join error: {e}")))?; + .map_err(crate::commands::join_err)?; } // Not paired / sync disabled — return the local active list. @@ -284,7 +284,7 @@ pub async fn sync_condition_chips_cmd( medical_db::condition_chips::ConditionChipsRepo::list_active(&conn).map_err(AppError::from) }) .await - .map_err(|e| AppError::Other(format!("Task join error: {e}")))? + .map_err(crate::commands::join_err)? } /// Reorder condition chips. @@ -312,7 +312,7 @@ pub async fn reorder_condition_chips( .map_err(AppError::from) }) .await - .map_err(|e| AppError::Other(format!("Task join error: {e}")))??; + .map_err(crate::commands::join_err)??; // 2. Best-effort background sync — push ALL chips (including tombstones) // so the server sees the new sort_order values. The owned diff --git a/src-tauri/src/commands/generation/helpers.rs b/src-tauri/src/commands/generation/helpers.rs index fd8b9960..f31861c4 100644 --- a/src-tauri/src/commands/generation/helpers.rs +++ b/src-tauri/src/commands/generation/helpers.rs @@ -72,7 +72,7 @@ pub(super) async fn load_recording_and_settings( Ok::<_, AppError>((recording, settings, config)) }) .await - .map_err(|e| AppError::Other(format!("Task join error: {e}")))? + .map_err(crate::commands::join_err)? } /// Resolve the AI provider from the registry using the settings provider name. @@ -102,7 +102,7 @@ pub(super) async fn persist_recording( RecordingsRepo::update(&conn, &recording).map_err(AppError::from) }) .await - .map_err(|e| AppError::Other(format!("Task join error: {e}")))? + .map_err(crate::commands::join_err)? } /// Parse a template string into the `SoapTemplate` enum. diff --git a/src-tauri/src/commands/letter_audiences.rs b/src-tauri/src/commands/letter_audiences.rs index 5fca8a5d..b431d78d 100644 --- a/src-tauri/src/commands/letter_audiences.rs +++ b/src-tauri/src/commands/letter_audiences.rs @@ -20,7 +20,7 @@ pub async fn list_letter_audiences( LetterAudiencesRepo::list_all(&conn).map_err(AppError::from) }) .await - .map_err(|e| AppError::Other(format!("Task join error: {e}")))? + .map_err(crate::commands::join_err)? } #[tauri::command] @@ -49,7 +49,7 @@ pub async fn upsert_letter_audience( LetterAudiencesRepo::upsert(&conn, &audience_clone).map_err(AppError::from) }) .await - .map_err(|e| AppError::Other(format!("Task join error: {e}")))??; + .map_err(crate::commands::join_err)??; // PHI guardrail: audience names are physician-authored free text that // could embed a recipient name; log the id + name length only. info!( @@ -72,7 +72,7 @@ pub async fn delete_letter_audience(state: tauri::State<'_, AppState>, id: Uuid) }) }) .await - .map_err(|e| AppError::Other(format!("Task join error: {e}")))??; + .map_err(crate::commands::join_err)??; info!(%id, "Deleted letter audience"); Ok(()) } diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index ea528f79..d3695265 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -27,6 +27,12 @@ use std::path::{Path, PathBuf}; use medical_core::error::{AppError, AppResult}; use medical_db::Database; +/// Convert a tokio JoinError into an AppError. Used by spawn_blocking call +/// sites to avoid repeating the format! boilerplate 36 times. +pub fn join_err(e: tokio::task::JoinError) -> AppError { + AppError::Other(format!("Task join error: {e}")) +} + /// Resolve the recordings directory from settings. /// /// If the user has configured a custom `storage_path`, use it. diff --git a/src-tauri/src/commands/recordings_edit.rs b/src-tauri/src/commands/recordings_edit.rs index 8e0aabe4..7a4e890a 100644 --- a/src-tauri/src/commands/recordings_edit.rs +++ b/src-tauri/src/commands/recordings_edit.rs @@ -72,7 +72,7 @@ pub async fn save_recording_field( ) }) .await - .map_err(|e| AppError::Other(format!("Task join error: {e}")))? + .map_err(crate::commands::join_err)? } /// Inner logic — testable without `tauri::State`. diff --git a/src-tauri/src/commands/support.rs b/src-tauri/src/commands/support.rs index 8c999f34..58887d33 100644 --- a/src-tauri/src/commands/support.rs +++ b/src-tauri/src/commands/support.rs @@ -87,7 +87,7 @@ pub async fn export_support_bundle(file_path: String) -> AppResult<()> { Ok(()) }) .await - .map_err(|e| AppError::Other(format!("Task join error: {e}")))? + .map_err(crate::commands::join_err)? } #[cfg(test)] diff --git a/src-tauri/src/commands/training_corpus.rs b/src-tauri/src/commands/training_corpus.rs index 7ae4dfeb..ccdb3122 100644 --- a/src-tauri/src/commands/training_corpus.rs +++ b/src-tauri/src/commands/training_corpus.rs @@ -34,7 +34,7 @@ pub async fn training_corpus_counts(state: tauri::State<'_, AppState>) -> AppRes GenerationsRepo::count_by_status(&conn) }) .await - .map_err(|e| AppError::Other(format!("Task join error: {e}")))??; + .map_err(crate::commands::join_err)??; Ok(CorpusCounts { candidates: c, promoted: p, @@ -56,7 +56,7 @@ pub async fn training_corpus_list( GenerationsRepo::list_by_status(&conn, &status, limit.unwrap_or(50), offset.unwrap_or(0)) }) .await - .map_err(|e| AppError::Other(format!("Task join error: {e}")))??; + .map_err(crate::commands::join_err)??; Ok(GenerationPage { items, total }) } @@ -74,6 +74,6 @@ pub async fn training_corpus_set_status( GenerationsRepo::set_corpus_status(&conn, id, &new_status) }) .await - .map_err(|e| AppError::Other(format!("Task join error: {e}")))??; + .map_err(crate::commands::join_err)??; Ok(()) } diff --git a/src-tauri/src/commands/transcription/inner.rs b/src-tauri/src/commands/transcription/inner.rs index 14463031..dc1d0ddb 100644 --- a/src-tauri/src/commands/transcription/inner.rs +++ b/src-tauri/src/commands/transcription/inner.rs @@ -94,7 +94,7 @@ pub async fn transcribe_recording_inner( Ok::<_, AppError>(recording) }) .await - .map_err(|e| AppError::Other(format!("Task join error: {e}")))??; + .map_err(crate::commands::join_err)??; let wav_path = if recording.audio_path.exists() { recording.audio_path.clone() diff --git a/src-tauri/src/commands/user_dictionary.rs b/src-tauri/src/commands/user_dictionary.rs index eae31273..16bd8f6f 100644 --- a/src-tauri/src/commands/user_dictionary.rs +++ b/src-tauri/src/commands/user_dictionary.rs @@ -39,7 +39,7 @@ pub async fn user_dict_list(state: tauri::State<'_, AppState>) -> AppResult, word: String) -> A medical_db::user_dictionary::UserDictionaryRepo::add(&conn, &word).map_err(AppError::from) }) .await - .map_err(|e| AppError::Other(format!("Task join error: {e}")))? + .map_err(crate::commands::join_err)? } #[tauri::command] @@ -72,5 +72,5 @@ pub async fn user_dict_remove(state: tauri::State<'_, AppState>, word: String) - .map_err(AppError::from) }) .await - .map_err(|e| AppError::Other(format!("Task join error: {e}")))? + .map_err(crate::commands::join_err)? } diff --git a/src-tauri/src/commands/vocabulary.rs b/src-tauri/src/commands/vocabulary.rs index 8bcce5bd..fbf0243c 100644 --- a/src-tauri/src/commands/vocabulary.rs +++ b/src-tauri/src/commands/vocabulary.rs @@ -50,7 +50,7 @@ pub async fn list_vocabulary_entries( } }) .await - .map_err(|e| AppError::Other(format!("Task join error: {e}")))? + .map_err(crate::commands::join_err)? } /// Add a new vocabulary correction entry. @@ -102,7 +102,7 @@ pub async fn add_vocabulary_entry( VocabularyRepo::insert(&conn, &entry_clone).map_err(AppError::from) }) .await - .map_err(|e| AppError::Other(format!("Task join error: {e}")))??; + .map_err(crate::commands::join_err)??; // PHI guard: do not log find_text/replacement — vocabulary entries may // contain patient-specific medication/dosage text (AGENTS.md line 6). info!("Vocabulary entry added"); @@ -146,7 +146,7 @@ pub async fn update_vocabulary_entry( VocabularyRepo::get_by_id(&conn, &uuid).map_err(AppError::from) }) .await - .map_err(|e| AppError::Other(format!("Task join error: {e}")))??; + .map_err(crate::commands::join_err)??; let entry = VocabularyEntry { id: existing.id, @@ -168,7 +168,7 @@ pub async fn update_vocabulary_entry( VocabularyRepo::update(&conn, &entry_clone).map_err(AppError::from) }) .await - .map_err(|e| AppError::Other(format!("Task join error: {e}")))??; + .map_err(crate::commands::join_err)??; Ok(entry) } @@ -190,7 +190,7 @@ pub async fn delete_vocabulary_entry( VocabularyRepo::delete(&conn, &uuid).map_err(AppError::from) }) .await - .map_err(|e| AppError::Other(format!("Task join error: {e}")))? + .map_err(crate::commands::join_err)? } /// Delete all vocabulary entries. @@ -207,7 +207,7 @@ pub async fn delete_all_vocabulary_entries(state: tauri::State<'_, AppState>) -> VocabularyRepo::delete_all(&conn).map_err(AppError::from) }) .await - .map_err(|e| AppError::Other(format!("Task join error: {e}")))? + .map_err(crate::commands::join_err)? } /// Get vocabulary counts as `(total, enabled)`. @@ -224,7 +224,7 @@ pub async fn get_vocabulary_count(state: tauri::State<'_, AppState>) -> AppResul VocabularyRepo::count(&conn).map_err(AppError::from) }) .await - .map_err(|e| AppError::Other(format!("Task join error: {e}")))? + .map_err(crate::commands::join_err)? } /// Import vocabulary entries from a JSON file. @@ -309,7 +309,7 @@ pub async fn import_vocabulary_json( Ok::<_, AppError>(()) }) .await - .map_err(|e| AppError::Other(format!("Task join error: {e}")))??; + .map_err(crate::commands::join_err)??; info!(count, path = %file_path, "Imported vocabulary entries"); Ok(count) @@ -336,7 +336,7 @@ pub async fn export_vocabulary_json( VocabularyRepo::list_all(&conn).map_err(AppError::from) }) .await - .map_err(|e| AppError::Other(format!("Task join error: {e}")))?? + .map_err(crate::commands::join_err)?? }; let count = entries.len() as u32; @@ -384,7 +384,7 @@ pub async fn test_vocabulary_correction( VocabularyRepo::list_enabled(&conn).map_err(AppError::from) }) .await - .map_err(|e| AppError::Other(format!("Task join error: {e}")))?? + .map_err(crate::commands::join_err)?? }; Ok(vocabulary_corrector::apply_corrections(&text, &entries)) From c9a0038fae8b3ce6b064db10878921f266c3914c Mon Sep 17 00:00:00 2001 From: Andre Hugo Date: Wed, 8 Jul 2026 10:45:17 -0700 Subject: [PATCH 09/20] test(db): condition chip sync round-trip integration test --- crates/db/tests/condition_chips_sync.rs | 149 ++++++++++++++++++++++++ 1 file changed, 149 insertions(+) create mode 100644 crates/db/tests/condition_chips_sync.rs diff --git a/crates/db/tests/condition_chips_sync.rs b/crates/db/tests/condition_chips_sync.rs new file mode 100644 index 00000000..242b17e5 --- /dev/null +++ b/crates/db/tests/condition_chips_sync.rs @@ -0,0 +1,149 @@ +//! Integration test: condition chip sync round-trip between two independent +//! databases (simulating two machines). + +use medical_core::types::condition_chip::{ConditionChip, deterministic_id}; +use medical_db::condition_chips::ConditionChipsRepo; +use medical_db::Database; + +/// ISO 8601 timestamp offset from a fixed base epoch. +fn now(offset_secs: i64) -> String { + let base = chrono::DateTime::parse_from_rfc3339("2026-07-08T10:00:00Z").unwrap(); + let t = base + chrono::Duration::seconds(offset_secs); + t.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string() +} + +/// Simulate a sync round-trip: A pushes its full list, server (B) merges, +/// returns active list, A merges the result back. +/// +/// Both sides push `list_all` (including tombstones) so tombstones propagate. +fn sync_roundtrip( + conn_a: &rusqlite::Connection, + conn_b: &rusqlite::Connection, +) -> (Vec, Vec) { + let a_all = ConditionChipsRepo::list_all(conn_a).unwrap(); + let _b_result = ConditionChipsRepo::merge_incoming(conn_b, &a_all).unwrap(); + let b_all = ConditionChipsRepo::list_all(conn_b).unwrap(); + let a_result = ConditionChipsRepo::merge_incoming(conn_a, &b_all).unwrap(); + let b_result = ConditionChipsRepo::list_active(conn_b).unwrap(); + (a_result, b_result) +} + +/// Collect the active chip texts in their displayed sort order. +fn texts(chips: &[ConditionChip]) -> Vec { + chips.iter().map(|c| c.text.clone()).collect() +} + +#[test] +fn both_add_unique_chips_converge() { + // A adds "Hypertension", B adds "Diabetes". After a round-trip both + // machines must hold both chips. + let db_a = Database::open_in_memory().unwrap(); + let db_b = Database::open_in_memory().unwrap(); + let conn_a = db_a.conn().unwrap(); + let conn_b = db_b.conn().unwrap(); + + ConditionChipsRepo::add(&conn_a, "Hypertension", &now(0)).unwrap(); + ConditionChipsRepo::add(&conn_b, "Diabetes", &now(0)).unwrap(); + + let (a_after, b_after) = sync_roundtrip(&conn_a, &conn_b); + + assert_eq!(a_after.len(), 2, "A should have both chips"); + assert_eq!(b_after.len(), 2, "B should have both chips"); + + let mut a_texts = texts(&a_after); + let mut b_texts = texts(&b_after); + a_texts.sort(); + b_texts.sort(); + assert_eq!(a_texts, vec!["Diabetes".to_string(), "Hypertension".to_string()]); + assert_eq!(b_texts, vec!["Diabetes".to_string(), "Hypertension".to_string()]); +} + +#[test] +fn both_add_same_chip_converge_to_one() { + // A adds "Asthma" at t=0, B adds "Asthma" at t=1. Both produce the same + // deterministic id, so after a round-trip there must be exactly one + // Asthma chip on each side (no duplicate). + let db_a = Database::open_in_memory().unwrap(); + let db_b = Database::open_in_memory().unwrap(); + let conn_a = db_a.conn().unwrap(); + let conn_b = db_b.conn().unwrap(); + + ConditionChipsRepo::add(&conn_a, "Asthma", &now(0)).unwrap(); + ConditionChipsRepo::add(&conn_b, "Asthma", &now(1)).unwrap(); + + let (a_after, b_after) = sync_roundtrip(&conn_a, &conn_b); + + assert_eq!(a_after.len(), 1, "A should have exactly one Asthma chip"); + assert_eq!(b_after.len(), 1, "B should have exactly one Asthma chip"); + assert_eq!(a_after[0].text, "Asthma"); + assert_eq!(b_after[0].text, "Asthma"); + // The newer timestamp (t=1) should have won on both sides. + assert_eq!(a_after[0].updated_at, now(1)); + assert_eq!(b_after[0].updated_at, now(1)); +} + +#[test] +fn tombstone_propagates_across_roundtrip() { + // Both start with "COPD". A removes it (tombstone at t=10). After a + // round-trip both sides must have zero active chips. + let db_a = Database::open_in_memory().unwrap(); + let db_b = Database::open_in_memory().unwrap(); + let conn_a = db_a.conn().unwrap(); + let conn_b = db_b.conn().unwrap(); + + ConditionChipsRepo::add(&conn_a, "COPD", &now(0)).unwrap(); + ConditionChipsRepo::add(&conn_b, "COPD", &now(0)).unwrap(); + + // A soft-deletes COPD at t=10. + ConditionChipsRepo::remove_by_text(&conn_a, "COPD", &now(10)).unwrap(); + + let (a_after, b_after) = sync_roundtrip(&conn_a, &conn_b); + + assert!(a_after.is_empty(), "A should have no active chips"); + assert!(b_after.is_empty(), "B should have no active chips (tombstone propagated)"); + + // The tombstone itself should be present in list_all on both sides. + let a_all = ConditionChipsRepo::list_all(&conn_a).unwrap(); + let b_all = ConditionChipsRepo::list_all(&conn_b).unwrap(); + assert_eq!(a_all.len(), 1, "A should retain the tombstone"); + assert_eq!(b_all.len(), 1, "B should retain the tombstone"); + assert!(a_all[0].deleted_at.is_some(), "A row must be a tombstone"); + assert!(b_all[0].deleted_at.is_some(), "B row must be a tombstone"); +} + +#[test] +fn reorder_propagates_across_roundtrip() { + // Both start with Alpha, Beta. Sync the initial state so B has both. + // A then reorders to [Beta, Alpha]. After a round-trip both sides must + // display [Beta, Alpha]. + let db_a = Database::open_in_memory().unwrap(); + let db_b = Database::open_in_memory().unwrap(); + let conn_a = db_a.conn().unwrap(); + let conn_b = db_b.conn().unwrap(); + + ConditionChipsRepo::add(&conn_a, "Alpha", &now(0)).unwrap(); + ConditionChipsRepo::add(&conn_a, "Beta", &now(0)).unwrap(); + ConditionChipsRepo::add(&conn_b, "Alpha", &now(0)).unwrap(); + ConditionChipsRepo::add(&conn_b, "Beta", &now(0)).unwrap(); + + // Sync initial state so both converge on the same ordering. + let _ = sync_roundtrip(&conn_a, &conn_b); + + // A reorders to [Beta, Alpha] at t=10. + let beta_id = deterministic_id("Beta"); + let alpha_id = deterministic_id("Alpha"); + ConditionChipsRepo::reorder(&conn_a, &[beta_id, alpha_id], &now(10)).unwrap(); + + let (a_after, b_after) = sync_roundtrip(&conn_a, &conn_b); + + assert_eq!( + texts(&a_after), + vec!["Beta".to_string(), "Alpha".to_string()], + "A should display [Beta, Alpha]" + ); + assert_eq!( + texts(&b_after), + vec!["Beta".to_string(), "Alpha".to_string()], + "B should display [Beta, Alpha] after reorder propagates" + ); +} From 6e4d6a7482c496ea22ae4c8549da37741c19e4c6 Mon Sep 17 00:00:00 2001 From: Andre Hugo Date: Wed, 8 Jul 2026 10:48:07 -0700 Subject: [PATCH 10/20] =?UTF-8?q?chore:=20triage=20dependabot=20backlog=20?= =?UTF-8?q?=E2=80=94=20close=20failing=20PRs=20+=20add=20ignore=20rules?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closed: rand, zip, rubato, sha2, rodio (all fail CI or need major migration) Closed: npm patch group (tiptap-markdown v3 peer dep conflict) Added ignore rules for all cargo major bumps that can't auto-merge. Remaining dependabot PRs: 0 open. All triaged. --- .github/dependabot.yml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index c3afe0ba..560bb72c 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -38,6 +38,18 @@ updates: update-types: - "minor" - "patch" + ignore: + # Major bumps requiring dedicated migration effort (multi-crate, API changes) + - dependency-name: "rand" + update-types: ["version-update:semver-major"] + - dependency-name: "zip" + update-types: ["version-update:semver-major"] + - dependency-name: "rubato" + update-types: ["version-update:semver-major"] + - dependency-name: "sha2" + update-types: ["version-update:semver-major"] + - dependency-name: "rodio" + update-types: ["version-update:semver-major"] - package-ecosystem: "github-actions" directory: "/" From 03900ba7f56bb6c47916e9371a17e4ef83c81faa Mon Sep 17 00:00:00 2001 From: Andre Hugo Date: Wed, 8 Jul 2026 10:53:02 -0700 Subject: [PATCH 11/20] refactor: split Sharing.svelte into SharingModes + ConditionChipSync --- src/lib/components/settings/Sharing.svelte | 99 ++++------------ .../settings/sharing/ConditionChipSync.svelte | 44 +++++++ .../settings/sharing/SharingModes.svelte | 108 ++++++++++++++++++ 3 files changed, 172 insertions(+), 79 deletions(-) create mode 100644 src/lib/components/settings/sharing/ConditionChipSync.svelte create mode 100644 src/lib/components/settings/sharing/SharingModes.svelte diff --git a/src/lib/components/settings/Sharing.svelte b/src/lib/components/settings/Sharing.svelte index ef343cf6..e70bf9b5 100644 --- a/src/lib/components/settings/Sharing.svelte +++ b/src/lib/components/settings/Sharing.svelte @@ -1,35 +1,17 @@