From 798bd5159757bd997bcba861c3fea5c2ca1fb87c Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Tue, 17 Mar 2026 16:47:43 +0100 Subject: [PATCH] docs(phase-13): research setup impact preview Co-Authored-By: Claude Sonnet 4.6 --- .../13-setup-impact-preview/13-RESEARCH.md | 518 ++++++++++++++++++ 1 file changed, 518 insertions(+) create mode 100644 .planning/phases/13-setup-impact-preview/13-RESEARCH.md diff --git a/.planning/phases/13-setup-impact-preview/13-RESEARCH.md b/.planning/phases/13-setup-impact-preview/13-RESEARCH.md new file mode 100644 index 0000000..61d2b55 --- /dev/null +++ b/.planning/phases/13-setup-impact-preview/13-RESEARCH.md @@ -0,0 +1,518 @@ +# Phase 13: Setup Impact Preview - Research + +**Researched:** 2026-03-17 +**Domain:** Pure frontend — delta computation + UI (React, Zustand, React Query) +**Confidence:** HIGH + +--- + +## Summary + +Phase 13 adds a setup-selector dropdown to the thread detail header. When a user picks a setup, every candidate card and list row gains two delta indicators: weight delta and cost delta. The computation has two modes determined at render time — replace mode (a setup item exists in the same category as the thread) and add mode (no category match). The entire feature is a pure frontend addition: all required data is already available through existing hooks with zero backend or schema changes needed. + +The delta logic is straightforward arithmetic over nullable numbers: `candidate.weightGrams - replacedItem.weightGrams` in replace mode, or `candidate.weightGrams` in add mode. The only real complexity is the null-weight indicator (IMPC-04) and driving the selected setup ID through Zustand state so it persists across view-mode switches within the same thread session. + +**Primary recommendation:** Add `selectedSetupId: number | null` to uiStore, render a setup dropdown in the thread header, compute deltas in a `useMemo` inside a new `useImpactDeltas` hook, and render inline delta indicators below the candidate weight/price badges in both list and grid views. + +--- + + +## Phase Requirements + +| ID | Description | Research Support | +|----|-------------|-----------------| +| IMPC-01 | User can select a setup and see weight and cost delta for each candidate | `useSetups()` returns all setups for dropdown; `useSetup(id)` returns items with categoryId for matching; delta computed in useMemo | +| IMPC-02 | Impact preview auto-detects replace mode when a setup item exists in the same category as the thread | Thread has `categoryId` (from `threads.categoryId`); setup items have `categoryId` via join; match on `categoryId` equality | +| IMPC-03 | Impact preview shows add mode (pure addition) when no category match exists in the selected setup | Default when no setup item matches `thread.categoryId`; label clearly as "+add" | +| IMPC-04 | Candidates with missing weight data show a clear indicator instead of misleading zero deltas | `candidate.weightGrams == null` → render `"-- (no weight data)"` instead of computing | + + +--- + +## Standard Stack + +### Core +| Library | Version | Purpose | Why Standard | +|---------|---------|---------|--------------| +| React 19 | ^19.2.4 | UI rendering | Project foundation | +| Zustand | ^5.0.11 | `selectedSetupId` UI state | Established pattern for all UI-only state (panel open/close, view mode) | +| TanStack React Query | ^5.90.21 | `useSetup(id)` for setup items | Established data fetching pattern | +| Tailwind CSS v4 | ^4.2.1 | Delta badge styling | Project styling system | + +### Supporting +| Library | Version | Purpose | When to Use | +|---------|---------|---------|-------------| +| framer-motion | ^12.37.0 | Optional entrance animation for delta indicators | Already installed; use AnimatePresence if subtle fade needed | +| lucide-react | ^0.577.0 | Dropdown chevron icon, delta arrow icons | Project icon system | + +### No New Dependencies +This phase requires zero new npm dependencies. All needed libraries are installed. + +**Installation:** +```bash +# No new packages needed +``` + +--- + +## Architecture Patterns + +### Recommended Project Structure +``` +src/client/ +├── stores/uiStore.ts # Add selectedSetupId: number | null + setter +├── hooks/ +│ └── useImpactDeltas.ts # New: compute add/replace deltas per candidate +├── components/ +│ ├── SetupImpactSelector.tsx # New: setup dropdown rendered in thread header +│ └── ImpactDeltaBadge.tsx # New (or inline): weight/cost delta pill +└── routes/threads/$threadId.tsx # Add selector to header, pass deltas down +``` + +### Pattern 1: selectedSetupId in Zustand + +**What:** Store selected setup ID as UI state in `uiStore.ts`, not as URL state or server state. + +**When to use:** The selection is ephemeral per session (no permalink needed), needs to survive view-mode switches (list/grid/compare), and must be accessible from any component in the thread page without prop drilling. + +**Example:** +```typescript +// In uiStore.ts — add to UIState interface +selectedSetupId: number | null; +setSelectedSetupId: (id: number | null) => void; + +// In create() initializer +selectedSetupId: null, +setSelectedSetupId: (id) => set({ selectedSetupId: id }), +``` + +### Pattern 2: useImpactDeltas Hook + +**What:** A pure computation hook that accepts candidates + a setup's item list + the thread's categoryId, and returns per-candidate delta objects. + +**When to use:** Delta computation must run in a single place so list, grid, and compare views all show consistent numbers. + +**Interface:** +```typescript +// src/client/hooks/useImpactDeltas.ts +import type { SetupItemWithCategory } from "./useSetups"; + +interface CandidateInput { + id: number; + weightGrams: number | null; + priceCents: number | null; +} + +type DeltaMode = "replace" | "add" | "none"; // "none" = no setup selected + +interface CandidateDelta { + candidateId: number; + mode: DeltaMode; + weightDelta: number | null; // null = candidate has no weight data + priceDelta: number | null; // null = candidate has no price data + replacedItemName: string | null; // populated in replace mode for tooltip +} + +interface ImpactDeltas { + mode: DeltaMode; + deltas: Record; +} + +export function useImpactDeltas( + candidates: CandidateInput[], + setupItems: SetupItemWithCategory[] | undefined, + threadCategoryId: number, +): ImpactDeltas +``` + +**Logic:** +```typescript +// Source: project codebase pattern — mirrors ComparisonTable useMemo +const impactDeltas = useMemo(() => { + if (!setupItems) return { mode: "none", deltas: {} }; + + // Find replaced item: setup item whose categoryId matches thread's categoryId + const replacedItem = setupItems.find( + (item) => item.categoryId === threadCategoryId + ) ?? null; + + const mode: DeltaMode = replacedItem ? "replace" : "add"; + + const deltas: Record = {}; + for (const c of candidates) { + let weightDelta: number | null = null; + let priceDelta: number | null = null; + + if (c.weightGrams != null) { + weightDelta = mode === "replace" && replacedItem?.weightGrams != null + ? c.weightGrams - replacedItem.weightGrams + : c.weightGrams; + } + // priceCents is integer (cents), same arithmetic + if (c.priceCents != null) { + priceDelta = mode === "replace" && replacedItem?.priceCents != null + ? c.priceCents - replacedItem.priceCents + : c.priceCents; + } + + deltas[c.id] = { + candidateId: c.id, + mode, + weightDelta, + priceDelta, + replacedItemName: replacedItem?.name ?? null, + }; + } + + return { mode, deltas }; +}, [candidates, setupItems, threadCategoryId]); +``` + +### Pattern 3: SetupImpactSelector Component + +**What:** A compact ` setSelectedSetupId(e.target.value ? Number(e.target.value) : null)} + className="text-sm border border-gray-200 rounded-lg px-2 py-1 text-gray-700 bg-white focus:outline-none focus:ring-1 focus:ring-gray-300" + > + + {setups.map((s) => ( + + ))} + + + ); +} +``` + +### Pattern 4: ImpactDeltaBadge Rendering + +**What:** Small inline indicator rendered below weight/price badges on each candidate. Three rendering cases per field: + +| Case | Render | +|------|--------| +| No setup selected | Nothing (no change to existing layout) | +| Candidate has no weight | `"-- (no weight data)"` in muted gray | +| Weight exists, replace mode | `"±Xg vs [ItemName]"` with sign-colored text | +| Weight exists, add mode | `"+Xg (add)"` in gray | + +**Where it renders:** Below the existing `formatWeight` / `formatPrice` badges in `CandidateListItem` and `CandidateCard`. In `ComparisonTable`, can be added as a sub-row or a second line within the weight/price cells. + +**Sign coloring convention:** +- Negative delta (lighter/cheaper when replacing) → green text +- Positive delta (heavier/more expensive) → red text +- Zero delta → gray text +- No weight data → muted gray, em-dash prefix + +```typescript +// Reusable inline component +function ImpactDeltaBadge({ + delta, + noDataLabel = "-- (no weight data)", + unit, + currency, + type, +}: { + delta: CandidateDelta | undefined; + noDataLabel?: string; + unit?: WeightUnit; + currency?: Currency; + type: "weight" | "price"; +}) { + if (!delta || delta.mode === "none") return null; + + const value = type === "weight" ? delta.weightDelta : delta.priceDelta; + + if (value === null) { + // Candidate has no data for this field + return ( + {noDataLabel} + ); + } + + const formatted = type === "weight" + ? formatWeight(Math.abs(value), unit) + : formatPrice(Math.abs(value), currency); + + const sign = value > 0 ? "+" : value < 0 ? "−" : "±"; + const colorClass = value < 0 ? "text-green-600" : value > 0 ? "text-red-500" : "text-gray-400"; + const modeLabel = delta.mode === "add" ? " (add)" : ""; + + return ( + + {sign}{formatted}{modeLabel} + + ); +} +``` + +### Data Flow + +``` +$threadId.tsx + ├── selectedSetupId ← useUIStore + ├── thread ← useThread(threadId) // has thread.categoryId + candidates + ├── setupData ← useSetup(selectedSetupId) // null when none selected + ├── impactDeltas ← useImpactDeltas(candidates, setupData?.items, thread.categoryId) + │ + ├── // sets selectedSetupId in uiStore + │ + ├── + ├── + └── +``` + +### Anti-Patterns to Avoid + +- **Computing deltas in each candidate component:** Delta mode (add vs replace) must be determined once from the full setup. Computing per-component means each card independently decides mode — a setup with multiple items in different categories could give inconsistent signals if the logic is subtle. +- **Storing selectedSetupId in URL search params:** Adds routing complexity with no benefit; the selection is ephemeral and non-shareable per project scope. +- **Calling `useSetup` inside each candidate component:** Causes N redundant React Query calls. Call once at page level, pass deltas down. +- **Treating `priceDelta = 0` as "no data":** Zero cost delta is a valid result (exact price match). The `null` check distinguishes missing data from zero. + +--- + +## Don't Hand-Roll + +| Problem | Don't Build | Use Instead | Why | +|---------|-------------|-------------|-----| +| Formatted weight delta strings | Custom formatter | Reuse `formatWeight(Math.abs(delta), unit)` + sign prefix | Already handles all 4 units (g/oz/lb/kg) correctly | +| Formatted price delta strings | Custom formatter | Reuse `formatPrice(Math.abs(delta), currency)` + sign prefix | Already handles all currencies and JPY integer case | +| Setup list fetching | Custom fetch | `useSetups()` hook | Already defined, cached by React Query | +| Setup items fetching | Custom fetch | `useSetup(id)` hook | Already defined with enabled guard | +| UI state management | Local useState | Zustand `selectedSetupId` | Persists across view mode switches within same session | + +**Key insight:** All data infrastructure exists. This phase is arithmetic + UI only. + +--- + +## Common Pitfalls + +### Pitfall 1: Thread categoryId vs Candidate categoryId + +**What goes wrong:** Using `candidate.categoryId` instead of `thread.categoryId` to find the replaced setup item. Candidates inherit the thread's category (they're in the same decision thread), but a user could theoretically pick a different category per candidate. The impact preview is about "what does buying something for this research thread do to my setup," so the match must be on the **thread** category, not individual candidate category. + +**Why it happens:** `CandidateWithCategory` has a `categoryId` field that looks natural to use. + +**How to avoid:** In `useImpactDeltas`, accept `threadCategoryId` as a separate parameter sourced from `thread.categoryId`, not from `candidate.categoryId`. + +**Warning signs:** Replace mode never triggers even when a setup contains an item in the expected category. + +### Pitfall 2: Null vs Zero for Missing Data (IMPC-04) + +**What goes wrong:** When `candidate.weightGrams` is `null`, the delta would be `null - replacedItem.weightGrams = NaN` or JavaScript coerces to `0`. Rendering "0g" is actively misleading — it implies the candidate has been weighed at zero. + +**Why it happens:** JavaScript's `null - 200 = -200` is NaN, not zero, but string formatting might swallow this silently. + +**How to avoid:** Explicit null guard BEFORE arithmetic: `if (c.weightGrams == null) { weightDelta = null; }`. Render `null` delta as `"-- (no weight data)"` per IMPC-04. + +**Warning signs:** Candidates with no weight show "0g" or "−200g" delta. + +### Pitfall 3: useSetup Enabled Guard + +**What goes wrong:** Calling `useSetup(null)` triggers a request to `/api/setups/null` — a 404 or server error. + +**Why it happens:** `useSetup` has `enabled: setupId != null` guard, but if the `selectedSetupId` from Zustand is not passed correctly, it might be `undefined` rather than `null`. + +**How to avoid:** Coerce to `null` explicitly: `useSetup(selectedSetupId ?? null)`. + +**Warning signs:** Network errors in dev tools when no setup is selected. + +### Pitfall 4: selectedSetupId Stale Across Thread Navigation + +**What goes wrong:** User selects "Setup A" on thread 1, navigates to thread 2, sees impact deltas for "Setup A" which may not be relevant. + +**Why it happens:** Zustand state persists in memory across route changes. + +**How to avoid:** Two acceptable approaches: +1. **Accept it** — the user chose a setup globally; they can clear it. Simplest. +2. **Reset on thread change** — call `setSelectedSetupId(null)` in a `useEffect` that fires on `threadId` change. + +Recommended: Accept cross-thread persistence (simpler, matches how `candidateViewMode` works currently). + +### Pitfall 5: ComparisonTable Integration + +**What goes wrong:** ComparisonTable already has its own `weightDeltas` computation (candidate-relative deltas: lightest vs others). Adding setup deltas as a third numeric display in the same weight cell risks visual clutter and ambiguity about which delta is which. + +**Why it happens:** Two delta systems in one cell with no visual separation. + +**How to avoid:** Render setup impact deltas in a **separate row** in ATTRIBUTE_ROWS, or as a clearly labeled sub-row below weight. Label it "Impact" with a small setup name indicator. + +--- + +## Code Examples + +Verified patterns from existing codebase: + +### Existing Delta Pattern (ComparisonTable.tsx) +```typescript +// Source: src/client/components/ComparisonTable.tsx +// This shows the established useMemo pattern for delta computation +const { bestWeightId, bestPriceId, weightDeltas, priceDeltas } = + useMemo(() => { + const withWeight = candidates.filter((c) => c.weightGrams != null); + let bestWeightId: number | null = null; + const weightDeltas: Record = {}; + // ... arithmetic over nullable numbers + return { bestWeightId, bestPriceId, weightDeltas, priceDeltas }; + }, [candidates, unit, currency]); +``` + +### Existing useSetup Hook +```typescript +// Source: src/client/hooks/useSetups.ts +export function useSetup(setupId: number | null) { + return useQuery({ + queryKey: ["setups", setupId], + queryFn: () => apiGet(`/api/setups/${setupId}`), + enabled: setupId != null, // CRITICAL: prevents null fetch + }); +} +// SetupItemWithCategory includes: categoryId, weightGrams, priceCents, name +``` + +### Existing formatWeight / formatPrice +```typescript +// Source: src/client/lib/formatters.ts +export function formatWeight(grams: number | null | undefined, unit: WeightUnit = "g"): string { + if (grams == null) return "--"; + // handles g / oz / lb / kg +} +export function formatPrice(cents: number | null | undefined, currency: Currency = "USD"): string { + if (cents == null) return "--"; + // handles JPY integer case, others to 2dp +} +// Pass Math.abs(delta) to these, prefix sign manually +``` + +### Existing Zustand UI State Pattern +```typescript +// Source: src/client/stores/uiStore.ts +// All ephemeral UI state lives here — follow same pattern +candidateViewMode: "list" | "grid" | "compare"; +setCandidateViewMode: (mode: "list" | "grid" | "compare") => void; +// Add analogously: +// selectedSetupId: number | null; +// setSelectedSetupId: (id: number | null) => void; +``` + +### Thread categoryId Availability +```typescript +// Source: src/server/services/thread.service.ts — getThreadWithCandidates +// thread object from useThread() has .categoryId (integer) directly available +// Note: ThreadWithCandidates interface in useThreads.ts does NOT expose categoryId +// The raw DB thread select does, but the hook return type may need updating +``` + +**Important finding:** The `ThreadWithCandidates` interface in `useThreads.ts` does NOT currently include `categoryId`. The server does return it (from `db.select().from(threads)`), but the TypeScript interface omits it. The planner must add `categoryId: number` to `ThreadWithCandidates` or source it from the candidates (each `CandidateWithCategory` has `categoryId`). + +--- + +## State of the Art + +| Old Approach | Current Approach | When Changed | Impact | +|--------------|------------------|--------------|--------| +| Fetch data inside components | Custom hooks with React Query | Established in project | All data fetching via hooks | +| Local component state for UI | Zustand store | Established in project | All UI state centralized | + +**No deprecated patterns in scope for this phase.** + +--- + +## Open Questions + +1. **Thread categoryId exposure in ThreadWithCandidates** + - What we know: `getThreadWithCandidates` in thread.service.ts returns the full thread row (including `categoryId`), but `ThreadWithCandidates` TypeScript interface in `useThreads.ts` does not declare `categoryId` + - What's unclear: Does the API actually serialize `categoryId` in the response or is it filtered? + - Recommendation: Planner should add `categoryId: number` to `ThreadWithCandidates` interface and verify the server route includes it. Alternatively, use `thread.candidates[0]?.categoryId` as a fallback since all candidates share the thread's category. + +2. **Selector placement on narrow viewports** + - What we know: Thread header already has breadcrumb, title/status pill, and toolbar row + - What's unclear: Three rows in mobile header may feel cramped + - Recommendation: Planner's discretion — can be inline with toolbar or as a third header row. Research finds no hard constraint. + +3. **ComparisonTable delta row placement** + - What we know: ATTRIBUTE_ROWS pattern is extensible (just add an object to the array) + - What's unclear: Whether impact rows should live inside the weight/price rows or as separate "Impact Weight" / "Impact Price" rows + - Recommendation: Separate labeled rows to avoid conflating candidate-relative deltas (lightest/cheapest highlighting) with setup impact deltas. + +--- + +## Validation Architecture + +### Test Framework +| Property | Value | +|----------|-------| +| Framework | Bun test (built-in) | +| Config file | none — bun test discovers tests automatically | +| Quick run command | `bun test tests/services/` | +| Full suite command | `bun test` | + +### Phase Requirements → Test Map +| Req ID | Behavior | Test Type | Automated Command | File Exists? | +|--------|----------|-----------|-------------------|-------------| +| IMPC-01 | Delta values computed and passed to candidates when setup selected | unit (hook logic) | `bun test tests/lib/impactDeltas.test.ts` | Wave 0 | +| IMPC-02 | Replace mode triggered when setup contains item in same category as thread | unit | `bun test tests/lib/impactDeltas.test.ts` | Wave 0 | +| IMPC-03 | Add mode used when no category match, delta equals candidate value | unit | `bun test tests/lib/impactDeltas.test.ts` | Wave 0 | +| IMPC-04 | Null weight candidate returns null delta (not zero, not NaN) | unit | `bun test tests/lib/impactDeltas.test.ts` | Wave 0 | + +**Note:** Delta computation is pure arithmetic logic and can be tested outside React via an extracted pure function. The recommended approach is to extract `computeImpactDeltas(candidates, setupItems, threadCategoryId)` as a pure function and test it directly — no React Testing Library needed. + +### Sampling Rate +- **Per task commit:** `bun test tests/lib/` +- **Per wave merge:** `bun test` +- **Phase gate:** Full suite green before `/gsd:verify-work` + +### Wave 0 Gaps +- [ ] `tests/lib/impactDeltas.test.ts` — covers IMPC-01 through IMPC-04 via pure function extracted from `useImpactDeltas` +- [ ] `tests/lib/` directory — create if not exists (pure utility tests go here) + +--- + +## Sources + +### Primary (HIGH confidence) +- Direct codebase read — `src/db/schema.ts` — verified `threadCandidates.categoryId`, `items.categoryId`, `setupItems` join structure +- Direct codebase read — `src/client/hooks/useSetups.ts` — verified `SetupItemWithCategory` type includes `categoryId`, `weightGrams`, `priceCents` +- Direct codebase read — `src/client/hooks/useThreads.ts` — identified missing `categoryId` in `ThreadWithCandidates` interface +- Direct codebase read — `src/client/components/ComparisonTable.tsx` — verified ATTRIBUTE_ROWS pattern and existing delta computation pattern +- Direct codebase read — `src/client/stores/uiStore.ts` — verified `selectedSetupId` does not yet exist, pattern for adding it +- Direct codebase read — `src/client/lib/formatters.ts` — verified `formatWeight` / `formatPrice` reusability with abs values +- Direct codebase read — `tests/helpers/db.ts` — verified test infrastructure, no schema changes needed + +### Secondary (MEDIUM confidence) +- `.planning/STATE.md` — confirms "Impact preview must distinguish add-mode vs replace-mode by category match" as locked architectural decision + +### Tertiary (LOW confidence) +- None + +--- + +## Metadata + +**Confidence breakdown:** +- Standard stack: HIGH — zero new dependencies; all libraries confirmed in package.json +- Architecture: HIGH — derived entirely from reading existing codebase; patterns are directly reusable +- Pitfalls: HIGH — identified from code inspection (ThreadWithCandidates missing categoryId is a concrete finding, not speculation) +- Delta math: HIGH — straightforward arithmetic, verified types from schema + +**Research date:** 2026-03-17 +**Valid until:** 2026-04-17 (stable codebase; no external library research)