Files
2026-03-17 16:47:43 +01:00

519 lines
23 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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)