docs(spec): item history popover redesign — build both data sources, compare for 1 week

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 <noreply@anthropic.com>

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
SJCoetz 2026-07-29 13:51:25 +02:00
parent 5783601abf
commit 74b7d4433f
2 changed files with 249 additions and 2 deletions

7
.gitignore vendored
View file

@ -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/

View file

@ -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.