Files
GearBox/.planning/phases/13-setup-impact-preview/13-RESEARCH.md
2026-03-17 16:47:43 +01:00

23 KiB
Raw Blame History

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

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

// 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

  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)