# 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)