From 74b7d4433fb5a1b11908a3d12e6465a1ef8d5c67 Mon Sep 17 00:00:00 2001 From: SJCoetz Date: Wed, 29 Jul 2026 13:51:25 +0200 Subject: [PATCH] =?UTF-8?q?docs(spec):=20item=20history=20popover=20redesi?= =?UTF-8?q?gn=20=E2=80=94=20build=20both=20data=20sources,=20compare=20for?= =?UTF-8?q?=201=20week?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Redesign the per-item ⓘ popover on /sprint so it surfaces actionable history (cheapest supplier, other quotes to this company, last sold, recommendation) instead of the current decorative rainbow card. - Deterministic PB-join service (new) computes suppliers, other quotes, last sold, stock from existing collections. - Existing n8n AI blob keeps running — render side by side for comparison. - After 1 week: delete whichever data source loses on speed / freshness / cost. - Process orders section is a separate follow-up round. Also ignore .superpowers/ (visual companion mockups are local-only). Co-Authored-By: Claude Opus 4.8 Co-authored-by: Orca --- .gitignore | 7 +- ...-07-29-order-sprint-item-history-design.md | 244 ++++++++++++++++++ 2 files changed, 249 insertions(+), 2 deletions(-) create mode 100644 docs/superpowers/specs/2026-07-29-order-sprint-item-history-design.md diff --git a/.gitignore b/.gitignore index 8ecaf11..e3b16f7 100644 --- a/.gitignore +++ b/.gitignore @@ -48,5 +48,8 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts - -/.next.stale.*/ + +/.next.stale.*/ + +# Superpowers brainstorming session (local-only mockups + state) +/.superpowers/ diff --git a/docs/superpowers/specs/2026-07-29-order-sprint-item-history-design.md b/docs/superpowers/specs/2026-07-29-order-sprint-item-history-design.md new file mode 100644 index 0000000..3a27400 --- /dev/null +++ b/docs/superpowers/specs/2026-07-29-order-sprint-item-history-design.md @@ -0,0 +1,244 @@ +# Order Sprint — actionable item history popover + +**Goal:** Rebuild the per-item ⓘ popover on the /sprint page so it surfaces actionable history (cheapest supplier, other quotes to this company, last sold, recommendation) instead of the current decorative rainbow card. Build both a deterministic PB-join service and keep the existing n8n AI blob, render side by side for comparison, then keep whichever wins. + +## Scope + +### In Scope + +- New deterministic service: `src/lib/server/item-history-service.ts`. + - Exports `getItemHistory(itemId)` → returns typed `ItemHistory`. + - Pure matching helpers (SKU exact → SKU normalised → Jaccard description) exported from the same file so other features (process orders, price-list backfill) can reuse them. +- New endpoint: `src/app/api/v1/quote_items/[id]/item-history/route.ts`. + - Returns `{ deterministic, ai, ai_updated_at, generated_at }`. + - Respects the `ITEM_HISTORY_COMPARE_MODE` env var. +- New renderer: `src/components/item-history-popover.tsx`. + - Replaces the `AiItemHistoryCard` branch in the sprint context. + - Renders two side-by-side cards when both sources have data; single card when one is missing; empty state when neither has data. +- Updated `src/components/ai-item-history-trigger.tsx` — now fetches the new endpoint instead of reading `ai_item_insights` inline. +- New types in `src/lib/types.ts`: `ItemHistory`, `ItemHistorySupplier`, `ItemHistoryOtherQuote`, `ItemHistoryLastSold`, `ItemHistoryStock`, `ItemHistoryMatch`. +- Env var: `ITEM_HISTORY_COMPARE_MODE` ∈ `both` (default) | `deterministic` | `ai` | `none`. +- Tests: + - Unit tests on pure matching functions (SKU exact, SKU normalised, Jaccard boundary, cheapest computation). + - Service-level integration tests with the admin client. + - Component tests on `ItemHistoryPopover` for the four render states. + +### Out of Scope + +- The Process orders section (the next brainstorm round after this ships). +- The document-level `AiInsightsPanel` on the quote/order detail pages. +- The "AI Pricing Suggestions" card inside `SprintItemPricingModal` (still reads `ai_item_insights` directly for the Apply buttons — that wiring stays for now). +- The n8n "AI Insights Generator" workflow — continues running unchanged. +- Caching. The deterministic path is ~150ms; we cache later if it ever becomes a problem. +- LLM-generated recommendation line. If deterministic facts alone aren't enough after the bake-in, we add a one-shot LLM recommendation call as a follow-up. +- Any change to the `/api/v1/ai-insights/*` routes — they stay working for the document-level cards. + +## Future Work + +- **Process orders section** — next brainstorm round. The matching helpers exported from `item-history-service.ts` are reused there. Selling price is the anchor (confirmed by the user as a rule for that round). Cost/markup auto-fill via Apply buttons. + +## Current State + +The ⓘ icon on each sprint row opens a popover containing `AiItemHistoryCard` from `src/components/ai-insights-renderer.tsx` (1288 lines total, document + item branches). The renderer is heavy on gradients, blur blobs, coloured stripes, and rainbow pills, but surfaces facts without making them actionable. It also reads `quote_items.ai_item_insights` directly — a JSON blob written by an n8n workflow that talks to PB. There's no canonical schema on the webapp side; the renderer speculates about field names and falls back to a raw JSON tree when the shape drifts. Two clicks separate "look at history" from "act on it". No recommendation line. + +## User Confirmed Rules + +- Keep the sprint list rows as they are now. The ⓘ icon becomes the actionable panel. +- Build both data sources this round (deterministic PB joins + existing n8n AI blob). +- Render side by side when both have data, single block when one is missing. +- One-week bake-in, then delete whichever data source loses on speed / freshness / cost. + +## Architecture + +Three layers, each with one job: + +1. **`item-history-service.ts`** (deterministic, new) — reads PB directly with admin client. Always fresh, instant. Returns typed `ItemHistory`. +2. **`ai_item_insights` blob** (existing, untouched) — n8n workflow keeps writing it. Same shape contract as today. +3. **Renderer** (`item-history-popover.tsx`, new) — accepts `{ deterministic, ai, ai_updated_at }`. Renders side by side, single, or empty. + +The endpoint composes both in one round trip. The trigger component opens the popover and fetches the endpoint; no inline reads of `ai_item_insights` remain in the trigger. + +## Data Shape + +```ts +type ItemHistory = { + generated_at: string; // ISO timestamp for the "fresh Xs ago" badge + + suppliers: Array<{ + supplier_id: string; + supplier_name: string; + last_cost: number; + last_bought_at: string; + times_bought: number; + last_order_id: string; + }>; + + other_quotes: Array<{ + quote_id: string; + quote_number: string; + company: string; + customer_name: string; + status: string; + selling_price: number; + quoted_at: string; + days_ago: number; + }>; + + last_sold: { + selling_price: number; + sold_at: string; + order_id: string; + order_number: string; + company: string; + days_ago: number; + } | null; + + stock: { + in_stock: number; + status: "in_stock" | "low" | "out"; + } | null; + + cheapest: { + supplier_id: string; + supplier_name: string; + last_cost: number; + } | null; + + match: { + sku: string; + match_kind: "sku_exact" | "sku_normalised" | "description_fuzzy" | "none"; + match_confidence: number; // 0..1, only fuzzy sets < 1 + }; +}; +``` + +## Matching Rule + +Three passes, in order, first hit wins: + +1. **SKU exact** — `quote_items.sku === targetSku`. Confidence 1.0. +2. **SKU normalised** — joined through `price_list.sku_normalised` (same normaliser the existing `price-list-match.ts` uses). Confidence 1.0. +3. **Jaccard description** — token-set similarity on `description` (lowercase, whitespace-split, stop-words removed). Threshold 0.6. Confidence = the Jaccard score. + +If all three miss, `match.match_kind === "none"`, the panel returns empty arrays, and the renderer shows the empty state. The renderer surfaces a "⚠ best-guess match" chip when `match_kind === "description_fuzzy"`, so the operator can spot bad matches before trusting the data. + +## Data Flow + +1. Operator clicks ⓘ on a sprint row. +2. `AiItemHistoryTrigger` opens the popover, fires `GET /api/v1/quote_items/[id]/item-history`. +3. Route validates the ID, then runs the steps below in parallel based on `ITEM_HISTORY_COMPARE_MODE`: + - `both` (default) — run both the service AND the AI read. + - `deterministic` — run only the service; skip the `ai_item_insights` read. + - `ai` — run only the AI read; skip the service call. + - `none` — return 404 so the trigger hides itself. + - The service call fans out 4 PB queries: `order_items` by SKU → supplier history; `quote_items` by SKU → other quotes; `order_items` delivered by SKU → last sold; `stock_purchase_items` joined to `stock_item` by SKU → in_stock. + - The AI read is a single fetch: `quote_items.ai_item_insights` + `quote_items.updated`. +4. Service computes `cheapest` and `match`. Returns `ItemHistory` in ~150ms. +5. Route assembles `{ deterministic, ai, ai_updated_at, generated_at }` (with `null` in the slots the mode skipped). +6. Popover renders. Both sides show their freshness badge when both are present. + +The n8n AI workflow continues running regardless of the mode env var. Flipping the mode doesn't pause the workflow — it just stops reading the blob. + +## Edge Cases + +| Case | Behaviour | +|---|---| +| Item has no SKU | Skip exact + normalised. Run Jaccard. If no hits → empty state. | +| Jaccard match, low confidence | Render with "⚠ best-guess match" chip + confidence %. | +| Many suppliers, one cheap + rare, another expensive + frequent | Both listed, sorted `times_bought DESC` then `last_cost ASC`. Cheapest surfaced separately. | +| 30+ quotes for the same SKU | Cap at 10 most recent. (Out of scope: a dedicated history page; "+ N more" deferred.) | +| Item has no supplier history AND no other quotes | Empty state — "No history for this item yet". | +| `purchase_orders.cost_price === 0` or `quote_items.selling_price === 0` | Skip that entry — operators sometimes forget to fill it. | +| Stock collection missing/renamed | Service returns `stock: null`. UI hides the row. No 500. | +| Service throws | Route returns `{ deterministic: null, ai: {...}, error }`. Popover shows AI side + an error chip on the PB side. | +| `ai_item_insights` blob is malformed JSON | Parse with try/catch → `ai: null`. Deterministic side still shows. | +| Empty state (neither source has data) | Friendly empty card — "No history for this item yet. Operator can still proceed: pick a supplier, set cost, save." | + +## Component File Map + +``` +src/lib/server/item-history-service.ts ← new deterministic service +src/app/api/v1/quote_items/[id]/item-history/route.ts ← new endpoint +src/components/item-history-popover.tsx ← new tight renderer +src/components/ai-item-history-trigger.tsx ← modified: calls new endpoint +src/lib/types.ts ← add ItemHistory types +src/lib/server/item-history-service.test.ts ← unit + integration tests +src/components/item-history-popover.test.tsx ← component tests +``` + +## UX + +### When both sources have data + +Side-by-side two-card layout: + +- **Left card — 📊 PB-derived** (indigo accent). Freshness badge "fresh Xs ago" in indigo. + - Cheapest supplier line, green text, prominent. + - "We buy this from:" — supplier list with last cost, last bought date, times bought. + - "Same customer (X):" — recent quotes to this company with status pill. + - "Other companies:" — recent quotes to other companies. + - "Last sold to X:" — single line with order link. + - "Stock at [supplier]:" — only if stock data exists. + +- **Right card — ✨ AI snapshot** (amber accent). Freshness badge "Xh ago" in amber. + - Recommendation line (LLM prose, plain English). + - Notes from the LLM (avg margin, stock, etc.). + - Cautions / warnings list. + +Each card is self-contained — operator reads either, both, or skips to Process. + +### When only one source has data + +Single full-width card (deterministic or AI). + +### When neither has data + +Dashed-border empty card with the operator-friendly copy above. + +## Testing + +**Unit (pure functions in `item-history-service.ts`):** +- SKU exact match returns the right rows. +- SKU normalised handles case, leading zeros, hyphens. +- Jaccard threshold works (0.6 boundary). +- `cheapest` is computed from `last_cost`, not `times_bought`. +- Empty-state detection: no SKU + no fuzzy hits → `match.match_kind === "none"`. + +**Service-level integration (admin client + test fixture):** +- A quote_item with a known SKU → expected 3 suppliers, correct ordering. +- Mixed valid + zero-cost entries → zero-cost ones filtered out. +- Jaccard match: two items with similar descriptions → matched with confidence < 1. + +**Component (`ItemHistoryPopover`):** +- Both sources present → both cards rendered side by side. +- Only deterministic → only the deterministic card. +- Only AI → only the AI card. +- Neither → empty state. +- Stale-fresh badge shows correct text. +- `description_fuzzy` match → "best-guess match" chip visible. + +## Observability + +Service logs at entry: +```json +{ + "itemId": "...", + "sku": "DRL-6MM-HSS", + "match_kind": "sku_exact", + "suppliers_count": 3, + "other_quotes_count": 5, + "has_last_sold": true, + "latency_ms": { "suppliers": 42, "quotes": 88, "last_sold": 35, "stock": 51 } +} +``` + +Per-query latency logged so we can flag any single PB query > 500ms. AI snapshot age logged when both render (`ai_updated_at - generated_at`) so we can see how often the AI side is stale by the time the operator reads it. + +## Kill-Switch & Decision Window + +- One env var: `ITEM_HISTORY_COMPARE_MODE` ∈ `both | deterministic | ai | none`. +- Default `both` for the 1-week bake-in. +- n8n workflow keeps running regardless — flipping the env stops rendering, not writing. +- After 1 week: review logs (which side gets more interaction), operator feedback, and LLM cost. Delete the loser. + +If the eventual winner is "deterministic facts + one LLM recommendation call", we build that as a third phase. \ No newline at end of file