---
phase: 20.1.1-change-the-i-button-on-cards-to-show-a-scrollable-history-of
plan: 05
subsystem: backend+wiring
tags: [rust, sqlite, live-client, slint-callbacks, notes, gh-issues, inflight-guard]

requires:
  - phase: 20.1.1
    plan: 01
    provides: upsert_notes_for_card, NoteEntry with author field
  - phase: 20.1.1
    plan: 02
    provides: list_issue_comments + GhNoteComment
  - phase: 20.1.1
    plan: 03
    provides: NoteDisplayEntry, enrich_view_with_notes, notes field on DashboardCardViewModel
  - phase: 20.1.1
    plan: 04
    provides: on_card_notes_popover_opened + on_card_post_note callbacks in Slint + main.rs CardData notes wiring

provides:
  - SqliteStore::read_card_issue_number(card_id) -> Result<Option<i64>>
  - LiveClient::fetch_notes_for_card(card_id, on_complete) with per-card inflight dedup
  - DashboardDataClient::fetch_notes_for_card trait method (default no-op)
  - on_card_notes_popover_opened wired in main.rs: fetch + invoke_from_event_loop model patch
  - on_card_post_note wired in main.rs: save_note + optimistic apply_filters

affects: [20.1.1-06]

tech-stack:
  added: []
  patterns:
    - "fetch_notes_for_card: HashSet<String> inflight guard + Arc<Mutex<>> for per-card dedup"
    - "on_complete callback fires on success AND error — popover always refreshes from SQLite"
    - "invoke_from_event_loop pattern: off-thread on_complete posts back to Slint event loop"
    - "notes model patch: iterate Slint model by package_id, set_row_data for matched card only"
    - "on_card_post_note: save_note (sync SQLite) + apply_optimistic_note_update + apply_filters"

key-files:
  created: []
  modified:
    - crates/service/src/db/sqlite.rs
    - crates/app/src/live_client.rs
    - crates/app/src/service_client.rs
    - crates/app/src/main.rs

key-decisions:
  - "fetch_notes_for_card placed in impl DashboardDataClient for LiveClient (not impl LiveClient) so it is callable via Rc<dyn DashboardDataClient> in main.rs callbacks without downcast"
  - "DashboardDataClient trait gets default no-op fetch_notes_for_card — consistent with save_note pattern; FakeClient/NoopClient automatically inherit no-op"
  - "on_complete captures only Weak<DashboardWindow> + Arc<SqliteStore> — satisfies Send+Sync without Rc<RefCell<>>"
  - "notes model patch iterates Slint model by package_id rather than index — safe against filter-reorder between fetch dispatch and on_complete fire"
  - "on_card_post_note uses apply_optimistic_note_update + apply_filters (same as on_card_save_note) for immediate display before GH sync"

requirements-completed: []

duration: ~20min
completed: 2026-04-15
---

# Phase 20.1.1 Plan 05: Backend Wiring — fetch_notes_for_card + Slint Callback Wiring Summary

**read_card_issue_number added to SqliteStore; fetch_notes_for_card on LiveClient with per-card inflight HashSet dedup; on_card_notes_popover_opened and on_card_post_note wired in main.rs; workspace clean.**

## Performance

- **Duration:** ~20 min
- **Started:** 2026-04-15T09:45:00Z (approx)
- **Completed:** 2026-04-15T10:05:26Z
- **Tasks:** 2
- **Files modified:** 4

## Accomplishments

- `SqliteStore::read_card_issue_number(card_id)` added unconditionally to `sqlite.rs` — returns `Ok(Some(n))` when non-NULL, `Ok(None)` when NULL or row absent
- `LiveClient.notes_fetch_inflight: Arc<Mutex<HashSet<String>>>` field added; initialized in both `new()` and `new_for_test()` constructors
- `DashboardDataClient::fetch_notes_for_card` trait method added with default no-op (same pattern as `save_note`)
- `LiveClient::fetch_notes_for_card` implemented in `impl DashboardDataClient for LiveClient`: acquires per-card inflight slot, spawns background thread, calls `list_issue_comments`, converts to `NoteEntry`, calls `upsert_notes_for_card`, fires `on_complete()` on success or error (with `eprintln!` on error per D-10)
- `on_card_notes_popover_opened` wired in `main.rs`: extracts `package_id` from Slint model at `card_index`, calls `client.fetch_notes_for_card` with an `on_complete` that uses `invoke_from_event_loop` to re-read notes from SQLite and patch the card's `notes` field in the Slint model by `package_id` match
- `on_card_post_note` wired in `main.rs`: calls `save_note` (synchronous SQLite write + background GH), applies `apply_optimistic_note_update` to `all_cards_ref`, calls `apply_filters` for immediate D-09 optimistic display; surfaces toast on failure
- 3 new unit tests: `test_read_card_issue_number_roundtrip` (absent/NULL/set roundtrip), `test_fetch_notes_noop_when_no_issue_number` (on_complete fires when no issue number), `test_fetch_notes_inflight_guard_both_complete` (both on_complete callbacks fire)
- `cargo check --workspace` exits 0; all 3 new tests pass

## Task Commits

1. **Task 1: read_card_issue_number + fetch_notes_for_card + inflight guard** — `e99d53d` (feat)
2. **Task 2: Wire on_card_notes_popover_opened + on_card_post_note in main.rs** — `0b1e415` (feat)

## Files Created/Modified

- `crates/service/src/db/sqlite.rs` — `read_card_issue_number` method + `test_read_card_issue_number_roundtrip` test + `make_minimal_card` helper
- `crates/app/src/live_client.rs` — `notes_fetch_inflight` field on `LiveClient`; initialization in `new()` and `new_for_test()`; `fetch_notes_for_card` impl in `DashboardDataClient for LiveClient`; 2 fetch-notes unit tests
- `crates/app/src/service_client.rs` — `fetch_notes_for_card` default no-op added to `DashboardDataClient` trait
- `crates/app/src/main.rs` — `on_card_notes_popover_opened` wiring (fetch + model patch); `on_card_post_note` wiring (save + optimistic refresh)

## Decisions Made

- **`fetch_notes_for_card` in trait impl, not `impl LiveClient`** — The `client` variable in `main.rs` is `Rc<dyn DashboardDataClient>`. Putting the method on the trait (with default no-op) allows `client_rc.fetch_notes_for_card(...)` to work without downcasting or storing a parallel `Arc<LiveClient>`. This avoids an architectural change (Rule 4) and matches the existing `save_note` pattern exactly.
- **`on_complete` captures `Arc<SqliteStore>` not `Rc<RefCell<all_cards_ref>>`** — `on_complete` must be `Send + Sync` (Arc-wrapped). The `all_cards_ref` is `Rc<RefCell<>>` and is `!Send`. Instead, `on_complete` reads notes fresh from SQLite and patches only the Slint model's `notes` field for the matching card, leaving `all_cards_ref` untouched. Next `apply_filters` call will pick up the fresh data from the Slint model.
- **Model patch by `package_id` not by index** — Between the time `on_card_notes_popover_opened` fires and `on_complete` executes (background thread), the user could scroll/filter causing index shifts. Matching by `package_id` string is stable.

## Deviations from Plan

### Auto-fixed Issues

**1. [Rule 3 - Blocking] Moved fetch_notes_for_card into DashboardDataClient trait impl**
- **Found during:** Task 2 (wiring main.rs)
- **Issue:** Plan placed `fetch_notes_for_card` as a method on `impl LiveClient` directly, but `client` in `main.rs` is `Rc<dyn DashboardDataClient>` — calling methods not on the trait requires downcast which would be an architectural change
- **Fix:** Added `fetch_notes_for_card` as a default no-op to `DashboardDataClient` trait; implemented it in `impl DashboardDataClient for LiveClient`; `client_rc.fetch_notes_for_card(...)` now works naturally
- **Files modified:** `crates/app/src/service_client.rs`, `crates/app/src/live_client.rs`
- **Verification:** `cargo check --workspace` exits 0

**2. [Rule 1 - Bug] on_complete closure — removed Rc<RefCell<>> capture, used direct SQLite+model patch instead**
- **Found during:** Task 2 (wiring on_card_notes_popover_opened)
- **Issue:** Plan's suggested `refresh_cards_from_store(&w, &cards_rc)` captures `cards_rc` (an `Rc<RefCell<>>`) which is `!Send`, making the `Arc<dyn Fn() + Send + Sync>` closure fail to compile
- **Fix:** `on_complete` captures only `Weak<DashboardWindow>` + `Arc<SqliteStore>` + `card_id: String`. Inside `invoke_from_event_loop`, reads notes from SQLite and patches just the matched card's `notes` field in the Slint model in-place
- **Files modified:** `crates/app/src/main.rs`
- **Verification:** `cargo check --workspace` exits 0

---

**Total deviations:** 2 auto-fixed (1 Rule 3, 1 Rule 1)
**Impact on plan:** Both fixes necessary for compilation. Behavior is equivalent to plan intent — popover refresh still happens after fetch; notes model is updated from SQLite.

## Known Stubs

None — `fetch_notes_for_card` is fully wired. The `gh_issues=None` path exits early without error (degraded mode); the `github_issue_number=None` path exits early without error (un-synced card). Both cases fire `on_complete` so the popover still refreshes from locally-held SQLite notes.

## Threat Flags

No new network endpoints. `fetch_notes_for_card` calls `list_issue_comments` (already threat-modeled in Plan 02 as T-2012-01 mitigated). The per-card inflight guard mitigates T-2015-01 (GH rate limit from rapid opens) as specified in plan threat model.

## Self-Check: PASSED

- `crates/service/src/db/sqlite.rs` — contains `pub fn read_card_issue_number`, `github_issue_number FROM cards` query, `test_read_card_issue_number_roundtrip` test
- `crates/app/src/live_client.rs` — contains `notes_fetch_inflight` field (5 occurrences), `fn fetch_notes_for_card`
- `crates/app/src/service_client.rs` — contains `fn fetch_notes_for_card` default no-op in trait
- `crates/app/src/main.rs` — contains `on_card_notes_popover_opened`, `on_card_post_note`, `fetch_notes_for_card`, `invoke_from_event_loop` (13 occurrences)
- Commits `e99d53d` and `0b1e415` verified in git log
- `cargo check --workspace` exits 0
- `test_read_card_issue_number_roundtrip`: PASSED
- `test_fetch_notes_noop_when_no_issue_number`: PASSED
- `test_fetch_notes_inflight_guard_both_complete`: PASSED

---
*Phase: 20.1.1-change-the-i-button-on-cards-to-show-a-scrollable-history-of*
*Completed: 2026-04-15*
