# Phase 7: Archive Lifecycle Controls - Research

**Researched:** 2026-03-10
**Domain:** Archive state management, filter pipeline extension, Slint UI controls
**Confidence:** HIGH

## Summary

Phase 7 adds a three-state archive lifecycle (Active / To Be Archived / Archived) with automatic archiving on "Returned" status, manual override controls, and a "Show archived" chip filter. The codebase already has strong patterns for every building block needed: the filter pipeline in `discovery.rs` is composable and ready for a new archive filter stage, `ModeState` accepts new per-mode boolean fields, `ChipBar` supports additional chips, `RecipientCard` already has hover-reveal controls to extend, and `RecipientOverride` on the domain model establishes the override persistence pattern.

The primary complexity lies in the three-state lifecycle with its time-based transition (TBA to Archived after 12 hours or app restart), the one-way latch for auto-archive, and the sticky manual unarchive override. All of these are state management concerns in Rust -- no new external dependencies are needed.

**Primary recommendation:** Implement archive state as a new `ArchiveState` enum with associated metadata (timestamps, manual override flag) stored alongside per-card state in `DashboardRuntime.card_ui` or a parallel map, computed during projection and filtered in the existing pipeline.

<user_constraints>
## User Constraints (from CONTEXT.md)

### Locked Decisions
- "Show archived" is a chip in the existing ChipBar component, alongside status filter chips
- Chip appears in all four discovery modes
- Toggle state is per-discovery-mode (stored in ModeState), resets to OFF on app start
- Default: OFF (archived cards hidden)
- When "Show archived" is OFF, search respects the toggle and excludes archived cards
- When archived results exist but are hidden, show a dynamic message: "N archived results hidden" with a button to reveal them
- Auto-archive fires when shipment_status transitions to exactly "Returned" (not "Return In Transit")
- Archive flag is computed during projection (when building DashboardCardViewModel from domain data)
- One-way latch: once auto-archived, the flag persists even if status changes away from "Returned". Only manual unarchive clears it
- Three-state lifecycle: Active -> To Be Archived -> Archived
- TBA cards have dimmed styling but are visible even without "Show archived" toggle
- Transition from TBA to Archived occurs on app restart OR after 12 hours
- TBA cards show "Archive Now" in hover controls to skip waiting
- Card-level hover-reveal controls bundle Refresh (moved from always-visible to hover-reveal) and Archive/Unarchive
- Any card can be manually archived regardless of status
- Manual unarchive wins over auto-archive (sticky override persists through syncs)
- TBA cards show only "Archive Now" (no separate Unarchive at this stage)
- Archived and TBA cards rendered with ~50% opacity / desaturated colors
- When "Show archived" is ON, archived cards mixed into natural sort position (not grouped)
- Toast notifications: manual archive "Archived [name]. Undo?", auto-archive "N cards auto-archived. Undo?", 5-second window

### Claude's Discretion
- Exact opacity/desaturation values for dimmed cards
- Toast component implementation and positioning
- How to track the 12-hour TBA timer (timestamp field on archive record)
- Database schema for archive state (likely an archive_state enum column + archived_at timestamp)
- Whether "Archive Now" uses same icon as initial archive or a different variant

### Deferred Ideas (OUT OF SCOPE)
None -- discussion stayed within phase scope
</user_constraints>

<phase_requirements>
## Phase Requirements

| ID | Description | Research Support |
|----|-------------|-----------------|
| ARCH-01 | UI provides `Show archived` checkbox with default unchecked | ChipBar extension + ModeState `show_archived: bool` + archive filter in pipeline |
| ARCH-02 | Cards auto-archive when status transitions to `Returned` | Projection-time computation in `project_snapshot()` with one-way latch + ArchiveRecord persistence |
| ARCH-03 | User can manually override archive state per card | Hover-reveal controls on RecipientCard + sticky manual override flag in ArchiveRecord |
</phase_requirements>

## Standard Stack

### Core
| Library | Version | Purpose | Why Standard |
|---------|---------|---------|--------------|
| slint | 1.x | UI framework | Already in use, all UI work extends existing components |
| Rust std | 2021 edition | Archive state enums, HashMap, SystemTime | No external deps needed |

### Supporting
No new dependencies required. All archive behavior is implemented with existing Rust stdlib types and Slint UI primitives.

## Architecture Patterns

### Recommended Project Structure
```
crates/app/src/dashboard/
  archive.rs          # NEW: ArchiveState enum, ArchiveRecord, ArchiveStore, filter_cards_by_archive()
  discovery.rs        # MODIFY: Add show_archived to ModeState
  projection.rs       # MODIFY: Compute archive flag in project_snapshot()
  view_model.rs       # MODIFY: Add archive_state field to DashboardCardViewModel
  state.rs            # MODIFY: (Optional) Add archive-related CardUiState fields
  mod.rs              # MODIFY: Add archive module, re-exports

crates/app/ui/
  card.slint          # MODIFY: Add opacity binding, hover archive controls
  chip-bar.slint      # NO CHANGE (chip data already dynamic)
  dashboard.slint     # MODIFY: Add CardData archive fields, pass to cards, toast enhancements
```

### Pattern 1: Three-State Archive Lifecycle
**What:** Archive state stored as an `ArchiveRecord` per recipient_id in a HashMap within DashboardRuntime.
**When to use:** Every card evaluation during projection and filtering.

```rust
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ArchiveState {
    Active,
    ToBeArchived,
    Archived,
}

#[derive(Debug, Clone)]
pub struct ArchiveRecord {
    pub state: ArchiveState,
    pub archived_at: Option<SystemTime>,      // When TBA was entered
    pub auto_archived: bool,                   // True if triggered by "Returned" status
    pub manual_unarchive_override: bool,       // Sticky: prevents re-auto-archive
}
```

### Pattern 2: Archive Filter in Pipeline
**What:** New `filter_cards_by_archive()` function slots into the existing composable filter pipeline.
**When to use:** Every time cards are pushed to the Slint model.

```rust
// Existing pipeline: search -> status -> push
// New pipeline:      search -> status -> archive -> push
pub fn filter_cards_by_archive(
    cards: &[DashboardCardViewModel],
    show_archived: bool,
) -> (Vec<DashboardCardViewModel>, usize) {
    // Returns (visible_cards, hidden_archived_count)
    if show_archived {
        return (cards.to_vec(), 0);
    }
    let mut visible = Vec::new();
    let mut hidden = 0;
    for c in cards {
        match c.archive_state {
            ArchiveState::Active => visible.push(c.clone()),
            ArchiveState::ToBeArchived => visible.push(c.clone()), // TBA always visible
            ArchiveState::Archived => hidden += 1,
        }
    }
    (visible, hidden)
}
```

### Pattern 3: One-Way Latch for Auto-Archive
**What:** During projection, if `shipment_status == "Returned"` AND no `manual_unarchive_override`, set archive state to TBA (or Archived if already past TBA).
**When to use:** In `project_snapshot()` or a post-projection step.

```rust
// In projection or a separate compute step:
fn compute_archive_state(
    snapshot: &RecipientCardSnapshot,
    existing_record: Option<&ArchiveRecord>,
    now: SystemTime,
) -> ArchiveRecord {
    let existing = existing_record.cloned().unwrap_or(ArchiveRecord::default());

    // Manual unarchive is sticky -- never re-auto-archive
    if existing.manual_unarchive_override {
        return ArchiveRecord { state: ArchiveState::Active, ..existing };
    }

    // One-way latch: once auto-archived, stays archived
    if existing.auto_archived {
        return promote_tba_if_due(existing, now);
    }

    // Auto-archive trigger
    if snapshot.shipment_status.as_deref() == Some("Returned") {
        return ArchiveRecord {
            state: ArchiveState::ToBeArchived,
            archived_at: Some(now),
            auto_archived: true,
            manual_unarchive_override: false,
        };
    }

    existing
}

fn promote_tba_if_due(record: ArchiveRecord, now: SystemTime) -> ArchiveRecord {
    if record.state != ArchiveState::ToBeArchived {
        return record;
    }
    if let Some(archived_at) = record.archived_at {
        if now.duration_since(archived_at).unwrap_or_default() > Duration::from_secs(12 * 60 * 60) {
            return ArchiveRecord { state: ArchiveState::Archived, ..record };
        }
    }
    record
}
```

### Pattern 4: Show Archived as a Chip
**What:** "Show archived" appears as a special chip in ChipBar, but its toggle state is stored in ModeState separately from status filters.
**When to use:** ChipBar rendering and chip-toggled callback.

```rust
// ModeState addition:
pub struct ModeState {
    pub search_text: String,
    pub selected_status_filters: HashSet<String>,
    pub view_state: ModeViewState,
    pub selected_tile: Option<String>,
    pub show_archived: bool,  // NEW: default false, per-mode
}
```

The "Show archived" chip is appended after status chips in `build_initial_chips()` / `sync_chips()`. Its index is `STATUS_LABELS.len()` (i.e., index 6). The `chip-toggled` callback checks if index == 6 to route to `show_archived` toggle instead of status filter toggle.

### Pattern 5: Hover Controls Restructure
**What:** Move Refresh button from always-visible to hover-reveal, add Archive/Unarchive button alongside it.
**When to use:** RecipientCard component modification.

Currently, `show-refresh` controls the Refresh button visibility. The new pattern:
- Add a card-level hover TouchArea for the entire card (or reuse the existing approach)
- On hover, show a control bar with: Refresh + Archive/Unarchive (or "Archive Now" for TBA cards)
- Pass `archive_state` as an `in property <int>` to the card (0=Active, 1=TBA, 2=Archived)

### Pattern 6: Toast with Undo
**What:** Extend existing toast to support 5-second duration with an undo action callback.
**When to use:** Manual archive, auto-archive events.

Current toast: simple text message, 1.5-second duration, no interaction.
New toast: text + optional "Undo" button, 5-second duration, callback `toast-undo()`.

```
// dashboard.slint toast extension:
in property <bool> toast-has-undo: false;
callback toast-undo();
```

### Anti-Patterns to Avoid
- **Storing archive state only in DashboardCardViewModel:** The view model is rebuilt on every projection. Archive state (especially manual overrides and timestamps) must persist in `DashboardRuntime` or a dedicated `ArchiveStore` HashMap.
- **Filtering archive state in Slint:** All filtering must happen in Rust to keep the filter pipeline consistent and testable. Slint only receives pre-filtered card lists.
- **Using opacity 0 for hidden archived cards:** Archived cards must be excluded from the cards model entirely when hidden, not just made invisible. Otherwise they consume layout space.
- **Separate "Show archived" checkbox outside ChipBar:** The decision locks this as a chip within ChipBar.

## Don't Hand-Roll

| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| Timer for TBA->Archived | Custom timer thread | `SystemTime` comparison at projection time + app restart check | App restart already promotes; 12h check is a simple timestamp comparison during projection. No background timer needed. |
| Toast component | Custom overlay system | Extend existing toast Rectangle in dashboard.slint | Already has animation, positioning, timer pattern |
| Archive persistence | File-based storage | In-memory HashMap in DashboardRuntime | Archive state resets on app restart (TBA promotes to Archived). No persistent storage needed beyond runtime. |

**Key insight:** The 12-hour TBA timer does NOT need a background thread. Every time cards are projected or filtered (which happens on any user interaction, mode switch, or refresh), the timestamp is checked against `SystemTime::now()`. The TBA->Archived promotion happens lazily.

## Common Pitfalls

### Pitfall 1: Auto-Archive Re-Triggering After Manual Unarchive
**What goes wrong:** User unarchives a "Returned" card, but the next sync/refresh re-triggers auto-archive because the status is still "Returned".
**Why it happens:** The auto-archive logic checks status without considering the manual override.
**How to avoid:** The `manual_unarchive_override` flag is sticky and checked BEFORE the auto-archive trigger. Once set, it persists until explicitly cleared.
**Warning signs:** Cards flipping back to TBA after being manually unarchived.

### Pitfall 2: Archive Filter Not Applied to Search Results
**What goes wrong:** Archived cards appear in search results even when "Show archived" is OFF.
**Why it happens:** Archive filter is added after status filter but before search, or search runs on unfiltered cards.
**How to avoid:** The archive filter must run AFTER both search and status filters in the pipeline (or equivalently, on the final filtered set). The requirement explicitly states "search respects the toggle."
**Warning signs:** Searching reveals cards that should be hidden.

### Pitfall 3: "Show Archived" Chip Index Collision with Status Chips
**What goes wrong:** Adding "Show archived" to the same ChipData array shifts status chip indices, breaking the `chip-toggled` callback routing.
**Why it happens:** Status chips use index 0-5 matching `STATUS_LABELS`. Adding a 7th chip at any position changes indices.
**How to avoid:** Always append "Show archived" as the LAST chip (index 6). In `chip-toggled`, check `if idx == STATUS_LABELS.len()` to route to archive toggle. Alternatively, use a separate boolean property and a distinct chip element in the Slint layout.
**Warning signs:** Toggling "Show archived" chip changes a status filter instead, or vice versa.

### Pitfall 4: TBA Cards Not Visible When "Show Archived" is OFF
**What goes wrong:** TBA cards are filtered out along with Archived cards.
**Why it happens:** Filter treats both TBA and Archived the same way.
**How to avoid:** `filter_cards_by_archive()` must explicitly keep TBA cards visible regardless of the toggle. Only `Archived` cards are hidden when toggle is OFF.
**Warning signs:** Newly archived cards immediately disappear instead of showing with dimmed styling.

### Pitfall 5: Card Opacity Not Applied in Slint
**What goes wrong:** TBA and Archived cards render at full opacity, indistinguishable from active cards.
**Why it happens:** Slint `RecipientCard` component doesn't have an opacity binding based on archive state.
**How to avoid:** Pass `archive-state` (as int) to RecipientCard and apply `opacity: archive-state > 0 ? 0.5 : 1.0;` on the root Rectangle.
**Warning signs:** All cards look the same regardless of archive status.

### Pitfall 6: Undo Toast Fires After Timer Expires
**What goes wrong:** User clicks "Undo" but the archive action has already been committed and the toast has disappeared.
**Why it happens:** The undo callback doesn't check if the toast is still active.
**How to avoid:** The undo action should revert the ArchiveRecord directly. Since archive state is in-memory, undo is just setting state back to Active. The toast timer hides the toast but the undo window is the toast visibility itself.
**Warning signs:** Undo appears to work but card doesn't return to Active state.

### Pitfall 7: Refresh Button Regression After Hover Controls Restructure
**What goes wrong:** Refresh button disappears entirely or is no longer clickable after moving to hover-reveal.
**Why it happens:** The card's `show-refresh` property was previously bound to a different condition.
**How to avoid:** The hover-reveal control area should wrap both Refresh and Archive buttons. Use a single hover area that shows both controls. The existing `item-hover` pattern in RecipientCard already handles hover stability for multiple buttons.
**Warning signs:** Cannot refresh individual cards after the change.

## Code Examples

### Adding show_archived to ModeState
```rust
// discovery.rs - ModeState modification
#[derive(Debug, Clone, Default)]
pub struct ModeState {
    pub search_text: String,
    pub selected_status_filters: HashSet<String>,
    pub view_state: ModeViewState,
    pub selected_tile: Option<String>,
    pub show_archived: bool,  // NEW - defaults to false
}
```

### Adding archive_state to DashboardCardViewModel
```rust
// view_model.rs - field addition
#[derive(Debug, Clone, PartialEq, Eq)]
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,  // NEW
}
```

### CardData struct extension in dashboard.slint
```slint
struct CardData {
    // ... existing fields ...
    archive-state: int,        // 0=Active, 1=TBA, 2=Archived
    show-archive-controls: bool,  // Whether hover controls are visible
}
```

### RecipientCard opacity binding
```slint
// card.slint - root Rectangle
in property <int> archive-state: 0;  // 0=Active, 1=TBA, 2=Archived

border-radius: 10px;
background: #242838;
opacity: archive-state > 0 ? 0.5 : 1.0;
```

### "N archived results hidden" message
```slint
// dashboard.slint - below card grid, above chip bar
if root.hidden-archived-count > 0 && !root.current-show-archived : Rectangle {
    x: 60px;
    y: parent.height - 96px;
    width: parent.width - 72px;
    height: 28px;

    HorizontalLayout {
        alignment: center;
        spacing: 8px;
        Text {
            text: root.hidden-archived-count + " archived results hidden";
            font-size: 12px;
            color: #6b7590;
            vertical-alignment: center;
        }
        Rectangle {
            width: show-text.preferred-width + 16px;
            height: 22px;
            border-radius: 11px;
            background: #2d3348;
            show-text := Text {
                text: "Show";
                font-size: 11px;
                color: #4a7cff;
                horizontal-alignment: center;
                vertical-alignment: center;
            }
            TouchArea {
                mouse-cursor: pointer;
                clicked => { root.toggle-show-archived(); }
            }
        }
    }
}
```

### Extend toast for undo support
```slint
// dashboard.slint - enhanced toast
in property <bool> toast-has-undo: false;
callback toast-undo();

if root.toast-visible : Rectangle {
    x: (parent.width - 260px) / 2;
    y: parent.height - 92px;
    width: 260px;
    height: 32px;
    border-radius: 16px;
    background: #4a7cffe0;

    HorizontalLayout {
        padding-left: 16px;
        padding-right: 8px;
        spacing: 8px;
        alignment: center;

        Text {
            text: root.toast-message;
            color: #ffffff;
            font-size: 12px;
            vertical-alignment: center;
        }

        if root.toast-has-undo : Rectangle {
            width: undo-text.preferred-width + 12px;
            height: 24px;
            border-radius: 12px;
            background: #ffffff30;
            undo-text := Text {
                text: "Undo";
                font-size: 11px;
                color: #ffffff;
                horizontal-alignment: center;
                vertical-alignment: center;
            }
            TouchArea {
                mouse-cursor: pointer;
                clicked => { root.toast-undo(); }
            }
        }
    }
}
```

## State of the Art

| Old Approach | Current Approach | When Changed | Impact |
|--------------|------------------|--------------|--------|
| Refresh always visible on card | Move to hover-reveal control bar | Phase 7 | Declutters card, groups with archive controls |
| Simple toast (text only) | Toast with optional undo action | Phase 7 | Supports archive undo workflow |
| Two-state filter (search + status) | Three-stage filter (search + status + archive) | Phase 7 | Pipeline remains composable |

## Validation Architecture

### Test Framework
| Property | Value |
|----------|-------|
| Framework | Rust built-in `#[cfg(test)]` with `cargo test` |
| Config file | None (Cargo workspace default) |
| Quick run command | `cargo test -p app` |
| Full suite command | `cargo test --workspace` |

### Phase Requirements to Test Map
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|--------|----------|-----------|-------------------|-------------|
| ARCH-01 | Show archived chip toggles archive visibility per mode | unit | `cargo test -p app archive` | No - Wave 0 |
| ARCH-01 | filter_cards_by_archive hides Archived, keeps TBA visible | unit | `cargo test -p app filter_cards_by_archive` | No - Wave 0 |
| ARCH-01 | Hidden archived count returned correctly | unit | `cargo test -p app hidden_archived` | No - Wave 0 |
| ARCH-02 | Auto-archive triggers on "Returned" status | unit | `cargo test -p app auto_archive` | No - Wave 0 |
| ARCH-02 | One-way latch: auto-archive persists after status change | unit | `cargo test -p app latch` | No - Wave 0 |
| ARCH-02 | TBA promotes to Archived after 12h | unit | `cargo test -p app promote_tba` | No - Wave 0 |
| ARCH-03 | Manual archive sets TBA state | unit | `cargo test -p app manual_archive` | No - Wave 0 |
| ARCH-03 | Manual unarchive overrides auto-archive (sticky) | unit | `cargo test -p app manual_unarchive` | No - Wave 0 |
| ARCH-03 | Undo reverts from TBA to Active | unit | `cargo test -p app undo_archive` | No - Wave 0 |

### 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
- [ ] `crates/app/src/dashboard/archive.rs` -- new module with ArchiveState, ArchiveRecord, ArchiveStore, filter and compute functions + tests
- [ ] Tests in archive.rs cover ARCH-01, ARCH-02, ARCH-03 (all unit tests for state logic)

## Open Questions

1. **ArchiveStore persistence across sessions**
   - What we know: Archive state resets on app restart (TBA promotes to Archived on restart per decision). Manual override flags exist only in memory.
   - What's unclear: Should manual_unarchive_override persist across sessions? The decision says "survives refresh/sync cycles" but doesn't mention app restart.
   - Recommendation: Keep in-memory for now. On restart, all TBA cards promote to Archived, and manual overrides reset. This is simplest and aligns with "resets to OFF on app start" for the toggle. If persistence is needed later, add serde serialization to a JSON file.

2. **"Show archived" chip visual distinction from status chips**
   - What we know: It goes in ChipBar alongside status chips.
   - What's unclear: Should it have a different color or separator to distinguish it from status filters?
   - Recommendation: Use same visual style but place it last with a subtle separator (small gap or divider). Keep it simple -- users will learn it quickly.

## Sources

### Primary (HIGH confidence)
- Codebase analysis: `crates/app/src/dashboard/` (discovery.rs, projection.rs, state.rs, view_model.rs, mod.rs, actions.rs)
- Codebase analysis: `crates/app/ui/` (dashboard.slint, card.slint, chip-bar.slint)
- Codebase analysis: `crates/core/src/domain/` (recipient.rs, package.rs)
- Codebase analysis: `crates/app/src/main.rs` (filter pipeline, chip routing, toast pattern)

### Secondary (MEDIUM confidence)
- Slint 1.x documentation for opacity, conditional rendering, animate

## Metadata

**Confidence breakdown:**
- Standard stack: HIGH -- no new deps, pure extension of existing patterns
- Architecture: HIGH -- all patterns directly derived from existing codebase conventions
- Pitfalls: HIGH -- identified from direct code analysis of existing filter pipeline and state management

**Research date:** 2026-03-10
**Valid until:** 2026-04-10 (stable -- internal codebase patterns)
