# Phase 11: Projection Pipeline & Sort Fixes - Research

**Researched:** 2026-03-20
**Domain:** Rust data pipeline, Slint UI wiring, internal crate integration
**Confidence:** HIGH

<user_constraints>
## User Constraints (from CONTEXT.md)

### Locked Decisions
- Build URLs in the **projection layer** (project_snapshot), not the service/snapshot layer — keeps service layer ID-only and projection responsible for display concerns
- Add `shopify_order_id: Option<String>` to `RecipientCardSnapshot` alongside existing `shopify_customer_id` — flow `latest_order_id` from `ShopifyProjection` through the merge pipeline to the snapshot
- Add `store_slug: &str` parameter to `project_snapshot()` — caller provides from config
- Use `build_shopify_customer_url` and `build_shopify_order_url` (already in `external.rs`) to construct URLs from IDs + store slug
- **Hide** menu items when no customer/order ID exists — no grayed-out dead items
- Call `sort_cards_by_mode` **after all filters** (search → status → archive → sort) in `apply_filters`, right before pushing to Slint model
- Sort on `DashboardCardViewModel` vec, then convert to `CardData` — reuses existing sort logic without changes
- Option grid tiles (By Recipient, By Product) also sorted alphabetically for consistency (already done in build_recipient_tiles / build_product_tiles)
- Shopify store slug provided via config/env variable (non-secret, just store identifier like `bigscreenvr`)
- Passed to `project_snapshot()` as parameter — no global state

### Claude's Discretion
- Exact env variable name for store slug
- How `shopify_order_id` flows through the merge pipeline from `ShopifyProjection` to `RecipientCardSnapshot`
- Whether to sort in-place or create a new sorted vec in apply_filters

### Deferred Ideas (OUT OF SCOPE)
- Production store slug configuration wiring — Phase 12 (Production Data Client Integration) will wire the actual store slug from config
- Credential security review for API tokens — already handled via WindowsCredentialManagerStore, no changes needed
</user_constraints>

<phase_requirements>
## Phase Requirements

| ID | Description | Research Support |
|----|-------------|-----------------|
| CARD-08 | Card action opens Shopify customer profile in default browser | URL must be constructed from `shopify_customer_id` + store slug and stored in `DashboardCardViewModel.shopify_customer_url`; `open_shopify_url` in `external.rs` already handles launch |
| CARD-09 | Card action opens Shopify order in default browser | Requires `shopify_order_id` threaded from `ShipmentProjection.latest_order_id` through merge pipeline → domain model → snapshot chain → projection layer URL construction |
</phase_requirements>

---

## Summary

Phase 11 closes two specific gaps deferred from Phase 8: (1) Shopify customer and order URLs were hardcoded to `None` in `project_snapshot()` pending config availability, and (2) `sort_cards_by_mode` is fully implemented but never called in `apply_filters`.

The URL pipeline gap spans multiple crate layers: `latest_order_id` lives in `ShipmentProjection` but is dropped by `merge_recipient` — it never reaches `Recipient`, `RecipientSnapshot`, `RecipientCardSnapshot`, or `project_snapshot`. Closing this requires adding `shopify_order_id` to four structs/tables across three crates plus one DB migration. The store slug for URL construction is non-secret and should be read from a `SHOPIFY_STORE_SLUG` env var at startup, then passed into `project_snapshot()` as a `&str` parameter.

The sort gap is a one-line wiring fix in `apply_filters`: call `sort_cards_by_mode(&mut after_archive_vms, mode)` before converting `DashboardCardViewModel` back to `CardData` for the Slint model push. The existing sort implementation in `discovery.rs` is battle-tested and covers all four modes.

**Primary recommendation:** Tackle in two plans — Plan 01: thread `shopify_order_id` through the pipeline and wire URL construction in `project_snapshot`; Plan 02: call `sort_cards_by_mode` in `apply_filters`.

---

## Standard Stack

No new external dependencies. Phase uses only crates already in the workspace.

### Core
| Library | Purpose | Already Present |
|---------|---------|----------------|
| `wit_core` | Domain model (`Recipient`) | Yes |
| `service` crate | `ShipmentProjection`, merge pipeline, `RecipientSnapshot` | Yes |
| `app` crate | `RecipientCardSnapshot`, `project_snapshot`, `apply_filters` | Yes |

### Don't Hand-Roll

| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| Shopify customer URL construction | Custom format string | `build_shopify_customer_url(store_slug, id)` in `external.rs` | Already implemented and tested |
| Shopify order URL construction | Custom format string | `build_shopify_order_url(store_slug, id)` in `external.rs` | Already implemented and tested |
| Card sort logic | Custom sort | `sort_cards_by_mode(&mut cards, mode)` in `discovery.rs` | Fully implemented with 8 unit tests covering all modes |
| Browser launch | Custom OS call | `open_shopify_url(url)` in `external.rs` | Already wired into existing menu callbacks |

---

## Architecture Patterns

### Data Flow: order_id Pipeline

The `latest_order_id` originates in `ShipmentProjection` (service crate) but is never persisted or forwarded. The full pipeline gap:

```
ShipmentProjection.latest_order_id   (service/sync/shopify_projection.rs)
        |
        | merge_recipient() DROPS IT — not assigned to Recipient
        v
Recipient (core/domain/recipient.rs)  ← needs: shopify_order_id: Option<String>
        |
        | DB persistence (service/migrations/)  ← needs: shopify_order_id TEXT NULL
        |
        v
RecipientAggregate → get_recipient_snapshot()
        |
        v
RecipientSnapshot (service/api/recipients.rs)  ← needs: shopify_order_id: Option<String>
        |  (consumed by service impl of DashboardDataClient)
        v
RecipientCardSnapshot (app/service_client.rs)  ← needs: shopify_order_id: Option<String>
        |
        v
project_snapshot(snapshot, store_slug, now)  ← needs: store_slug param, URL construction
        |
        v
DashboardCardViewModel.shopify_customer_url / shopify_order_url  ← already Option<String>
```

### Merge Pipeline Fix

`merge_recipient` in `service/src/sync/merge.rs` currently builds `Recipient` without `shopify_order_id`. After adding the field to `Recipient`, set it from `input.shipment.latest_order_id`:

```rust
// In merge_recipient(), inside MergeResult { recipient: Recipient { ... } }
shopify_order_id: input.shipment.latest_order_id,
```

### project_snapshot Signature Change

Current signature:
```rust
pub fn project_snapshot(snapshot: &RecipientCardSnapshot, now: SystemTime) -> DashboardCardViewModel
```

New signature:
```rust
pub fn project_snapshot(snapshot: &RecipientCardSnapshot, store_slug: &str, now: SystemTime) -> DashboardCardViewModel
```

URL construction inside:
```rust
shopify_customer_url: snapshot.shopify_customer_id.as_deref()
    .map(|id| build_shopify_customer_url(store_slug, id)),
shopify_order_url: snapshot.shopify_order_id.as_deref()
    .map(|id| build_shopify_order_url(store_slug, id)),
```

This replaces the current hardcoded `None, None`.

### DashboardView::from_snapshots Signature Change

`from_snapshots` in `crates/app/src/dashboard/mod.rs` calls `project_snapshot`. It must accept `store_slug`:

```rust
pub fn from_snapshots(
    snapshots: &[crate::service_client::RecipientCardSnapshot],
    store_slug: &str,
    now: std::time::SystemTime,
) -> Self
```

All three callers in `dashboard_projection_tests.rs` must be updated to pass `""` (empty slug) since they test non-URL fields. Tests that verify URL construction should be new.

### Sort Wiring in apply_filters

`apply_filters` in `main.rs` currently has this filter sequence (lines 401-539):

```
filter_models (Vec<DashboardCardViewModel>)
  → after_search (filtered by search)
  → after_status (filtered by status chips)
  → (after_archive, hidden_count) via filter_cards_by_archive   ← returns Vec<DashboardCardViewModel>
```

Then `after_archive` is used to reconstruct `filtered_cards: Vec<CardData>` by identity-matching on `(recipient_name, item_summary, status_pill)`.

**Sort insertion point:** After `filter_cards_by_archive` returns `after_archive: Vec<DashboardCardViewModel>`, sort that vec in-place before the identity-match conversion:

```rust
let (mut after_archive, hidden_count) = filter_cards_by_archive(&after_status, show_archived);
let mode = runtime.current_mode();
sort_cards_by_mode(&mut after_archive, mode);
// ... then existing identity-match to rebuild filtered_cards: Vec<CardData>
```

Note: `mode` is already computed later in the function. Move that computation up or compute it twice — prefer a single early computation.

### Store Slug Env Variable

Existing env var pattern in the codebase: `SHOPIFY_ACCESS_TOKEN`, `SHOPIFY_SHOP_DOMAIN`. The store slug for URL construction is a separate, non-secret identifier (e.g., `bigscreenvr`). The natural env var name following existing conventions is `SHOPIFY_STORE_SLUG`.

Read at startup in `main()` with `std::env::var("SHOPIFY_STORE_SLUG").unwrap_or_default()` and pass wherever `project_snapshot` / `from_snapshots` is called. An empty slug means URLs will be malformed but the app will not crash — menu items with empty IDs are hidden anyway (existing Slint menu logic checks for non-empty URL before showing items).

### Existing Slint Menu Wiring

The Slint UI already has menu item visibility logic that hides items when URL fields are empty/None. The Phase 8 decision confirmed: "hide menu items when no customer/order ID exists." The `has-external-link-items` computed bool in the card component drives separator visibility. No Slint changes are needed for the hide behavior — the existing menu code already handles `None` URL by checking emptiness before rendering.

---

## Common Pitfalls

### Pitfall 1: Missing DB Migration for shopify_order_id

**What goes wrong:** `Recipient.shopify_order_id` is added to the domain model but not the DB schema. Compile succeeds but data is never persisted or loaded.
**Why it happens:** The `Repository` in `service/src/db/repository.rs` uses raw SQL with explicit column lists — adding a Rust field does not auto-migrate.
**How to avoid:** Add `shopify_order_id TEXT NULL` to the `recipients` table in a new migration file. Check `repository.rs` upsert/load SQL for the `recipients` table and add the column to all SELECT, INSERT, and UPDATE statements.
**Warning signs:** `shopify_order_id` is always `None` even when `latest_order_id` is populated in `ShipmentProjection`.

### Pitfall 2: Struct Literal Completeness Failures

**What goes wrong:** Adding `shopify_order_id` to `Recipient`, `RecipientSnapshot`, and `RecipientCardSnapshot` causes compile errors at every struct literal in tests.
**Why it happens:** Rust struct literals require all fields unless `..default()` is used.
**How to avoid:** Use `grep` to find all construction sites for each struct before adding the field. Files to check:
- `crates/service/tests/merge_pipeline_tests.rs` (Recipient construction)
- `crates/service/tests/api_smoke_tests.rs` (Recipient construction)
- `crates/service/tests/repository_persistence_tests.rs`
- `crates/app/tests/dashboard_projection_tests.rs` (RecipientCardSnapshot construction — 3 sites)
- `crates/service/tests/shopify_sync_tests.rs`

### Pitfall 3: from_snapshots Signature Breaking Tests

**What goes wrong:** Adding `store_slug: &str` to `project_snapshot` and `from_snapshots` breaks all three integration tests in `dashboard_projection_tests.rs`.
**Why it happens:** Tests call `DashboardView::from_snapshots(&[snapshot], now)` — now needs a third argument.
**How to avoid:** Pass `""` as `store_slug` in the existing tests — they test non-URL fields, so an empty slug is harmless.

### Pitfall 4: Sort Reordering Identity-Match Logic

**What goes wrong:** Sorting `after_archive: Vec<DashboardCardViewModel>` AFTER the identity-match reconstruction (on `filtered_cards: Vec<CardData>`) breaks the sort — `CardData` has no `last_updated_at` field for sort comparison.
**Why it happens:** The filter-to-CardData reconstruction and sort must happen in the right order.
**How to avoid:** Sort `after_archive` (the `DashboardCardViewModel` vec) BEFORE the identity-match loop that reconstructs `filtered_cards`. The identity-match will then produce `CardData` in sorted order.

### Pitfall 5: Double mode Computation

**What goes wrong:** `mode` is currently computed on line 465 of `apply_filters`, but sort needs it before line 429 (where the identity-match begins).
**Why it happens:** `sort_cards_by_mode` needs `mode` earlier than it's currently available.
**How to avoid:** Move `let mode = runtime.current_mode();` to before `filter_cards_by_archive`. The variable is used identically in the match on line 471, so this is a simple hoist.

---

## Code Examples

### Adding shopify_order_id to Recipient Domain Model

```rust
// crates/core/src/domain/recipient.rs
pub struct Recipient {
    pub recipient_id: String,
    pub github_item_id: Option<String>,
    pub shopify_customer_id: Option<String>,
    pub shopify_order_id: Option<String>,   // NEW
    pub github_profile_url: Option<String>,
    pub shipment_status: Option<String>,
    // ... rest unchanged
}
```

### Flowing order_id through merge_recipient

```rust
// crates/service/src/sync/merge.rs - inside MergeResult { recipient: Recipient { ... } }
shopify_customer_id: input.linked.shopify_customer_id,
shopify_order_id: input.shipment.latest_order_id,   // NEW
```

### project_snapshot with URL construction

```rust
// crates/app/src/dashboard/projection.rs
pub fn project_snapshot(
    snapshot: &RecipientCardSnapshot,
    store_slug: &str,              // NEW parameter
    now: SystemTime,
) -> DashboardCardViewModel {
    // ... existing fields unchanged ...
    DashboardCardViewModel {
        // ...
        shopify_customer_url: snapshot.shopify_customer_id.as_deref()
            .map(|id| build_shopify_customer_url(store_slug, id)),  // WAS: None
        shopify_order_url: snapshot.shopify_order_id.as_deref()
            .map(|id| build_shopify_order_url(store_slug, id)),     // WAS: None
        // ...
    }
}
```

### Sort in apply_filters

```rust
// crates/app/src/main.rs - apply_filters function
let mode = runtime.current_mode();   // MOVE UP to here (was line 465)
let (mut after_archive, hidden_count) = filter_cards_by_archive(&after_status, show_archived);
sort_cards_by_mode(&mut after_archive, mode);   // NEW - sort before identity-match

// ... existing identity-match to filtered_cards: Vec<CardData> follows unchanged ...
```

---

## State of the Art

| Old Approach | Current Approach | Impact |
|--------------|------------------|--------|
| `shopify_customer_url: None` hardcoded in projection | Construct from `shopify_customer_id` + store slug | Menu items become functional |
| `shopify_order_url: None` hardcoded | Construct from `shopify_order_id` + store slug | CARD-09 becomes functional |
| No sort in apply_filters | Call `sort_cards_by_mode` after archive filter | Cards order correctly per discovery mode |
| `latest_order_id` dropped at merge | Flow to `Recipient.shopify_order_id` | Order data survives to UI layer |

**Confirmed deferred (Phase 8 notes):**
- `shopify_customer_url/order_url set to None in projection layer - URL construction deferred to Plan 03 when ShopifyConfig is wired` — Phase 11 resolves this.

---

## Open Questions

1. **Repository SQL coverage**
   - What we know: `repository.rs` uses explicit SQL column lists for recipients
   - What's unclear: Exact SQL strings for SELECT/INSERT/UPDATE on recipients table — need to read before writing migration + repo changes
   - Recommendation: Read `crates/service/src/db/repository.rs` and `crates/service/src/db/schema.rs` early in Plan 01 before writing any SQL

2. **`DashboardView::from_snapshots` call sites in production**
   - What we know: Called in `dashboard/mod.rs` and test files; NOT called from main.rs (main.rs uses seed data directly via `seed_cards()`)
   - What's unclear: When the production client path wires `from_snapshots`, who passes `store_slug`?
   - Recommendation: In Phase 11, main.rs reads `SHOPIFY_STORE_SLUG` env var and passes it wherever `from_snapshots` is called. Phase 12 will replace seed data with real client calls.

---

## 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` |
| Full suite command | `cargo test --workspace` |

### Phase Requirements → Test Map

| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|--------|----------|-----------|-------------------|-------------|
| CARD-08 | `shopify_customer_url` constructed from customer_id + store slug | unit | `cargo test -p app -- project_snapshot` | Partial — existing tests cover other fields; new test needed |
| CARD-09 | `shopify_order_url` constructed from order_id + store slug | unit | `cargo test -p app -- project_snapshot` | No — new test needed |
| CARD-08 + CARD-09 | URLs are `None` when IDs absent | unit | `cargo test -p app -- project_snapshot` | No — new test needed |
| CARD-09 | `shopify_order_id` flows from merge through to snapshot | unit | `cargo test -p service -- merge` | Partial — merge tests exist, new assertion needed |
| sort | Cards ordered per discovery mode in apply_filters | integration | `cargo test -p app` | No — new integration test needed |

### Sampling Rate
- **Per task commit:** `cargo test -p app`
- **Per wave merge:** `cargo test --workspace`
- **Phase gate:** Full suite green before `/gsd:verify-work`

### Wave 0 Gaps
- [ ] New test in `crates/app/tests/dashboard_projection_tests.rs` — covers CARD-08/CARD-09 URL construction with store slug
- [ ] New test for `None` IDs → `None` URLs (no grayed-out items)
- [ ] New test in merge pipeline for `shopify_order_id` flowing through
- [ ] New integration test for sort in apply_filters

*(Existing test infrastructure covers framework — no setup needed)*

---

## Sources

### Primary (HIGH confidence)
- Direct code reading: `crates/app/src/dashboard/projection.rs` — confirmed `shopify_customer_url: None, shopify_order_url: None` hardcoded
- Direct code reading: `crates/app/src/dashboard/discovery.rs` — confirmed `sort_cards_by_mode` fully implemented with 8 tests, never called from `apply_filters`
- Direct code reading: `crates/app/src/main.rs` lines 401-539 — confirmed no `sort_cards_by_mode` call in `apply_filters`
- Direct code reading: `crates/service/src/sync/merge.rs` — confirmed `latest_order_id` is dropped, not stored in `Recipient`
- Direct code reading: `crates/core/src/domain/recipient.rs` — confirmed no `shopify_order_id` field on `Recipient`
- Direct code reading: `crates/app/src/service_client.rs` — confirmed no `shopify_order_id` on `RecipientCardSnapshot`
- Direct code reading: `crates/service/src/api/recipients.rs` — confirmed no `shopify_order_id` on `RecipientSnapshot`
- Direct code reading: `crates/service/migrations/0001_initial_schema.sql` — confirmed no `shopify_order_id` column in `recipients` table
- Direct code reading: `crates/app/src/dashboard/external.rs` — confirmed `build_shopify_customer_url` and `build_shopify_order_url` already implemented and tested

---

## Metadata

**Confidence breakdown:**
- Pipeline gap analysis: HIGH — confirmed by reading every struct in the chain
- Sort wiring: HIGH — `sort_cards_by_mode` code read, `apply_filters` code read, gap confirmed
- Store slug env var name: MEDIUM — follows `SHOPIFY_SHOP_DOMAIN` pattern but is a discretion item
- Migration SQL: MEDIUM — schema file confirms missing column, exact repository SQL needs reading in Plan 01

**Research date:** 2026-03-20
**Valid until:** 2026-04-20 (stable internal codebase)
