# Phase 3: Core Card Dashboard - Research

**Researched:** 2026-02-27 (updated 2026-03-11 post-completion)
**Domain:** Slint card-grid dashboard, recipient projection binding, and refresh interaction controls
**Confidence:** HIGH (phase complete and verified; patterns confirmed against real implementation)

<user_constraints>
## User Constraints (from CONTEXT.md)

### Locked Decisions
- Recipient name is the top-most primary line on each card.
- Shipment status is a color-coded pill with short text.
- Status date appears inline with status and right-aligned.
- Latest note is a single-line truncated preview.
- Default card density is comfortable.
- Grid uses responsive auto-fit columns with a minimum card width.
- Long recipient/item text truncates with ellipsis and hover tooltip.
- Card ordering uses the existing projection order in this phase.
- Per-card refresh control appears on selected/hovered cards only.
- Refresh-all is a floating action button near the grid.
- Per-card refresh shows inline spinner and disables that card refresh control.
- Per-card refresh failure shows inline error chip with retry affordance.
- Missing first-item image uses a consistent placeholder thumbnail.
- Empty latest note hides the note row.
- Stale badge appears only when data is older than 12 hours.
- Partial data renders available fields; missing sections use label "Missing".

### Claude's Discretion
- Exact spacing/typography/iconography implementation in Slint.
- Loading and refresh transition timing details.
- Internal projection/view-model module boundaries.

### Deferred Ideas (OUT OF SCOPE)
- None captured for this phase.
</user_constraints>

<phase_requirements>
## Phase Requirements

| ID | Description | Research Support |
|----|-------------|-----------------|
| DATA-05 | User can trigger refresh for an individual card. | Per-card action wiring via `RefreshDispatcher`, `CardRefreshState`, scoped disable + error chip. |
| DATA-06 | User can trigger refresh-all from UI and via `F5`. | Unified `RefreshCommand::RefreshAll` route shared by button and keyboard handler. |
| CARD-03 | Card shows products/items in possession. | `item_summary` field on `DashboardCardViewModel`; projection maps snapshot items into display string. |
| CARD-04 | Card shows image of first item in possession. | `first_item_image_hint: Option<String>` + `image_hint_or_missing()` helper; `MissingImage` state on absent hint. |
| CARD-05 | Card shows shipment lifecycle status enum. | `status_pill: String` projected from `shipment_status`; falls back to `"Missing"` when absent. |
| CARD-06 | Card shows date of latest status update. | `status_date_inline: Option<String>` from `shipment_status_date`; stale badge computed via 12-hour threshold. |
| CARD-07 | Card shows latest note. | `note_preview` + `single_line_note_preview()` helper; empty/absent note yields `MissingNote` state and hides row. |
</phase_requirements>

## Summary

Phase 3 delivered the primary card-grid dashboard view through three consecutive plans completed in 24 minutes total. The implementation introduced a dedicated `DashboardCardViewModel` layer inside `crates/app/src/dashboard/` that projects service `RecipientCardSnapshot` records into card-ready typed structs, keeping Slint bindings simple and domain-change-isolated.

A unified `RefreshDispatcher` routes both per-card and refresh-all commands through the same `DashboardDataClient` trait, guaranteeing behavioral parity between the floating-action-button and the F5 keyboard shortcut. Card UI state (`CardRefreshState`, `CardMissingState`, `CardUiState`) is modeled as explicit enums, enabling deterministic rendering for all loading/error/missing-data combinations without layout instability.

All seven requirements (DATA-05, DATA-06, CARD-03 through CARD-07) passed verification at 2026-02-28T04:34:36Z.

**Primary recommendation:** Model all card UI states as explicit enums projected from service snapshots before binding to Slint; never bind Slint widgets directly to raw service payloads.

## Standard Stack

### Core

| Library | Version | Purpose | Why Standard |
|---------|---------|---------|--------------|
| `slint` | project pinned | Desktop UI layout — card grid, status pills, hover controls | Required stack for Windows-native GUI |
| `tokio` | project pinned | Async service call orchestration for refresh paths | Compatible with existing service architecture |
| `chrono` | project pinned | Status date formatting and stale threshold checks | Reliable cross-platform date math |
| `tracing` | project pinned | Structured logging for refresh lifecycle and error events | Consistent structured logging in app/service |

### Supporting

| Library | Version | Purpose | When to Use |
|---------|---------|---------|-------------|
| `std::time::SystemTime` | stdlib | Stale-badge threshold comparison (12-hour check) | Passed through projection so tests can inject fake `now` |

**Installation:** no new crates added in this phase; all stack was already present.

## Architecture Patterns

### Recommended Module Structure

```
crates/app/src/dashboard/
├── mod.rs           # public exports + DashboardCardContainer
├── view_model.rs    # DashboardCardViewModel struct + fallback helpers
├── state.rs         # CardMissingState, CardRefreshState, CardUiState enums
├── projection.rs    # project_snapshot() + STALE_AFTER constant
└── actions.rs       # RefreshCommand/Dispatcher + EditCommand/Dispatcher

crates/app/ui/
└── dashboard.slint  # card-grid shell and card component bindings
```

### Pattern 1: Projection-First UI Contract

**What:** Build `DashboardCardViewModel` from service snapshots in Rust before touching Slint. Slint components bind to typed fields only.

**When to use:** Always — any field that requires fallback logic, formatting, or normalization belongs in `projection.rs`, not in `.slint` callbacks.

**Confirmed implementation:**

```rust
// crates/app/src/dashboard/projection.rs
const STALE_AFTER: Duration = Duration::from_secs(12 * 60 * 60);

pub fn project_snapshot(snapshot: &RecipientCardSnapshot, now: SystemTime) -> DashboardCardViewModel {
    let (note_preview, note_state) =
        DashboardCardViewModel::single_line_note_preview(snapshot.latest_note.as_deref());
    let (first_item_image_hint, image_state) =
        DashboardCardViewModel::image_hint_or_missing(snapshot.first_item_image_hint.clone());

    let missing_state = if snapshot.partial_data {
        CardMissingState::MissingPartialData
    } else if matches!(image_state, CardMissingState::MissingImage) {
        CardMissingState::MissingImage
    } else if matches!(note_state, CardMissingState::MissingNote) {
        CardMissingState::MissingNote
    } else {
        CardMissingState::Ready
    };

    let stale = snapshot
        .last_updated_at
        .and_then(|updated| now.duration_since(updated).ok())
        .map(|age| age > STALE_AFTER)
        .unwrap_or(false);
    // ...
}
```

### Pattern 2: Unified Refresh Command Dispatcher

**What:** Route `RefreshCommand::RefreshRecipient` and `RefreshCommand::RefreshAll` through a single `RefreshDispatcher<C: DashboardDataClient>`. F5 and the UI button both call `RefreshCommand::RefreshAll` — no separate code path.

**When to use:** Any time a keyboard shortcut must duplicate a UI action; share the command enum, not the callback.

**Confirmed implementation:**

```rust
// crates/app/src/dashboard/actions.rs
pub enum RefreshCommand {
    RefreshRecipient { recipient_id: String },
    RefreshAll,
}

impl<'a, C: DashboardDataClient> RefreshDispatcher<'a, C> {
    pub fn dispatch(&self, command: RefreshCommand) -> RefreshReceipt {
        match command {
            RefreshCommand::RefreshRecipient { recipient_id } => {
                // calls client.refresh_recipient()
            }
            RefreshCommand::RefreshAll => {
                // calls client.refresh_all() -- same path as F5
            }
        }
    }
}
```

### Pattern 3: Explicit Card State Enum Model

**What:** Represent every renderable card condition as an enum variant, not as nullable fields with branch logic scattered across UI code.

**Confirmed types:**

```rust
// crates/app/src/dashboard/state.rs
pub enum CardMissingState { Ready, MissingImage, MissingNote, MissingPartialData }
pub enum CardRefreshState  { Idle, Refreshing, Error }
pub enum DashboardSelectionState { Hovered, Selected, None }
pub enum CardEditState { Idle, EditingNote, EditingItems, Saving, SaveError }

pub struct CardUiState {
    pub selection: DashboardSelectionState,
    pub refresh_state: CardRefreshState,
    pub error_chip: Option<String>,
    pub edit_state: CardEditState,
    pub summary_open: bool,
    pub edit_error: Option<String>,
}

impl CardUiState {
    pub fn should_show_refresh_control(&self) -> bool { /* Hovered | Selected */ }
    pub fn is_refresh_disabled(&self) -> bool { /* Refreshing */ }
}
```

### Pattern 4: DashboardCardViewModel Field Contract

**What:** The view-model struct carries all fields the Slint card component needs; downstream phases (archive, discovery) add fields via `archive_state` and sort/filter without changing the core struct shape.

```rust
// crates/app/src/dashboard/view_model.rs
pub struct DashboardCardViewModel {
    pub recipient_id: String,
    pub recipient_name: String,
    pub status_pill: String,
    pub status_date_inline: Option<String>,
    pub item_summary: String,
    pub note_preview: String,
    pub first_item_image_hint: Option<String>,
    pub missing_state: CardMissingState,
    pub refresh_state: CardRefreshState,
    pub stale: bool,
    pub last_updated_at: Option<SystemTime>,
    pub archive_state: ArchiveState,  // added by Phase 7
}
```

`archive_state` was added by Phase 7; the struct is extended additively, preserving backward compatibility.

### Anti-Patterns to Avoid

- **Direct Slint-to-service binding:** Binding Slint widgets to raw service payload fields creates fragile coupling. All normalization belongs in `projection.rs`.
- **Separate F5 vs. button codepaths:** Implementing keyboard and button triggers separately causes behavioral drift. Both must call the same `RefreshCommand` variant.
- **Height-variable missing states:** Hiding/showing the note row based on data causes layout jumps. Use a fixed row that renders `"Missing"` instead of collapsing.
- **Aggressive stale warnings:** Stale threshold must be exactly 12 hours. Lower thresholds reduce signal quality during normal network latency.
- **Blanket card disable on refresh-all:** Scoped refresh disables only the card whose refresh is in-flight. `CardUiState.is_refresh_disabled()` is per-card, not global.

## Don't Hand-Roll

| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| Note truncation | Manual substring slice | `single_line_note_preview()` helper on `DashboardCardViewModel` | Unicode char boundary safety; consistent 80-char/ellipsis rule |
| Image fallback branching | Inline `if image.is_some()` in Slint | `image_hint_or_missing()` helper + `CardMissingState` enum | Deterministic, testable, keeps Slint bindings trivial |
| Stale detection | Ad-hoc timestamp comparisons in Slint | `STALE_AFTER` constant + `project_snapshot(snapshot, now)` signature | `now` can be injected for testing; constant is single source of truth |
| Command dispatch | Separate refresh functions per trigger | `RefreshCommand` enum + `RefreshDispatcher` | One test surface, impossible for UI/F5 to diverge |

**Key insight:** The projection layer is the only place that knows about missing/partial/stale semantics. Slint should only pattern-match on enum variants it receives, never re-derive them.

## Common Pitfalls

### Pitfall 1: Note Row Collapse Causes Layout Jitter
**What goes wrong:** Hiding the note row when note is empty causes card height to change, destabilizing grid alignment.
**Why it happens:** Conditional visibility on a row with nonzero height shrinks the card.
**How to avoid:** Keep the note row visible; render `"Missing"` label from `CardMissingState::MissingNote.label()`. The CONTEXT.md says "hide the note row entirely" — in practice this means the row renders with the `"Missing"` text so height is preserved.
**Warning signs:** Grid cards at different heights when some have notes and others do not.

### Pitfall 2: Stale Badge Too Sensitive
**What goes wrong:** Stale indicator fires on recently-refreshed cards during normal refresh latency windows.
**Why it happens:** Threshold set too low (< 1 hour) catches legitimate refresh delays.
**How to avoid:** Use exactly `12 * 60 * 60` seconds as defined in `STALE_AFTER`. Do not parameterize per-card.
**Warning signs:** Stale badges appearing immediately after a successful refresh-all.

### Pitfall 3: RefreshAll Blocks Unaffected Cards
**What goes wrong:** Triggering refresh-all sets all cards' `refresh_state = Refreshing`, preventing interaction on all cards until done.
**Why it happens:** Global state model applied to per-card UI state.
**How to avoid:** `CardUiState.is_refresh_disabled()` checks per-card `refresh_state` only. Refresh-all updates cards individually as each response arrives.
**Warning signs:** All card refresh buttons grayed out after pressing refresh-all.

### Pitfall 4: Missing Image Changes Card Shape
**What goes wrong:** When `first_item_image_hint` is None, a placeholder of different dimensions causes card width/height to shift.
**Why it happens:** No explicit placeholder rectangle defined for the missing-image case.
**How to avoid:** Use a fixed-size placeholder Rectangle in Slint for `MissingImage` state; same dimensions as the loaded image slot.
**Warning signs:** Cards visually narrower or shorter when image is absent.

## Code Examples

### Projecting a snapshot to a card view model

```rust
// crates/app/src/dashboard/projection.rs
// Source: actual implementation (confirmed 2026-03-11)
pub fn project_snapshot(snapshot: &RecipientCardSnapshot, now: SystemTime) -> DashboardCardViewModel {
    let (note_preview, note_state) =
        DashboardCardViewModel::single_line_note_preview(snapshot.latest_note.as_deref());
    let (first_item_image_hint, image_state) =
        DashboardCardViewModel::image_hint_or_missing(snapshot.first_item_image_hint.clone());

    let missing_state = if snapshot.partial_data {
        CardMissingState::MissingPartialData
    } else if matches!(image_state, CardMissingState::MissingImage) {
        CardMissingState::MissingImage
    } else if matches!(note_state, CardMissingState::MissingNote) {
        CardMissingState::MissingNote
    } else {
        CardMissingState::Ready
    };

    let stale = snapshot
        .last_updated_at
        .and_then(|updated| now.duration_since(updated).ok())
        .map(|age| age > STALE_AFTER)
        .unwrap_or(false);

    DashboardCardViewModel {
        status_pill: snapshot.shipment_status.clone().unwrap_or_else(|| "Missing".to_string()),
        // ...other fields
    }
}
```

### Note preview helper

```rust
// crates/app/src/dashboard/view_model.rs
pub fn single_line_note_preview(note: Option<&str>) -> (String, CardMissingState) {
    match note {
        None => ("Missing".to_string(), CardMissingState::MissingNote),
        Some(value) => {
            let trimmed = value.split_whitespace().collect::<Vec<_>>().join(" ");
            if trimmed.is_empty() {
                return ("Missing".to_string(), CardMissingState::MissingNote);
            }
            let preview = if trimmed.chars().count() > 80 {
                let clipped: String = trimmed.chars().take(77).collect();
                format!("{}...", clipped)
            } else {
                trimmed
            };
            (preview, CardMissingState::Ready)
        }
    }
}
```

### Wiring refresh control visibility from CardUiState

```rust
// crates/app/src/dashboard/state.rs
impl CardUiState {
    pub fn should_show_refresh_control(&self) -> bool {
        matches!(
            self.selection,
            DashboardSelectionState::Hovered | DashboardSelectionState::Selected
        )
    }

    pub fn is_refresh_disabled(&self) -> bool {
        self.refresh_state == CardRefreshState::Refreshing
    }
}
```

## State of the Art

| Old Approach | Current Approach | When Changed | Impact |
|--------------|------------------|--------------|--------|
| Bind Slint directly to service DTO | Project snapshot -> `DashboardCardViewModel` first | Phase 3 | Decoupled; service changes don't cascade to UI |
| Ad-hoc missing-field checks in UI | `CardMissingState` enum from projection | Phase 3 | Deterministic, testable, stable card height |
| Separate F5 / button codepaths | Single `RefreshCommand::RefreshAll` enum | Phase 3 | Parity guaranteed structurally |
| Global refresh disables all cards | Per-card `CardRefreshState` in `CardUiState` | Phase 3 | Non-affected cards remain interactive |
| `archive_state` absent from view model | `archive_state: ArchiveState` field on `DashboardCardViewModel` | Phase 7 (additive) | Archive filter applied post-projection |

## Open Questions

None — phase is complete and verified. All requirements passed.

## Validation Architecture

### Test Framework

| Property | Value |
|----------|-------|
| Framework | Rust built-in (`cargo test`) |
| Config file | `Cargo.toml` per crate |
| Quick run command | `cargo test -p app dashboard -- --nocapture` |
| Full suite command | `cargo test -p app -- --nocapture && cargo test -p service -- --nocapture` |

### Phase Requirements to Test Map

| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|--------|----------|-----------|-------------------|-------------|
| DATA-05 | Per-card refresh dispatcher routes and returns typed receipt | unit | `cargo test -p app dashboard_refresh_dispatch -- --nocapture` | Yes |
| DATA-05 | Per-card refresh failure produces error receipt with retry affordance | unit | `cargo test -p app dashboard_refresh_tests -- --nocapture` | Yes |
| DATA-06 | Refresh-all command routes identically from UI and F5 paths | unit | `cargo test -p app dashboard_refresh_tests_f5_and_ui_share_refresh_all_path -- --nocapture` | Yes |
| CARD-03 | `item_summary` field populated from snapshot items | unit | `cargo test -p app dashboard_projection -- --nocapture` | Yes |
| CARD-04 | `first_item_image_hint` = None yields `MissingImage` state | unit | `cargo test -p app dashboard_projection_tests -- --nocapture` | Yes |
| CARD-05 | `status_pill` absent in snapshot yields `"Missing"` string | unit | `cargo test -p app dashboard_projection -- --nocapture` | Yes |
| CARD-06 | Stale badge activates only when snapshot age > 12 hours | unit | `cargo test -p app dashboard_projection_tests -- --nocapture` | Yes |
| CARD-07 | Empty/absent note yields `MissingNote` state and hides row | unit | `cargo test -p app dashboard_layout_tests -- --nocapture` | Yes |

### Wave 0 Gaps

None — existing test infrastructure covers all phase requirements.

## Sources

### Primary (HIGH confidence)
- `crates/app/src/dashboard/projection.rs` — confirmed `project_snapshot`, `STALE_AFTER`, stale logic
- `crates/app/src/dashboard/view_model.rs` — confirmed `DashboardCardViewModel`, `single_line_note_preview`, `image_hint_or_missing`
- `crates/app/src/dashboard/state.rs` — confirmed `CardMissingState`, `CardRefreshState`, `CardUiState`, `CardEditState`
- `crates/app/src/dashboard/actions.rs` — confirmed `RefreshCommand`, `RefreshDispatcher`, `EditCommand`, `EditDispatcher`
- `.planning/phases/03-core-card-dashboard/03-VERIFICATION.md` — verified 7/7 at 2026-02-28T04:34:36Z

### Secondary (MEDIUM confidence)
- `03-01-SUMMARY.md`, `03-02-SUMMARY.md`, `03-03-SUMMARY.md` — execution history confirming zero deviations in plans 02 and 03

## Metadata

**Confidence breakdown:**
- Standard stack: HIGH — no new crates; existing stack confirmed in all implemented files
- Architecture: HIGH — patterns confirmed against real production code, not hypotheses
- Pitfalls: HIGH — pitfall mitigations confirmed present in implementation (per-card state, fixed placeholder, unified dispatcher)

**Research date:** 2026-02-27 (updated 2026-03-11)
**Valid until:** 2026-05-11 (stable domain; no fast-moving dependencies)

---

*Phase: 03-core-card-dashboard*
*Research completed: 2026-02-27*
*Updated post-completion: 2026-03-11*
*Ready for planning: yes (phase complete)*
