docs(phase-13): research setup impact preview

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-17 16:47:43 +01:00
parent 14a4c65b94
commit 798bd51597

View File

@@ -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>
## 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:**
```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<number, CandidateDelta>;
}
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<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:**
```typescript
// 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
```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 (
<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 `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<number, string | null> = {};
// ... 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<SetupWithItems>(`/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)