23 KiB
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>
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 |
| </phase_requirements> |
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:
# 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:
// 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:
// 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<number, CandidateDelta>;
}
export function useImpactDeltas(
candidates: CandidateInput[],
setupItems: SetupItemWithCategory[] | undefined,
threadCategoryId: number,
): ImpactDeltas
Logic:
// 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<number, CandidateDelta> = {};
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 <select> dropdown in the thread detail header, rendered between the thread title and the toolbar.
When to use: Always present on active and resolved thread pages (impact preview is read-only, no mutation side effects).
Example:
// Placed in thread header, after thread name row
function SetupImpactSelector() {
const { data: setups } = useSetups();
const selectedSetupId = useUIStore((s) => s.selectedSetupId);
const setSelectedSetupId = useUIStore((s) => s.setSelectedSetupId);
if (!setups || setups.length === 0) return null;
return (
<div className="flex items-center gap-2">
<label className="text-xs text-gray-500 whitespace-nowrap">
Impact on setup:
</label>
<select
value={selectedSetupId ?? ""}
onChange={(e) => 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"
>
<option value="">None</option>
{setups.map((s) => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
</select>
</div>
);
}
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
// 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 (
<span className="text-xs text-gray-300">{noDataLabel}</span>
);
}
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 (
<span className={`text-xs font-medium ${colorClass}`}>
{sign}{formatted}{modeLabel}
</span>
);
}
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)
│
├── <SetupImpactSelector /> // sets selectedSetupId in uiStore
│
├── <CandidateListItem delta={impactDeltas.deltas[c.id]} />
├── <CandidateCard delta={impactDeltas.deltas[c.id]} />
└── <ComparisonTable deltas={impactDeltas.deltas} />
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
useSetupinside each candidate component: Causes N redundant React Query calls. Call once at page level, pass deltas down. - Treating
priceDelta = 0as "no data": Zero cost delta is a valid result (exact price match). Thenullcheck 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:
- Accept it — the user chose a setup globally; they can clear it. Simplest.
- Reset on thread change — call
setSelectedSetupId(null)in auseEffectthat fires onthreadIdchange.
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)
// 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<number, string | null> = {};
// ... arithmetic over nullable numbers
return { bestWeightId, bestPriceId, weightDeltas, priceDeltas };
}, [candidates, unit, currency]);
Existing useSetup Hook
// Source: src/client/hooks/useSetups.ts
export function useSetup(setupId: number | null) {
return useQuery({
queryKey: ["setups", setupId],
queryFn: () => apiGet<SetupWithItems>(`/api/setups/${setupId}`),
enabled: setupId != null, // CRITICAL: prevents null fetch
});
}
// SetupItemWithCategory includes: categoryId, weightGrams, priceCents, name
Existing formatWeight / formatPrice
// 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
// 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
// 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
-
Thread categoryId exposure in ThreadWithCandidates
- What we know:
getThreadWithCandidatesin thread.service.ts returns the full thread row (includingcategoryId), butThreadWithCandidatesTypeScript interface inuseThreads.tsdoes not declarecategoryId - What's unclear: Does the API actually serialize
categoryIdin the response or is it filtered? - Recommendation: Planner should add
categoryId: numbertoThreadWithCandidatesinterface and verify the server route includes it. Alternatively, usethread.candidates[0]?.categoryIdas a fallback since all candidates share the thread's category.
- What we know:
-
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.
-
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 fromuseImpactDeltastests/lib/directory — create if not exists (pure utility tests go here)
Sources
Primary (HIGH confidence)
- Direct codebase read —
src/db/schema.ts— verifiedthreadCandidates.categoryId,items.categoryId,setupItemsjoin structure - Direct codebase read —
src/client/hooks/useSetups.ts— verifiedSetupItemWithCategorytype includescategoryId,weightGrams,priceCents - Direct codebase read —
src/client/hooks/useThreads.ts— identified missingcategoryIdinThreadWithCandidatesinterface - 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— verifiedselectedSetupIddoes not yet exist, pattern for adding it - Direct codebase read —
src/client/lib/formatters.ts— verifiedformatWeight/formatPricereusability 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)