# Pirkumu plāns - Implementation Plan

**Status:** Approved v1.1 - 2026-07-14
**Functional source:** `PURCHASE-PLAN-FUNCTIONAL-SPEC.md` (Approved v1.2)
**Scope:** Technical design, delivery sequence, and verification for the first purchase-plan implementation.

**Supersession note (2026-07-16):** Product measurement, search IDs, click logging, and URL tracking decoration were removed when search history shipped. References below to measured links describe the original rollout and are no longer active.

---

## 1. Implementation goals

The implementation must add the purchase-plan behavior without changing existing search behavior. The single-user backend owns the shared plan.

The design follows these boundaries:

- the plan and offer snapshots are stored as one singleton document in the existing SQLite database;
- local storage is an immediate fallback and migration source, not the shared source of truth;
- the backend remains responsible for making and identifying fresh store requests;
- plan calculations and direct-result verification are pure functions with focused unit tests;
- Angular services own state and I/O, while sheet components own presentation;
- existing search ordering, outbound links, politeness limits, timeouts, circuit breakers, and challenge solving remain intact;
- opening the plan never starts store traffic.

## 2. Target architecture

### 2.1 Application shell and plan sheet

Keep the current search as the application's only page. The header gains a persistent `Pirkumu plāns` action with the part-requirement count. It opens a modal side sheet over the current search instead of navigating to another route.

Desktop behavior:

- fixed to the right edge and full viewport height;
- width constrained to approximately 44-48rem, while leaving a useful portion of the search visible on normal desktop viewports;
- backdrop visually separates the sheet without discarding the search context;
- sheet content scrolls independently of the search page;
- opening and closing preserves search results, filters, expanded attributes, and scroll position.

Mobile behavior:

- at the small-screen breakpoint, the sheet occupies the full viewport width and dynamic viewport height;
- it still enters from the right so it remains the same interaction model;
- its header, view switcher, and critical total/refresh actions remain reachable while the content region scrolls;
- safe-area insets and the on-screen keyboard must not hide actions or quantity inputs.

Implement the sheet with Angular CDK Overlay, FocusTrap, and scroll blocking. It has dialog semantics, an accessible title, focus restoration, backdrop close, an explicit close button, and Escape handling. The transition uses CSS transforms and respects `prefers-reduced-motion`. The sheet's open state is session-only and does not enter browser history.

The existing noVNC overlay remains distinct and stacks above the plan sheet. After solving, the user returns to the still-open sheet and only its blocked direct-refresh task resumes.

### 2.2 Frontend modules

The planned frontend ownership is:

```text
web/src/app/
  app.ts / app.html                  current search UI plus plan entry point
  purchase-plan/                     sheet, orders view, parts view, dialogs
  core/purchase-plan.models.ts       persisted and view-state types
  core/purchase-plan.domain.ts       identities, reducers, totals, sorting, projections
  core/purchase-plan.storage.ts      versioned parsing and per-entry recovery
  core/purchase-plan.store.ts        signal state, mutations, persistence status
  core/purchase-plan-refresh.service.ts
                                      direct product checks and incremental coordination
  core/offer-links.ts                shared measured/unmeasured outbound URL builder
```

Small view components should be extracted only where they own a real interaction boundary, such as the sheet, add-candidate dialog, store-order section, part-requirement section, or solve overlay. Pure calculations do not belong in components.

### 2.3 Backend changes

Add singleton plan persistence and an exact-offer refresh endpoint separate from broad search:

```http
GET /api/purchase-plan
PUT /api/purchase-plan
```

The plan endpoints read and replace one schema-validated, versioned document. Writes are serialized by the frontend to preserve user action order. On first launch after the migration, a valid browser plan is uploaded only when the backend has no saved plan; an existing backend plan is authoritative.

```http
POST /api/offers/refresh
```

Request:

```ts
interface OfferRefreshRequest {
  store: string;
  url: string;
  sourceRef: string | null;
  expectedBrand: string | null;
  expectedPartNumber: string;
}
```

Response:

```ts
interface OfferRefreshResponse {
  store: string;
  ok: boolean;
  status: 'found' | 'not_found' | 'error';
  offer?: RefreshedOfferSnapshot;
  checkedAt: number | null;
  errorCode?: OfferRefreshErrorCode;
  needsSolve?: boolean;
  solveUrl?: string;
}
```

The endpoint means:

- validate that the store is enabled and the URL belongs to that store's allowed HTTPS host;
- call a new adapter-level direct refresh capability with the saved product target;
- fetch the actual product page or a product-specific price/availability API used by that page;
- use the existing HTTP/browser context, timeout, politeness, circuit-breaker, and challenge behavior;
- verify canonical brand and part identity before returning `found`;
- return `not_found` only for an explicit product-level gone/discontinued response; out of stock remains a verified availability state;
- return `error` for parse failure, identity mismatch, unsupported direct refresh, timeout, paused store, network failure, or challenge;
- never read from or write to the broad search-response cache;
- never perform a broad part-number search as an implicit fallback.

`checkedAt` is the completion time for a confirmed `found` or `not_found` product-level response and is `null` for unresolved errors. A plan candidate updates its successful `observedAt` only for `found`.

The shared contract will expose one `OFFER_FRESHNESS_MS` constant set to 15 minutes. The broad response cache and purchase-plan stale calculation use that constant so their windows cannot drift, even though direct refresh itself is uncached.

Ordinary `StoreSearchResponse` also gains `observedAt: number | null`. A live successful search uses its completion time; a cached response uses the cache row's original `cached_at`; an unresolved response uses `null`. This metadata is required only to timestamp the initial saved snapshot correctly and does not change search presentation or trigger refresh traffic.

### 2.4 Adapter capability

Extend `StoreAdapter` with an explicit product-level capability:

```ts
interface StoreAdapter {
  // Existing search methods remain unchanged.
  refreshOffer?: (
    target: OfferRefreshTarget,
    ctx: FetchContext,
  ) => Promise<AdapterOfferRefreshResult>;
}
```

The implementation may parse a product page or call a fixed product-specific API. `sourceRef` is an opaque store identifier captured with the original offer, such as EOLTAS's product hash. It is untrusted input when returned by the browser: every adapter validates its format and interpolates it only into its own fixed URL or request body.

Before implementing the common endpoint, record one representative product-detail fixture per enabled store and establish how that store exposes current price, availability, and identity. A store is marked direct-refresh capable only after its fixture test proves this path. Lack of capability produces an honest `could_not_check` result; it does not trigger search.

### 2.5 Direct-refresh reconnaissance

One exact product URL from the existing search fixtures was probed per enabled store on 2026-07-13. No broad searches were run during this reconnaissance.

| Store | Direct product source | Exact-product signals | Planning status |
|---|---|---|---|
| Partsale | Plain HTTP product page | Product JSON-LD contains brand, part number, current price, and stock schema. The live page showed `5.37 EUR` versus `5.42 EUR` in the recorded search fixture, proving that this path detects real changes. | Confirmed; parse Product JSON-LD and verify canonical URL/identity. |
| XPARTS | Plain HTTP product page | The custom product module exposes the exact `data-ga-id`, `data-ga-price`, stock marker, and canonical product path. Related offers are present on the same page. | Confirmed; scope parsing to the requested main-product block and never take the first related offer. |
| EOLTAS | Plain HTTP product page or its product-price API | Product JSON-LD and embedded product data contain brand, part number, price, availability, URL, and opaque product hash. | Confirmed; retain the hash as `sourceRef`, with JSON-LD as a direct-page verification path. |
| Carparts | Plain HTTP product page | Product JSON-LD and Next.js server payload contain brand, part number, price, stock count, and product ID. | Confirmed; JSON-LD verifies identity/price and the scoped server payload supplies the stock count. |
| Handler | Plain HTTP product page | Nuxt server payload and rendered main-product content contain the route article ID, exact identity, current selected offer, price, and availability. Related offers are also embedded. | Confirmed; bind parsing to the requested article ID/main-product state. |
| Trodo | Resident-browser product page | Rendered product heading, current price, and availability are present on the exact page; related products also appear below it. | Confirmed through resident Chrome; parse only the main product panel. |
| Rezervesdalas24 | Resident-browser product page | Product JSON-LD contains URL, brand/name, `sku`, `mpn`, price, and schema availability. | Confirmed through resident Chrome; JSON-LD is the primary parser. |
| EUAutodalas | Resident-browser product page | Page title/heading provide exact identity; the main offer exposes `itemprop=price`, `data-price`, and availability text. | Confirmed through resident Chrome; scope selectors to the main offer. |
| Autodoc | Resident-browser product page | Product JSON-LD contains exact name, `sku`, `mpn`, URL, price, availability, and delivery metadata. | Confirmed through resident Chrome; JSON-LD is the primary parser. |
| IC24 | Resident-browser product page | Homepage warmup initially reached the human challenge. After the existing noVNC solve flow, a same-origin fetch of the exact product returned `200`; Product JSON-LD contained brand, `sku`, URL, price, and schema availability, while the scoped gross-price node carried the same `data-price`. | Confirmed through a human-warmed session; use Product JSON-LD as the primary parser, retain the VAT-inclusive gross price, and retry only the exact page after future challenges. |

Implementation consequences:

- product-page parsers are store-specific and live beside their existing search adapters;
- JSON-LD is preferred when it identifies the exact requested product, but related-product markup must always be ignored;
- plain-HTTP adapters use `PoliteHttp`; resident-browser adapters reuse `BrowserHub` and its per-store lock;
- IC24's solved product HTML must be recorded as a fixture during implementation so its confirmed JSON-LD and VAT-inclusive gross-price path remain regression-tested;
- the capability matrix is rechecked when a store fixture changes.

## 3. Persisted data model

### 3.1 Storage envelope

Use one stable local-storage key:

```text
car-parts-comparator.purchase-plan
```

The value is a versioned JSON envelope:

```ts
interface PersistedPurchasePlanV1 {
  schemaVersion: 1;
  storeSort: 'lines' | 'subtotal' | 'alphabetical';
  requirements: PersistedPartRequirementV1[];
}
```

The schema version belongs inside the value rather than in the key so later migrations have one known lookup location.

### 3.2 Part requirements

```ts
interface PersistedPartRequirementV1 {
  id: string;
  partKey: string;
  brand: string | null;
  partNumber: string;
  title: string;
  imageUrl: string | null;
  quantity: number;
  chosenCandidateId: string;
  candidates: PersistedCandidateV1[];
}
```

Rules:

- `partKey` is the existing canonical brand key plus canonical part number;
- one valid requirement exists per `partKey`;
- quantity is an integer of at least 1 and is shared by every candidate;
- `chosenCandidateId` must reference a candidate in the requirement;
- a requirement without a valid chosen candidate is rejected during recovery rather than silently choosing one;
- requirement display metadata is retained so an offline plan remains understandable.

### 3.3 Candidate snapshots

```ts
interface PersistedCandidateV1 {
  id: string;
  storeId: string;
  storeName: string;
  sourceRef: string | null;
  offer: PersistedOfferSnapshotV1;
  addedAt: number;
  observedAt: number | null;
  lastAttempt: PersistedRefreshAttemptV1 | null;
  unreviewedChange: PersistedOfferChangeV1 | null;
}
```

The persisted offer snapshot contains the current known brand, part number, title, unit price, structured stock state, display availability, delivery note, product URL, image URL, and attributes. It deliberately copies values instead of depending on the current wire `Offer` shape, allowing storage migrations to be explicit. `sourceRef` retains a store-specific product identifier needed for direct refresh but is never displayed.

Candidate IDs are generated once and remain stable if a refreshed product URL or title changes. Exact duplicate detection uses a computed fingerprint:

```text
store id + sourceRef when present, otherwise store id + part key + normalized product URL
```

Two different products from the same store may therefore remain separate candidates. URL fragments are ignored for URL fingerprinting; the rest of the URL is retained exactly to avoid unsafe product substitution.

### 3.4 Refresh state

`lastAttempt` persists the attempt time, outcome, and machine-readable reason where applicable. Persisted outcomes are:

- `up_to_date`;
- `changed`;
- `not_found`;
- `could_not_check`;
- `human_check_required`.

`checking` is operation state in memory and is never written to the persisted plan. An interrupted reload consequently returns the item to an unresolved/stale state instead of leaving it permanently marked as checking.

`unreviewedChange` stores the before and current price/availability values plus the first detection time. Every refresh compares against the immediately preceding accepted snapshot. If another refresh occurs before review, the first unseen baseline is retained for display and the current side advances to the newest accepted snapshot. A successful unchanged refresh does not erase an earlier unreviewed change. Marking reviewed removes this record only; it does not alter the current offer snapshot.

### 3.5 Recovery and storage failure

Loading uses runtime type guards and validates every requirement and candidate independently.

- malformed JSON produces an empty in-memory plan and a recovery message;
- malformed requirements are skipped while valid requirements remain available;
- malformed candidates are skipped when the requirement still has a valid chosen candidate;
- duplicate requirement keys and candidate IDs are treated as invalid entries;
- an unknown newer schema version is preserved without overwrite and requires an explicit reset;
- normal malformed-v1 recovery is persisted only after the user makes a deliberate mutation;
- local-storage read/write exceptions set a visible `not persisted` state while the in-memory plan remains usable for the current page lifetime.

No automatic expiry or plan-size TTL is introduced.

## 4. Purchase-plan domain behavior

### 4.1 Pure state transitions

All mutations use pure reducer-style functions before the store persists the next state:

- add a new requirement with its chosen candidate;
- add a candidate and retain the current choice;
- add a candidate and choose it atomically;
- change chosen candidate;
- set or step quantity;
- remove an alternative;
- remove a chosen candidate only with an explicit replacement;
- remove a requirement;
- clear the plan;
- apply one refresh outcome;
- mark one or all changes reviewed;
- change the persisted store sort.

Every transition rechecks the invariant that each requirement has one and only one valid chosen candidate.

### 4.2 Derived views and totals

Computed selectors derive, without storing duplicate data:

- chosen candidates grouped by store;
- store line count, total quantity, and subtotal;
- plan order count, unique parts, chosen lines, alternatives, total quantity, and merchandise grand total;
- plan freshness and chosen-total verification state;
- chosen and alternative counts per store for search cues;
- exact candidate state for each current result;
- stable matching-part marker index;
- alternative-switch projections.

Money calculations convert each unit price to integer cents before multiplying by quantity. Display conversion back to euros happens only at the formatting boundary.

Store ordering follows the approved rules:

- `lines`: descending chosen line count, then alphabetically;
- `subtotal`: descending merchandise subtotal, then alphabetically;
- `alphabetical`: locale-aware ascending store name.

### 4.3 Alternative projections

Projection uses a simulated chosen-candidate replacement and reports:

- candidate price difference from the current choice;
- resulting number of store orders;
- resulting chosen lines at the candidate's store;
- resulting merchandise subtotal at that store;
- whether the switch adds a store, removes an emptied store, consolidates into an existing store, replaces one store with another, or leaves the store layout unchanged.

No projected result includes shipping or claims to recommend the best option.

### 4.4 Stable matching markers

Use a deterministic string hash of `partKey` into a fixed accessible accent palette. The palette is not persisted. The same requirement therefore receives the same marker after sorting, refreshing, and reloading.

The UI always pairs the accent with brand and part-number text. Full row backgrounds are not used.

## 5. Search-page integration

Each offer row asks the purchase-plan store for one of the five functional states:

1. no relationship;
2. store under consideration;
3. store in current orders;
4. exact saved alternative;
5. exact chosen offer.

Exact candidate state takes precedence over store-level state. Search result ordering remains sourced exclusively from the current grouping logic.

The row action behavior is:

- new part: add immediately as chosen;
- exact candidate already saved: do not mutate; expose `Open plan`, which opens the sheet at that requirement;
- same part with a new candidate: open a CDK-backed accessible dialog with `Use new offer`, `Add as alternative`, and `Cancel`;
- after a mutation, badges, counts, and header state update immediately from the same store signal.

When a candidate is added from an ordinary search response, its `observedAt` comes from that store response. A cached result therefore retains the true cache observation time instead of being labelled fresh at add time. `addedAt` is recorded separately.

Search and plan links use the same unmeasured outbound behavior. Hosted web uses the validated `/out` redirect; native shells open the original validated URL directly.

## 6. Purchase-plan sheet

### 6.1 Shared sheet structure

The sheet contains:

- a compact summary band;
- storage recovery or persistence warning when relevant;
- stale/unresolved freshness banner and refresh action;
- a segmented `Orders` / `Parts and alternatives` view control;
- a persisted store-sort control in the Orders view;
- clear-plan action with confirmation;
- responsive empty state with a close-and-return-to-search action.

The selected plan view may remain session-only; only the approved store ordering is persisted. Opening from an exact search offer or an order-line alternatives link selects the Parts and alternatives view and scrolls the corresponding requirement into view after the sheet opens.

### 6.2 Orders view

Render one unframed store section per proposed order. Each section shows its summary and responsive line items with quantity, unit price, line total, availability, freshness/change status, part marker, alternatives link, and `View at store` action. The layout uses the sheet width, not the obscured search page width, as its responsive container.

Changed chosen offers are visible here until reviewed. A chosen offer that is not found or could not be checked remains included using its last known price, while the total is marked not fully verified.

### 6.3 Parts and alternatives view

Render one section per requirement with shared quantity controls and candidate rows. Candidate rows show the approved comparison and projection fields, refresh status, change details, choose action, remove action, and store link.

Removing the chosen candidate opens a dialog listing every valid replacement plus `Remove complete part`. No candidate is selected implicitly.

Quantity input is clamped to an integer minimum of 1 on commit. Invalid intermediate typing does not mutate the persisted plan.

### 6.4 Responsive and accessible behavior

- desktop uses aligned comparison columns where they improve scanning inside the bounded sheet;
- mobile uses the full-screen sheet and rows wrap into labelled value pairs without horizontal action overflow;
- icon-only controls use Phosphor icons and accessible names/tooltips;
- the sheet and its dialogs use Angular CDK focus management, Escape handling, scroll blocking, and focus restoration;
- status is never communicated by color alone;
- controls have stable dimensions so counts and progress text do not shift surrounding layout.

## 7. Refresh orchestration

### 7.1 Work planning

A complete refresh takes a snapshot of current candidate IDs and builds tasks keyed by exact product identity:

```text
store id + validated sourceRef, or store id + normalized product URL
```

All candidates sharing an exact-product key consume one direct response. Tasks run sequentially within each store and different stores run in parallel. This prevents concurrent requests from competing for the same store session while allowing a slow or blocked store to leave other stores unaffected.

Only one complete refresh operation runs at a time. Candidate mutations received while it runs are safe:

- removed candidates ignore later responses;
- newly added candidates wait for the next refresh;
- a response is applied only if the candidate ID and part identity still match.

### 7.2 Verifying direct results

For every candidate covered by a direct response:

1. validate the submitted URL and source reference before fetching;
2. fetch only the product page or its product-specific endpoint;
3. require the adapter to extract or otherwise verify canonical brand and part number;
4. accept `found` only when both match the saved target;
5. accept `not_found` only for a confirmed product-level gone/discontinued response, never ordinary out-of-stock status;
6. treat a redirect to another product, identity mismatch, missing price, missing identity, or unrecognized page shape as `could_not_check`.

An analogue, different brand, different part number, or offer from a broad search is never substituted. A failed, timed-out, paused, challenged, or unparseable response cannot become `not_found`.

### 7.3 Applying successful changes

Compare these fields:

- integer-cent unit price;
- structured `inStock` value;
- display availability;
- delivery note.

Title, image, attributes, and a verified canonical URL may be refreshed without being reported as price/availability changes. The complete accepted snapshot and `observedAt` are still updated.

Each task commits results immediately so visible item state and chosen totals update incrementally.

### 7.4 Failures and challenges

- transport, HTTP, timeout, and paused-store outcomes preserve the old snapshot and record `could_not_check` with the backend error code;
- a challenge preserves the old snapshot, records `human_check_required`, and pauses the remaining queue for that store only;
- the plan sheet offers the existing noVNC solve flow;
- after `Gatavs`, the coordinator retries the blocked direct-product task and then continues that store's remaining queue;
- already successful tasks and other stores are not repeated;
- individual retry creates a new task only for that candidate's exact-product key.

### 7.5 Freshness calculation

A candidate is current only when its latest accepted `observedAt` is within `OFFER_FRESHNESS_MS` and its last attempt is not unresolved. Staleness is calculated from a supplied `now` value so it is unit-testable.

The banner clears only when every candidate is current. The merchandise total is fully verified when every chosen candidate is current, independently of stale alternatives.

Opening the plan sheet computes these labels locally and makes no HTTP request.

## 8. Backend implementation details

Planned changes:

1. Add optional `sourceRef` to the shared `Offer` contract and runtime schema so adapters can retain product-specific refresh identifiers.
2. Add `observedAt` to ordinary store-search responses and return the cache row's original timestamp on a cache hit.
3. Add the direct-refresh target, result, error-code, and wire-response contracts.
4. Add optional `refreshOffer` to `StoreAdapter`; leave existing search methods unchanged.
5. Validate the request body, enabled store, URL protocol, hostname, credential absence, length limits, expected identity, and source-reference length at the route boundary.
6. Reuse the store's existing timeout and map `ChallengeRequiredError`, `StorePausedError`, timeout, HTTP not-found, other HTTP failure, identity mismatch, unsupported capability, and parse failure to distinct machine-readable results.
7. Return `checkedAt` only for confirmed `found` and `not_found` responses.
8. Keep direct refresh outside `ResponseCache`; it is a user-requested verification, not a new comparison search.
9. Add product-detail fixtures and parser tests store by store before enabling that adapter's refresh capability.

No new database table, account, cookie, or stored plan endpoint is needed.

## 9. Tests and verification

### 9.1 Backend unit tests

- request URLs outside the selected store's allowed host are rejected before adapter I/O;
- malformed or oversized source references are rejected;
- cached ordinary search results retain their original observation time;
- direct refresh invokes `refreshOffer` and never invokes `fetchRaw` search;
- a verified product produces `found` with `checkedAt` and current values;
- explicit HTTP/product gone produces `not_found` with `checkedAt`;
- identity mismatch and parse failure produce errors, not `not_found`;
- failures and challenges preserve machine-readable outcomes and have no `checkedAt`;
- every enabled direct-refresh adapter passes a recorded product-detail fixture test;
- the shared freshness constant remains the broad response-cache default TTL.

### 9.2 Frontend domain tests

- canonical part identity and candidate fingerprinting;
- add-new, exact-duplicate, add-alternative, and choose-new transitions;
- exactly-one-chosen invariant across selection and removal;
- quantity validation and integer-cent totals;
- all store sorts and alphabetical tie-breaking;
- chosen-only counts, subtotals, and totals;
- all alternative projection classifications;
- deterministic marker assignment;
- five search-row relationship states and store counts;
- stale, unresolved, and chosen-total verification selectors;
- direct-result identity verification, confirmed not-found, mismatch, and parse-failure handling;
- price/availability change creation, persistence, accumulation, and review;
- deduplicated refresh task planning;
- storage round trip, partial salvage, unsupported version, and unavailable storage.

The current Node-based frontend Vitest setup remains sufficient because the risk-bearing logic stays pure and browser APIs are behind an injected storage adapter.

### 9.3 Integration and visual verification

For each delivery stage:

- run root and frontend unit tests;
- run TypeScript typecheck, ESLint, and the production Angular build;
- start the backend and Angular development server;
- verify search behavior before and after plan mutations;
- verify persistence through reload;
- verify opening and closing the sheet preserves search state and scroll position;
- verify desktop side-sheet and mobile full-screen layouts with browser screenshots;
- verify focus trap, Escape, backdrop close, focus restoration, reduced motion, and no background scroll;
- verify network traces show product-page or product-specific requests and no repeated broad search;
- verify one changed, one not-found, one failed, and one challenged refresh path without requiring live store changes where fixtures/stubs can produce the state.

Live-store verification is limited to a small deliberate smoke test because refreshes create real store traffic. The canary is not part of routine UI verification.

## 10. Delivery sequence

### Stage 1: Sheet scaffold

- add the header plan action and responsive CDK side sheet;
- implement desktop bounded width, mobile full-screen behavior, focus management, and scroll preservation;
- keep the sheet content to an empty state;
- prove existing search, filters, solving, and clickouts are unchanged beneath it.

### Stage 2: Domain model and persistence

- implement versioned types, parsing, storage adapter, reducer, selectors, and tests;
- expose plan count and storage status in the application header and sheet shell.

### Stage 3: Adding and organizing offers

- add search-row actions, exact/store cues, and add-candidate dialog;
- implement Orders and Parts/Alternatives views;
- implement quantities, selection, removals, clear, sorting, totals, markers, projections, and outbound links;
- verify all local behavior and persistence before adding fresh network traffic.

### Stage 4: Direct product capability and backend contract

- record a representative product-detail response for every enabled store;
- identify each store's product-page or product-specific price/availability path and required source reference;
- add `sourceRef`, direct adapter capability, endpoint, identity validation, and result contracts;
- implement and fixture-test adapters individually, leaving unsupported stores explicitly unavailable rather than falling back to search;
- propagate source references and original observation metadata through ordinary frontend search results.

### Stage 5: Direct refresh coordinator and UI

- implement exact-product deduplication, per-store queues, and incremental outcomes;
- add stale banner, full refresh, item retry, challenge resume, change display, and review actions;
- verify network calls never repeat a broad part-number search and totals update only after a verified direct response.

### Stage 6: Final hardening

- run the full test, typecheck, lint, and build suite;
- perform responsive and accessibility checks;
- exercise storage corruption and server-offline states;
- update `TECHNICAL-SPEC.md` and README documentation to describe the shipped architecture;
- compare the implementation against all 19 functional acceptance criteria.

Each stage should leave the app buildable and testable. Stages 1 through 3 can be reviewed as the local purchase-plan experience before Stage 4 adds store-specific product verification and Stage 5 creates any new live-store traffic.

## 11. Confirmed implementation decisions

The revised plan resolves the two newly clarified choices:

1. **Plan presentation:** use a right-side modal sheet on desktop and the same sheet full-screen on mobile. Do not add a `/plan` route.
2. **Refresh source:** check the exact product page or a product-specific API used by that page. Do not silently rerun broad part-number search.
3. **Challenge or unavailable direct refresh:** retain the last snapshot as unverified, offer the existing human solve flow where applicable, and retry only the exact product. Do not fall back to search.

The following technical defaults are accepted:

4. **Refresh scheduling:** run different stores in parallel but exact-product requests sequentially within a store.
5. **Unreviewed changes:** preserve the first unseen value as the baseline while advancing the current value through later successful refreshes.
6. **Unknown storage versions:** preserve them without overwrite and require an explicit reset instead of silently discarding future-version data.

These defaults do not expand the functional scope. They define the implementation of its previously ambiguous technical edges.
