From fad1329853dce6c012f40685be72c84a318c9d97 Mon Sep 17 00:00:00 2001 From: Andre Hugo Date: Fri, 3 Jul 2026 09:17:09 -0700 Subject: [PATCH 01/13] docs: condition chip sync design spec Two-way merge with per-item LWW + tombstones. Opt-in setting. Approved across all design sections (data model, merge algorithm, API layer, frontend integration, error handling, testing). --- .../2026-07-03-condition-chip-sync-design.md | 226 ++++++++++++++++++ 1 file changed, 226 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-03-condition-chip-sync-design.md diff --git a/docs/superpowers/specs/2026-07-03-condition-chip-sync-design.md b/docs/superpowers/specs/2026-07-03-condition-chip-sync-design.md new file mode 100644 index 0000000..24ef072 --- /dev/null +++ b/docs/superpowers/specs/2026-07-03-condition-chip-sync-design.md @@ -0,0 +1,226 @@ +# Condition Chip Sync — Design Spec + +**Date:** 2026-07-03 +**Status:** Approved (all design sections) +**Approach:** A — Dedicated `condition_chips` table + +## Goal + +Add an opt-in setting that synchronizes the "known condition" chip presets between the office server and remote clients. Two-way merge with per-item last-write-wins (LWW) and tombstones. Default off — each machine keeps its own list unless the user opts in. + +## Scope + +**In scope:** `AppConfig.custom_conditions` — the practice-wide quick-add chip presets (Hypertension, Diabetes, etc.). These are clinician convenience shortcuts, not patient-specific PHI. + +**Out of scope:** Per-recording `PatientContext.conditions` (the actual conditions attached to each SOAP encounter). Those stay local per recording. + +## Requirements (from brainstorming) + +1. **Sync model:** Two-way merge (both server and clients can add/remove) +2. **Conflict rule:** Per-item last-write-wins with tombstones (soft-deletes) +3. **Sync trigger:** Real-time — push on edit, pull on connect (app launch + reconnect) +4. **Setting:** Opt-in, defaults to `false` (`sync_condition_chips` in `AppConfig`) +5. **Scope:** Chip presets only, not per-recording conditions + +## Data Model + +### New table (migration `m010_condition_chips`) + +```sql +CREATE TABLE IF NOT EXISTS condition_chips ( + id TEXT PRIMARY KEY, -- deterministic UUID v5 from normalized text + text TEXT NOT NULL, -- display text, e.g. "Hypertension" + updated_at TEXT NOT NULL, -- ISO 8601 UTC — the LWW clock + deleted_at TEXT -- tombstone (NULL = active) +); +CREATE INDEX idx_condition_chips_active ON condition_chips(text) WHERE deleted_at IS NULL; +``` + +### Key decisions + +- **Deterministic ID (UUID v5):** Normalization is `text.trim().to_lowercase()`, then UUID v5 with a fixed namespace UUID. "Hypertension", "hypertension ", and "HYPERTENSION" all produce the same `id`. This is what makes two-way merge work — same condition on two machines maps to the same row. + +- **Soft-delete tombstones (`deleted_at`):** Removing a chip sets `deleted_at = now()` rather than deleting the row. On sync, a tombstone with a newer `updated_at` wins over an older active row, preventing "ghost chip" reappearance. + +- **Migration of existing `custom_conditions`:** On first run after upgrade, the migration reads `AppConfig.custom_conditions` and inserts each as an active row with `updated_at = now()`. The old field stays in `AppConfig` (inert, for backward compat) but is no longer read. The migration does NOT clear `custom_conditions` — leaving it populated provides a rollback path if the feature is reverted. + +- **Tombstone pruning:** Tombstones older than 30 days are pruned by a best-effort sweep on each sync. If a pruned tombstone's condition is re-added later, it creates a fresh active row. + +### `ConditionChip` struct (`crates/core/src/types/condition_chip.rs`) + +New file. The struct is defined in `medical-core` so both the DB crate (repo) and the Tauri layer (commands/API DTOs) can reference it without circular dependencies. + +```rust +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ConditionChip { + pub id: String, + pub text: String, + pub updated_at: String, // ISO 8601 UTC + pub deleted_at: Option, +} +``` + +### `ConditionChipsRepo` (`crates/db/src/condition_chips.rs`) + +- `list_active(conn) -> Vec` — active rows, ordered by text +- `upsert(conn, chip)` — insert or update by id +- `soft_delete(conn, id)` — set `deleted_at = now()`, bump `updated_at` +- `merge_incoming(conn, remote_chips) -> Vec` — core LWW merge, returns resolved active list +- `prune_tombstones(conn, older_than: Duration)` — remove stale tombstones +- `deterministic_id(text: &str) -> String` — UUID v5 from normalized text + +## Merge Algorithm + +Runs identically on server and client (same function, different DB instances). The merge is idempotent and commutative — both sides converge to identical state after one round-trip. + +``` +fn merge_incoming(remote_chips): + for R in remote_chips: + local = find local chip with same id as R + if local does not exist: + INSERT R as-is + else if R.updated_at > local.updated_at: + REPLACE local with R + else if R.updated_at < local.updated_at: + local wins — do nothing + else (timestamps equal): + keep whichever has deleted_at set (deleted wins on exact tie) + return list_active() +``` + +### Scenario table + +| Scenario | Machine A | Machine B | Result | +|----------|-----------|-----------|--------| +| Both add "Diabetes" independently | `{Diabetes, t=10:00}` | `{Diabetes, t=10:05}` | One row, B's timestamp wins | +| A adds "Asthma", B hasn't synced | `{Asthma, t=10:00}` | (nothing) | B gets Asthma on next pull | +| A removes "COPD" (was active) | tombstone t=11:00 | active t=09:00 | Removed everywhere (11:00 > 09:00) | +| Both remove "COPD" | tombstone t=11:00 | tombstone t=11:05 | Both deleted, B's timestamp wins | +| A removes "COPD", B re-adds after | tombstone t=11:00 | active t=12:00 | Re-added everywhere (12:00 > 11:00) | + +### Clock source + +`chrono::Utc::now()` formatted as ISO 8601 with milliseconds. String comparison of ISO 8601 timestamps is chronological, so LWW is a simple string `cmp`. Wall clock is sufficient for a single-practice deployment where clock skew is seconds. + +## API Layer + +### New HTTP endpoints (server, `sharing_vocab_api.rs`, port 11437, bearer-authed) + +| Method | Route | Body | Returns | Purpose | +|--------|-------|------|---------|---------| +| `GET` | `/v1/condition-chips` | — | `Vec` | Pull all active chips | +| `POST` | `/v1/condition-chips/sync` | `Vec` | `Vec` | Push local list, get merged list back | + +`ConditionChipDto` = `{ id: String, text: String, updated_at: String, deleted_at: Option }` + +**Why `POST /sync` (full-list round-trip) instead of per-operation endpoints:** Simpler than per-operation endpoints (which need ordering/retry), naturally handles offline edits — a client that added 3 and removed 2 while disconnected sends its current list on reconnect, merge reconciles in one round-trip. + +### Client-side dispatch (`commands/conditions.rs`) + +Every chip operation checks: is sync enabled AND are we paired? + +```rust +fn list_chips(state) -> Vec: + if sync_enabled(state) && paired(state): + ConditionsRemote::from(paired_target())?.list().await + else: + ConditionChipsRepo::list_active(&local_db) + +fn sync_chips(state, local_chips) -> Vec: + if sync_enabled(state) && paired(state): + ConditionsRemote::from(paired_target())?.sync(local_chips).await + else: + local_chips +``` + +### `ConditionsRemote` (`conditions_remote.rs`) + +Mirrors `templates_remote.rs`: same `from()` constructor gating on `conn.ports.vocab`, same bearer auth, same 404-fallback for old servers (returns `None` → local-only). + +### Sync flow (real-time) + +1. **App launch / reconnect:** Frontend calls `list_chips` → if sync on + paired, pulls server list → `merge_incoming(server_list)` locally → returns merged active chips → UI renders. + +2. **User adds/removes a chip:** Frontend calls `add_condition_chip(text)` or `remove_condition_chip(id)` Tauri command → updates local DB immediately (instant UI) → if sync on + paired, fires `sync_chips(local_chips)` in background → server merges → client merges response → silent UI refresh if changed. + +3. **Another client's change:** Surfaces on next pull (app launch, reconnect, manual refresh). No websocket/SSE push — consistent with how vocabulary/templates work. + +## Opt-in Setting + +New `AppConfig` field: +```rust +/// When true, condition chip presets sync two-way between this machine and +/// the paired server. Defaults to false — each machine keeps its own list. +#[serde(default)] +pub sync_condition_chips: bool, +``` + +Added to the `AppConfig` struct in `crates/core/src/types/settings.rs`. The TS `AppConfig` interface (`src/lib/types/index.ts`) and frontend defaults (`settings.svelte.ts`) are updated to match. + +### Settings UI toggle (`Sharing.svelte`) + +Rendered below the existing mode selector, greyed out when sharing is off. Hint text explains two-way merge. When toggled on while sharing is active, triggers an immediate pull. + +## Frontend Integration + +### `ConditionChips.svelte` changes + +- **Read:** `$derived` from local state populated by calling `list_chips` on mount + after each operation. No longer reads `settings.state.custom_conditions`. +- **Add:** calls `invoke('add_condition_chip', { text })` — returns updated list, refreshes chip row. +- **Remove:** calls `invoke('remove_condition_chip', { id })` — same return-and-refresh pattern. +- **Clicking a chip** (to add to textarea): unchanged — still calls `onAdd(condition)`. + +### `AppConfig.custom_conditions` retirement + +The old field stays in the struct (serde keeps deserializing) but is no longer read by any code path after migration seeds the new table. Not removed from the struct to avoid breaking old config snapshots — it becomes inert. + +## Error Handling + +| Failure | Behavior | User sees | +|---------|----------|-----------| +| Server unreachable (push on edit) | Local DB already updated. Background sync fails silently, logs warning. Next sync reconciles. | Nothing — works instantly | +| Server unreachable (pull on launch) | Falls back to local list. Silent. | Local chips load normally | +| 404 (old server, no route) | `ConditionsRemote::from()` returns `None` → local-only | Chips work locally; no sync | +| 401 (token revoked) | Treated as "not paired" → local-only | Same as unpaired state | +| Malformed JSON from server | Error caught → local-only | Local chips; sync silently off | + +**Core principle:** sync failures never block the UI. A chip add/remove always succeeds locally first; sync is best-effort, always. + +## PHI / HIPAA Compliance + +- Condition chip presets are practice-wide convenience shortcuts, not patient-specific PHI. A clinician could theoretically type patient-identifying text, so: +- **Transport:** rides on existing `vocab_port` HTTP with bearer auth — same envelope as vocabulary/templates. No new ports or auth surface. +- **Logging:** log counts and lengths only, never chip text. Sync log lines: `chips_synced=14 tombstones=2`. +- **No telemetry:** stays within LAN/Tailscale mesh — no external endpoints. + +## Testing Strategy + +### `ConditionChipsRepo` unit tests (`crates/db/src/condition_chips.rs`) +- `merge_incoming` — all 5 scenarios from the merge table +- Deterministic ID generation (case/whitespace-insensitive) +- Tombstone wins over older active +- Re-add after tombstone (newer timestamp resurrects) +- Idempotency: merging same list twice = identical state +- `prune_tombstones` removes old entries, keeps recent +- Migration seeding: existing `custom_conditions` → active rows + +### API handler tests +- Auth required (401 without bearer, 200 with) +- `POST /sync` returns merged list + +### `ConditionsRemote` tests +- `from()` returns None when `conn.ports.vocab` is None (old server) +- Graceful error handling on connection failure + +### Dispatch tests (`commands/conditions.rs`) +- Sync off → local only (no HTTP attempt) +- Sync on + unpaired → local only +- Sync on + paired → routes to remote + +### Settings backward-compat test (`settings.rs`) +- `sync_condition_chips` defaults to `false` when absent from old config JSON + +### Frontend test (`ConditionChips.test.ts`) +- Renders chips from `list_chips` invoke result +- Add/remove calls correct invoke commands +- Falls back gracefully when invoke fails From a898905cbb36a84c923a4fcbbf6a02e09f1d7b10 Mon Sep 17 00:00:00 2001 From: Andre Hugo Date: Fri, 3 Jul 2026 09:24:48 -0700 Subject: [PATCH 02/13] docs: condition chip sync implementation plan (11 tasks) --- .../plans/2026-07-03-condition-chip-sync.md | 1630 +++++++++++++++++ 1 file changed, 1630 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-03-condition-chip-sync.md diff --git a/docs/superpowers/plans/2026-07-03-condition-chip-sync.md b/docs/superpowers/plans/2026-07-03-condition-chip-sync.md new file mode 100644 index 0000000..0f00174 --- /dev/null +++ b/docs/superpowers/plans/2026-07-03-condition-chip-sync.md @@ -0,0 +1,1630 @@ +# Condition Chip Sync Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add an opt-in setting that synchronizes "known condition" chip presets between the office server and remote clients via two-way merge with per-item last-write-wins and tombstones. + +**Architecture:** New `condition_chips` table with deterministic UUID-v5 IDs, a `ConditionChipsRepo` with an idempotent `merge_incoming` function, server HTTP endpoints on the existing `vocab_port`, a `ConditionsRemote` HTTP client, and Tauri command dispatchers that route to the server when sync is enabled + paired. The opt-in `sync_condition_chips` setting (defaults false) gates all sync behavior. + +**Tech Stack:** Rust (rusqlite, axum, reqwest, uuid, chrono), Svelte 5 runes, Tauri v2 commands, Vitest. + +**Spec:** `docs/superpowers/specs/2026-07-03-condition-chip-sync-design.md` + +--- + +## File Structure + +### New files + +| File | Responsibility | +|------|----------------| +| `crates/core/src/types/condition_chip.rs` | `ConditionChip` struct + `deterministic_id()` helper | +| `crates/db/src/condition_chips.rs` | `ConditionChipsRepo` — CRUD + `merge_incoming` + `prune_tombstones` | +| `crates/db/src/migrations/m010_condition_chips.rs` | Migration: create table + seed from `custom_conditions` | +| `src-tauri/src/conditions_remote.rs` | HTTP client mirroring `templates_remote.rs` | +| `src-tauri/src/commands/conditions.rs` | Tauri commands: `list_condition_chips`, `add_condition_chip`, `remove_condition_chip`, `sync_condition_chips` | +| `src/lib/api/conditions.ts` | Frontend typed wrappers for the 4 Tauri commands | +| `src/lib/components/ConditionChips.test.ts` | Frontend test for the rewired chip component | + +### Modified files + +| File | Change | +|------|--------| +| `crates/core/src/types/mod.rs` | Register `pub mod condition_chip;` + re-export | +| `crates/db/src/lib.rs` | Register `pub mod condition_chips;` + re-export | +| `crates/db/src/migrations/mod.rs` | Register `pub mod m010_condition_chips;` + add to `all_migrations()` | +| `crates/core/src/types/settings.rs` | Add `sync_condition_chips: bool` field + backward-compat test | +| `src-tauri/src/sharing_vocab_api.rs` | Add `/v1/condition-chips` GET + `/v1/condition-chips/sync` POST routes + handlers | +| `src-tauri/src/commands/mod.rs` | Register `pub mod conditions;` | +| `src-tauri/src/lib.rs` | Register `mod conditions_remote;` + add conditions commands to `generate_handler!` | +| `src/lib/types/index.ts` | Add `sync_condition_chips: boolean` to `AppConfig` interface | +| `src/lib/stores/settings.svelte.ts` | Add `sync_condition_chips: false` to defaults | +| `src/lib/components/ConditionChips.svelte` | Rewire from `settings.state.custom_conditions` to `invoke` calls | +| `src/lib/components/settings/Sharing.svelte` | Add opt-in toggle for condition chip sync | + +--- + +## Task 1: `ConditionChip` struct + `deterministic_id` + +**Files:** +- Create: `crates/core/src/types/condition_chip.rs` +- Modify: `crates/core/src/types/mod.rs` (lines 21-43) + +- [ ] **Step 1: Create the type module** + +Create `crates/core/src/types/condition_chip.rs`: + +```rust +//! Condition chip type used by the condition-chip sync feature. +//! +//! A condition chip is a practice-wide quick-add preset shown under "Known +//! conditions" (e.g. "Hypertension"). Each chip has a deterministic ID derived +//! from its normalized text so that two machines independently adding the same +//! condition produce the same row — enabling per-item last-write-wins merge. + +use serde::{Deserialize, Serialize}; + +/// Fixed namespace for UUID v5 generation of condition chip IDs. +/// Generated once and hardcoded — must never change (would break ID stability). +const CONDITION_NAMESPACE: uuid::Uuid = uuid::Uuid::from_bytes([ + 0x4a, 0x3e, 0xc1, 0x07, 0x9b, 0x2d, 0x4f, 0x6a, + 0xa1, 0x10, 0xd8, 0x4f, 0xa2, 0xb3, 0xc5, 0xe7, +]); + +/// A condition chip entry with sync metadata. +/// +/// - `id`: deterministic UUID v5 from `normalize_for_id(&text)`. Two machines +/// adding "Hypertension" produce the same id. +/// - `updated_at`: ISO 8601 UTC string — the last-write-wins clock. +/// - `deleted_at`: tombstone timestamp. `None` means active. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ConditionChip { + pub id: String, + pub text: String, + pub updated_at: String, + pub deleted_at: Option, +} + +/// Normalize condition text for deterministic ID generation. +/// +/// Lowercases and trims so "Hypertension", "hypertension ", and +/// "HYPERTENSION" all produce the same id. +pub fn normalize_for_id(text: &str) -> String { + text.trim().to_lowercase() +} + +/// Generate a deterministic UUID v5 from normalized condition text. +/// +/// Same text always produces the same UUID, across machines and restarts. +pub fn deterministic_id(text: &str) -> String { + uuid::Uuid::new_v5(&CONDITION_NAMESPACE, normalize_for_id(text).as_bytes()) + .to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn deterministic_id_is_stable() { + assert_eq!(deterministic_id("Hypertension"), deterministic_id("Hypertension")); + } + + #[test] + fn deterministic_id_is_case_insensitive() { + assert_eq!(deterministic_id("Hypertension"), deterministic_id("hypertension")); + } + + #[test] + fn deterministic_id_ignores_whitespace() { + assert_eq!(deterministic_id("Hypertension"), deterministic_id(" Hypertension ")); + } + + #[test] + fn different_conditions_have_different_ids() { + assert_ne!(deterministic_id("Hypertension"), deterministic_id("Diabetes")); + } +} +``` + +- [ ] **Step 2: Check that `uuid` is available in `medical-core`** + +Run: `grep 'uuid' crates/core/Cargo.toml` +Expected: a line like `uuid = { version = "1", features = ["v5", "serde"] }` or similar. + +If `uuid` is NOT in `Cargo.toml`, add it. Check existing usage first: +Run: `grep -r 'use uuid' crates/core/src/ | head -3` +If uuid is already used in the crate, it's already a dependency. + +- [ ] **Step 3: Register the module** + +In `crates/core/src/types/mod.rs`, add after line 23 (`pub mod endpoint;`): + +```rust +pub mod condition_chip; +``` + +And in the re-export block (after line 36, `pub use letter_audience::LetterAudience;`), add: + +```rust +pub use condition_chip::*; +``` + +- [ ] **Step 4: Run tests** + +Run: `cargo test -p medical-core --lib condition_chip` +Expected: 4 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add crates/core/src/types/condition_chip.rs crates/core/src/types/mod.rs +git commit -m "feat(core): add ConditionChip type with deterministic UUID v5 id" +``` + +--- + +## Task 2: `ConditionChipsRepo` with merge logic + +**Files:** +- Create: `crates/db/src/condition_chips.rs` +- Modify: `crates/db/src/lib.rs` (lines 26-42) + +- [ ] **Step 1: Write the failing tests first (TDD)** + +Create `crates/db/src/condition_chips.rs` with tests at the bottom. Start with the test module to define behavior: + +```rust +//! Repository for the `condition_chips` table — condition chip CRUD and +//! the last-write-wins merge used by condition chip sync. + +use rusqlite::{Connection, Row}; + +use medical_core::types::condition_chip::{ + ConditionChip, deterministic_id, normalize_for_id, +}; + +use crate::{DbError, DbResult}; + +/// Repository for the `condition_chips` table. +/// +/// All methods are associated functions that take a `&Connection`. +pub struct ConditionChipsRepo; + +impl ConditionChipsRepo { + /// Map a SQL row to a `ConditionChip`. + fn row_to_chip(row: &Row) -> rusqlite::Result { + Ok(ConditionChip { + id: row.get(0)?, + text: row.get(1)?, + updated_at: row.get(2)?, + deleted_at: row.get(3)?, + }) + } + + /// Return all active (non-deleted) chips, ordered alphabetically by text. + pub fn list_active(conn: &Connection) -> DbResult> { + let mut stmt = conn.prepare( + "SELECT id, text, updated_at, deleted_at + FROM condition_chips + WHERE deleted_at IS NULL + ORDER BY text COLLATE NOCASE", + )?; + let chips = stmt + .query_map([], Self::row_to_chip)? + .filter_map(|r| { + r.map_err(|e| tracing::warn!(error = %e, "dropping unreadable chip row")) + .ok() + }) + .collect(); + Ok(chips) + } + + /// Return ALL chips including tombstones (for sync). + pub fn list_all(conn: &Connection) -> DbResult> { + let mut stmt = conn.prepare( + "SELECT id, text, updated_at, deleted_at + FROM condition_chips", + )?; + let chips = stmt + .query_map([], Self::row_to_chip)? + .filter_map(|r| { + r.map_err(|e| tracing::warn!(error = %e, "dropping unreadable chip row")) + .ok() + }) + .collect(); + Ok(chips) + } + + /// Insert or replace a chip by id. + pub fn upsert(conn: &Connection, chip: &ConditionChip) -> DbResult<()> { + conn.execute( + "INSERT INTO condition_chips (id, text, updated_at, deleted_at) + VALUES (?1, ?2, ?3, ?4) + ON CONFLICT(id) DO UPDATE SET + text = excluded.text, + updated_at = excluded.updated_at, + deleted_at = excluded.deleted_at", + rusqlite::params![chip.id, chip.text, chip.updated_at, chip.deleted_at], + )?; + Ok(()) + } + + /// Soft-delete a chip by id: set deleted_at and bump updated_at. + pub fn soft_delete(conn: &Connection, id: &str, now_iso: &str) -> DbResult<()> { + conn.execute( + "UPDATE condition_chips + SET deleted_at = ?1, updated_at = ?1 + WHERE id = ?2", + rusqlite::params![now_iso, id], + )?; + Ok(()) + } + + /// Core merge: reconcile incoming remote chips with local state using + /// per-item last-write-wins. + /// + /// For each remote chip: + /// - If no local chip with the same id: insert it as-is. + /// - If local exists: compare `updated_at`. Newer wins. On exact tie, + /// deleted wins (conservative — avoids ghost reappearance). + /// + /// Local-only chips (not in remote list) are left untouched — they'll + /// propagate to the other side on its next pull. + /// + /// Returns the active (non-deleted) chips after merge. + pub fn merge_incoming( + conn: &Connection, + remote_chips: &[ConditionChip], + ) -> DbResult> { + for remote in remote_chips { + let local: Option = conn + .query_row( + "SELECT id, text, updated_at, deleted_at + FROM condition_chips WHERE id = ?1", + [&remote.id], + Self::row_to_chip, + ) + .map(Some) + .unwrap_or(None); + + match local { + None => { + // New chip from remote — insert as-is. + Self::upsert(conn, remote)?; + } + Some(local) => { + let remote_wins = remote.updated_at.cmp(&local.updated_at) + .is_gt(); + let tie_and_remote_deleted = remote.updated_at == local.updated_at + && remote.deleted_at.is_some(); + if remote_wins || tie_and_remote_deleted { + Self::upsert(conn, remote)?; + } + // else: local wins, do nothing. + } + } + } + Self::list_active(conn) + } + + /// Remove tombstones older than the given duration. Active chips are + /// never removed. If a pruned tombstone's condition is re-added later, + /// it creates a fresh active row. + pub fn prune_tombstones(conn: &Connection, cutoff_iso: &str) -> DbResult { + let affected = conn.execute( + "DELETE FROM condition_chips + WHERE deleted_at IS NOT NULL AND deleted_at < ?1", + [&cutoff_iso], + )?; + Ok(affected) + } + + /// Add a condition chip (used by the local add command). + /// Returns the full active list after insertion. + pub fn add(conn: &Connection, text: &str, now_iso: &str) -> DbResult> { + let chip = ConditionChip { + id: deterministic_id(text), + text: text.trim().to_string(), + updated_at: now_iso.to_string(), + deleted_at: None, + }; + Self::upsert(conn, &chip)?; + Self::list_active(conn) + } + + /// Remove a condition chip by its text (used by the local remove command). + /// Returns the full active list after removal. + pub fn remove_by_text(conn: &Connection, text: &str, now_iso: &str) -> DbResult> { + let id = deterministic_id(text); + // Only soft-delete if the chip exists and is active. + conn.execute( + "UPDATE condition_chips + SET deleted_at = ?1, updated_at = ?1 + WHERE id = ?2 AND deleted_at IS NULL", + rusqlite::params![now_iso, id], + )?; + Self::list_active(conn) + } +} +``` + +Now add the tests at the bottom of the same file: + +```rust +#[cfg(test)] +mod tests { + use super::*; + use crate::Database; + + fn now(offset_secs: i64) -> String { + // Build ISO 8601 timestamps relative to a base, for deterministic LWW tests. + let base = chrono::DateTime::parse_from_rfc3339("2026-07-03T10:00:00Z").unwrap(); + let t = base + chrono::Duration::seconds(offset_secs); + t.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string() + } + + fn chip(text: &str, updated_offset: i64, deleted: bool) -> ConditionChip { + ConditionChip { + id: deterministic_id(text), + text: text.to_string(), + updated_at: now(updated_offset), + deleted_at: if deleted { Some(now(updated_offset)) } else { None }, + } + } + + #[test] + fn merge_inserts_new_remote_chip() { + let db = Database::open_in_memory().unwrap(); + let conn = db.conn().unwrap(); + // Table doesn't exist yet — this test will fail until Task 3 migration runs. + // For now we create it manually so the repo logic can be tested independently. + conn.execute_batch( + "CREATE TABLE condition_chips ( + id TEXT PRIMARY KEY, text TEXT NOT NULL, + updated_at TEXT NOT NULL, deleted_at TEXT + );" + ).unwrap(); + + let result = ConditionChipsRepo::merge_incoming(&conn, &[chip("Asthma", 0, false)]).unwrap(); + assert_eq!(result.len(), 1); + assert_eq!(result[0].text, "Asthma"); + } + + #[test] + fn merge_remote_newer_wins() { + let db = Database::open_in_memory().unwrap(); + let conn = db.conn().unwrap(); + conn.execute_batch( + "CREATE TABLE condition_chips ( + id TEXT PRIMARY KEY, text TEXT NOT NULL, + updated_at TEXT NOT NULL, deleted_at TEXT + );" + ).unwrap(); + + // Local has "Diabetes" at t=0. + ConditionChipsRepo::upsert(&conn, &chip("Diabetes", 0, false)).unwrap(); + // Remote has "Diabetes" at t=300 (newer). + let result = ConditionChipsRepo::merge_incoming(&conn, &[chip("Diabetes", 300, false)]).unwrap(); + assert_eq!(result.len(), 1); + assert_eq!(result[0].updated_at, now(300)); + } + + #[test] + fn merge_local_newer_wins() { + let db = Database::open_in_memory().unwrap(); + let conn = db.conn().unwrap(); + conn.execute_batch( + "CREATE TABLE condition_chips ( + id TEXT PRIMARY KEY, text TEXT NOT NULL, + updated_at TEXT NOT NULL, deleted_at TEXT + );" + ).unwrap(); + + // Local has "Diabetes" at t=300. + ConditionChipsRepo::upsert(&conn, &chip("Diabetes", 300, false)).unwrap(); + // Remote has "Diabetes" at t=0 (older). + let result = ConditionChipsRepo::merge_incoming(&conn, &[chip("Diabetes", 0, false)]).unwrap(); + assert_eq!(result.len(), 1); + assert_eq!(result[0].updated_at, now(300)); + } + + #[test] + fn merge_tombstone_wins_over_older_active() { + let db = Database::open_in_memory().unwrap(); + let conn = db.conn().unwrap(); + conn.execute_batch( + "CREATE TABLE condition_chips ( + id TEXT PRIMARY KEY, text TEXT NOT NULL, + updated_at TEXT NOT NULL, deleted_at TEXT + );" + ).unwrap(); + + // Local has "COPD" active at t=0. + ConditionChipsRepo::upsert(&conn, &chip("COPD", 0, false)).unwrap(); + // Remote has "COPD" tombstone at t=600 (newer). + let result = ConditionChipsRepo::merge_incoming(&conn, &[chip("COPD", 600, true)]).unwrap(); + assert!(result.is_empty(), "COPD should be tombstoned"); + } + + #[test] + fn merge_re_add_after_tombstone() { + let db = Database::open_in_memory().unwrap(); + let conn = db.conn().unwrap(); + conn.execute_batch( + "CREATE TABLE condition_chips ( + id TEXT PRIMARY KEY, text TEXT NOT NULL, + updated_at TEXT NOT NULL, deleted_at TEXT + );" + ).unwrap(); + + // Local has "COPD" tombstone at t=600. + ConditionChipsRepo::upsert(&conn, &chip("COPD", 600, true)).unwrap(); + // Remote has "COPD" active at t=1200 (newer — re-added). + let result = ConditionChipsRepo::merge_incoming(&conn, &[chip("COPD", 1200, false)]).unwrap(); + assert_eq!(result.len(), 1); + assert_eq!(result[0].text, "COPD"); + assert!(result[0].deleted_at.is_none()); + } + + #[test] + fn merge_tie_deleted_wins() { + let db = Database::open_in_memory().unwrap(); + let conn = db.conn().unwrap(); + conn.execute_batch( + "CREATE TABLE condition_chips ( + id TEXT PRIMARY KEY, text TEXT NOT NULL, + updated_at TEXT NOT NULL, deleted_at TEXT + );" + ).unwrap(); + + // Local active, remote tombstone, same timestamp. + let local = chip("Asthma", 100, false); + let remote_tombstone = chip("Asthma", 100, true); + ConditionChipsRepo::upsert(&conn, &local).unwrap(); + let result = ConditionChipsRepo::merge_incoming(&conn, &[remote_tombstone]).unwrap(); + assert!(result.is_empty(), "on tie, deleted wins"); + } + + #[test] + fn merge_is_idempotent() { + let db = Database::open_in_memory().unwrap(); + let conn = db.conn().unwrap(); + conn.execute_batch( + "CREATE TABLE condition_chips ( + id TEXT PRIMARY KEY, text TEXT NOT NULL, + updated_at TEXT NOT NULL, deleted_at TEXT + );" + ).unwrap(); + + let remote = vec![chip("Asthma", 0, false), chip("Diabetes", 0, false)]; + let r1 = ConditionChipsRepo::merge_incoming(&conn, &remote).unwrap(); + let r2 = ConditionChipsRepo::merge_incoming(&conn, &remote).unwrap(); + assert_eq!(r1, r2); + } + + #[test] + fn prune_tombstones_removes_old_only() { + let db = Database::open_in_memory().unwrap(); + let conn = db.conn().unwrap(); + conn.execute_batch( + "CREATE TABLE condition_chips ( + id TEXT PRIMARY KEY, text TEXT NOT NULL, + updated_at TEXT NOT NULL, deleted_at TEXT + );" + ).unwrap(); + + // Old tombstone (t=-3000000, way in the past). + ConditionChipsRepo::upsert(&conn, &chip("OldGone", -3000000, true)).unwrap(); + // Recent tombstone (t=0). + ConditionChipsRepo::upsert(&conn, &chip("RecentGone", 0, true)).unwrap(); + // Active chip. + ConditionChipsRepo::upsert(&conn, &chip("Active", 0, false)).unwrap(); + + // Prune tombstones older than t=-100. + let pruned = ConditionChipsRepo::prune_tombstones(&conn, &now(-100)).unwrap(); + assert_eq!(pruned, 1, "only the old tombstone should be pruned"); + + // Recent tombstone still exists, active chip untouched. + let all = ConditionChipsRepo::list_all(&conn).unwrap(); + assert_eq!(all.len(), 2); // RecentGone + Active + } + + #[test] + fn add_and_remove_by_text() { + let db = Database::open_in_memory().unwrap(); + let conn = db.conn().unwrap(); + conn.execute_batch( + "CREATE TABLE condition_chips ( + id TEXT PRIMARY KEY, text TEXT NOT NULL, + updated_at TEXT NOT NULL, deleted_at TEXT + );" + ).unwrap(); + + let after_add = ConditionChipsRepo::add(&conn, "Hypertension", &now(0)).unwrap(); + assert_eq!(after_add.len(), 1); + + let after_remove = ConditionChipsRepo::remove_by_text(&conn, "Hypertension", &now(100)).unwrap(); + assert!(after_remove.is_empty(), "should be removed"); + + // Verify it's a tombstone, not hard-deleted: + let all = ConditionChipsRepo::list_all(&conn).unwrap(); + assert_eq!(all.len(), 1); + assert!(all[0].deleted_at.is_some()); + } +} +``` + +- [ ] **Step 2: Register the module in `crates/db/src/lib.rs`** + +Add after line 28 (`pub mod letter_audiences;`): + +```rust +pub mod condition_chips; +``` + +And after line 42 (`pub use user_dictionary::UserDictionaryRepo;`): + +```rust +pub use condition_chips::ConditionChipsRepo; +``` + +- [ ] **Step 3: Run tests to verify they fail (table doesn't exist yet — but we create it manually in tests)** + +Run: `cargo test -p medical-db --lib condition_chips` +Expected: All tests pass (because each test creates the table manually). The repo logic is fully tested independently of the migration. + +- [ ] **Step 4: Commit** + +```bash +git add crates/db/src/condition_chips.rs crates/db/src/lib.rs +git commit -m "feat(db): add ConditionChipsRepo with LWW merge + tombstones" +``` + +--- + +## Task 3: Migration `m010_condition_chips` + +**Files:** +- Create: `crates/db/src/migrations/m010_condition_chips.rs` +- Modify: `crates/db/src/migrations/mod.rs` (lines 15, 49-84) + +- [ ] **Step 1: Create the migration file** + +Create `crates/db/src/migrations/m010_condition_chips.rs`: + +```rust +use rusqlite::Connection; + +use crate::DbResult; + +/// Create the `condition_chips` table and seed it from existing +/// `AppConfig.custom_conditions` values (if any). +/// +/// Each existing condition becomes an active row with `updated_at = now()`. +/// The old `custom_conditions` field in the settings blob is left intact +/// (inert) for rollback safety. +pub fn up(conn: &Connection) -> DbResult<()> { + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS condition_chips ( + id TEXT PRIMARY KEY, + text TEXT NOT NULL, + updated_at TEXT NOT NULL, + deleted_at TEXT + ); + CREATE INDEX IF NOT EXISTS idx_condition_chips_active + ON condition_chips(text) WHERE deleted_at IS NULL;", + )?; + + // Seed from existing custom_conditions in the settings blob. + seed_from_custom_conditions(conn)?; + + Ok(()) +} + +/// Read `custom_conditions` from the `settings` table (key "app_config") +/// and insert each as an active condition chip. +fn seed_from_custom_conditions(conn: &Connection) -> DbResult<()> { + use medical_core::types::condition_chip::{ConditionChip, deterministic_id}; + use medical_core::types::settings::AppConfig; + + let json: Option = conn + .query_row( + "SELECT value FROM settings WHERE key = 'app_config'", + [], + |row| row.get(0), + ) + .ok(); + + let Some(json) = json else { return Ok(()); }; + let config: AppConfig = match serde_json::from_str(&json) { + Ok(c) => c, + Err(_) => return Ok(()), // unparseable config — skip seeding + }; + + let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string(); + for text in &config.custom_conditions { + let chip = ConditionChip { + id: deterministic_id(text), + text: text.clone(), + updated_at: now.clone(), + deleted_at: None, + }; + let _ = conn.execute( + "INSERT OR IGNORE INTO condition_chips (id, text, updated_at, deleted_at) + VALUES (?1, ?2, ?3, NULL)", + rusqlite::params![chip.id, chip.text, chip.updated_at], + ); + } + + tracing::info!(count = config.custom_conditions.len(), "Seeded condition chips from custom_conditions"); + Ok(()) +} +``` + +- [ ] **Step 2: Register the migration in `crates/db/src/migrations/mod.rs`** + +Add after line 15 (`pub mod m009_soft_delete;`): + +```rust +pub mod m010_condition_chips; +``` + +In the `all_migrations()` array, after the m009 entry (before the closing `]`), add: + +```rust + Migration { version: 10, name: "condition_chips", up: m010_condition_chips::up }, +``` + +- [ ] **Step 3: Run the full migration test suite** + +Run: `cargo test -p medical-db --lib migrations` +Expected: all migration tests pass including the new m010. + +- [ ] **Step 4: Run the condition_chips repo tests again (now with real migration)** + +Run: `cargo test -p medical-db --lib condition_chips` +Expected: all pass. + +- [ ] **Step 5: Commit** + +```bash +git add crates/db/src/migrations/m010_condition_chips.rs crates/db/src/migrations/mod.rs +git commit -m "feat(db): m010 migration — condition_chips table + seed from custom_conditions" +``` + +--- + +## Task 4: Add `sync_condition_chips` setting + +**Files:** +- Modify: `crates/core/src/types/settings.rs` (before line 491) +- Modify: `src/lib/types/index.ts` (lines 77-134) +- Modify: `src/lib/stores/settings.svelte.ts` (lines 4-53) + +- [ ] **Step 1: Add the Rust field** + +In `crates/core/src/types/settings.rs`, add before line 491 (just before the closing `}` of `AppConfig`), after the `capture_for_training` field: + +```rust + // Condition chip sync + /// When true, condition chip presets sync two-way between this machine + /// and the paired server via the vocab API. Defaults to false — each + /// machine keeps its own list unless the user opts in. + #[serde(default)] + pub sync_condition_chips: bool, +``` + +- [ ] **Step 2: Add the backward-compat test** + +In `crates/core/src/types/settings.rs`, in the `#[cfg(test)]` module, add: + +```rust + #[test] + fn sync_condition_chips_defaults_to_false_in_older_configs() { + let old_json = r#"{"ai_provider":"ollama","stt_mode":"local"}"#; + let cfg: AppConfig = serde_json::from_str(old_json).expect("should parse with serde defaults"); + assert!(!cfg.sync_condition_chips, "default must be false"); + } +``` + +- [ ] **Step 3: Run Rust test** + +Run: `cargo test -p medical-core --lib sync_condition_chips` +Expected: 1 test passes. + +- [ ] **Step 4: Add the TS type** + +In `src/lib/types/index.ts`, inside the `AppConfig` interface, after the `capture_for_training` field (around line 125), add: + +```typescript + /** When true, condition chip presets sync two-way with the paired server. Defaults to false. */ + sync_condition_chips: boolean; +``` + +- [ ] **Step 5: Add the frontend default** + +In `src/lib/stores/settings.svelte.ts`, in the `defaults` object (lines 4-53), after `custom_conditions: []`, add: + +```typescript + sync_condition_chips: false, +``` + +- [ ] **Step 6: Run type check** + +Run: `npm run check` +Expected: 0 errors, 0 warnings. + +- [ ] **Step 7: Commit** + +```bash +git add crates/core/src/types/settings.rs src/lib/types/index.ts src/lib/stores/settings.svelte.ts +git commit -m "feat(settings): add sync_condition_chips opt-in (defaults false)" +``` + +--- + +## Task 5: Server API handlers + +**Files:** +- Modify: `src-tauri/src/sharing_vocab_api.rs` + +- [ ] **Step 1: Read the current router setup** + +Read `src-tauri/src/sharing_vocab_api.rs` lines 68-121 to see the exact Router construction. Find the `.route()` chain. + +- [ ] **Step 2: Add the two new routes** + +In the Router builder chain (after the user-dictionary routes, before `.with_state`), add: + +```rust + .route("/v1/condition-chips", get(condition_chips_list_handler)) + .route("/v1/condition-chips/sync", post(condition_chips_sync_handler)) +``` + +Add `use axum::routing::{get, post};` if not already imported (check the existing imports — templates use `post` already, so it may be imported). + +- [ ] **Step 3: Add the handler functions** + +At the end of `sharing_vocab_api.rs` (before the closing of the module, or in the same file as the other handlers), add: + +```rust +// ────────────────────────────────────────────────────────────────────────── +// Condition chips handlers +// ────────────────────────────────────────────────────────────────────────── + +/// GET /v1/condition-chips — return all active condition chips. +async fn condition_chips_list_handler( + State(state): State, +) -> Result>, AppError> { + let _client_id = authorize(&state).await?; + let db = Arc::clone(&state.db); + let chips = tokio::task::spawn_blocking(move || { + let conn = db.conn()?; + medical_db::condition_chips::ConditionChipsRepo::list_active(&conn) + .map_err(AppError::from) + }) + .await + .map_err(|e| AppError::Other(format!("Task join error: {e}")))??; + tracing::debug!(count = chips.len(), "condition chips listed"); + Ok(Json(chips)) +} + +/// POST /v1/condition-chips/sync — two-way merge. +/// Body: the client's full chip list (active + tombstones). +/// Returns: the merged active chip list. +async fn condition_chips_sync_handler( + State(state): State, + Json(incoming): Json>, +) -> Result>, AppError> { + let _client_id = authorize(&state).await?; + let db = Arc::clone(&state.db); + + // Prune old tombstones opportunistically (30 days). + let cutoff = chrono::Utc::now() + - chrono::Duration::days(30); + let cutoff_iso = cutoff.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string(); + + let merged = tokio::task::spawn_blocking(move || { + let conn = db.conn()?; + let result = + medical_db::condition_chips::ConditionChipsRepo::merge_incoming(&conn, &incoming) + .map_err(AppError::from)?; + // Best-effort prune — don't fail the sync if pruning errors. + let _ = medical_db::condition_chips::ConditionChipsRepo::prune_tombstones(&conn, &cutoff_iso); + Ok::<_, AppError>(result) + }) + .await + .map_err(|e| AppError::Other(format!("Task join error: {e}")))??; + + tracing::debug!( + incoming = incoming.len(), + result = merged.len(), + "condition chips synced" + ); + Ok(Json(merged)) +} +``` + +- [ ] **Step 4: Build to verify compilation** + +Run: `cargo build -p rust-medical-assistant 2>&1 | tail -20` +Expected: compiles without errors. If `Json` or `State` are not imported, add them from the existing imports section. + +- [ ] **Step 5: Commit** + +```bash +git add src-tauri/src/sharing_vocab_api.rs +git commit -m "feat(sharing): add /v1/condition-chips GET + /sync POST server handlers" +``` + +--- + +## Task 6: `ConditionsRemote` HTTP client + +**Files:** +- Create: `src-tauri/src/conditions_remote.rs` +- Modify: `src-tauri/src/lib.rs` (add `mod conditions_remote;`) + +- [ ] **Step 1: Create the remote client** + +Create `src-tauri/src/conditions_remote.rs`: + +```rust +//! HTTP client for condition chip sync with a paired server. +//! +//! Mirrors `templates_remote.rs` — same `from()` constructor gating on +//! `conn.ports.vocab`, same bearer auth, same graceful None fallback for +//! old servers that don't have the `/v1/condition-chips` routes. + +use std::sync::Arc; +use std::time::Duration; + +use medical_core::types::condition_chip::ConditionChip; + +use crate::commands::sharing::PairedConnection; +use medical_core::error::{AppError, AppResult}; + +/// HTTP client for the condition-chips sync API on a paired server. +pub struct ConditionsRemote<'a> { + conn: &'a PairedConnection, + bearer: String, + client: Arc, +} + +impl<'a> ConditionsRemote<'a> { + /// Construct from a paired connection. Returns `None` if there's no + /// bearer token or no vocab port (old server without condition-chips + /// routes) — callers fall back to local DB. + pub fn from( + conn: &'a PairedConnection, + bearer: Option, + client: Arc, + ) -> Option { + let bearer = bearer?; + conn.ports.vocab?; + Some(Self { conn, bearer, client }) + } + + /// Build the base URL for the vocab API, preferring LAN then Tailscale. + fn base_url(&self) -> Option { + let port = self.conn.ports.vocab?; + let host = self.conn.lan.as_ref().or(self.conn.tailscale.as_ref())?; + Some(format!("http://{}:{}", host, port)) + } + + /// GET /v1/condition-chips — pull all active chips from the server. + pub async fn list(&self) -> AppResult> { + let url = format!("{}/v1/condition-chips", 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(15)) + .bearer_auth(&self.bearer) + .send() + .await + .map_err(|e| AppError::Other(format!("conditions remote list: {e}")))?; + check_status(&resp).await?; + resp.json::>() + .await + .map_err(|e| AppError::Other(format!("conditions remote parse: {e}"))) + } + + /// POST /v1/condition-chips/sync — push local list, get merged list back. + pub async fn sync(&self, local_chips: Vec) -> AppResult> { + let url = format!("{}/v1/condition-chips/sync", self.base_url().ok_or_else(|| { + AppError::Other("no vocab base URL for conditions remote".into()) + })?); + let resp = self + .client + .post(&url) + .timeout(Duration::from_secs(15)) + .bearer_auth(&self.bearer) + .json(&local_chips) + .send() + .await + .map_err(|e| AppError::Other(format!("conditions remote sync: {e}")))?; + check_status(&resp).await?; + resp.json::>() + .await + .map_err(|e| AppError::Other(format!("conditions remote parse: {e}"))) + } +} + +/// Check HTTP status and map common error codes to user-facing messages. +async fn check_status(resp: &reqwest::Response) -> AppResult<()> { + if resp.status().is_success() { + return Ok(()); + } + let status = resp.status(); + match status { + reqwest::StatusCode::NOT_FOUND => Err(AppError::Other( + "Server does not support condition chip sync (update required)".into(), + )), + reqwest::StatusCode::UNAUTHORIZED => Err(AppError::Other( + "Authentication failed — re-pair with the server".into(), + )), + _ => Err(AppError::Other(format!("Server error: {status}"))), + } +} +``` + +- [ ] **Step 2: Register the module** + +In `src-tauri/src/lib.rs`, near the other `mod *_remote;` declarations (search for `mod templates_remote`), add: + +```rust +mod conditions_remote; +``` + +- [ ] **Step 3: Build to verify** + +Run: `cargo build -p rust-medical-assistant 2>&1 | tail -20` +Expected: compiles without errors. + +- [ ] **Step 4: Commit** + +```bash +git add src-tauri/src/conditions_remote.rs src-tauri/src/lib.rs +git commit -m "feat(sharing): add ConditionsRemote HTTP client for chip sync" +``` + +--- + +## Task 7: Tauri commands + dispatch + +**Files:** +- Create: `src-tauri/src/commands/conditions.rs` +- Modify: `src-tauri/src/commands/mod.rs` (line ~5, alphabetical) +- Modify: `src-tauri/src/lib.rs` (add to `generate_handler!`) + +- [ ] **Step 1: Create the commands file** + +Create `src-tauri/src/commands/conditions.rs`: + +```rust +//! Tauri commands for condition chips — list, add, remove, sync. +//! +//! Each command checks whether sync is enabled (`sync_condition_chips` setting) +//! and whether this machine is paired. If both are true, operations route to +//! the paired server via `ConditionsRemote`. Otherwise, they operate on the +//! local DB. Sync failures never block the UI — a failed push leaves the +//! local state correct, and the next successful sync reconciles. + +use std::sync::Arc; + +use medical_core::error::{AppError, AppResult}; +use medical_core::types::condition_chip::ConditionChip; + +use crate::state::AppState; + +/// Check if condition chip sync is on AND we're paired. +/// Returns the paired target if sync should route to the server. +fn paired_conditions_target( + state: &AppState, +) -> Option<(crate::commands::sharing::PairedConnection, String)> { + // Gate on the opt-in setting. + let config = crate::commands::settings::load_config_sync(&state.db).ok()?; + if !config.sync_condition_chips { + return None; + } + // Gate on pairing. + let conn = crate::state::load_paired_connection()?; + conn.ports.vocab?; + let bearer = crate::state::load_sharing_bearer()?; + Some((conn, bearer)) +} + +/// List all active condition chips. +/// If sync is on + paired, pulls from the server (which may trigger a merge +/// on the next sync). For the initial pull-on-connect, this returns the +/// server's list directly. +#[tauri::command] +#[instrument(skip(state), name = "conditions::list")] +pub async fn list_condition_chips( + state: tauri::State<'_, AppState>, +) -> AppResult> { + if let Some((conn, bearer)) = paired_conditions_target(&state) { + if let Some(remote) = crate::conditions_remote::ConditionsRemote::from( + &conn, + Some(bearer), + state.http_client.clone(), + ) { + // Pull from server, merge locally, return result. + let server_chips = remote.list().await.map_err(|e| { + tracing::warn!(error = %e, "conditions remote list failed, using local"); + e + })?; + let db = Arc::clone(&state.db); + return tokio::task::spawn_blocking(move || { + let conn = db.conn()?; + // Merge server chips into local to stay in sync. + let merged = medical_db::condition_chips::ConditionChipsRepo::merge_incoming( + &conn, &server_chips, + ) + .map_err(AppError::from)?; + Ok(merged) + }) + .await + .map_err(|e| AppError::Other(format!("Task join error: {e}")))??; + } + } + // Local fallback. + let db = Arc::clone(&state.db); + tokio::task::spawn_blocking(move || { + let conn = db.conn()?; + medical_db::condition_chips::ConditionChipsRepo::list_active(&conn) + .map_err(AppError::from) + }) + .await + .map_err(|e| AppError::Other(format!("Task join error: {e}")))? +} + +/// Add a condition chip. Updates local DB immediately, then pushes to server +/// if sync is enabled. +#[tauri::command] +#[instrument(skip(state), name = "conditions::add")] +pub async fn add_condition_chip( + state: tauri::State<'_, AppState>, + text: String, +) -> AppResult> { + let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string(); + + // 1. Update local DB immediately (instant UI). + let db = Arc::clone(&state.db); + let local_list = tokio::task::spawn_blocking(move || { + let conn = db.conn()?; + medical_db::condition_chips::ConditionChipsRepo::add(&conn, &text, &now) + .map_err(AppError::from) + }) + .await + .map_err(|e| AppError::Other(format!("Task join error: {e}")))??; + + // 2. Best-effort background sync (non-blocking — local state is already correct). + if let Some((conn, bearer)) = paired_conditions_target(&state) { + if let Some(remote) = crate::conditions_remote::ConditionsRemote::from( + &conn, + Some(bearer), + state.http_client.clone(), + ) { + let chips_to_push = local_list.clone(); + // Fire and forget — failures log but don't surface. + tokio::spawn(async move { + match remote.sync(chips_to_push).await { + Ok(_) => tracing::debug!("condition chip sync push succeeded"), + Err(e) => tracing::warn!(error = %e, "condition chip sync push failed (will retry on next pull)"), + } + }); + } + } + + Ok(local_list) +} + +/// Remove a condition chip by text. Soft-deletes locally, then pushes. +#[tauri::command] +#[instrument(skip(state), name = "conditions::remove")] +pub async fn remove_condition_chip( + state: tauri::State<'_, AppState>, + text: String, +) -> AppResult> { + let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string(); + + // 1. Soft-delete locally. + let db = Arc::clone(&state.db); + let local_list = tokio::task::spawn_blocking(move || { + let conn = db.conn()?; + medical_db::condition_chips::ConditionChipsRepo::remove_by_text(&conn, &text, &now) + .map_err(AppError::from) + }) + .await + .map_err(|e| AppError::Other(format!("Task join error: {e}")))??; + + // 2. Best-effort background sync. + if let Some((conn, bearer)) = paired_conditions_target(&state) { + if let Some(remote) = crate::conditions_remote::ConditionsRemote::from( + &conn, + Some(bearer), + state.http_client.clone(), + ) { + // For sync, we need the full list INCLUDING the tombstone we just created. + let db2 = Arc::clone(&state.db); + let all_chips = tokio::task::spawn_blocking(move || { + let conn = db2.conn()?; + medical_db::condition_chips::ConditionChipsRepo::list_all(&conn) + .map_err(AppError::from) + }) + .await + .map_err(|e| AppError::Other(format!("Task join error: {e}")))?; + + if let Ok(all_chips) = all_chips { + tokio::spawn(async move { + match remote.sync(all_chips).await { + Ok(_) => tracing::debug!("condition chip sync push (remove) succeeded"), + Err(e) => tracing::warn!(error = %e, "condition chip sync push (remove) failed"), + } + }); + } + } + } + + Ok(local_list) +} + +/// Manual sync trigger — push local full list, merge with server, return result. +/// Used when toggling sync on or reconnecting. +#[tauri::command] +#[instrument(skip(state), name = "conditions::sync")] +pub async fn sync_condition_chips_cmd( + state: tauri::State<'_, AppState>, +) -> AppResult> { + // Load local full list (including tombstones). + let db = Arc::clone(&state.db); + let local_all = tokio::task::spawn_blocking(move || { + let conn = db.conn()?; + medical_db::condition_chips::ConditionChipsRepo::list_all(&conn) + .map_err(AppError::from) + }) + .await + .map_err(|e| AppError::Other(format!("Task join error: {e}")))??; + + if let Some((conn, bearer)) = paired_conditions_target(&state) { + if let Some(remote) = crate::conditions_remote::ConditionsRemote::from( + &conn, + Some(bearer), + state.http_client.clone(), + ) { + let merged = remote.sync(local_all).await?; + // Merge the server result back into local. + let db = Arc::clone(&state.db); + return tokio::task::spawn_blocking(move || { + let conn = db.conn()?; + medical_db::condition_chips::ConditionChipsRepo::merge_incoming(&conn, &merged) + .map_err(AppError::from) + }) + .await + .map_err(|e| AppError::Other(format!("Task join error: {e}")))??; + } + } + + // No pairing — just return active local list. + let db = Arc::clone(&state.db); + tokio::task::spawn_blocking(move || { + let conn = db.conn()?; + medical_db::condition_chips::ConditionChipsRepo::list_active(&conn) + .map_err(AppError::from) + }) + .await + .map_err(|e| AppError::Other(format!("Task join error: {e}")))? +} +``` + +- [ ] **Step 2: Register the module in `commands/mod.rs`** + +In `src-tauri/src/commands/mod.rs`, add after `pub mod audio;` (line ~2): + +```rust +pub mod conditions; +``` + +- [ ] **Step 3: Register commands in `generate_handler!`** + +In `src-tauri/src/lib.rs`, in the `tauri::generate_handler![...]` macro (search for `commands::user_dictionary`), add after the user_dictionary entries: + +```rust + commands::conditions::list_condition_chips, + commands::conditions::add_condition_chip, + commands::conditions::remove_condition_chip, + commands::conditions::sync_condition_chips_cmd, +``` + +- [ ] **Step 4: Check that `load_config_sync` exists** + +Run: `grep -n 'fn load_config_sync' src-tauri/src/commands/settings.rs` + +If it does NOT exist, add a synchronous config loader. If it exists, skip. The function should be: + +```rust +/// Synchronously load the app config (for use in non-async dispatch helpers). +pub fn load_config_sync(db: &Arc) -> Result { + let conn = db.conn().map_err(AppError::from)?; + medical_db::settings::SettingsRepo::load_config(&conn) + .map(|mut c| { c.migrate(); c }) + .map_err(AppError::from) +} +``` + +Add this to `src-tauri/src/commands/settings.rs` if missing. + +- [ ] **Step 5: Build to verify** + +Run: `cargo build -p rust-medical-assistant 2>&1 | tail -30` +Expected: compiles without errors. Fix any missing imports. + +- [ ] **Step 6: Commit** + +```bash +git add src-tauri/src/commands/conditions.rs src-tauri/src/commands/mod.rs src-tauri/src/lib.rs src-tauri/src/commands/settings.rs +git commit -m "feat(commands): condition chip Tauri commands with sync dispatch" +``` + +--- + +## Task 8: Frontend — API helpers + rewire `ConditionChips.svelte` + +**Files:** +- Create: `src/lib/api/conditions.ts` +- Modify: `src/lib/components/ConditionChips.svelte` + +- [ ] **Step 1: Create the API helpers** + +Create `src/lib/api/conditions.ts`: + +```typescript +import { invoke } from '@tauri-apps/api/core'; + +export interface ConditionChip { + id: string; + text: string; + updated_at: string; + deleted_at: string | null; +} + +export async function listConditionChips(): Promise { + return invoke('list_condition_chips'); +} + +export async function addConditionChip(text: string): Promise { + return invoke('add_condition_chip', { text }); +} + +export async function removeConditionChip(text: string): Promise { + return invoke('remove_condition_chip', { text }); +} + +export async function syncConditionChips(): Promise { + return invoke('sync_condition_chips_cmd'); +} +``` + +- [ ] **Step 2: Read the current `ConditionChips.svelte`** + +Read `src/lib/components/ConditionChips.svelte` fully to understand current structure before modifying. + +- [ ] **Step 3: Rewire the chip component** + +The key changes to `ConditionChips.svelte`: +1. Remove the `settings` import and `settings.state.custom_conditions` reads. +2. Add a `chips` local state populated by `listConditionChips()` on mount. +3. Change `addNewCondition` to call `addConditionChip(text)` and update local state from the response. +4. Change `removeCondition` to call `removeConditionChip(text)` and update local state from the response. + +Replace the script section (lines ~1-65) with: + +```svelte + +``` + +Keep the markup section (lines ~78-120) unchanged — it already iterates `conditions` and calls `addNewCondition`/`removeCondition`. + +- [ ] **Step 4: Run type check** + +Run: `npm run check` +Expected: 0 errors. + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/api/conditions.ts src/lib/components/ConditionChips.svelte +git commit -m "feat(frontend): rewire ConditionChips to Tauri commands (prep for sync)" +``` + +--- + +## Task 9: Frontend — settings toggle + +**Files:** +- Modify: `src/lib/components/settings/Sharing.svelte` + +- [ ] **Step 1: Read the current `Sharing.svelte`** + +Read `src/lib/components/settings/Sharing.svelte` fully. + +- [ ] **Step 2: Add the settings import + toggle** + +Add at the top of the script section: + +```typescript + import { settings } from '../../stores/settings.svelte'; + import { syncConditionChips } from '../../api/conditions'; +``` + +After the mode selector section (after line ~53, before the conditional sub-component rendering), add the toggle. This should render whenever sharing is active (server or client mode): + +```svelte + {#if sharingOn} + + {/if} +``` + +- [ ] **Step 3: Run type check** + +Run: `npm run check` +Expected: 0 errors. + +- [ ] **Step 4: Commit** + +```bash +git add src/lib/components/settings/Sharing.svelte +git commit -m "feat(frontend): add condition chip sync toggle in Sharing settings" +``` + +--- + +## Task 10: Frontend test + +**Files:** +- Create: `src/lib/components/ConditionChips.test.ts` + +- [ ] **Step 1: Write the test** + +Create `src/lib/components/ConditionChips.test.ts`: + +```typescript +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/svelte'; + +// Mock the conditions API before importing the component. +const mockListConditionChips = vi.fn(); +const mockAddConditionChip = vi.fn(); +const mockRemoveConditionChip = vi.fn(); + +vi.mock('../api/conditions', () => ({ + listConditionChips: mockListConditionChips, + addConditionChip: mockAddConditionChip, + removeConditionChip: mockRemoveConditionChip, +})); + +import ConditionChips from './ConditionChips.svelte'; + +beforeEach(() => { + vi.clearAllMocks(); + mockListConditionChips.mockResolvedValue([]); + mockAddConditionChip.mockResolvedValue([]); + mockRemoveConditionChip.mockResolvedValue([]); +}); + +describe('ConditionChips', () => { + it('renders default conditions while loading', async () => { + mockListConditionChips.mockReturnValue(new Promise(() => {})); // never resolves + render(ConditionChips, { props: { onAdd: () => {} } }); + // Default conditions should show immediately. + expect(screen.getByText('Hypertension')).toBeTruthy(); + expect(screen.getByText('Asthma')).toBeTruthy(); + }); + + it('loads chips from backend on mount', async () => { + mockListConditionChips.mockResolvedValue([ + { id: '1', text: 'Custom Condition', updated_at: '', deleted_at: null }, + ]); + render(ConditionChips, { props: { onAdd: () => {} } }); + await waitFor(() => { + expect(screen.getByText('Custom Condition')).toBeTruthy(); + }); + }); + + it('calls addConditionChip when adding a new chip', async () => { + mockAddConditionChip.mockResolvedValue([ + { id: '1', text: 'NewCond', updated_at: '', deleted_at: null }, + ]); + render(ConditionChips, { props: { onAdd: () => {} } }); + // Click the + button to reveal the input. + const addButton = screen.getByText('+'); + await fireEvent.click(addButton); + // Type and submit. + const input = screen.getByPlaceholderText(/add/i) as HTMLInputElement; + await fireEvent.input(input, { target: { value: 'NewCond' } }); + await fireEvent.keyDown(input, { key: 'Enter' }); + await waitFor(() => { + expect(mockAddConditionChip).toHaveBeenCalledWith('NewCond'); + }); + }); + + it('falls back gracefully when list fails', async () => { + mockListConditionChips.mockRejectedValue(new Error('network')); + render(ConditionChips, { props: { onAdd: () => {} } }); + // Should still show defaults, no crash. + await waitFor(() => { + expect(screen.getByText('Hypertension')).toBeTruthy(); + }); + }); +}); +``` + +- [ ] **Step 2: Check if `@testing-library/svelte` is available** + +Run: `grep '@testing-library/svelte' package.json` + +If NOT present, install it: +Run: `npm install -D @testing-library/svelte` + +- [ ] **Step 3: Run the test** + +Run: `npx vitest run src/lib/components/ConditionChips.test.ts` +Expected: tests pass (may need minor selector adjustments based on actual markup). + +- [ ] **Step 4: Commit** + +```bash +git add src/lib/components/ConditionChips.test.ts +git commit -m "test(frontend): ConditionChips component tests for sync-aware behavior" +``` + +--- + +## Task 11: Full integration verification + +- [ ] **Step 1: Run the complete Rust test suite** + +Run: `cargo test --workspace --lib 2>&1 | tail -30` +Expected: all pass. + +- [ ] **Step 2: Run clippy** + +Run: `cargo clippy --workspace 2>&1 | tail -20` +Expected: no warnings. + +- [ ] **Step 3: Run frontend tests** + +Run: `npx vitest run 2>&1 | tail -20` +Expected: all pass. + +- [ ] **Step 4: Run type check** + +Run: `npm run check` +Expected: 0 errors. + +- [ ] **Step 5: Manual smoke test (optional, if dev environment available)** + +Run: `npm run tauri dev` +- Open the app, go to Settings → Sharing. +- Verify the "Sync known condition chips" toggle appears when sharing is on. +- Go to Record tab, verify chips load and display. +- Add a chip, verify it persists. +- Remove a chip, verify it disappears. + +- [ ] **Step 6: Final commit (if any remaining changes)** + +```bash +git add -A +git commit -m "test: full integration verification for condition chip sync" +``` + +--- + +## Self-Review Notes + +### Spec coverage check +- ✅ Data model (table + struct + repo) — Tasks 1, 2, 3 +- ✅ Merge algorithm (LWW + tombstones) — Task 2 (comprehensive tests) +- ✅ API layer (server handlers + remote client) — Tasks 5, 6 +- ✅ Sync flow (push on edit, pull on connect) — Task 7 (commands) +- ✅ Opt-in setting — Task 4 +- ✅ Frontend integration — Tasks 8, 9 +- ✅ Error handling (graceful degradation) — Task 7 (paired_conditions_target gates + try/catch) +- ✅ PHI (log counts not text) — handlers use `count = chips.len()` +- ✅ Testing strategy — Tasks 1, 2, 4, 10 + integration Task 11 + +### Type consistency check +- `ConditionChip` struct fields match across: core type, DB repo, server DTO, remote client, TS interface. +- `deterministic_id` used consistently in repo `add`/`remove_by_text` and tests. +- Command names: `list_condition_chips`, `add_condition_chip`, `remove_condition_chip`, `sync_condition_chips_cmd` — consistent between Rust and TS. +- The Tauri command `sync_condition_chips_cmd` is suffixed `_cmd` to avoid clashing with the setting field name `sync_condition_chips`. + +### Known caveats for implementer +1. The `load_config_sync` helper in `commands/settings.rs` may not exist — Task 7 Step 4 handles this. +2. The frontend test selectors (placeholder text, button text) may need adjustment based on actual markup — read the real `ConditionChips.svelte` before finalizing. +3. `chrono::Duration::days(30)` in the prune cutoff — ensure `chrono` has the `chrono` crate available in `src-tauri` (it should, it's used extensively). From d20e9e44ece314a0d1f52cd1a481684705e34e33 Mon Sep 17 00:00:00 2001 From: Andre Hugo Date: Fri, 3 Jul 2026 09:31:04 -0700 Subject: [PATCH 03/13] feat(core): add ConditionChip type with deterministic UUID v5 id --- crates/core/src/types/condition_chip.rs | 70 +++++++++++++++++++++++++ crates/core/src/types/mod.rs | 3 ++ 2 files changed, 73 insertions(+) create mode 100644 crates/core/src/types/condition_chip.rs diff --git a/crates/core/src/types/condition_chip.rs b/crates/core/src/types/condition_chip.rs new file mode 100644 index 0000000..bc9e26b --- /dev/null +++ b/crates/core/src/types/condition_chip.rs @@ -0,0 +1,70 @@ +//! Condition chip type used by the condition-chip sync feature. +//! +//! A condition chip is a practice-wide quick-add preset shown under "Known +//! conditions" (e.g. "Hypertension"). Each chip has a deterministic ID derived +//! from its normalized text so that two machines independently adding the same +//! condition produce the same row — enabling per-item last-write-wins merge. + +use serde::{Deserialize, Serialize}; + +/// Fixed namespace for UUID v5 generation of condition chip IDs. +/// Generated once and hardcoded — must never change (would break ID stability). +const CONDITION_NAMESPACE: uuid::Uuid = uuid::Uuid::from_bytes([ + 0x4a, 0x3e, 0xc1, 0x07, 0x9b, 0x2d, 0x4f, 0x6a, + 0xa1, 0x10, 0xd8, 0x4f, 0xa2, 0xb3, 0xc5, 0xe7, +]); + +/// A condition chip entry with sync metadata. +/// +/// - `id`: deterministic UUID v5 from `normalize_for_id(&text)`. Two machines +/// adding "Hypertension" produce the same id. +/// - `updated_at`: ISO 8601 UTC string — the last-write-wins clock. +/// - `deleted_at`: tombstone timestamp. `None` means active. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ConditionChip { + pub id: String, + pub text: String, + pub updated_at: String, + pub deleted_at: Option, +} + +/// Normalize condition text for deterministic ID generation. +/// +/// Lowercases and trims so "Hypertension", "hypertension ", and +/// "HYPERTENSION" all produce the same id. +pub fn normalize_for_id(text: &str) -> String { + text.trim().to_lowercase() +} + +/// Generate a deterministic UUID v5 from normalized condition text. +/// +/// Same text always produces the same UUID, across machines and restarts. +pub fn deterministic_id(text: &str) -> String { + uuid::Uuid::new_v5(&CONDITION_NAMESPACE, normalize_for_id(text).as_bytes()) + .to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn deterministic_id_is_stable() { + assert_eq!(deterministic_id("Hypertension"), deterministic_id("Hypertension")); + } + + #[test] + fn deterministic_id_is_case_insensitive() { + assert_eq!(deterministic_id("Hypertension"), deterministic_id("hypertension")); + } + + #[test] + fn deterministic_id_ignores_whitespace() { + assert_eq!(deterministic_id("Hypertension"), deterministic_id(" Hypertension ")); + } + + #[test] + fn different_conditions_have_different_ids() { + assert_ne!(deterministic_id("Hypertension"), deterministic_id("Diabetes")); + } +} diff --git a/crates/core/src/types/mod.rs b/crates/core/src/types/mod.rs index 4f028e1..5344e4a 100644 --- a/crates/core/src/types/mod.rs +++ b/crates/core/src/types/mod.rs @@ -10,6 +10,7 @@ //! | [`processing`] | Queue tasks, batch processing, priority | //! | [`agent`] | Agent context, tools, patient context | //! | [`ai`] | Completion request/response, messages, streaming | +//! | [`condition_chip`] | [`condition_chip::ConditionChip`] with deterministic UUID v5 id | //! | [`stt`] | Audio data, transcription config/results | //! | [`tts`] | TTS config, voice info | //! | [`rag`] | RAG results, search config, knowledge graph types | @@ -20,6 +21,7 @@ pub mod agent; pub mod ai; +pub mod condition_chip; pub mod endpoint; pub mod letter_audience; pub mod processing; @@ -32,6 +34,7 @@ pub mod vocabulary; pub use agent::*; pub use ai::*; +pub use condition_chip::*; pub use endpoint::*; pub use letter_audience::LetterAudience; pub use processing::*; From fccea47e01ef78cecd8d7a816c9603b7f1b174b5 Mon Sep 17 00:00:00 2001 From: Andre Hugo Date: Fri, 3 Jul 2026 09:38:49 -0700 Subject: [PATCH 04/13] feat(db): add ConditionChipsRepo with LWW merge + tombstones --- crates/db/src/condition_chips.rs | 366 +++++++++++++++++++++++++++++++ crates/db/src/lib.rs | 2 + 2 files changed, 368 insertions(+) create mode 100644 crates/db/src/condition_chips.rs diff --git a/crates/db/src/condition_chips.rs b/crates/db/src/condition_chips.rs new file mode 100644 index 0000000..ad3f0aa --- /dev/null +++ b/crates/db/src/condition_chips.rs @@ -0,0 +1,366 @@ +//! CRUD + sync-merge operations for the `condition_chips` table. +//! +//! A condition chip is a practice-wide quick-add preset shown under "Known +//! conditions" (e.g. "Hypertension"). Each chip has a deterministic ID derived +//! from its normalized text, enabling per-item last-write-wins merge across +//! machines. +//! +//! Deletion is soft: a tombstone timestamp is written to `deleted_at` and the +//! row is retained so the deletion can propagate to other machines during sync. +//! Tombstones older than a cutoff can eventually be pruned via +//! [`ConditionChipsRepo::prune_tombstones`]. + +use rusqlite::{Connection, Row, params}; + +use medical_core::types::condition_chip::{ConditionChip, deterministic_id}; + +use crate::DbResult; + +/// Repository for the `condition_chips` table. +/// +/// All methods are associated functions taking a `&Connection` as the first +/// argument, following the same pattern as [`crate::RecordingsRepo`] and +/// [`crate::UserDictionaryRepo`]. +pub struct ConditionChipsRepo; + +impl ConditionChipsRepo { + /// List active (non-deleted) chips, ordered by text case-insensitively. + pub fn list_active(conn: &Connection) -> DbResult> { + let mut stmt = conn.prepare( + "SELECT id, text, updated_at, deleted_at + FROM condition_chips + WHERE deleted_at IS NULL + ORDER BY LOWER(text)", + )?; + let chips = stmt + .query_map([], Self::row_to_chip)? + .collect::>>()?; + Ok(chips) + } + + /// List all chips including tombstones (for sync). + pub fn list_all(conn: &Connection) -> DbResult> { + let mut stmt = conn.prepare( + "SELECT id, text, updated_at, deleted_at + FROM condition_chips + ORDER BY LOWER(text)", + )?; + let chips = stmt + .query_map([], Self::row_to_chip)? + .collect::>>()?; + Ok(chips) + } + + /// Insert or replace a chip by id (`ON CONFLICT DO UPDATE`). + /// + /// Used both for direct writes and as the primitive underlying the merge. + pub fn upsert(conn: &Connection, chip: &ConditionChip) -> DbResult<()> { + conn.execute( + "INSERT INTO condition_chips (id, text, updated_at, deleted_at) + VALUES (?1, ?2, ?3, ?4) + ON CONFLICT(id) DO UPDATE SET + text = excluded.text, + updated_at = excluded.updated_at, + deleted_at = excluded.deleted_at", + params![chip.id, chip.text, chip.updated_at, chip.deleted_at], + )?; + Ok(()) + } + + /// Soft-delete a chip by id: set `deleted_at` and `updated_at` to `now_iso`. + pub fn soft_delete(conn: &Connection, id: &str, now_iso: &str) -> DbResult<()> { + conn.execute( + "UPDATE condition_chips + SET deleted_at = ?1, updated_at = ?1 + WHERE id = ?2", + params![now_iso, id], + )?; + Ok(()) + } + + /// Merge a batch of remote chips into the local store using + /// last-write-wins semantics. + /// + /// # Algorithm + /// + /// For each remote chip `R`: + /// - If no local chip with the same id exists → **insert** `R` as-is + /// (it is new — an addition or a tombstone from the other side). + /// - If `R.updated_at > local.updated_at` → **replace** local with `R`. + /// - If `R.updated_at < local.updated_at` → local wins, **do nothing**. + /// - If timestamps are equal (tie) → the **tombstone wins**: if `R` is a + /// tombstone (`deleted_at` is `Some`) replace local, otherwise keep local. + /// + /// The tie-break rule (deleted wins on exact timestamp equality) is + /// conservative — it avoids ghost reappearance of a condition that one side + /// deleted. + /// + /// Timestamps are ISO 8601 UTC strings, which compare chronologically under + /// lexicographic ordering as long as the format/timezone is consistent. + /// + /// Returns the active chip list after merging. + pub fn merge_incoming( + conn: &Connection, + remote_chips: &[ConditionChip], + ) -> DbResult> { + // Load all local chips once and index by id for O(1) lookup. + let local_all = Self::list_all(conn)?; + let local_map: std::collections::HashMap<&str, &ConditionChip> = local_all + .iter() + .map(|c| (c.id.as_str(), c)) + .collect(); + + for remote in remote_chips { + match local_map.get(remote.id.as_str()) { + None => { + // New chip — insert as-is (addition or tombstone). + Self::upsert(conn, remote)?; + } + Some(local) => { + match remote.updated_at.cmp(&local.updated_at) { + std::cmp::Ordering::Greater => { + // Remote is newer — remote wins. + Self::upsert(conn, remote)?; + } + std::cmp::Ordering::Less => { + // Local is newer — local wins, do nothing. + } + std::cmp::Ordering::Equal => { + // Tie — tombstone wins to avoid ghost reappearance. + if remote.deleted_at.is_some() { + Self::upsert(conn, remote)?; + } + } + } + } + } + } + + Self::list_active(conn) + } + + /// Permanently delete tombstones whose `deleted_at` is older than + /// `cutoff_iso`. Returns the number of rows removed. + /// + /// Active chips are never touched. + pub fn prune_tombstones(conn: &Connection, cutoff_iso: &str) -> DbResult { + let removed = conn.execute( + "DELETE FROM condition_chips + WHERE deleted_at IS NOT NULL AND deleted_at < ?1", + params![cutoff_iso], + )?; + Ok(removed) + } + + /// Add a new chip with the given text. The id is derived deterministically + /// from the normalized text. Returns the active chip list afterwards. + pub fn add(conn: &Connection, text: &str, now_iso: &str) -> DbResult> { + let chip = ConditionChip { + id: deterministic_id(text), + text: text.to_string(), + updated_at: now_iso.to_string(), + deleted_at: None, + }; + Self::upsert(conn, &chip)?; + Self::list_active(conn) + } + + /// Soft-delete the chip whose text matches (case-insensitively, via the + /// deterministic id). Returns the active chip list afterwards. + pub fn remove_by_text( + conn: &Connection, + text: &str, + now_iso: &str, + ) -> DbResult> { + let id = deterministic_id(text); + Self::soft_delete(conn, &id, now_iso)?; + Self::list_active(conn) + } + + /// Map a `rusqlite::Row` (columns: id, text, updated_at, deleted_at) to a + /// [`ConditionChip`]. + fn row_to_chip(row: &Row) -> rusqlite::Result { + Ok(ConditionChip { + id: row.get(0)?, + text: row.get(1)?, + updated_at: row.get(2)?, + deleted_at: row.get(3)?, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Create an in-memory database with the `condition_chips` table. + /// The table migration is Task 3, so tests create it manually here. + fn fresh() -> Connection { + let conn = Connection::open_in_memory().expect("open in-memory"); + conn.execute_batch( + "CREATE TABLE condition_chips ( + id TEXT PRIMARY KEY, text TEXT NOT NULL, + updated_at TEXT NOT NULL, deleted_at TEXT + );", + ) + .expect("create condition_chips table"); + conn + } + + /// ISO 8601 timestamp offset from a fixed base epoch. + fn now(offset_secs: i64) -> String { + let base = chrono::DateTime::parse_from_rfc3339("2026-07-03T10:00:00Z").unwrap(); + let t = base + chrono::Duration::seconds(offset_secs); + t.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string() + } + + /// Build a chip with a deterministic id for the given text. + fn chip(text: &str, updated_offset: i64, deleted: bool) -> ConditionChip { + ConditionChip { + id: deterministic_id(text), + text: text.to_string(), + updated_at: now(updated_offset), + deleted_at: if deleted { + Some(now(updated_offset)) + } else { + None + }, + } + } + + #[test] + fn merge_inserts_new_remote_chip() { + let conn = fresh(); + let remote = vec![chip("Hypertension", 0, false)]; + + let result = ConditionChipsRepo::merge_incoming(&conn, &remote).expect("merge"); + + assert_eq!(result.len(), 1); + assert_eq!(result[0].text, "Hypertension"); + assert!(result[0].deleted_at.is_none()); + } + + #[test] + fn merge_remote_newer_wins() { + let conn = fresh(); + // Local chip at t=0. + ConditionChipsRepo::upsert(&conn, &chip("Hypertension", 0, false)).expect("upsert local"); + // Remote chip at t=300 — should win. + let remote = vec![chip("Hypertension", 300, false)]; + + let result = ConditionChipsRepo::merge_incoming(&conn, &remote).expect("merge"); + + assert_eq!(result.len(), 1); + assert_eq!(result[0].updated_at, now(300)); + } + + #[test] + fn merge_local_newer_wins() { + let conn = fresh(); + // Local chip at t=300. + ConditionChipsRepo::upsert(&conn, &chip("Hypertension", 300, false)).expect("upsert local"); + // Remote chip at t=0 — local should win. + let remote = vec![chip("Hypertension", 0, false)]; + + let result = ConditionChipsRepo::merge_incoming(&conn, &remote).expect("merge"); + + assert_eq!(result.len(), 1); + assert_eq!(result[0].updated_at, now(300)); + } + + #[test] + fn merge_tombstone_wins_over_older_active() { + let conn = fresh(); + // Local active chip at t=0. + ConditionChipsRepo::upsert(&conn, &chip("Hypertension", 0, false)).expect("upsert local"); + // Remote tombstone at t=600 — newer, so tombstone wins. + let remote = vec![chip("Hypertension", 600, true)]; + + let result = ConditionChipsRepo::merge_incoming(&conn, &remote).expect("merge"); + + assert!(result.is_empty(), "active list should be empty after tombstone merge"); + } + + #[test] + fn merge_re_add_after_tombstone() { + let conn = fresh(); + // Local tombstone at t=600. + ConditionChipsRepo::upsert(&conn, &chip("Hypertension", 600, true)).expect("upsert tombstone"); + // Remote active at t=1200 — newer, so the chip is resurrected. + let remote = vec![chip("Hypertension", 1200, false)]; + + let result = ConditionChipsRepo::merge_incoming(&conn, &remote).expect("merge"); + + assert_eq!(result.len(), 1, "chip should be resurrected"); + assert!(result[0].deleted_at.is_none()); + } + + #[test] + fn merge_tie_deleted_wins() { + let conn = fresh(); + // Local active chip at t=500. + ConditionChipsRepo::upsert(&conn, &chip("Hypertension", 500, false)).expect("upsert local"); + // Remote tombstone at the SAME t=500 — tie, tombstone wins. + let remote = vec![chip("Hypertension", 500, true)]; + + let result = ConditionChipsRepo::merge_incoming(&conn, &remote).expect("merge"); + + assert!(result.is_empty(), "on tie the tombstone should win"); + } + + #[test] + fn merge_is_idempotent() { + let conn = fresh(); + let remote = vec![ + chip("Hypertension", 100, false), + chip("Diabetes", 200, false), + ]; + + let first = ConditionChipsRepo::merge_incoming(&conn, &remote).expect("first merge"); + let second = ConditionChipsRepo::merge_incoming(&conn, &remote).expect("second merge"); + + assert_eq!(first, second, "merging the same list twice must yield the same result"); + assert_eq!(second.len(), 2); + } + + #[test] + fn prune_tombstones_removes_old_only() { + let conn = fresh(); + // Old tombstone at t=0. + ConditionChipsRepo::upsert(&conn, &chip("Old Condition", 0, true)).expect("upsert old tombstone"); + // Recent tombstone at t=900. + ConditionChipsRepo::upsert(&conn, &chip("Recent Condition", 900, true)).expect("upsert recent tombstone"); + // Active chip — must be untouched. + ConditionChipsRepo::upsert(&conn, &chip("Active Condition", 300, false)).expect("upsert active"); + + // Prune tombstones older than t=500. + let removed = ConditionChipsRepo::prune_tombstones(&conn, &now(500)).expect("prune"); + + assert_eq!(removed, 1, "only the old tombstone should be pruned"); + + let all = ConditionChipsRepo::list_all(&conn).expect("list_all"); + assert_eq!(all.len(), 2, "recent tombstone + active chip should remain"); + let active = ConditionChipsRepo::list_active(&conn).expect("list_active"); + assert_eq!(active.len(), 1, "active chip should still be present"); + assert_eq!(active[0].text, "Active Condition"); + } + + #[test] + fn add_and_remove_by_text() { + let conn = fresh(); + + // Add returns a single active chip. + let after_add = ConditionChipsRepo::add(&conn, "Hypertension", &now(100)).expect("add"); + assert_eq!(after_add.len(), 1); + assert_eq!(after_add[0].text, "Hypertension"); + + // Remove returns an empty active list. + let after_remove = ConditionChipsRepo::remove_by_text(&conn, "Hypertension", &now(200)).expect("remove"); + assert!(after_remove.is_empty(), "active list should be empty after remove"); + + // The tombstone should still exist in list_all. + let all = ConditionChipsRepo::list_all(&conn).expect("list_all"); + assert_eq!(all.len(), 1, "tombstone should be retained"); + assert!(all[0].deleted_at.is_some(), "chip should be a tombstone"); + } +} diff --git a/crates/db/src/lib.rs b/crates/db/src/lib.rs index d8b51a0..20cefbc 100644 --- a/crates/db/src/lib.rs +++ b/crates/db/src/lib.rs @@ -24,6 +24,7 @@ //! writer; `busy_timeout=5000` mitigates transient write contention. pub mod audit; +pub mod condition_chips; pub mod encryption; pub mod letter_audiences; pub mod migrations; @@ -40,6 +41,7 @@ pub mod vectors; pub use generations::{Generation, GenerationInsert, GenerationsRepo}; pub mod user_dictionary; pub use user_dictionary::UserDictionaryRepo; +pub use condition_chips::ConditionChipsRepo; use std::path::Path; From dee78ae29b73a9457b77393fc663726c8b465c32 Mon Sep 17 00:00:00 2001 From: Andre Hugo Date: Fri, 3 Jul 2026 09:43:40 -0700 Subject: [PATCH 05/13] =?UTF-8?q?feat(db):=20m010=20migration=20=E2=80=94?= =?UTF-8?q?=20condition=5Fchips=20table=20+=20seed=20from=20custom=5Fcondi?= =?UTF-8?q?tions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../db/src/migrations/m010_condition_chips.rs | 66 +++++++++++++++++++ crates/db/src/migrations/mod.rs | 6 ++ 2 files changed, 72 insertions(+) create mode 100644 crates/db/src/migrations/m010_condition_chips.rs diff --git a/crates/db/src/migrations/m010_condition_chips.rs b/crates/db/src/migrations/m010_condition_chips.rs new file mode 100644 index 0000000..c5585cc --- /dev/null +++ b/crates/db/src/migrations/m010_condition_chips.rs @@ -0,0 +1,66 @@ +use rusqlite::Connection; + +use crate::DbResult; + +/// Create the `condition_chips` table and seed it from existing +/// `AppConfig.custom_conditions` values (if any). +/// +/// Each existing condition becomes an active row with `updated_at = now()`. +/// The old `custom_conditions` field in the settings blob is left intact +/// (inert) for rollback safety. +pub fn up(conn: &Connection) -> DbResult<()> { + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS condition_chips ( + id TEXT PRIMARY KEY, + text TEXT NOT NULL, + updated_at TEXT NOT NULL, + deleted_at TEXT + ); + CREATE INDEX IF NOT EXISTS idx_condition_chips_active + ON condition_chips(text) WHERE deleted_at IS NULL;", + )?; + + // Seed from existing custom_conditions in the settings blob. + seed_from_custom_conditions(conn)?; + + Ok(()) +} + +/// Read `custom_conditions` from the `settings` table (key "app_config") +/// and insert each as an active condition chip. +fn seed_from_custom_conditions(conn: &Connection) -> DbResult<()> { + use medical_core::types::condition_chip::{ConditionChip, deterministic_id}; + use medical_core::types::settings::AppConfig; + + let json: Option = conn + .query_row( + "SELECT value FROM settings WHERE key = 'app_config'", + [], + |row| row.get(0), + ) + .ok(); + + let Some(json) = json else { return Ok(()); }; + let config: AppConfig = match serde_json::from_str(&json) { + Ok(c) => c, + Err(_) => return Ok(()), // unparseable config — skip seeding + }; + + let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string(); + for text in &config.custom_conditions { + let chip = ConditionChip { + id: deterministic_id(text), + text: text.clone(), + updated_at: now.clone(), + deleted_at: None, + }; + let _ = conn.execute( + "INSERT OR IGNORE INTO condition_chips (id, text, updated_at, deleted_at) + VALUES (?1, ?2, ?3, NULL)", + rusqlite::params![chip.id, chip.text, chip.updated_at], + ); + } + + tracing::info!(count = config.custom_conditions.len(), "Seeded condition chips from custom_conditions"); + Ok(()) +} diff --git a/crates/db/src/migrations/mod.rs b/crates/db/src/migrations/mod.rs index 624213b..0fa8ee3 100644 --- a/crates/db/src/migrations/mod.rs +++ b/crates/db/src/migrations/mod.rs @@ -13,6 +13,7 @@ pub mod m006_letter_audiences; pub mod m007_processing_queue_indexes; pub mod m008_peer_discussion; pub mod m009_soft_delete; +pub mod m010_condition_chips; use rusqlite::Connection; @@ -82,6 +83,11 @@ pub fn all_migrations() -> &'static [Migration] { name: "soft_delete", up: m009_soft_delete::up, }, + Migration { + version: 10, + name: "condition_chips", + up: m010_condition_chips::up, + }, ] } From 0095cf706a2b9bfb2f547e816f1859851b1f121d Mon Sep 17 00:00:00 2001 From: Andre Hugo Date: Fri, 3 Jul 2026 09:46:22 -0700 Subject: [PATCH 06/13] feat(settings): add sync_condition_chips opt-in (defaults false) --- crates/core/src/types/settings.rs | 15 +++++++++++++++ src/lib/stores/settings.svelte.ts | 1 + src/lib/types/index.ts | 2 ++ 3 files changed, 18 insertions(+) diff --git a/crates/core/src/types/settings.rs b/crates/core/src/types/settings.rs index b32b0ba..796be78 100644 --- a/crates/core/src/types/settings.rs +++ b/crates/core/src/types/settings.rs @@ -489,6 +489,13 @@ pub struct AppConfig { /// docs/superpowers/specs/2026-05-11-training-corpus-design.md. #[serde(default)] pub capture_for_training: bool, + + // Condition chip sync + /// When true, condition chip presets sync two-way between this machine + /// and the paired server via the vocab API. Defaults to false — each + /// machine keeps its own list unless the user opts in. + #[serde(default)] + pub sync_condition_chips: bool, } impl Default for AppConfig { @@ -729,6 +736,14 @@ mod tests { assert!(!cfg.capture_for_training, "default must be false"); } + #[test] + fn sync_condition_chips_defaults_to_false_in_older_configs() { + let old_json = r#"{"ai_provider":"ollama","stt_mode":"local"}"#; + let cfg: AppConfig = + serde_json::from_str(old_json).expect("should parse with serde defaults"); + assert!(!cfg.sync_condition_chips, "default must be false"); + } + #[test] fn capture_for_training_round_trips() { let mut cfg = AppConfig::default(); diff --git a/src/lib/stores/settings.svelte.ts b/src/lib/stores/settings.svelte.ts index bb38c09..b6f6ff5 100644 --- a/src/lib/stores/settings.svelte.ts +++ b/src/lib/stores/settings.svelte.ts @@ -50,6 +50,7 @@ const defaults: AppConfig = { onboarding_completed: false, auto_update_check: true, custom_conditions: [], + sync_condition_chips: false, }; class SettingsStore { diff --git a/src/lib/types/index.ts b/src/lib/types/index.ts index 4d66184..412574f 100644 --- a/src/lib/types/index.ts +++ b/src/lib/types/index.ts @@ -131,6 +131,8 @@ export interface AppConfig { auto_update_check: boolean; // Quick-add condition chips custom_conditions: string[]; + /** When true, condition chip presets sync two-way with the paired server. Defaults to false. */ + sync_condition_chips: boolean; } // ── Chat ────────────────────────────────────────────────────────────────────── From fa59aa8e48abab9dd6fc3b6ec81617407407504c Mon Sep 17 00:00:00 2001 From: Andre Hugo Date: Fri, 3 Jul 2026 09:50:37 -0700 Subject: [PATCH 07/13] feat(sharing): add /v1/condition-chips GET + /sync POST server handlers --- src-tauri/src/sharing_vocab_api.rs | 84 +++++++++++++++++++++++++++++- 1 file changed, 83 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/sharing_vocab_api.rs b/src-tauri/src/sharing_vocab_api.rs index 2fb6370..055a94f 100644 --- a/src-tauri/src/sharing_vocab_api.rs +++ b/src-tauri/src/sharing_vocab_api.rs @@ -21,6 +21,9 @@ //! GET / — list all words //! POST / — add word { word } //! DELETE /{word} — remove word +//! /v1/condition-chips +//! GET / — list active chips +//! POST /sync — two-way merge (client → server) //! //! Wire formats reuse the existing `VocabularyEntry` and `ContextTemplate` //! serde definitions, so clients deserialize directly into the same types @@ -39,7 +42,7 @@ use axum::{ Json, Router, extract::{Path, Query, State as AxumState}, http::{HeaderMap, StatusCode}, - routing::{get, put}, + routing::{get, post, put}, }; use chrono::Utc; use medical_core::types::settings::ContextTemplate; @@ -104,6 +107,8 @@ pub async fn spawn( "/v1/user-dictionary/{word}", axum::routing::delete(dict_remove_handler), ) + .route("/v1/condition-chips", get(condition_chips_list_handler)) + .route("/v1/condition-chips/sync", post(condition_chips_sync_handler)) .with_state(state); let addr: std::net::SocketAddr = format!("0.0.0.0:{port}") @@ -599,3 +604,80 @@ async fn dict_remove_handler( info!(word_len, removed, "dict_api: remove"); Ok(Json(removed)) } + +// ── Condition chips handlers ──────────────────────────────────────────── +// +// Practice-wide quick-add condition presets stored in the dedicated +// `condition_chips` table (not the settings blob). Reads/writes hit +// `medical_db::condition_chips::ConditionChipsRepo` directly — the same +// pattern as the vocabulary handlers above. Deletion is soft (tombstoned), +// so a two-way merge can propagate add/remove across machines. No PHI in +// logs; only counts and lengths are logged. + +/// GET /v1/condition-chips — return all active condition chips. +async fn condition_chips_list_handler( + AxumState(state): AxumState, + headers: HeaderMap, +) -> Result>, StatusCode> { + let _ = authorize(&state, &headers)?; + let db = Arc::clone(&state.db); + let chips = tokio::task::spawn_blocking( + move || -> Result, medical_core::error::AppError> { + let conn = db.conn()?; + medical_db::condition_chips::ConditionChipsRepo::list_active(&conn) + .map_err(medical_core::error::AppError::from) + }, + ) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .map_err(|e| { + warn!("condition_chips_api list failed: {e}"); + StatusCode::INTERNAL_SERVER_ERROR + })?; + debug!(count = chips.len(), "condition_chips_api: list"); + Ok(Json(chips)) +} + +/// POST /v1/condition-chips/sync — two-way merge. +/// +/// Body: the client's full chip list (active chips + tombstones). +/// Returns: the merged active chip list after applying last-write-wins. +async fn condition_chips_sync_handler( + AxumState(state): AxumState, + headers: HeaderMap, + Json(incoming): Json>, +) -> Result>, StatusCode> { + let _ = authorize(&state, &headers)?; + let db = Arc::clone(&state.db); + + let incoming_count = incoming.len(); + + // Prune old tombstones opportunistically (30 days). Best-effort — a + // prune failure must not fail the sync. + let cutoff = chrono::Utc::now() - chrono::Duration::days(30); + let cutoff_iso = cutoff.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string(); + + let merged = tokio::task::spawn_blocking( + move || -> Result, medical_core::error::AppError> { + let conn = db.conn()?; + let result = medical_db::condition_chips::ConditionChipsRepo::merge_incoming(&conn, &incoming) + .map_err(medical_core::error::AppError::from)?; + // Best-effort prune — don't fail the sync if pruning errors. + let _ = medical_db::condition_chips::ConditionChipsRepo::prune_tombstones(&conn, &cutoff_iso); + Ok(result) + }, + ) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .map_err(|e| { + warn!("condition_chips_api sync failed: {e}"); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + info!( + incoming_count, + result_count = merged.len(), + "condition_chips_api: sync" + ); + Ok(Json(merged)) +} From 4ed236ee8b6942e64897f4d89950398b8265b28e Mon Sep 17 00:00:00 2001 From: Andre Hugo Date: Fri, 3 Jul 2026 09:55:30 -0700 Subject: [PATCH 08/13] feat(sharing): add ConditionsRemote HTTP client for chip sync --- src-tauri/src/conditions_remote.rs | 125 +++++++++++++++++++++++++++++ src-tauri/src/lib.rs | 1 + 2 files changed, 126 insertions(+) create mode 100644 src-tauri/src/conditions_remote.rs diff --git a/src-tauri/src/conditions_remote.rs b/src-tauri/src/conditions_remote.rs new file mode 100644 index 0000000..61b07c4 --- /dev/null +++ b/src-tauri/src/conditions_remote.rs @@ -0,0 +1,125 @@ +//! HTTP client for the office server's condition-chips API. +//! +//! Used by the client side of the sharing feature: when a paired connection +//! is present and the office server advertised a vocab port (which also +//! hosts /v1/condition-chips), the condition-chip Tauri commands route +//! through here instead of the local repo so the server stays the +//! canonical source of truth. + +use medical_core::error::{AppError, AppResult}; +use medical_core::types::condition_chip::ConditionChip; +use medical_core::types::endpoint::http_url; + +use crate::commands::sharing::PairedConnection; + +/// HTTP client for the office server's `/v1/condition-chips` API. +/// +/// Created via [`ConditionsRemote::from`] when a paired connection is present. +/// All methods send bearer-authenticated requests and return `AppResult`. +pub struct ConditionsRemote<'a> { + pub conn: &'a PairedConnection, + pub bearer: String, + pub client: std::sync::Arc, +} + +impl<'a> ConditionsRemote<'a> { + /// Create a `ConditionsRemote` if the paired connection supports chip + /// sync (has a vocab port and a bearer token). Returns `None` when the + /// office server predates the chip sync feature or no bearer is + /// available -- callers fall back to local DB operations. + pub fn from( + conn: &'a PairedConnection, + bearer: Option, + client: std::sync::Arc, + ) -> Option { + let bearer = bearer?; + // Condition chips ride on the same port as the vocab API. Absence + // means the office server predates the chip-sync release; treat + // that as "chip sync unavailable" and fall back to local. + conn.ports.vocab?; + Some(Self { + conn, + bearer, + client, + }) + } + + fn base_url(&self) -> Option { + let port = self.conn.ports.vocab?; + let host = self + .conn + .lan + .as_deref() + .or(self.conn.tailscale.as_deref())?; + Some(http_url(host, port)) + } + + /// List all active condition chips from the office server. + pub async fn list(&self) -> AppResult> { + let base = self + .base_url() + .ok_or_else(|| AppError::Other("paired server has no vocab address".into()))?; + let url = format!("{base}/v1/condition-chips"); + let resp = self + .client + .get(&url) + .timeout(std::time::Duration::from_secs(15)) + .bearer_auth(&self.bearer) + .send() + .await + .map_err(|e| AppError::Other(format!("conditions list: {e}")))?; + check_status(&resp).await?; + resp.json::>() + .await + .map_err(|e| AppError::Other(format!("conditions list parse: {e}"))) + } + + /// Push local chips and receive the server-merged list back. + /// + /// The server applies per-item last-write-wins merge against its own + /// rows and returns the full resulting list. + pub async fn sync(&self, local_chips: Vec) -> AppResult> { + let base = self + .base_url() + .ok_or_else(|| AppError::Other("paired server has no vocab address".into()))?; + let url = format!("{base}/v1/condition-chips/sync"); + let resp = self + .client + .post(&url) + .timeout(std::time::Duration::from_secs(15)) + .bearer_auth(&self.bearer) + .json(&local_chips) + .send() + .await + .map_err(|e| AppError::Other(format!("conditions sync: {e}")))?; + check_status(&resp).await?; + resp.json::>() + .await + .map_err(|e| AppError::Other(format!("conditions sync parse: {e}"))) + } +} + +async fn check_status(resp: &reqwest::Response) -> AppResult<()> { + let status = resp.status(); + if status.is_success() { + return Ok(()); + } + if status == reqwest::StatusCode::NOT_FOUND { + return Err(AppError::Other( + "Office server does not support condition-chip sync (update it to a later release)." + .to_string(), + )); + } + if status == reqwest::StatusCode::UNAUTHORIZED { + return Err(AppError::Other( + "Office server rejected the bearer token. Try unpair → re-pair from this client." + .to_string(), + )); + } + if status == reqwest::StatusCode::CONFLICT { + return Err(AppError::Other( + "A condition chip conflict occurred on the office server.".to_string(), + )); + } + Err(AppError::Other(format!("conditions API: HTTP {status}"))) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index edff212..5112449 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -38,6 +38,7 @@ mod commands; pub mod corpus_export; mod sharing_vocab_api; mod state; +mod conditions_remote; mod templates_remote; mod user_dict_remote; mod vocab_remote; From e21899d892cc67ac3f76a6ab02bd8b69eebdee83 Mon Sep 17 00:00:00 2001 From: Andre Hugo Date: Fri, 3 Jul 2026 10:05:06 -0700 Subject: [PATCH 09/13] feat(commands): condition chip Tauri commands with sync dispatch --- src-tauri/src/commands/conditions.rs | 290 +++++++++++++++++++++++++++ src-tauri/src/commands/mod.rs | 1 + src-tauri/src/commands/settings.rs | 15 ++ src-tauri/src/lib.rs | 4 + 4 files changed, 310 insertions(+) create mode 100644 src-tauri/src/commands/conditions.rs diff --git a/src-tauri/src/commands/conditions.rs b/src-tauri/src/commands/conditions.rs new file mode 100644 index 0000000..0a177b0 --- /dev/null +++ b/src-tauri/src/commands/conditions.rs @@ -0,0 +1,290 @@ +//! Tauri commands for condition chips ("Known conditions" quick-add presets). +//! +//! Dispatch model: condition-chip operations check two gates before routing +//! through the office server's HTTP API: +//! +//! 1. The `sync_condition_chips` opt-in setting must be enabled. +//! 2. This client must be paired with an office server that advertised a +//! `vocab` port (which also hosts `/v1/condition-chips`). +//! +//! When both hold, list pulls from the server and merges locally, and +//! add/remove push the local state to the server in the background. When +//! either gate fails, operations run against the local SQLite repo only — +//! the feature is fully usable offline or unpaired. +//! +//! Writes (add/remove) always update the local DB first for instant UI +//! feedback, then fire a best-effort background sync push that does not +//! block the command's return. A failed push is retried implicitly on the +//! next `list` (which always pulls + merges when paired). + +use std::sync::Arc; + +use tracing::instrument; + +use medical_core::error::{AppError, AppResult}; +use medical_core::types::condition_chip::ConditionChip; + +use crate::state::{self, AppState}; + +/// Returns `Some((conn, bearer))` when this client should route condition-chip +/// operations through the office server: the `sync_condition_chips` opt-in is +/// on AND the client is paired with a server that exposes the vocab/chips port. +/// +/// Returns `None` otherwise, in which case commands fall back to the local +/// SQLite repo. +fn paired_conditions_target( + state: &AppState, +) -> Option<(crate::commands::sharing::PairedConnection, String)> { + // Gate 1: the user must opt in to condition-chip sync. + let config = crate::commands::settings::load_config_sync(&state.db).ok()?; + if !config.sync_condition_chips { + return None; + } + // Gate 2: this client must be paired with a server that advertises the + // vocab port (condition chips ride on the same port as the dictionary API). + let conn = state::load_paired_connection()?; + conn.ports.vocab?; + let bearer = state::load_sharing_bearer()?; + Some((conn, bearer)) +} + +/// ISO 8601 UTC timestamp with millisecond precision, matching the format used +/// by [`medical_db::condition_chips`] rows. +fn now_iso() -> String { + chrono::Utc::now() + .format("%Y-%m-%dT%H:%M:%S%.3fZ") + .to_string() +} + +/// List active condition chips. +/// +/// When paired + sync enabled, pull from the server and merge into the local +/// store (last-write-wins) so both sides converge, then return the resulting +/// active list. A remote failure logs a warning and falls back to the local +/// active list so the UI keeps working offline. +#[tauri::command] +#[instrument(skip(state), name = "conditions::list")] +pub async fn list_condition_chips( + state: tauri::State<'_, AppState>, +) -> AppResult> { + if let Some((conn, bearer)) = paired_conditions_target(&state) { + if let Some(remote) = crate::conditions_remote::ConditionsRemote::from( + &conn, + Some(bearer), + state.http_client.clone(), + ) { + match remote.list().await { + Ok(server_chips) => { + let db = Arc::clone(&state.db); + return tokio::task::spawn_blocking(move || { + let conn = db.conn()?; + medical_db::condition_chips::ConditionChipsRepo::merge_incoming( + &conn, &server_chips, + ) + .map_err(AppError::from) + }) + .await + .map_err(|e| AppError::Other(format!("Task join error: {e}")))?; + } + Err(e) => { + tracing::warn!(error = %e, "conditions remote list failed, using local"); + // Fall through to local fallback below. + } + } + } + } + // Local fallback (also reached when not paired / sync disabled, or when the + // remote call failed). + let db = Arc::clone(&state.db); + tokio::task::spawn_blocking(move || { + let conn = db.conn()?; + medical_db::condition_chips::ConditionChipsRepo::list_active(&conn) + .map_err(AppError::from) + }) + .await + .map_err(|e| AppError::Other(format!("Task join error: {e}")))? +} + +/// Add a condition chip. +/// +/// Writes locally first (instant UI), then fires a non-blocking background +/// sync push of the resulting active list so the server converges. The push +/// is best-effort; a failure is retried on the next pull (list). +#[tauri::command] +#[instrument(skip(state), name = "conditions::add")] +pub async fn add_condition_chip( + state: tauri::State<'_, AppState>, + text: String, +) -> AppResult> { + let now = now_iso(); + + // 1. Update local DB immediately (instant UI feedback). + let db = Arc::clone(&state.db); + let local_list = tokio::task::spawn_blocking(move || { + let conn = db.conn()?; + medical_db::condition_chips::ConditionChipsRepo::add(&conn, &text, &now) + .map_err(AppError::from) + }) + .await + .map_err(|e| AppError::Other(format!("Task join error: {e}")))??; + + // 2. Best-effort background sync push (non-blocking). The owned + // `PairedConnection` is moved into the task and the `ConditionsRemote` + // borrows it from within the task's scope (it cannot borrow from this + // frame because `tokio::spawn` requires `'static`). + if let Some((conn, bearer)) = paired_conditions_target(&state) { + let http_client = state.http_client.clone(); + let chips_to_push = local_list.clone(); + tokio::spawn(async move { + let remote = match crate::conditions_remote::ConditionsRemote::from( + &conn, + Some(bearer), + http_client, + ) { + Some(r) => r, + None => { + tracing::warn!("condition chip sync push target unavailable"); + return; + } + }; + match remote.sync(chips_to_push).await { + Ok(_) => tracing::debug!("condition chip sync push succeeded"), + Err(e) => tracing::warn!( + error = %e, + "condition chip sync push failed (will retry on next pull)" + ), + } + }); + } + + Ok(local_list) +} + +/// Remove (soft-delete) a condition chip by text. +/// +/// Writes the tombstone locally first, then pushes the FULL list (including +/// tombstones) so the server sees the deletion. Using `list_all` (not +/// `list_active`) is essential — otherwise the tombstone would never reach the +/// server and the chip would ghost-resurface on other machines. +#[tauri::command] +#[instrument(skip(state), name = "conditions::remove")] +pub async fn remove_condition_chip( + state: tauri::State<'_, AppState>, + text: String, +) -> AppResult> { + let now = now_iso(); + + // 1. Soft-delete locally (returns the active list, sans the removed chip). + let db = Arc::clone(&state.db); + let local_list = tokio::task::spawn_blocking(move || { + let conn = db.conn()?; + medical_db::condition_chips::ConditionChipsRepo::remove_by_text(&conn, &text, &now) + .map_err(AppError::from) + }) + .await + .map_err(|e| AppError::Other(format!("Task join error: {e}")))??; + + // 2. Best-effort background sync — push ALL chips (including tombstones) + // so the server records the deletion. The owned `PairedConnection` is + // moved into the task and `ConditionsRemote` borrows it there. + if let Some((conn, bearer)) = paired_conditions_target(&state) { + let http_client = state.http_client.clone(); + let db2 = Arc::clone(&state.db); + tokio::spawn(async move { + // Load the full local list (incl. tombstones) on the blocking pool. + let all_chips = match tokio::task::spawn_blocking(move || { + let conn = db2.conn()?; + medical_db::condition_chips::ConditionChipsRepo::list_all(&conn) + .map_err(AppError::from) + }) + .await + { + Ok(Ok(chips)) => chips, + Ok(Err(e)) => { + tracing::warn!(error = %e, "failed to load chips for sync push (remove)"); + return; + } + Err(e) => { + tracing::warn!( + error = %e, + "task join error loading chips for sync push (remove)" + ); + return; + } + }; + let remote = match crate::conditions_remote::ConditionsRemote::from( + &conn, + Some(bearer), + http_client, + ) { + Some(r) => r, + None => { + tracing::warn!("condition chip sync push (remove) target unavailable"); + return; + } + }; + match remote.sync(all_chips).await { + Ok(_) => tracing::debug!("condition chip sync push (remove) succeeded"), + Err(e) => tracing::warn!( + error = %e, + "condition chip sync push (remove) failed (will retry on next pull)" + ), + } + }); + } + + Ok(local_list) +} + +/// Manually trigger a full bidirectional condition-chip sync. +/// +/// Pushes the local full list (including tombstones) to the server, receives +/// the server's merged result, and merges that back into the local store. +/// Returns the active list afterwards. +/// +/// Used when the user toggles `sync_condition_chips` on or reconnects after +/// being offline. When not paired / sync disabled, it simply returns the local +/// active list. +#[tauri::command] +#[instrument(skip(state), name = "conditions::sync")] +pub async fn sync_condition_chips_cmd( + state: tauri::State<'_, AppState>, +) -> AppResult> { + // Load the local full list (including tombstones) to push. + let db = Arc::clone(&state.db); + let local_all = tokio::task::spawn_blocking(move || { + let conn = db.conn()?; + medical_db::condition_chips::ConditionChipsRepo::list_all(&conn) + .map_err(AppError::from) + }) + .await + .map_err(|e| AppError::Other(format!("Task join error: {e}")))??; + + if let Some((conn, bearer)) = paired_conditions_target(&state) { + if let Some(remote) = crate::conditions_remote::ConditionsRemote::from( + &conn, + Some(bearer), + state.http_client.clone(), + ) { + let merged = remote.sync(local_all).await?; + let db = Arc::clone(&state.db); + return tokio::task::spawn_blocking(move || { + let conn = db.conn()?; + medical_db::condition_chips::ConditionChipsRepo::merge_incoming(&conn, &merged) + .map_err(AppError::from) + }) + .await + .map_err(|e| AppError::Other(format!("Task join error: {e}")))?; + } + } + + // Not paired / sync disabled — return the local active list. + let db = Arc::clone(&state.db); + tokio::task::spawn_blocking(move || { + let conn = db.conn()?; + medical_db::condition_chips::ConditionChipsRepo::list_active(&conn) + .map_err(AppError::from) + }) + .await + .map_err(|e| AppError::Other(format!("Task join error: {e}")))? +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 53de154..ea528f7 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -1,5 +1,6 @@ pub mod audio; pub mod chat; +pub mod conditions; pub mod context_templates; pub mod export; pub mod generation; diff --git a/src-tauri/src/commands/settings.rs b/src-tauri/src/commands/settings.rs index 26f36b0..c756d79 100644 --- a/src-tauri/src/commands/settings.rs +++ b/src-tauri/src/commands/settings.rs @@ -6,6 +6,21 @@ use medical_db::settings::SettingsRepo; use crate::state::AppState; +/// Synchronously load the app config (for use in non-async dispatch helpers). +/// +/// This mirrors the load half of [`get_settings`] but takes a borrowed +/// `Database` and applies all migrations, without the onboarding auto-mark +/// side effects. It exists so sync-dispatch helpers (e.g. condition-chip +/// routing) can read the opt-in flags without an `async` context. +pub fn load_config_sync( + db: &Arc, +) -> AppResult { + let conn = db.conn()?; + let mut config = SettingsRepo::load_config(&conn)?; + config.migrate(); + Ok(config) +} + /// Load the current application settings from the database. /// /// Returns the full `AppConfig` with all migrations applied. diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5112449..7dbf93e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -362,6 +362,10 @@ pub fn run() { commands::user_dictionary::user_dict_list, commands::user_dictionary::user_dict_add, commands::user_dictionary::user_dict_remove, + commands::conditions::list_condition_chips, + commands::conditions::add_condition_chip, + commands::conditions::remove_condition_chip, + commands::conditions::sync_condition_chips_cmd, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); From c64176999130e23fc18e9005bd79473e49c6187c Mon Sep 17 00:00:00 2001 From: Andre Hugo Date: Fri, 3 Jul 2026 10:08:58 -0700 Subject: [PATCH 10/13] feat(frontend): rewire ConditionChips to Tauri commands (prep for sync) --- src/lib/api/conditions.ts | 24 +++++++++ src/lib/components/ConditionChips.svelte | 62 +++++++++++++----------- 2 files changed, 58 insertions(+), 28 deletions(-) create mode 100644 src/lib/api/conditions.ts diff --git a/src/lib/api/conditions.ts b/src/lib/api/conditions.ts new file mode 100644 index 0000000..b3e0088 --- /dev/null +++ b/src/lib/api/conditions.ts @@ -0,0 +1,24 @@ +import { invoke } from '@tauri-apps/api/core'; + +export interface ConditionChip { + id: string; + text: string; + updated_at: string; + deleted_at: string | null; +} + +export async function listConditionChips(): Promise { + return invoke('list_condition_chips'); +} + +export async function addConditionChip(text: string): Promise { + return invoke('add_condition_chip', { text }); +} + +export async function removeConditionChip(text: string): Promise { + return invoke('remove_condition_chip', { text }); +} + +export async function syncConditionChips(): Promise { + return invoke('sync_condition_chips_cmd'); +} diff --git a/src/lib/components/ConditionChips.svelte b/src/lib/components/ConditionChips.svelte index f317ded..cff2994 100644 --- a/src/lib/components/ConditionChips.svelte +++ b/src/lib/components/ConditionChips.svelte @@ -1,14 +1,12 @@