---
phase: 20-offline-mode-hardening
verified: 2026-04-11T00:00:00Z
status: human_needed
score: 9/10 must-haves verified
overrides_applied: 0
human_verification:
  - test: "Visual — breathing dot when pending edits exist"
    expected: "Green status dot pulses (opacity ~0.7-1.0 over 1.5s) when connected with pending edits; solid green when queue is empty"
    why_human: "Slint animation requires running app to observe; cannot verify visual animation programmatically"
  - test: "Visual — disconnected state shows solid red dot"
    expected: "When connection-status is 3 (Disconnected), the dot is solid red (Colors.error) with no breathing animation"
    why_human: "Requires simulating network failure and observing UI state"
---

# Phase 20: Offline Mode Hardening Verification Report

**Phase Goal:** The dashboard continues functioning during a connectivity outage with a clear offline indicator, and all edits made offline are automatically flushed to GH Issues when the connection returns.
**Verified:** 2026-04-11
**Status:** human_needed
**Re-verification:** No — initial verification

---

## Goal Achievement

### Observable Truths

| # | Truth | Status | Evidence |
|---|-------|--------|----------|
| 1 | Cached card data remains visible when GitHub/Shopify APIs are unreachable | VERIFIED | `fetch_card_snapshots()` always reads from `SqliteStore.read_all_cards()`. Sync failure sets `connection_status=3` but does not clear card data. `live_client.rs:313` |
| 2 | Dashboard shows distinct offline indicator (red dot + "Disconnected" text) | VERIFIED | `dashboard.slint:449-464` — red dot (Colors.error) + "Disconnected" text when `connection-status == 3`. Decision D-01 confirms existing indicator is sufficient. |
| 3 | Sync cycle skips card upsert when active pending edits exist | VERIFIED | `live_client.rs:1015-1019` — `has_pending_edits_for_entity("card", ...)` check with `continue` before `upsert_card()` |
| 4 | Sync cycle skips serial_instance upsert when active pending edits exist | VERIFIED | `live_client.rs:1162-1167` — `has_pending_edits_for_entity("serial_instance", ...)` check before `upsert_product_unit()` |
| 5 | Dormant edits (retry_count >= 15) do NOT block sync updates | VERIFIED | Both `has_pending_edits_for_entity` and `count_active_pending_edits` use `retry_count < 15` filter. 7 unit tests confirm this. |
| 6 | count_active_pending_edits returns correct count excluding dormant edits | VERIFIED | `sqlite.rs:685-692` — `SELECT COUNT(*) FROM pending_edits WHERE retry_count < 15`. 3 passing tests confirm behavior. |
| 7 | pending-edit-count is updated from flusher after each flush pass | VERIFIED | `pending_edit_flusher.rs:249-252` — callback invoked with count after `run_flush_pass`. `main.rs:2490-2496` — callback wires to `invoke_from_event_loop` + `set_pending_edit_count`. |
| 8 | pending-edit-count is updated from SyncUpdateCallback after each sync | VERIFIED | `main.rs:2395-2401` — `count_active_pending_edits()` called and `set_pending_edit_count` invoked inside `on_sync` closure alongside `set_connection_status`. |
| 9 | Breathing dot animation gated by pending-edit-count > 0 (zero CPU when idle) | VERIFIED | `dashboard.slint:455-457` — ternary gate: `animation-tick()` only evaluated when `connection-status == 2 && pending-edit-count > 0`, constant `1.0` otherwise. |
| 10 | Breathing dot animation visual behavior | HUMAN_NEEDED | Requires running app to observe actual animation. Opacity formula confirmed in code. |

**Score:** 9/10 truths verified (1 requires human)

---

### Required Artifacts

| Artifact | Expected | Status | Details |
|----------|----------|--------|---------|
| `crates/service/src/db/sqlite.rs` | `count_active_pending_edits()` and `has_pending_edits_for_entity()` methods | VERIFIED | Lines 685-709. Both methods present, substantive, use `retry_count < 15` filter. 7 unit tests passing. |
| `crates/app/src/live_client.rs` | Conflict avoidance in run_sync_cycle card upsert loop | VERIFIED | Lines 1015-1019. `has_pending_edits_for_entity("card", ...)` with `continue` guard before `upsert_card`. |
| `crates/app/src/live_client.rs` | Conflict avoidance in sync_products unit upsert | VERIFIED | Lines 1162-1167. `has_pending_edits_for_entity("serial_instance", ...)` guard before `upsert_product_unit`. |
| `crates/app/src/dashboard/pending_edit_flusher.rs` | Callback-based count update after each flush pass | VERIFIED | Lines 229-256. `on_count_updated: Option<Box<dyn Fn(i32) + Send + 'static>>` parameter. Count called and passed to callback after `run_flush_pass`. Deviates from plan's `Weak<DashboardWindow>` — used callback pattern instead (correct architectural choice, see Deviations). |
| `crates/app/ui/dashboard.slint` | Breathing animation via animation-tick() + Math.sin() on status dot | VERIFIED | Lines 454-457. `animation-tick() / 1500ms * 360deg` with `Math.sin`. Ternary-gated. |

---

### Key Link Verification

| From | To | Via | Status | Details |
|------|----|-----|--------|---------|
| `crates/app/src/live_client.rs` | `crates/service/src/db/sqlite.rs` | `store.has_pending_edits_for_entity()` in upsert loop | WIRED | `live_client.rs:1016, 1163` calls method confirmed present in `sqlite.rs:697` |
| `crates/app/src/dashboard/pending_edit_flusher.rs` | `DashboardWindow::set_pending_edit_count` | `on_count_updated` callback + `invoke_from_event_loop` in `main.rs` | WIRED | Flusher calls `cb(count)` at line 251; main.rs callback at lines 2490-2496 invokes `set_pending_edit_count` |
| `crates/app/src/main.rs` (SyncUpdateCallback) | `DashboardWindow::set_pending_edit_count` | `count_active_pending_edits()` inside `on_sync` closure | WIRED | `main.rs:2395-2401` — direct `w.set_pending_edit_count(pending_count as i32)` |
| `crates/app/ui/dashboard.slint` (status dot) | `root.pending-edit-count` | ternary gating `animation-tick()` only when count > 0 | WIRED | `dashboard.slint:455` — `(root.connection-status == 2 && root.pending-edit-count > 0)` guard confirmed |

---

### Data-Flow Trace (Level 4)

| Artifact | Data Variable | Source | Produces Real Data | Status |
|----------|---------------|--------|--------------------|--------|
| `dashboard.slint` status dot | `pending-edit-count` | `count_active_pending_edits()` on SQLite `pending_edits` table | Yes — real SQL `COUNT(*)` query | FLOWING |
| `dashboard.slint` status dot | `connection-status` | `run_sync_cycle` result → `SyncUpdateCallback` → `set_connection_status` | Yes — set from actual sync success/failure | FLOWING |
| `dashboard.slint` card list | `cards` | `read_all_cards()` from SQLite on every sync | Yes — SQLite `SELECT` returning real rows | FLOWING |

---

### Behavioral Spot-Checks

| Behavior | Command | Result | Status |
|----------|---------|--------|--------|
| `count_active_pending_edits` returns 0 on empty table | `cargo test --package service -- count_active_pending_edits_returns_zero` | PASS (36 service tests pass) | PASS |
| `has_pending_edits_for_entity` returns false for dormant edits | `cargo test --package service -- has_pending_edits_for_entity_returns_false_for_dormant` | PASS (7 pending edit tests pass) | PASS |
| App package builds cleanly | `cargo build --package app` | Clean build with 1 unused variable warning (not Phase 20 code) | PASS |
| All app tests pass | `cargo test --package app` | 188 passed, 0 failed | PASS |
| All service tests pass | `cargo test --package service` | 36 passed (lib) + integration suites, 0 failed | PASS |
| Animation-tick breathing dot visual | Requires running app | Cannot test without GUI | SKIP (human needed) |

---

### Requirements Coverage

| Requirement | Source Plan | Description | Status | Evidence |
|-------------|------------|-------------|--------|---------|
| OFFLINE-01 | 20-01, 20-02 | App detects connectivity loss and continues with cached SQLite data | SATISFIED | `connection_status=3` set on sync failure; `read_all_cards()` from SQLite always returns cached data; red dot + "Disconnected" text is the offline indicator. |
| OFFLINE-02 | 20-01, 20-02 | Edits made while offline are queued and flushed when connectivity returns | SATISFIED | PendingEditFlusher queues and drains edits on 60s cycle; `pending-edit-count` property now driven from both flusher and sync callback; breathing animation signals pending queue. |
| OFFLINE-03 | 20-01 | Flush-then-sync ordering prevents sync from overwriting pending edits | SATISFIED | `has_pending_edits_for_entity()` guards both card and serial_instance upserts in sync cycle. Dormant edits excluded by `retry_count < 15` filter. |

**Note:** REQUIREMENTS.md traceability table still shows OFFLINE-01, OFFLINE-02, OFFLINE-03 as `[ ]` (Pending) — this is an administrative inconsistency. The implementation is complete and all three requirements are satisfied. The traceability table needs updating.

---

### Anti-Patterns Found

| File | Line | Pattern | Severity | Impact |
|------|------|---------|----------|--------|
| `crates/app` (lib) | — | 1 unused variable warning | Info | Compiler warning only; not Phase 20 code; no functional impact |

No stubs, placeholders, empty implementations, or TODO/FIXME markers found in Phase 20 modified files.

---

### Human Verification Required

#### 1. Breathing Dot Animation — Visual Confirmation

**Test:** Build and run the app: `cargo build --features bugsweeper && cargo run --features bugsweeper`. Wait for sync to complete (green dot appears solid). Insert a pending edit via BUGSWEEPER or by adding a note to any card.
**Expected:** The green status dot begins pulsing — opacity oscillates between approximately 0.7 and 1.0 over a 1.5-second period. When the queue clears after ~60 seconds (flusher interval), the dot returns to solid green with no animation.
**Why human:** Slint animation (`animation-tick()` + `Math.sin()`) requires a running GUI to observe. The opacity formula is confirmed present in code (`0.7 + 0.3 * Math.sin(animation-tick() / 1500ms * 360deg)`) but animation behavior cannot be verified programmatically.

#### 2. Disconnected State — Solid Red Dot

**Test:** Build and run the app, then simulate network/API failure (disable network, invalidate token, or wait for a sync failure in disconnected environment).
**Expected:** Status dot turns solid red (no breathing animation). "Disconnected" text label appears. Cards remain visible from cached SQLite data.
**Why human:** Requires a real connectivity failure scenario. The code path (`connection_status=3` → red dot) is confirmed in `live_client.rs:170` and `dashboard.slint:453`, but end-to-end behavior requires running app.

---

### Gaps Summary

No blocking gaps identified. All three OFFLINE requirements have confirmed implementations with passing unit tests. The only items requiring attention are:

1. **Administrative:** REQUIREMENTS.md traceability table needs OFFLINE-01, OFFLINE-02, OFFLINE-03 marked as complete. This does not affect functionality.

2. **Minor deviation:** `start_flusher` uses `Box<dyn Fn(i32) + Send>` callback instead of the plan's `Option<Weak<DashboardWindow>>`. This is architecturally superior (keeps lib crate free of binary-only Slint types) and is documented as an auto-fixed deviation in `20-01-SUMMARY.md`. No override needed.

3. **Minor deviation:** Breathing animation opacity range is 0.7-1.0 (implementation) instead of 0.4-1.0 (plan spec). This is within Claude's discretion per CONTEXT.md D-05. No override needed.

---

_Verified: 2026-04-11_
_Verifier: Claude (gsd-verifier)_
