From 777782d165ace3a658dd431759062bd952737fed Mon Sep 17 00:00:00 2001 From: poketonova Date: Sat, 18 Jul 2026 18:01:34 -0600 Subject: [PATCH] docs: replace stale feature proposals with grounded specs --- docs/harness/specs/README.md | 18 ++ docs/harness/specs/master-set-compact-mode.md | 160 ++++++++++ .../specs/meta-deck-resolver-regression.md | 70 +++-- .../specs/pokemon-standard-legality.md | 288 ++++++++++++++++++ docs/harness/specs/shelf-link-polish.md | 157 ++++++++++ docs/harness/specs/trade-history.md | 229 ++++++++++++++ 6 files changed, 894 insertions(+), 28 deletions(-) create mode 100644 docs/harness/specs/README.md create mode 100644 docs/harness/specs/master-set-compact-mode.md create mode 100644 docs/harness/specs/pokemon-standard-legality.md create mode 100644 docs/harness/specs/shelf-link-polish.md create mode 100644 docs/harness/specs/trade-history.md diff --git a/docs/harness/specs/README.md b/docs/harness/specs/README.md new file mode 100644 index 0000000..797bddc --- /dev/null +++ b/docs/harness/specs/README.md @@ -0,0 +1,18 @@ +# Harness feature specs + +Phase 0 documents under this folder. Most files are **upcoming feature specs** +(following `docs/harness/templates/feature-spec.md`). One file is a **closed +regression record**, not planned work. + +Specs separate **verified live-code facts** from **planned** behavior. They are +specifications only — not implementation claims. + +| Doc | Kind | Summary | +|-----|------|---------| +| [trade-history.md](./trade-history.md) | Feature spec | Log Trade Analyzer sessions into bounded history on existing `trade_state` | +| [master-set-compact-mode.md](./master-set-compact-mode.md) | Feature spec | Optional compact gallery density; canonical cards only | +| [shelf-link-polish.md](./shelf-link-polish.md) | Feature spec | Same-device shelf copy-link + not-found; no cross-device host | +| [pokemon-standard-legality.md](./pokemon-standard-legality.md) | Feature spec | Deck-builder Pokémon Standard card-eligibility advisory via versioned offline data | +| [meta-deck-resolver-regression.md](./meta-deck-resolver-regression.md) | **CLOSED** regression record | Positional `None` resolver fix + harness eval already wired | + +User-facing copy in specs uses **shelves** (not “portfolios”). Code identifiers may keep legacy `portfolio` naming. diff --git a/docs/harness/specs/master-set-compact-mode.md b/docs/harness/specs/master-set-compact-mode.md new file mode 100644 index 0000000..b9b25a4 --- /dev/null +++ b/docs/harness/specs/master-set-compact-mode.md @@ -0,0 +1,160 @@ +# Feature Spec: Master Set Compact Mode + +## Goal + +Add an **optional compact mode** inside the existing Master Set showcase UI (`MasterSetStack` / `MasterSetGallery`) so collectors can scan large sets quickly on phones — without inventing a parallel ownership store, without naive `1..setSize` number grids, and without breaking hunt marks, wantlist ISO, add-found, EN/JP, or offline-cached set lists. + +User-facing outcome: on a showcased master set, the user can toggle **Compact** and still see accurate owned / not-owned / found state for every **real card record** in the set. + +## Non-goals + +- Replacing binder-stack showcase with a different shelf model. +- A new Pinia store or Dexie table for master-set progress. +- Generating placeholder slots from `1..total` / `1..setSize` without card identities. +- Changing how shelf items are stored (items remain the source of ownership). +- Auto-completing sets or bulk-deleting duplicates. +- Desktop-only density controls that break 280px layouts. +- Non-master-set shelf table density (separate concern). +- **v1 persistence of the compact preference** (session-only in v1; persist is a later phase). + +## Verified current facts (live code) + +| Fact | Evidence | +|------|----------| +| Showcase metadata on each shelf: `portfolio.masterSets[key]` | `showcaseMasterSet` in `src/stores/portfolio.js` | +| Keys like `` `${game}:` + optional `ja:` + setId `` | `PortfolioView.vue` `msItemKey` / `msCanonicalKey`; JP qualified to avoid EN/JP id collisions | +| Grouping is **view-layer only**; unshowcase restores single rows | `portfolio.js` comments; items not deleted on showcase | +| Hunt marks: `masterSets[key].hunt = { [cardId]: timestamp }` | `toggleHuntMark` / `clearHuntMarks` | +| Stack UI: `MasterSetStack.vue` — progress, value, Show cards / Unstack | component props `group`, `open` | +| Gallery UI: `MasterSetGallery.vue` — full set fetch, filters, marks, ISO wantlist, add-found, lightbox | props `group`, `marks` | +| Full card list from `fetchSetCards` + `sortByNumber` | `src/utils/masterSets.js` | +| Pokémon EN pages until dry (pageSize 250); JP via `getJapaneseCardsBySet` | `masterSets.js` | +| Normalized card fields include `id`, `name`, `number`, `images`, `rarity`, `set`, `price`, `_lang`, `game` | `masterSets.js` map | +| **`$0` price stays 0** (numeric finite check, not `\|\|`) | `masterSets.js` | +| Ownership match: `cardId` set, else `name\|number`, else bare `name` | `MasterSetGallery.vue` `isOwned` | +| Alphanumeric / secret-style numbers sorted via parseInt digits + localeCompare fallback | `sortByNumber` | +| Gallery refuses empty `setId` (avoids wrong-set dump) | `MasterSetGallery.vue` onMounted | +| Offline: uses same cached layers as Browse; error copy if uncached offline | gallery catch + `masterSets.js` header comment | +| Wantlist ISO + add-found → `store.addItem` + `clearHuntMarks` | gallery + `PortfolioView.addFoundCards` | +| Completion denominator prefers fetched list length backfilled into `saved.total` | `msGalleryLoaded` | +| Yu-Gi-Oh API totals intentionally null (rarity printings); gallery backfills | `loadMsSetMeta` comment | +| No compact mode flag or density toggle exists today | component templates | + +## Planned behavior + +### Compact mode (optional, in-gallery) + +- Toggle on `MasterSetGallery` header (and optionally reflect density on stack subtitle only if it does not clutter). +- **Default:** compact **off** (current comfortable grid) so existing muscle memory stays. +- **v1 preference scope (pinned):** **session-only** UI state, default off. Reload / new session returns to non-compact. **Do not** persist on `masterSets[key]` or settings in v1. +- **Later phase (separate):** optional persist of preference via store method — out of v1 acceptance. +- Compact layout still renders **one cell per canonical card record** from `fetchSetCards` / `cards[]`, never a synthetic index range. +- Compact cells must still encode: owned / need / found (marks), collector number, and enough affordance to tap (mark or preview). +- **Hit targets (required despite denser visuals):** compact **cells**, density **toggle**, **mark**, and **preview** actions must each expose a usable hit area of **≥44px** in at least one dimension pair (min height/width or padded hit-box). Visual density may shrink artwork/labels; interactive targets must not drop below 44px. +- **Do not** drop hunt, ISO wantlist, add-found footer, or lightbox; they may reflow but remain reachable with ≥44px controls. +- Variants / secret rares / TG-SV style numbers appear only if present in the fetched set list (source of truth = API/cache list). + +### Ownership and identity (unchanged rules) + +- Continue matching via existing `isOwned` logic (cardId → name|number → name). +- EN vs JP: keep `lang` / `_lang: 'ja'` and `ja:` key prefix; compact mode must not merge EN/JP sets. +- Multi-game: same gallery path via `getProvider(game).getSetCards` normalization in `fetchSetCards`. + +### What compact must not do + +- Naive `for (let i = 1; i <= setSize; i++)` grids. +- Parallel “progress only” store disconnected from shelf items. +- Hide missing cards that exist in the set list. +- Break 280px width (horizontal scroll forbidden). +- Hardcode hex colors; use `src/assets/main.css` tokens. +- Touch targets < 44px for toggle, mark, preview, or compact cell activation. +- Persist compact preference in v1. + +## User-facing behavior + +- **First render behavior:** Stack and gallery behave as today; compact off until user toggles. Gallery still async-loads set list; progress header can show owned/total from existing group meta before load finishes. Session-only: compact resets on full reload. +- **Offline / slow API behavior:** Unchanged — cached set list works offline; clear error if not cached. Compact toggle does not trigger extra network. +- **Mobile behavior:** Primary motivation; 280px: compact grid may use smaller visual minmax but keep ≥44px hit areas for toggle / mark / preview / cell actions; filters and footer wrap. +- **Empty/error states:** Preserve current loading / error / no-setId messages; compact toggle disabled or hidden while loading if grid empty. + +## Likely files / systems + +- **Views/components:** `src/components/MasterSetGallery.vue` (primary), possibly `MasterSetStack.vue` (badge only), `src/views/PortfolioView.vue` (pass-through props if needed). +- **Stores / IndexedDB:** **none for v1 preference** (session-only). Later persist phase may use existing `showcaseMasterSet` / `persist` in `src/stores/portfolio.js`. **No** direct IndexedDB from components. +- **Services / APIs:** reuse `fetchSetCards` / `sortByNumber` in `src/utils/masterSets.js`; no new endpoints. +- **Routes / meta:** none. +- **Harness evals / smoke tests:** `scripts/test_master_sets.mjs` if present patterns fit; browser smoke if shelf UI flow changes; manual gallery checks for EN/JP/secret numbers and 44px targets. + +### Integration symbols + +- `isOwned`, `visibleCards`, `marks`, `toggle-mark`, `add-found`, `isoTheRest` +- `msCanonicalKey`, `masterSetGroups`, `addFoundCards`, `msGalleryLoaded` +- `fetchSetCards`, `sortByNumber` +- `toggleHuntMark`, `clearHuntMarks`, `addItem` + +## Data and compatibility impact + +- **Existing shelf data affected?** No item schema change. **v1 writes no new fields.** +- **Backup/import affected?** No in v1 (session-only). +- **Snapshot/history affected?** No (shelf value snapshots unrelated). +- **Migration needed?** No. + +## API impact + +- **Third-party APIs called:** None new; same set-card fetches as gallery open today. +- **Caching / TTL:** Existing Browse/pokemon/provider caches. +- **Concurrency limits:** Unchanged pagination in `fetchSetCards`. +- **Timeout / retry behavior:** Unchanged gallery error handling. +- **Fallback behavior:** Offline message unchanged. + +## Phased dependencies + +1. **Phase A (v1):** Compact CSS/layout + toggle in `MasterSetGallery`; **session-only**, default off; ≥44px hit areas; preserve all actions. +2. **Phase B (later, separate):** Persist preference on showcase meta via store method (no component IDB writes) — **not** part of v1 acceptance. +3. **Phase C:** Hardening — 280px QA, reduced-motion, focus-visible on toggle. + +Independent of trade history and shelf-link polish. Benefits from existing master-set key migration already in `PortfolioView`. + +## Acceptance criteria + +- [ ] Optional compact mode available inside master set gallery (**default off**). +- [ ] **v1 preference is session-only** (reload returns to non-compact); no persisted compact flag required. +- [ ] Compact mode lists **canonical fetched cards only** — no `1..setSize` placeholders. +- [ ] Owned matching still uses cardId / name|number / name; no parallel ownership store. +- [ ] EN and JP sets remain distinct; JP `_lang` / key prefix preserved. +- [ ] Alphanumeric, secret, and variant numbers that appear in the set list remain visible and sortable as today. +- [ ] Hunt marks, ISO wantlist, add-found, and preview/lightbox still work in compact mode. +- [ ] **Compact cells, toggle, mark, and preview actions each provide ≥44px hit areas** despite denser visuals. +- [ ] Offline cached set lists still open; uncached offline still errors clearly. +- [ ] No direct Dexie/component writes for this feature in v1. +- [ ] Tactile tokens only; usable at 280px without horizontal scroll. +- [ ] Unshowcase / completion / stack progress behavior unchanged. +- [ ] Existing shelf items and backups remain valid with no new required fields. + +## Verification plan + +- [ ] `npm run eval:harness` +- [ ] `npm run eval:danger` +- [ ] `npm run build` +- [ ] `npm run smoke:browser` if shelf UI flow/layout materially changes +- [ ] Manual/browser steps: + 1. Showcase an EN Pokémon set with secrets/TG-style numbers; open gallery; toggle compact; counts match non-compact. + 2. JP set: owned marks correct; no EN bleed. + 3. Mark found → add to shelf → marks clear; items appear; compact still consistent. + 4. ISO the rest → wantlist entries created. + 5. Go offline after one successful load → reopen gallery (cached). + 6. 280px width: toggle + grid usable; no horizontal page scroll; measure ≥44px on toggle/mark/preview/cell hit areas. + 7. Reload: compact resets to off (session-only). + +## Risk + +- Over-dense cells becoming untappable on small phones — mitigate with mandatory ≥44px hit boxes. +- CSS regressions on complete-set gold treatment / filters. +- Accidentally persisting compact in v1 without store `persist` — out of scope; keep session-only. +- Mistaken “fake grid” shortcuts for sets with null totals (YGO/Lorcast) — must always wait for fetched list. + +## Rollback / safety notes + +- Remove toggle + compact CSS classes; no data flag to clean in v1. +- No Dexie schema rollback required. +- Safest ship: session-only compact first (this spec); persist later under a separate phase. diff --git a/docs/harness/specs/meta-deck-resolver-regression.md b/docs/harness/specs/meta-deck-resolver-regression.md index e4c24a4..c6336f1 100644 --- a/docs/harness/specs/meta-deck-resolver-regression.md +++ b/docs/harness/specs/meta-deck-resolver-regression.md @@ -1,28 +1,42 @@ -# Meta Deck Resolver Index Regression - -## Verified Problem -`resolve_cards_parallel` filters failed resolutions (`results = [r for r in ordered if r]`), -shortening the returned list. The caller in `build_pokemon_meta_decks` then uses -`enumerate(resolved_all)` against the original `card_deck_map` (which has one entry -per input card, including failures). This causes resolved cards to be appended to -the wrong deck after any failure in the batch. - -Evidence: code inspection of `resolve_cards_parallel` and its caller showed that -failed resolutions shortened the result list while `card_deck_map` retained every -input. `scripts/evals/meta-deck-resolver.py` covers a failure between two successes. - -## Scope -- Narrow fix in build_meta_decks.py only (no store, db, or runtime app paths) -- Add a deterministic regression eval to the standard harness -- No change to generated assets or network behavior - -## Non-goals -- Regenerating meta-deck JSONs -- Changes for other games' resolvers -- UI or provenance label changes - -## Risk -P2 pure logic; covered by regression; restricted paths untouched. - -## Rollback -Close PR before merge; or `git revert ` after. +# CLOSED: Meta Deck Resolver Index Regression + +**Status: CLOSED** — fix and harness coverage are already in the tree. This file is a durable regression record, not an open feature spec. + +## Problem (historical) + +`resolve_cards_parallel` previously filtered failed resolutions out of the returned list (e.g. dropping falsy entries). That shortened the result relative to the input. The Pokémon caller then zipped resolved cards against `card_deck_map` by index, so any failure shifted later cards onto the wrong deck. + +## Current fix (verified live code) + +| Piece | Behavior | Evidence | +|-------|----------|----------| +| `resolve_cards_parallel` | Returns a **positional list** the same length as the input; failures stay as `None` at their index | `scripts/build_meta_decks.py` — `ordered = [None] * len(tasks)`; `results = ordered` | +| Caller `scrape_pokemon` | Enumerates `resolved_all` and **skips `None` by index** before appending to `deck_cards[deck_idx]` | same file — `if resolved is None: continue` | +| Harness eval | Asserts length alignment and that a middle failure remains `None` between two successes | `scripts/evals/meta-deck-resolver.py` | +| Harness wiring | Eval is already listed in the standard harness runner | `scripts/evals/run-all.mjs` — `python3 scripts/evals/meta-deck-resolver.py` | + +No open work remains for this regression. Do not re-open as a feature unless a new misalignment is reproduced. + +## Scope of the closed change + +- Narrow logic in `scripts/build_meta_decks.py` only (build-time meta decks; not app store/db/runtime paths). +- Deterministic regression eval under `scripts/evals/`. +- No change required to generated assets solely because of this record. + +## Non-goals (still) + +- Regenerating meta-deck JSONs as part of “closing” this note. +- Changes for other games’ resolvers beyond the shared `resolve_cards_parallel` contract. +- UI or provenance label changes. + +## Risk / rollback + +- P2 pure build-script logic; covered by regression eval. +- If a future edit reintroduces filtering of `None`, `scripts/evals/meta-deck-resolver.py` must fail in `npm run eval:harness`. +- Rollback of the fix would be a normal code revert of the resolver/caller; this doc stays as the failure mode description. + +## Acceptance (already met) + +- [x] Failed resolutions retain index positions (`None`). +- [x] `scrape_pokemon` skips `None` without shifting deck membership. +- [x] `scripts/evals/meta-deck-resolver.py` is wired into `run-all.mjs`. diff --git a/docs/harness/specs/pokemon-standard-legality.md b/docs/harness/specs/pokemon-standard-legality.md new file mode 100644 index 0000000..ce8a17d --- /dev/null +++ b/docs/harness/specs/pokemon-standard-legality.md @@ -0,0 +1,288 @@ +# Feature Spec: Pokémon Standard Legality (Deck Builder) + +## Goal + +Ship a **first-phase, deck-builder-only** Pokémon **Standard card-eligibility advisory**: given a deck’s cards, show legal / illegal / **unknown** using **versioned, dated, offline** legality data (regulation marks, explicit exceptions/bans), with clear stale/source copy — without runtime scraping and without global collection badges. + +User-facing outcome: in the Pokémon deck builder, the collector sees **per-card Standard eligibility** under the bundled ruleset date (mark / ban / exception), plus when data is missing or stale. The UI must **not** claim the whole deck is tournament-legal. + +## Non-goals + +- Other games’ formats (MTG, Lorcana, etc.) in this phase. +- Global shelf/collection “Standard legal” badges on Portfolio/Search/Browse. +- Expanded, Unlimited, GLC, or multi-format toggles beyond a single Standard ruleset (unless trivially stubbed off). +- Runtime scraping of Limitless, Pokémon.com, or other sites for live banlists. +- Auto-fixing illegal decks or blocking export. +- **Complete tournament deck validation** (v1 is **card eligibility advisory only**). +- **Deck construction rules as v1 goals:** 60-card deck size, 4-copy limits, ACE SPEC limits, Radiant Pokémon limits, Basic Energy exceptions, prize-card rules, or “this list is tournament-legal” certification — **explicit follow-up / non-goals**. +- Guaranteeing tournament compliance as legal advice (copy must stay informational). +- Changing meta-deck generation pipelines except where offline data is produced offline in CI/scripts. +- Japanese-only regulation systems as a separate product track in phase 1 (document if data lacks JP marks). +- **v1 requirement to widen live pokemontcg.io `select=` lists** for `regulationMark` (optional later; offline map is the v1 path). + +## Verified current facts (live code) + +| Fact | Evidence | +|------|----------| +| Deck builder route `/decks/:id` → `DeckBuilderView.vue` | `src/router/index.js` | +| Decks Pinia store; persistence `localStorage` key `rarebox_decks` | `src/stores/decks.js` | +| Deck card rows: `cardId`, `name`, `setName`, `setCode`, `number`, `quantity`, `price`, `image`, `game` | `addCardToDeck` / `addCardRaw` | +| Ownership stats vs shelves via `cardId` counts | `getDeckStats` | +| **No** legality, regulation mark, banlist, or Standard check in app code | repo search (no `regulationMark` / legality modules) | +| Meta decks labeled “Standard format” in static data only — not enforced | `src/data/metaDecks.js` header comment | +| EN Pokémon API selects often omit regulation fields: `select=id,name,number,set,supertype,rarity,tcgplayer,images` | `pokemonApi.js`, `multiSearch.js`, `cardPreloader.js`, `searchIntel.js` | +| Normalized multi-TCG search shape: `{ id, name, number, set, image, price, rarity, game }` — **no regulationMark** | AGENTS.md + preloader maps | +| JP cards set `_lang: 'ja'` in pokemon/JP paths | `pokemonApi.js` / `masterSets.js` | +| Backup includes decks from `rarebox_decks` | `backup.js` | +| Price/meta assets are often **prebuilt static** files (pattern to mirror for legality) | e.g. en prices, meta decks build scripts | +| Deck not-found empty state already exists | `DeckBuilderView.vue` | + +### Normalization impact (v1 decision) + +**Verified gap:** live fetches and cache normalizers do not request or retain `regulationMark`. + +**v1 decision (pinned):** ship an offline map **`cardId → regulationMark`** (plus ban/exception lists) inside the versioned legality pack so **existing deck rows resolve without widening runtime API selects**. Live `select=` expansion and storing marks on deck cards become **optional later** phases, not v1 blockers. + +Implications: + +- `cardPreloader` / `multiSearch` select lists: **unchanged in v1** +- Existing decks in `rarebox_decks` without stored marks: resolve via offline map by `cardId` (or fallback identity); else **unknown** +- Backup size: no required per-card mark fields in v1 + +Do **not** assume docs alone; probe one live card payload and one cached card during implementation if a later phase widens selects. + +## Planned behavior + +### Scope: deck-builder-only, Pokémon Standard, card eligibility advisory + +- When `deck.game === 'pokemon'`, show a Standard **card eligibility** summary in the builder (pill/banner + optional per-card indicators). +- Summary language must describe **card eligibility under bundled Standard mark/ban/exception data**, not “this deck is tournament-legal.” +- Other games: no legality UI in this phase. +- Shelf views: no badges. +- Quantity on a row may be displayed but **v1 does not enforce** copy-count, ACE SPEC, Radiant, or deck-size rules. + +### Versioned dated offline data — frozen v1 schema + +Bundle a static artifact (e.g. `public/data/pokemon-standard-legality.json` or under `src/data/`) produced by a **build script**, not scraped at runtime in the browser. + +#### Root object (required fields) + +| Field | Type | Notes | +|-------|------|--------| +| `format` | `string` | Exactly `"standard"` | +| `version` | `string` | Semver or monotonic id (e.g. `"2026.07.01"` or `"1.0.0"`) | +| `asOf` | `string` | ISO date `YYYY-MM-DD` (ruleset effective / publish date for UI) | +| `source` | `string` | Short human-readable attribution | +| `staleAfterDays` | `number` | **Exactly `60`** for v1 consumers (app may hardcode 60; field documents intent) | +| `regulationMarksAllowed` | `string[]` | Marks legal in Standard (e.g. `["F","G","H"]` — illustrative only) | +| `marksByCardId` | `Record` | **Primary v1 resolution map:** canonical pokemontcg **card id** → single regulation mark character/string | +| `bans` | `BanExceptionEntry[]` | Explicitly illegal identities | +| `exceptions` | `BanExceptionEntry[]` | Legal despite mark rules (or other mark exceptions); v1 does not encode quantity exceptions as deck-construction enforcement | +| `notes` | `string` (optional) | UI “What’s in this ruleset?” | + +#### Ban / exception entry identity (pinned) + +```ts +// BanExceptionEntry +{ + // Preferred: canonical pokemontcg.io card id + cardId?: string, // e.g. "sv3pt5-199" + + // Fallback when cardId missing or deck row lacks cardId: + setId?: string, // pokemontcg set id, e.g. "sv3pt5" + number?: string, // normalized collector number (see below) + + name?: string, // optional display / secondary disambiguation only + reason?: string // short UI reason +} +``` + +**Identity match order (pinned):** + +1. **Primary:** exact `cardId` (canonical pokemontcg card id) against deck row `cardId`. +2. **Fallback (only when step 1 does not hit):** compare bundle entry `setId` to deck row **`setCode`**, then compare **normalized collector number** (bundle `number` vs deck row `number`). +3. Else no ban/exception hit for that entry. + +**Normalized number (pinned):** trim; uppercase; strip leading zeros from the numeric run where safe for collector numbers (document helper in evaluator); compare case-insensitively. Prefer storing already-normalized numbers in the bundle. + +**Canonical card id format:** pokemontcg.io style `{setId}-{number}` as returned by the API (e.g. `sv4-12`). Do not invent a parallel id scheme in v1. + +**Bans vs exceptions (pinned):** **bans win over exceptions.** If the same card identity matches both a `bans` entry and an `exceptions` entry, classify **illegal**. Bundle producer (build script) **and** harness eval **must reject** (fail) packs that list the **same identity** in both `bans` and `exceptions` when that identity is detectable (same `cardId`, or same `setId` + normalized `number`). Runtime still applies bans-win if a bad pack ever ships. + +#### Fixture schema example + +```json +{ + "format": "standard", + "version": "1.0.0", + "asOf": "2026-06-01", + "source": "Bundled offline Standard pack (illustrative fixture)", + "staleAfterDays": 60, + "regulationMarksAllowed": ["G", "H", "I"], + "marksByCardId": { + "sv4-1": "H", + "sv3pt5-199": "G", + "swsh12-1": "F" + }, + "bans": [ + { + "cardId": "xyp-XY179", + "reason": "Banned in Standard (fixture)" + } + ], + "exceptions": [ + { + "setId": "sv4", + "number": "1", + "reason": "Explicit exception example (fixture)" + } + ], + "notes": "v1 advisory data: regulation marks, bans, exceptions only. Not full deck construction validation." +} +``` + +App loads JSON from same-origin static hosting (cacheable, offline once SW/cache allows — follow existing static data patterns). **No** runtime HTML scrape. + +### Mark resolution strategy (pinned) + +For each Pokémon deck card row, resolve regulation mark as follows: + +1. Look up `marksByCardId[cardId]` when `cardId` present. +2. If no mark and ban/exception identity needs mark only via map miss → treat mark as missing. +3. **Do not** require live API `regulationMark` in v1. +4. **v1 does not** read live/cache `regulationMark` on the deck row. (A later phase may add that; out of scope for v1 acceptance.) + +Then classify (**order pinned** — apply ban/exception before mark-only legal): + +| State | Meaning (v1) | +|-------|----------------| +| **Illegal** | Identity matches **`bans`** (**bans win** even if also in `exceptions`); **or** mark present, **not** in `regulationMarksAllowed`, and **not** solely excepted | +| **Legal** | Not banned; and (mark present and ∈ `regulationMarksAllowed`, **or** identity matches **`exceptions`** for eligibility) | +| **Unknown** | Bundle loaded but no mark in offline map and no ban/exception identity hit that settles the card; **or** bundle **missing / failed to load** (see missing-bundle copy) | + +Deck **summary rollup (advisory only):** + +- If any card **illegal** → summary emphasizes illegal count. +- Else if any card **unknown** → summary emphasizes unknown count. +- Else all resolved cards **legal** under mark/ban/exception rules. + +**Copy constraint:** never label the deck “tournament legal” / “event ready.” Prefer “All listed cards look Standard-eligible (marks/bans)” vs “N cards not eligible” vs “N cards unknown.” + +### Stale / source / missing-bundle copy + +- Always show **as-of date** (`asOf`) and **source** near the summary when a bundle is loaded (“Rules as of YYYY-MM-DD · bundled data, not live tournament feed”). +- **Stale definition (pinned, calendar days, UTC date-only):** parse `asOf` as a UTC calendar date (`YYYY-MM-DD`). Let `todayUtc` be the UTC calendar date at evaluation time. Let `ageDays` = whole calendar days from `asOf` to `todayUtc` (non-negative integer day difference on the UTC date grid). + - `ageDays < 60` → **not stale** (e.g. **59** → not stale). + - `ageDays >= 60` → **stale** (e.g. **60** → stale; 61+ → stale). + - Equivalent: stale iff `todayUtc >= asOf + 60 calendar days` (UTC). +- **When stale (bundle present):** show **stale/warning** styling with Tactile tokens (not red panic hardcode); **still evaluate** with the loaded bundle rules; mark/ban/exception results stand; disclaimer that data may be outdated. +- **When bundle missing or failed to load (pinned):** do **not** claim to run “last-known” or any nonexistent prior ruleset. Show **error/unknown** state: **every card is unknown**, plus a clear error line that legality data could not load. No fabricated eligibility. +- Informational disclaimer: not official ruling advice; not full deck-construction validation. + +### UX constraints + +- Tactile tokens only; ≥44px targets for any legality filter toggles; 280px wrap. +- Do not block adding cards when illegal; legality is advisory. +- Prefer calm summary bar; one sticker max only if celebrating fully eligible cards — still without “tournament legal” wording. + +## User-facing behavior + +- **First render behavior:** Builder renders deck immediately; eligibility evaluates synchronously from bundled JSON + deck cards (or after tiny async import of JSON). Do not block on network card DB preload. +- **Offline / slow API behavior:** Eligibility works fully offline if bundle is present in the app build. Card search may still need network; legality does not. +- **Mobile behavior:** Summary bar stacks; per-card badges can hide detail text on narrow widths but keep color/dot + title tooltip/aria. +- **Empty/error states:** Empty deck → neutral “Add cards to check Standard eligibility”; failed/missing JSON → **unknown + error line only** (no last-known rules claim). + +## Likely files / systems + +- **Views/components:** `src/views/DeckBuilderView.vue`; optional `StandardLegalityBar.vue`. +- **Stores / IndexedDB:** `src/stores/decks.js` unchanged required shape in v1; no Dexie requirement (decks are localStorage). **No** component-direct storage writes beyond existing store `persist`. +- **Services / data:** new pure evaluator module; static JSON asset; offline builder script under `scripts/`. +- **Normalization touchpoints:** **v1 does not require** changes to `pokemonApi.js` / `multiSearch.js` / `cardPreloader.js` selects. Optional later phase only. +- **Routes / meta:** optional meta description tweak for Deck Builder only. +- **Harness evals / smoke tests:** new eval with fixture pack (legal / banned / unknown / stale boundary at 60 days); `npm run smoke:browser` if builder chrome changes. + +### Integration symbols + +- `useDeckStore`, `deck.cards`, `getDeckStats` (ownership separate from eligibility) +- `importMetaDeck` — imported meta decks should evaluate after resolve +- Backup `data.decks` round-trip remains compatible without new required fields + +## Data and compatibility impact + +- **Existing shelf data affected?** No (phase 1). +- **Backup/import affected?** No required deck field changes in v1. +- **Snapshot/history affected?** No. +- **Migration needed?** Soft defaults only; no wipe. + +## API impact + +- **Third-party APIs called:** None at evaluation time in v1. +- **Caching / TTL:** Static legality JSON = app-versioned; stale UI when **`ageDays >= 60`** from `asOf` (59 not stale, 60 stale). +- **Concurrency limits:** Unchanged search stack. +- **Timeout / retry behavior:** N/A for static JSON (build-time). +- **Fallback behavior:** Missing mark in offline map → **unknown**, never silent legal. Missing/failed bundle → unknown + error (no last-known rules). +- **Optional later:** extend pokemontcg.io select to include `regulationMark` with existing cache/concurrency/timeouts — not v1. + +## Phased dependencies + +1. **Phase 0 (this spec):** Frozen offline schema, identity rules, 60-day stale threshold, deck-only advisory UI, non-goals for construction rules. +2. **Phase 1a:** Bundle + pure evaluator + deck summary UI with source/as-of/stale copy + offline `marksByCardId`. +3. **Phase 1b (optional later):** Live select / cache normalization for `regulationMark`; optional persist on deck rows. +4. **Phase 2 (later specs):** Deck construction rules (size, copies, ACE SPEC, Radiant), Expanded formats, shelf badges, JP-specific rules, CI auto-refresh — **out of scope now**. + +Independent of trade history, master-set compact, and shelf-link polish. May share “static dated asset” operational patterns with price/meta builders. + +## Acceptance criteria + +- [ ] Pokémon deck builder shows Standard **card-eligibility** summary using bundled versioned dated data. +- [ ] Per-card or summary breakdown distinguishes legal / illegal / unknown per v1 mark/ban/exception rules. +- [ ] Offline `marksByCardId` resolves existing deck rows without widening runtime API selects. +- [ ] Bans/exceptions match canonical `cardId` first; fallback compares bundle `setId` → deck row `setCode`, then normalized collector number. +- [ ] **Bans win over exceptions** on the same identity; build/eval should reject conflicting ban+exception identities when practical. +- [ ] Stale warning: **`ageDays >= 60`** from `asOf` is stale; **59 not stale, 60 stale**. +- [ ] Missing/failed bundle → unknown + error; **no** claim to run last-known/nonexistent rules. +- [ ] No runtime scraping for banlists/marks. +- [ ] Source + as-of (and stale warning when appropriate) visible in UI when bundle loaded. +- [ ] UI copy does **not** claim the whole deck is tournament-legal or fully construction-valid. +- [ ] Deck-size / copy-count / ACE SPEC / Radiant rules are **not** enforced or implied as complete in v1. +- [ ] Other games unchanged; no shelf-wide legality badges. +- [ ] Existing decks without marks do not crash; yield unknown where map misses. +- [ ] Backup/restore of decks remains compatible. +- [ ] Evaluation does not require card database preload to finish. +- [ ] Tactile tokens; 44px targets; 280px usable. +- [ ] Fixture/eval coverage includes schema example fields (`marksByCardId`, bans, exceptions, `asOf`, stale boundary). + +## Verification plan + +- [ ] `npm run eval:harness` (add legality fixture eval when implemented) +- [ ] `npm run eval:danger` +- [ ] `npm run build` (ensures static asset is included) +- [ ] `npm run smoke:browser` if deck builder chrome/routes touched +- [ ] Manual/browser steps: + 1. Open Pokémon deck → summary shows as-of/source; no “tournament legal” claim. + 2. Fixture banned `cardId` → illegal. + 3. Card with missing map entry → unknown. + 4. Fully mapped allowed marks, no bans → all cards eligible (advisory wording). + 5. Airplane mode with bundled app → still evaluates. + 6. MTG/other game deck → no Standard bar. + 7. Import old backup decks → no data loss; unknown OK. + 8. Bundle with `asOf` age **60** days → stale; **59** days → not stale; **61+** → stale. + 9. Missing/corrupt bundle → unknown + error; no last-known rules wording. + 10. Same identity in bans and exceptions (if fixture forces it) → illegal (bans win). + 11. Fallback identity: ban/exception with only `setId`+`number` matches deck row `setCode`+normalized `number`. + 12. 280px layout check. + +## Risk + +- **Wrong eligibility** harming tournament prep — mitigate with source/date/disclaimer, unknown default, and no full-deck “legal” claim. +- **Users mistaking advisory for full construction validation** — mitigate with explicit non-goal copy. +- **Stale bundled data** after rotation — mitigate 60-day banner + easy rebuild script. +- **Cache/select churn** if a later phase widens selects — keep offline map as default path. +- **Confusing ownership “Need” badges with eligibility badges** — distinct labels/colors via tokens. + +## Rollback / safety notes + +- Remove UI bar + import of evaluator; leave static JSON unused or delete asset. +- No required deck field migration to roll back. +- Revert optional select-list expansions independently if added later. +- No shelf data migration to roll back. diff --git a/docs/harness/specs/shelf-link-polish.md b/docs/harness/specs/shelf-link-polish.md new file mode 100644 index 0000000..355d1d3 --- /dev/null +++ b/docs/harness/specs/shelf-link-polish.md @@ -0,0 +1,157 @@ +# Feature Spec: Shelf Link Polish + +## Goal + +Polish **same-device shelf links** for the existing shelf routes so collectors can copy a link to a shelf, open `/shelf/:id` or `/portfolio/:id` predictably, and see a clear **not-found** state when the id is missing on **this** device — with explicit privacy copy that Rarebox does **not** host shelf data for strangers. + +User-facing outcome: from a shelf, “Copy link” puts a same-origin URL on the clipboard; opening that URL on the same browser profile shows the shelf; opening it where the shelf id does not exist shows a calm not-found empty state (not a blank page or a silent dashboard redirect for that id). + +## Non-goals + +- **True cross-device sharing** of shelf contents via URL alone (no server-side shelf payload). +- Replacing Local Sync, file backup/export, or booth share QR as the multi-device transfer story. +- Public shelf pages, accounts, or short-links for collections. +- Changing Dexie schema or writing shelf data from the router. +- Renaming code identifiers (`portfolio`, `usePortfolioStore`) or removing `/portfolio/:id`. +- Auto-import of shelf JSON embedded in query/hash (future payload format would be a separate spec). + +## Verified current facts (live code) + +| Fact | Evidence | +|------|----------| +| Canonical shelf route: `/portfolio/:id` → `PortfolioView`, `meta.title: 'Shelf'` | `src/router/index.js` | +| Friendly alias: `/shelf/:id` **redirects** to `/portfolio/:id` | same file | +| Unknown paths `/:pathMatch(.*)*` redirect to `/` (dashboard) | router — **not** `NotFoundView` | +| `NotFoundView.vue` exists (404 sticker + Back to Dashboard) but is **not** wired as the catch-all | `src/views/NotFoundView.vue` vs router | +| `PortfolioView` root is `v-if="portfolio"` with **no `v-else`** | template ends after portfolio-only tree → **blank main area** if id missing | +| Shelf resolved by `store.portfolios.find(p => p.id === route.params.id)` | `PortfolioView.vue` | +| Portfolio store exposes `initialized` | `src/stores/portfolio.js` | +| Navigation to shelves uses `/portfolio/${id}` in App/Dashboard/Booth | e.g. `App.vue`, `DashboardView.vue` | +| Booth has rich share/copy-link patterns (`navigator.clipboard`, Tactile rows) | `BoothShareModal.vue` — **pattern reference**, not shelf data share | +| Local Sync / backup move full collection between devices | `LocalSyncModal.vue`, `backup.js` | +| Privacy posture: local-first, no account required | product mission / Terms | +| No “Copy shelf link” control on `PortfolioView` today | header actions: Add Card, Sealed, Master Set, Import, Prices, Export, Delete | + +## Planned behavior + +### Copy same-device shelf link + +- Add a secondary action on the shelf header (desktop + mobile menu): **Copy link**. +- **Clipboard path (pinned):** copy a same-origin URL whose path is **`/shelf/`** (friendly user-facing form). The existing redirect keeps `/portfolio/` working when opened. +- Success affordance: brief “Copied” on the button (mirror booth copy UX). +- Fallback if `clipboard` unavailable: select-in-input or `document.execCommand` only if already used elsewhere; otherwise show the URL in a small read-only field. + +### Not-found state + +- **Gate on `store.initialized`:** do not evaluate missing-id / not-found until the portfolio store has finished `init` (`initialized === true`). While not initialized, show the existing loading/shell behavior (or a neutral wait) — **never** flash “Shelf not found” then the real shelf on cold load. +- When initialized and `route.params.id` does not match any shelf: + - Show explicit empty state: title like “Shelf not found”, short explanation, button back to Dashboard / shelves list. + - Do **not** leave a blank `v-if` hole. + - Do **not** create a shelf implicitly from the URL. +- Optional: reuse styling patterns from `NotFoundView` / deck builder’s “Deck not found” (`DeckBuilderView.vue`) for consistency. +- Router catch-all behavior for *unknown paths* may stay dashboard redirect; this spec is about **valid shelf route shape, unknown id**. + +### Privacy / local-data limitation (explicit copy) + +Near the copy control or in a one-line helper: + +- Link only opens this shelf **on a device/browser that already has this Rarebox data**. +- It does **not** upload the shelf or let another phone see cards by URL alone. +- For another device: use **Export**, **Local Sync**, or a **future** share payload — not this link. + +User-facing words: **shelf** (not “portfolio”), even though routes/code say `portfolio`. + +### Architecture constraints + +- Components call store getters/actions only; **no** direct Dexie reads/writes in the view. +- No new persistence required for the feature itself. +- Do not embed item payloads in the URL in this phase. + +## User-facing behavior + +- **First render behavior:** Wait for `store.initialized` before showing shelf content or not-found. After init, show shelf or not-found — no cold-load not-found flash. +- **Offline / slow API behavior:** Fully local; copy link works offline. Price refresh remains separate. +- **Mobile behavior:** Copy action in header overflow on small screens if needed; control ≥44px; 280px layout without horizontal scroll; Tactile tokens only. +- **Empty/error states:** Shelf not found (only after init); clipboard permission denied message. + +## Likely files / systems + +- **Views/components:** `src/views/PortfolioView.vue` (copy + not-found + init gate); optionally share small empty-state markup with `NotFoundView.vue` patterns. +- **Stores / IndexedDB:** read-only `usePortfolioStore().portfolios` / `initialized` / existing init — no new keys. +- **Services / APIs:** none. +- **Routes / meta:** keep `/portfolio/:id` and `/shelf/:id` redirect; optionally improve `meta.description` only if copy changes SEO text. +- **Harness evals / smoke tests:** `scripts/evals/route-safety.mjs` if it asserts shelf routes; `npm run smoke:browser` for `/shelf/:id` and missing id. + +### Integration symbols + +- `portfolio` computed from `route.params.id` +- `store.portfolios`, **`store.initialized`** (required gate for not-found) +- Router entries for `Portfolio` and `/shelf/:id` redirect +- Clipboard pattern from booth share modals (UX only) + +## Data and compatibility impact + +- **Existing shelf data affected?** No. +- **Backup/import affected?** No. +- **Snapshot/history affected?** No. +- **Migration needed?** No. + +## API impact + +- **Third-party APIs called:** None. +- **Caching / TTL:** N/A. +- **Concurrency limits:** N/A. +- **Timeout / retry behavior:** N/A. +- **Fallback behavior:** Clipboard failure → visible URL / error text. + +## Phased dependencies + +1. **Phase A:** Not-found `v-else` (or equivalent) on `PortfolioView`, **gated on `store.initialized`** — highest UX fix. +2. **Phase B:** Copy link control copying **`/shelf/`** + privacy helper line. +3. **Phase C:** Smoke/route eval coverage for `/shelf/:id` redirect and missing id after init. + +**Non-goal path (future spec):** cross-device shelf payload (export subset, QR, or signed blob) — must not be implied by this polish. + +No dependency on trade history or legality. + +## Acceptance criteria + +- [ ] User can copy a same-origin shelf link from the shelf UI. +- [ ] Copied path is **`/shelf/`** (same-origin absolute or origin-absolute URL with that path). +- [ ] `/shelf/:id` continues to resolve to the same shelf as `/portfolio/:id` when data exists. +- [ ] Unknown shelf id shows an explicit not-found state (not blank, not a fake shelf). +- [ ] **Not-found is shown only after `store.initialized` is true** — no cold-load flash of not-found before shelves load. +- [ ] Privacy/local-data limitation is visible near copy or in helper copy. +- [ ] True cross-device content sharing is not claimed; export/Local Sync pointed to instead. +- [ ] No direct Dexie or external Pinia bulk mutation from the new UI. +- [ ] User-facing copy says “shelf,” not “portfolio.” +- [ ] Tactile tokens; ≥44px targets; usable at 280px. +- [ ] Existing export/delete/master-set actions remain intact. + +## Verification plan + +- [ ] `npm run eval:harness` +- [ ] `npm run eval:danger` +- [ ] `npm run build` +- [ ] `npm run smoke:browser` (route + shelf UI) +- [ ] Manual/browser steps: + 1. Open a real shelf → Copy link → paste shows `/shelf/` with correct id. + 2. Open copied link in same profile → same shelf. + 3. Hard-reload `/shelf/` and `/portfolio/`: never flash not-found before content. + 4. Open `/shelf/not-a-real-id` after load settles → not-found UI + path home. + 5. Open `/portfolio/not-a-real-id` → same not-found. + 6. Confirm `/shelf/` redirects/shows content equivalently. + 7. Mobile 280px: copy control reachable; privacy line readable. + 8. Clipboard blocked (if simulable): graceful fallback. + +## Risk + +- Users assuming the link works like booth share (public payload) — mitigate with explicit privacy copy. +- Flashing not-found before store init — **required** gate on `store.initialized`. +- Putting copy only behind desktop header and omitting mobile menu. + +## Rollback / safety notes + +- Remove copy button and not-found block; restore prior `v-if="portfolio"` only if necessary (blank missing-id is worse — prefer keeping not-found even if copy reverts). +- No data migration to reverse. +- Does not affect backup compatibility. diff --git a/docs/harness/specs/trade-history.md b/docs/harness/specs/trade-history.md new file mode 100644 index 0000000..a4bfc82 --- /dev/null +++ b/docs/harness/specs/trade-history.md @@ -0,0 +1,229 @@ +# Feature Spec: Trade History + +## Goal + +Let collectors **complete/log** a Trade Analyzer session into a **bounded, immutable history** of past trades, then browse that history as a list — without losing the in-progress trade draft, without a second Dexie database, and without breaking backup/restore/Local Sync/reset. + +User-facing outcome: after a fair (or intentionally uneven) trade is finished, the user can confirm **Log trade**, the sides clear for the next deal, and a durable history entry remains available on-device. + +## Non-goals + +- Trade value **charts** / time-series graphs of history (follow-up only; list first). +- Server sync, accounts, or multi-device live trade rooms. +- Mutating shelf inventory automatically when a trade is logged (no auto remove/add of shelf items). +- A second Dexie DB, second Dexie table, or parallel Pinia store outside `useTradeStore`. +- Replacing the existing in-progress draft (`sideA` / `sideB`) persistence model. +- Cross-device “share this trade” deep links (use export / Local Sync if needed). +- Booth table-mode journal (`boothJournal`) integration — separate product surface. +- Naming the store action `logTrade` (that name collides with booth table UI; see Planned behavior). + +## Verified current facts (live code) + +These are **as implemented today**, not planned behavior: + +| Fact | Evidence | +|------|----------| +| **One** Dexie database named `Rarebox` | `src/db.js` — `new Dexie('Rarebox')` | +| Schema versions on that DB: v1 `state`, v2 `prices_cache`, v3 `cards` (cards store registered from card cache module) | `src/db.js` v1/v2; `src/services/tcg/cardCache.js` `db.version(3).stores({ cards: ... })` | +| In-progress trade lives at `state` table key `trade_state` (JSON blob) | `loadTradeState` / `saveTradeState` in `src/db.js` | +| Pinia store `useTradeStore` holds only `sideA`, `sideB`, `initialized` | `src/stores/trade.js` | +| Side shape is `{ items: [], totalValue: 0 }`, not bare arrays | `trade.js` `getState` / `applyState`; `backup.js` | +| Debounced persist (3s) + `pagehide` / `visibilitychange` flush | `trade.js` — **no** exported `persistNow` today | +| Actions: `addToSide`, `updateCardGrade`, `removeFromSide`, `clearSide`, `resetTrade` — **no** complete/history | `trade.js` return | +| Booth table has a separate `logTrade` handler (journal), not trade-store history | `BoothTableView.vue` + `boothJournal` | +| UI: Trade Analyzer at `/trade/analyzer`; Reset Trade calls `resetTrade()` with **no** confirmation | `TradeAnalyzerView.vue` | +| Delta UI treats `abs(delta) < 0.01` as even / `$0.00` | `TradeAnalyzerView.vue` | +| Totals use `??` so **`$0` is a valid price**, not null-collapse | `trade.js` `calculateTotal` | +| Backup exports `data.trade` only when either side has items | `buildBackupPayload` in `src/utils/backup.js` | +| Restore always writes trade via `saveTradeState(data.data.trade \|\| empty sides)` | `restoreBackupData` | +| Settings “Reset Everything” resets trade store + writes empty `trade_state` immediately | `SettingsView.vue` `doReset` | +| Local Sync compact path calls `slimItems(trade.sideA)` / `slimItems(trade.sideB)` on **side objects**, but `slimItems` expects an **items array** | `LocalSyncModal.vue` `compactForTransfer` / `slimItems` — verified mismatch; history work **must** walk `.items` | +| No `tradeHistory` / `trade_history` key or UI exists | repo search | + +## Planned behavior + +### Complete / log action + +1. User builds sides A/B as today. +2. **User-facing copy:** **Log trade**. +3. **Code action name:** `completeTrade` only — **do not** name the trade-store action `logTrade` (avoids collision with booth `logTrade` in `BoothTableView.vue`). +4. **Confirmation** required when either side has items (disable or no-op when both sides empty). + 5. On confirm: + - If either side has **>200** items: **reject** `completeTrade` with a **visible error**; do not snapshot, append, clear, or persist. (Never silent clamp — totals/snapshots would diverge.) + - Else build an **immutable snapshot** of both sides + totals + delta + timestamp + id (deep-clone; full side item lists as confirmed, ≤200 each). + - Cancel any pending debounced persist, then call **`persistNow()`** (immediate `saveTradeState`) so **history + empty draft** are written **before** UI treats the draft as cleared. `persistNow` returns **`true`/`false`**. + - If `persistNow` returns **`false`**: **do not clear** sides; surface error; leave draft intact; do not keep a partial in-memory history row (rollback-safe). + - If `persistNow` returns **`true`**: clear live draft sides in memory to match the persisted empty draft. + 6. History is append-only from the user’s perspective; edits to past entries are out of scope (delete-one optional later; not required for v1). + +### Snapshot shape (planned) + +Store under the **same** `trade_state` value blob on the existing `state` table (extend object — **not** a new Dexie table or DB): + +```js +{ + sideA: { items: [], totalValue: 0 }, + sideB: { items: [], totalValue: 0 }, + history: [ /* newest first — required order */ ] +} +``` + +**Order (pinned):** `history[0]` is the **newest** entry; older entries follow. On overflow, drop from the **end** (oldest). + +Each history entry (immutable after write): + +| Field | Notes | +|-------|--------| +| `id` | `crypto.randomUUID()` | +| `loggedAt` | ISO timestamp | +| `sideA` / `sideB` | Deep-cloned `{ items, totalValue }` at log time | +| `priceDelta` | `sideB.totalValue - sideA.totalValue` (number; **0 and near-zero allowed**) | +| `isFairTrade` | Optional boolean using existing ≤10% rule | + +**Bounds (required, hard caps):** + +- History length: **exactly 100** entries max; drop oldest on overflow. +- Snapshot items per side: **exactly 200** items max per side at `completeTrade` time. + - **Pinned:** if either side has **more than 200** items, **`completeTrade` must reject** with a **visible error** and leave the draft unchanged. + - **Never silently clamp** the snapshot to 200 on complete — clamping would make `totalValue` / `priceDelta` diverge from the live analyzer totals the user just confirmed. + - Import/restore paths may still clamp oversized history entries to 200/side (defensive sanitize of hostile/old payloads); that path is separate from complete. +- Clamp string fields on import (name, set, grade company, etc.). +- Strip `__proto__` / `constructor` / `prototype` on import (reuse `sanitizeBackupData` patterns). +- Deep-clone on complete so later draft edits cannot mutate history. + +**Do not** invent a second Dexie store or DB name. Trade history remains inside the `trade_state` blob on the shared Rarebox DB. + +### History list (v1 UI) + +- Entry point from Trade Analyzer and/or Trade landing: **Trade history**. +- List: date, side totals, delta (with `$0.00` / even styling consistent with analyzer), item counts — **newest first**. +- Detail expand optional: card names/qty/grades from snapshot. +- Empty state: calm Tactile empty copy; no chart placeholder required. +- **Chart of history over time = non-goal / follow-up.** + +### $0 and zero difference + +- `$0` market price on a card remains **0**, not “missing.” +- Logged delta of `0` or `|delta| < 0.01` displays as even / `$0.00`, same as live analyzer. +- Never use `price || null` patterns that drop zero. + +### Confirmation and rollback + +- Confirm dialog before log (and before destructive clear-all if log is not used). +- **`persistNow` before clear** is the primary safety net. +- **`persistNow` contract (pinned):** wraps immediate `saveTradeState`; returns **`true` on success**, **`false` on failure**. Callers (`completeTrade`, Settings reset) must branch on that boolean — never treat a failed write as success. +- Failed IDB write → keep draft; no partial history row. +- User can still **Reset Trade** without logging (confirm recommended if dirty). `resetTrade` clears draft only (not history) unless Settings Reset Everything runs the full reset sequence above. +- **Debounce safety (pinned):** any path that calls `persistNow` or Settings reset must **cancel the pending debounced persist timer before** the authoritative immediate write, so a stale 3s timer cannot overwrite the just-written state. + +## User-facing behavior + +- **First render behavior:** Analyzer loads existing draft from `trade_state` as today. History list reads `history` from the same blob after store `init()`; no network. +- **Offline / slow API behavior:** Logging and history are fully local. Card search/scan for *new* trades still uses existing search/scan paths; history browse does not call APIs. +- **Mobile behavior:** Works down to **280px**; primary actions ≥ **44px** touch targets; Tactile tokens only (no hardcoded hex in new UI). One calm sticker moment max on the history screen. +- **Empty/error states:** Empty history; persist failure toast/banner; confirm cancel leaves draft unchanged. + +## Likely files / systems + +- **Views/components:** `src/views/TradeAnalyzerView.vue`, `src/views/TradeLanding.vue`; optional small `TradeHistoryList` component. +- **Stores / IndexedDB:** `src/stores/trade.js` (extend); `src/db.js` `loadTradeState` / `saveTradeState` only — **same key** `trade_state` on existing Rarebox `state` table. +- **Services / APIs:** none new for history itself. +- **Routes / meta:** optional `/trade/history` or in-page panel; if route added, set `meta.title` / description; user copy says “trade,” not portfolio. +- **Backup / sync:** `src/utils/backup.js` `buildBackupPayload` / `restoreBackupData`; `src/components/LocalSyncModal.vue` compact path; `src/views/SettingsView.vue` `doReset`. +- **Harness evals / smoke tests:** extend `scripts/evals/backup-roundtrip.mjs` (+ fixture) for `data.trade.history`; optional unit-style eval for clamp/bounds; `npm run smoke:browser` if routes/UI shell change. + +### Integration symbols (touch carefully) + +- `useTradeStore` → add `history`, **`completeTrade`**, **`persistNow`**, load path for `history`. +- `getState` / `applyState` must round-trip `history`. +- `resetTrade` vs complete: reset clears draft only; Settings reset clears draft **and** history (document in UI copy for Reset Everything). +- Backup: **must export `data.trade` when `history` is non-empty even if both draft sides are empty** (today trade is omitted when both sides empty — required compatibility change). Old backups without `history` must still restore with `history: []`. + +## Data and compatibility impact + +- **Existing shelf data affected?** No (history does not write `app_state` / shelves). +- **Backup/import affected?** Yes — `data.trade` may gain `history[]`. Old backups with only `sideA`/`sideB` must restore with `history: []`. New exports must include history-only trade blobs. +- **Snapshot/history affected?** Shelf `snapshots` in portfolio store: **No**. Trade history is separate. +- **Migration needed?** Soft only: `applyState` defaults missing `history` to `[]`. No Dexie version bump required if value remains one JSON blob under `trade_state`. + +### Import / Local Sync / reset clamps + +| Path | Requirement | +|------|-------------| +| File backup export | **Required:** include `data.trade` whenever `history.length > 0`, even if both draft sides are empty. Also include when sides have items (as today). | +| File backup import | Sanitize; clamp history to 100; clamp per-entry items to 200/side; default missing history to `[]`. | +| Local Sync send | Include history in compact payload. **Mandatory hard requirement:** slim only via `.items` arrays — `trade.sideA.items`, `trade.sideB.items`, and each `history[i].sideA.items` / `history[i].sideB.items`. **Never** call `slimItems` on side objects (`{ items, totalValue }`). | +| Local Sync receive | Same clamps as backup import via `importBackup`. | +| Settings reset | **Pinned Reset Everything sequence** (must all happen, in order): (1) **cancel any pending debounce** timer; (2) set in-memory draft sides empty and **`history = []`**; (3) **`persistNow()`** writing the **exact empty shape** `{ sideA: { items: [], totalValue: 0 }, sideB: { items: [], totalValue: 0 }, history: [] }` (always include `history: []`, never omit); (4) treat `persistNow` **`true` = success**, **`false` = failure** — surface failure and do not claim reset succeeded on `false`; (5) success path leaves no pending debounce that can resurrect pre-reset history or draft. | +| Old backups | No `history` → empty list; sides restore as today. | + +## API impact + +- **Third-party APIs called:** None for log/list. +- **Caching / TTL:** N/A for history blob. +- **Concurrency limits:** N/A. +- **Timeout / retry behavior:** IDB write failure via `persistNow` → surface error; no silent drop. +- **Fallback behavior:** Keep in-memory draft if persist fails. + +## Phased dependencies + +1. **Phase A — Store + persistence:** extend `trade_state` shape; `completeTrade` + `persistNow` with persist-before-clear; hard caps 100 / 200; newest-first; init defaults. +2. **Phase B — Backup/restore/Local Sync/reset:** history-only export; import clamps; **mandatory** Local Sync `.items` walk; reset clears history; eval coverage. +3. **Phase C — History list UI:** Tactile list + confirm on Log trade; wire analyzer CTA. +4. **Follow-up (explicit non-goal now):** history value chart. + +No dependency on shelf-link, master-set compact, or legality work. + +## Acceptance criteria + +- [ ] **Log trade** (UI) calls store action **`completeTrade`** (not `logTrade`). +- [ ] Completing a trade appends one immutable history entry (**newest first**) and clears the live draft **only after** successful **`persistNow`**. +- [ ] Failed `persistNow` leaves draft intact and does not append a partial entry. +- [ ] History is stored in existing Rarebox Dexie `state` key `trade_state` — no second DB/table. +- [ ] History length hard-capped at **100**; overflow drops oldest. +- [ ] Snapshot items hard-capped at **200 per side**: `completeTrade` **rejects** (visible error, no clear, no append) when either side has **>200** items — **never** silent clamp on complete. +- [ ] `$0` card prices and `$0.00` deltas survive complete + reload + backup round-trip. +- [ ] Old backups without `history` restore cleanly (empty history, sides as before). +- [ ] New backups export trade when **history-only** (empty draft sides, non-empty history) and restore that history on a fresh profile. +- [ ] Settings Reset Everything: in-memory `history=[]` + empty sides; cancel pending debounce; immediate write exact empty shape including `history: []`; `persistNow`/`saveTradeState` success=`true` / failure=`false`; no late debounce rewrite of pre-reset state. +- [ ] Local Sync / import paths sanitize prototypes, clamp sizes, and **must** slim via `.items` arrays on draft sides **and** each history entry’s sides (not side objects). +- [ ] Confirmation required before complete when sides are non-empty. +- [ ] History **list** is usable newest-first; no chart required. +- [ ] UI uses Tactile tokens, ≥44px targets, usable at 280px width. +- [ ] User-facing copy does not say “portfolio” for shelves (N/A on this screen except any shelf deep-links left untouched). +- [ ] No automatic shelf item mutation on complete. + +## Verification plan + +- [ ] `npm run eval:harness` (after implementation; include backup/trade history cases) +- [ ] `npm run eval:danger` +- [ ] `npm run build` +- [ ] `npm run smoke:browser` if route/app-shell/UI touched +- [ ] Manual/browser steps: + 1. Add cards both sides (include a `$0` price card); note delta. + 2. Log trade → confirm → draft empty; history shows one row (top) with same totals/delta. + 3. Reload app → history and empty draft persist. + 4. With history present and empty sides, export backup → payload includes `data.trade.history`. + 5. Import that backup on clean profile → history present. + 6. Import pre-history backup → no crash; history empty. + 7. Fill history past 100 → oldest dropped; order remains newest-first. + 8. Attempt complete with >200 items on a side → visible error; draft unchanged; no history row. + 9. Simulate IDB failure (if testable) → draft retained; `persistNow` returns false. + 10. Reset Everything → history gone; `trade_state` is exact empty shape with `history: []`; no late debounce rewrite. + 11. Mobile width 280px: list and confirm usable. + +## Risk + +- **Data loss:** clearing draft before `persistNow` (mitigate: persist-before-clear). +- **Quota:** large item snapshots bloat IDB/backups (mitigate: hard caps 100 / 200 + optional slim fields on snapshot items). +- **Backup size / Local Sync QR frame count:** history multiplies payload (mitigate: caps; optional exclude images in snapshots). +- **Local Sync slim bug** if `.items` walk is skipped (mitigate: **mandatory** correct walk while touching that path). +- **Name collision** with booth `logTrade` (mitigate: store action `completeTrade` only). +- **User expectation** that log moves cards between shelves (mitigate: copy — “logs values only”). + +## Rollback / safety notes + +- Safest revert: remove history UI + `completeTrade`; keep reading unknown `history` key ignored, or strip on read. +- Feature-flag optional but not required if change stays in trade store. +- Revert does not need Dexie migration down if blob remains JSON-compatible. +- Never ship a path that writes shelf `app_state` from trade complete without explicit future spec.