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:
parent
5783601abf
commit
74b7d4433f
2 changed files with 249 additions and 2 deletions
7
.gitignore
vendored
7
.gitignore
vendored
|
|
@ -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/
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
Loading…
Reference in a new issue