Files
GearBox/.planning/research/ARCHITECTURE.md

28 KiB

Architecture Research

Domain: Gear management app -- v1.3 Research & Decision Tools (candidate comparison, setup impact preview, ranking/pros-cons) Researched: 2026-03-16 Confidence: HIGH

System Overview: Integration Map

The v1.3 features integrate primarily on the thread detail page and its data layer. This diagram shows where new components slot in relative to the current architecture.

CLIENT LAYER
+-----------------------------------------------------------------+
|  Routes                                                         |
|  +-------------------+  +--------------------+                  |
|  | /threads/$threadId|  | (existing routes)   |                 |
|  |     [MODIFIED]    |  |   [NO CHANGE]       |                 |
|  +--------+----------+  +--------------------+                  |
|           |                                                     |
|  Components (NEW)                                               |
|  +----------------------+  +---------------------------+        |
|  | CandidateCompare     |  | SetupImpactSelector       |        |
|  | (side-by-side table) |  | (setup picker + delta row)|        |
|  +----------------------+  +---------------------------+        |
|  +----------------------+                                       |
|  | CandidateRankList    |                                       |
|  | (drag-to-reorder)    |                                       |
|  +----------------------+                                       |
|                                                                 |
|  Components (MODIFIED)                                          |
|  +----------------------+  +---------------------------+        |
|  | CandidateCard        |  | CandidateForm             |        |
|  | +rank badge          |  | +pros/cons fields         |        |
|  | +pros/cons display   |  +---------------------------+        |
|  +----------------------+                                       |
|                                                                 |
|  Hooks (NEW)                Hooks (MODIFIED)                    |
|  +---------------------+   +---------------------+             |
|  | useReorderCandidates|   | useCandidates.ts    |             |
|  | useSetupImpact      |   | +reorder mutation   |             |
|  +---------------------+   | +pros/cons in update|             |
|                             +---------------------+             |
|                                                                 |
|  Stores (MODIFIED)                                              |
|  +---------------------+                                       |
|  | uiStore.ts          |                                       |
|  | +compareMode bool   |                                       |
|  | +selectedSetupId    |                                       |
|  +---------------------+                                       |
+-----------------------------------------------------------------+
|  API Layer: lib/api.ts -- NO CHANGE                             |
+-----------------------------------------------------------------+
SERVER LAYER
|  Routes (MODIFIED)                                              |
|  +--------------------+                                         |
|  | threads.ts         |                                         |
|  | +PATCH /reorder    |                                         |
|  +--------------------+                                         |
|                                                                 |
|  Services (MODIFIED)                                            |
|  +--------------------+                                         |
|  | thread.service.ts  |                                         |
|  | +reorderCandidates |                                         |
|  | +pros/cons in CRUD |                                         |
|  +--------------------+                                         |
+---------+---------------------------------------------------+---+
DATABASE LAYER
|  schema.ts (MODIFIED)                                           |
|  +----------------------------------------------------------+  |
|  | thread_candidates:                                        |  |
|  |   +sort_order INTEGER NOT NULL DEFAULT 0                 |  |
|  |   +pros TEXT                                             |  |
|  |   +cons TEXT                                             |  |
|  +----------------------------------------------------------+  |
|                                                                 |
|  tests/helpers/db.ts (MODIFIED -- add new columns)             |
+-----------------------------------------------------------------+

Feature-by-Feature Integration

Feature 1: Side-by-Side Candidate Comparison

Scope: Client-only derived view. All candidate data is already fetched by useThread(threadId) which returns thread.candidates[] with all fields including weight, price, notes, image, productUrl, and status. No new API endpoint is needed. The comparison view is a toggle on the thread detail page that reorganizes the existing data into a table layout.

Integration points:

Layer File Change Type Details
Client routes/threads/$threadId.tsx MODIFY Add "Compare" toggle button; conditionally render grid vs comparison layout
Client NEW components/CandidateCompare.tsx NEW Side-by-side table component; accepts candidates[] array
Client stores/uiStore.ts MODIFY Add compareMode: boolean, toggleCompareMode()

Data flow:

useThread(threadId) -> thread.candidates[] (already fetched)
    |
    +-- compareMode? (local UI state in uiStore)
           |
           +-- false: existing CandidateCard grid (unchanged)
           +-- true: CandidateCompare table (new component)
                  |
                  +-- Columns: [Field Label] [Candidate A] [Candidate B] [Candidate N]
                  +-- Rows: Image, Name, Weight, Price, Status, Notes, Link
                  +-- Delta row: weight and price diffs relative to lightest/cheapest

No server changes required. Thread data already includes all comparison fields. The component is purely presentational, transforming the existing CandidateWithCategory[] array into a column-per-candidate table.

CandidateCompare component structure:

// src/client/components/CandidateCompare.tsx
interface CandidateCompareProps {
  candidates: CandidateWithCategory[];
  isActive: boolean;
}

export function CandidateCompare({ candidates, isActive }: CandidateCompareProps) {
  // Rows: image, name, weight (with delta), price (with delta), status, notes, link
  // Highlight lowest weight in blue, lowest price in green
  // Delta: weight diff from lightest candidate, price diff from cheapest
  // Scrollable horizontally if > 3-4 candidates
}

Weight/price delta display:

// Derive relative comparison -- no server needed
const minWeight = Math.min(...candidates.filter(c => c.weightGrams != null).map(c => c.weightGrams!));
const minPrice  = Math.min(...candidates.filter(c => c.priceCents  != null).map(c => c.priceCents!));

// For each candidate:
const weightDelta = candidate.weightGrams != null
  ? candidate.weightGrams - minWeight
  : null;
// Display: "+34g" in gray, "lightest" in blue (delta === 0)

UI toggle placement: A "Compare" button in the thread detail header, next to "Add Candidate". Toggling it swaps the layout from the card grid to the comparison table. Toggle state lives in uiStore.compareMode so it persists if the user navigates to a candidate edit panel and returns.


Feature 2: Setup Impact Preview

Scope: For each candidate, show the weight and cost delta it would create if added to a user-selected setup. The user picks a setup from a dropdown on the thread detail page; all candidate cards (or comparison table) then display a "+/-" delta row.

Integration points:

Layer File Change Type Details
Client routes/threads/$threadId.tsx MODIFY Add setup selector; pass selectedSetupId to card/compare components
Client NEW components/SetupImpactRow.tsx NEW Small delta display row: "+320g / +$89"
Client hooks/useSetups.ts NO CHANGE useSetup(setupId) already fetches setup with items and their weightGrams/priceCents
Client stores/uiStore.ts MODIFY Add `impactSetupId: number
Server ALL NO CHANGE Impact is computed client-side from already-available data

Data flow:

User selects setup from dropdown
    |
    +-- uiStore.impactSetupId = selectedSetupId
    |
    +-- useSetup(impactSetupId) (conditional query, already exists)
           |
           +-- setup.items[] with weightGrams and priceCents
           |
           +-- setupTotalWeight = setup.items.reduce((sum, i) => sum + (i.weightGrams ?? 0), 0)
           +-- setupTotalCost   = setup.items.reduce((sum, i) => sum + (i.priceCents  ?? 0), 0)
           |
           +-- Per candidate:
                  weightImpact = candidate.weightGrams (absolute, not a replacement)
                  costImpact   = candidate.priceCents
                  // "Adding this item would add +320g / +$89 to the setup"
                  // Note: this is an "add to" preview, not a "replace existing item" preview
                  // Thread items are potential new additions, not replacements

Why client-only: The setup total weight is available from useSetup(setupId) which returns items[] with all fields. Adding a candidate to a setup is purely additive: newTotal = setupCurrentTotal + candidateWeight. No server endpoint needed. The useSetup query is already conditionally enabled and cached by React Query.

Setup selector placement: A compact dropdown (using existing useSetups() data) in the thread detail header area beneath the thread name. "Preview impact on setup: [Select setup...]". When null, the impact row is hidden. Persists in uiStore for the session.

SetupImpactRow component:

// src/client/components/SetupImpactRow.tsx
interface SetupImpactRowProps {
  candidateWeightGrams: number | null;
  candidatePriceCents: number | null;
  setupTotalWeight: number;     // existing setup total
  setupTotalCost: number;       // existing setup total (cents)
}

export function SetupImpactRow({ candidateWeightGrams, candidatePriceCents, setupTotalWeight, setupTotalCost }: SetupImpactRowProps) {
  const unit = useWeightUnit();
  const currency = useCurrency();
  // Display: "+320g" and "+$89" with soft color (gray or blue)
  // If null weight/price, show "--"
}

Feature 3: Candidate Ranking (Drag-to-Reorder) with Pros/Cons

Scope: Users can drag candidates to set a preferred rank order. Each candidate gains pros and cons text fields. Rank order is persisted to the database as a sortOrder integer on thread_candidates. framer-motion is already installed (v12.37.0) and has Reorder components built-in.

Integration points:

Layer File Change Type Details
DB schema.ts MODIFY Add sortOrder INTEGER NOT NULL DEFAULT 0, pros TEXT, cons TEXT to threadCandidates
DB Drizzle migration NEW Three new columns via db:generate
Shared schemas.ts MODIFY Add sortOrder, pros, cons to createCandidateSchema and updateCandidateSchema
Shared types.ts NO CHANGE Auto-infers from Drizzle schema
Server thread.service.ts MODIFY getThreadWithCandidates orders by sort_order ASC; add reorderCandidates function; createCandidate sets sortOrder to max+1; include pros/cons in create/update
Server routes/threads.ts MODIFY Add PATCH /:id/candidates/reorder endpoint
Client hooks/useCandidates.ts MODIFY Add useReorderCandidates(threadId) mutation
Client routes/threads/$threadId.tsx MODIFY Render Reorder.Group from framer-motion; wire reorder mutation
Client components/CandidateCard.tsx MODIFY Add rank badge (1st, 2nd, 3rd); add pros/cons display (collapsed by default, expand on hover or click)
Client components/CandidateForm.tsx MODIFY Add pros and cons textarea fields
Test tests/helpers/db.ts MODIFY Add sort_order, pros, cons columns to thread_candidates CREATE TABLE

Schema change:

// In src/db/schema.ts -- thread_candidates table additions
export const threadCandidates = sqliteTable("thread_candidates", {
  // ... existing fields unchanged ...
  sortOrder: integer("sort_order").notNull().default(0),
  pros: text("pros"),
  cons: text("cons"),
});

Sort order in service:

// In thread.service.ts -- getThreadWithCandidates
// Change: add .orderBy(asc(threadCandidates.sortOrder))
const candidateList = db
  .select({ ... })
  .from(threadCandidates)
  .innerJoin(categories, eq(threadCandidates.categoryId, categories.id))
  .where(eq(threadCandidates.threadId, threadId))
  .orderBy(asc(threadCandidates.sortOrder))  // NEW
  .all();

New service function for batch reorder:

// In thread.service.ts
export function reorderCandidates(
  db: Db = prodDb,
  threadId: number,
  orderedIds: number[],  // candidate IDs in new rank order
) {
  return db.transaction((tx) => {
    for (let i = 0; i < orderedIds.length; i++) {
      tx.update(threadCandidates)
        .set({ sortOrder: i, updatedAt: new Date() })
        .where(
          sql`${threadCandidates.id} = ${orderedIds[i]} AND ${threadCandidates.threadId} = ${threadId}`,
        )
        .run();
    }
  });
}

New API endpoint:

// In routes/threads.ts -- new PATCH route
// Schema: z.object({ orderedIds: z.array(z.number().int().positive()) })
app.patch("/:id/candidates/reorder", zValidator("json", reorderCandidatesSchema), (c) => {
  const db = c.get("db");
  const threadId = Number(c.req.param("id"));
  const { orderedIds } = c.req.valid("json");
  reorderCandidates(db, threadId, orderedIds);
  return c.json({ success: true });
});

Client-side drag with framer-motion Reorder:

// In routes/threads/$threadId.tsx
import { Reorder } from "framer-motion";

// Local state tracks optimistic order
const [orderedCandidates, setOrderedCandidates] = useState(thread.candidates);

// Sync with server data when it changes
useEffect(() => {
  setOrderedCandidates(thread.candidates);
}, [thread.candidates]);

// On drag end, persist to server
const reorderCandidates = useReorderCandidates(threadId);

function handleReorderEnd() {
  reorderCandidates.mutate(orderedCandidates.map(c => c.id));
}

// Render
<Reorder.Group
  axis="y"
  values={orderedCandidates}
  onReorder={setOrderedCandidates}
>
  {orderedCandidates.map((candidate, index) => (
    <Reorder.Item key={candidate.id} value={candidate} onDragEnd={handleReorderEnd}>
      <CandidateCard ... rank={index + 1} />
    </Reorder.Item>
  ))}
</Reorder.Group>

Rank badge on CandidateCard:

// Rank is passed as a prop, displayed as "1" / "2" / "3" / "..." badge
// Top 3 get medal-style styling (gold/silver/bronze), rest are plain gray
const RANK_STYLES = {
  1: "bg-amber-100 text-amber-700",   // gold
  2: "bg-gray-200 text-gray-600",     // silver
  3: "bg-orange-100 text-orange-600", // bronze
};

Pros/cons display in CandidateCard: Show a small "+" (green) and "-" (red) indicator if the candidate has pros/cons content. Full text shown on hover tooltip or in the edit panel. Not shown inline on the card to preserve the compact layout.

New candidate sort order: When creating a new candidate, set sortOrder to the current count of candidates in the thread (appends to end):

// In createCandidate service
const existingCount = db
  .select({ count: sql<number>`COUNT(*)` })
  .from(threadCandidates)
  .where(eq(threadCandidates.threadId, threadId))
  .get()?.count ?? 0;

// Insert with sortOrder = existingCount (0-indexed, so new item goes to end)

New vs Modified Files -- Complete Inventory

New Files (3)

File Purpose
src/client/components/CandidateCompare.tsx Side-by-side comparison table; pure presentational, no new API
src/client/components/SetupImpactRow.tsx Delta display row (+weight/+price vs setup total); pure presentational
Drizzle migration file Three new columns on thread_candidates (sort_order, pros, cons)

Modified Files (12)

File What Changes
src/db/schema.ts Add sortOrder, pros, cons to threadCandidates
src/shared/schemas.ts Add sortOrder, pros, cons to candidate schemas; add reorderCandidatesSchema
src/server/services/thread.service.ts Order candidates by sortOrder; add reorderCandidates function; include pros/cons in create/update; set sortOrder on create
src/server/routes/threads.ts Add PATCH /:id/candidates/reorder endpoint
src/client/hooks/useCandidates.ts Add useReorderCandidates(threadId) mutation
src/client/components/CandidateCard.tsx Accept rank prop; show rank badge; show pros/cons indicators
src/client/components/CandidateForm.tsx Add pros and cons textarea fields
src/client/routes/threads/$threadId.tsx Add compare toggle; add setup selector; add Reorder.Group DnD; manage local order state
src/client/stores/uiStore.ts Add compareMode, toggleCompareMode(), impactSetupId, setImpactSetupId()
tests/helpers/db.ts Add sort_order, pros, cons columns to thread_candidates CREATE TABLE

Unchanged Files

File Why No Change
src/client/lib/api.ts Existing apiPatch handles reorder endpoint
src/client/hooks/useSetups.ts useSetup(id) already fetches items with weight/price; useSetups() provides dropdown data
src/client/hooks/useThreads.ts CandidateWithCategory type auto-updates from schema inference
src/server/services/setup.service.ts No setup changes needed
src/server/routes/setups.ts No setup endpoint changes needed
src/server/services/totals.service.ts Impact is client-computed, not a server aggregate
src/server/routes/totals.ts No new endpoints
package.json framer-motion already installed at v12.37.0

Data Flow Changes Summary

Existing Data Flows (unchanged)

useThread(id)  -> GET /api/threads/:id -> getThreadWithCandidates(db, id) -> thread + candidates[]
useSetups()    -> GET /api/setups      -> getAllSetups(db)                 -> setups[]
useSetup(id)   -> GET /api/setups/:id  -> getSetupWithItems(db, id)       -> setup + items[]

New Data Flows

Side-by-Side Comparison (client-only):
  thread.candidates[] (already in cache)
      -> uiStore.compareMode == true
      -> CandidateCompare component
      -> renders column-per-candidate table
      -> no API call

Setup Impact Preview (client-only computation):
  uiStore.impactSetupId -> useSetup(impactSetupId)
      -> setup.items[].reduce(sum, weightGrams) = setupCurrentWeight
      -> per candidate: impactWeight = candidate.weightGrams
      -> SetupImpactRow displays "+Xg / +$Y"
      -> no API call

Candidate Reorder (new write endpoint):
  DnD drag end -> setOrderedCandidates (local state)
      -> useReorderCandidates.mutate(orderedIds)
      -> PATCH /api/threads/:id/candidates/reorder
      -> reorderCandidates(db, threadId, orderedIds)  [transaction loop]
      -> invalidate ["threads", threadId]
      -> useThread refetches -> candidates come back in new sortOrder

Build Order (Dependency-Aware)

Features have a clear dependency order based on shared schema migration and UI surface:

Phase 1: Schema + Pros/Cons fields
  +-- Add sort_order, pros, cons to threadCandidates schema
  +-- Run single Drizzle migration (batch all three columns together)
  +-- Update tests/helpers/db.ts
  +-- Add pros/cons to candidate create/update service + route
  +-- Add pros/cons fields to CandidateForm
  +-- Add pros/cons display indicators to CandidateCard
  +-- Dependencies: none -- foundation for Phase 2 and 3

Phase 2: Ranking (Drag-to-Reorder)
  +-- Requires Phase 1 (sort_order column)
  +-- Add reorderCandidates service function
  +-- Add PATCH /reorder route
  +-- Add useReorderCandidates hook
  +-- Add Reorder.Group to threadId route
  +-- Show rank badges on CandidateCard
  +-- Dependencies: Phase 1 (sort_order in DB)

Phase 3: Side-by-Side Comparison
  +-- No schema dependency (uses existing fields)
  +-- Can be built alongside Phase 2, but benefits from rank display being complete
  +-- Add compareMode to uiStore
  +-- Create CandidateCompare component
  +-- Wire toggle button in thread detail header
  +-- Dependencies: none (pure client-side with existing data)

Phase 4: Setup Impact Preview
  +-- No schema dependency
  +-- Easiest to build after the thread detail page already has setup selector UI
  +-- Add impactSetupId to uiStore
  +-- Create SetupImpactRow component
  +-- Wire setup selector dropdown in thread detail header
  +-- useSetup() conditionally enabled when impactSetupId set
  +-- Dependencies: none (uses existing useSetup hook)

Recommended sequence: Phase 1 + 2 together (schema-touching work in one pass), then Phase 3, then Phase 4. Phases 3 and 4 are independent pure-client additions that can be built in parallel.


Architectural Patterns

Pattern 1: Optimistic Local State for Drag Reorder

What: Maintain a local orderedCandidates state in the route component. Apply drag updates immediately (optimistic), sync to server on drag end only. When to use: Any list that supports drag reorder where immediate visual feedback matters. Trade-offs: Local state can drift from server if the reorder request fails. Given single-user SQLite, failure is extremely unlikely. A full optimistic update with rollback would be overengineering here.

// Local state drives the render; server state updates it on fetch
const [orderedCandidates, setOrderedCandidates] = useState(thread.candidates);

useEffect(() => {
  // Sync when server data changes (after reorder mutation settles)
  setOrderedCandidates(thread.candidates);
}, [thread.candidates]);

Pattern 2: Client-Computed Derived Data (No New Endpoints)

What: Derive comparison deltas and setup impact numbers from data already in the React Query cache. When to use: When all required data is already fetched, computation is simple (arithmetic), and the result is not needed on the server. Trade-offs: Correctly avoids API proliferation. The risk is stale data, but React Query's default staleTime: 0 means data is fresh.

// Impact preview: pure client arithmetic, no fetch
const setupWeight = setup.items.reduce((sum, i) => sum + (i.weightGrams ?? 0), 0);
const candidateImpact = candidateWeightGrams ?? 0;
const newTotal = setupWeight + candidateImpact;
const delta = candidateImpact; // "+320g"

Pattern 3: uiStore for Cross-Panel Persistent UI State

What: The existing pattern — Zustand uiStore holds UI mode flags like panel open/closed, dialog state. Extend this for compareMode and impactSetupId. When to use: UI state that needs to survive component unmount/remount within a session (e.g., user opens a candidate edit panel and returns to find compare mode still active). Trade-offs: Do not put server data in uiStore. Only boolean flags and selected IDs. Server data stays in React Query.


Anti-Patterns to Avoid

Anti-Pattern 1: Server Endpoint for Comparison Deltas

What people do: Build a GET /api/threads/:id/compare endpoint that returns weight/price diffs Why it's wrong: All candidate data is already fetched by useThread(threadId). A round-trip to compute Math.min and subtraction is unnecessary. Do this instead: Compute deltas in the CandidateCompare component from thread.candidates[].

Anti-Pattern 2: Server Endpoint for Impact Preview

What people do: Build a GET /api/threads/:id/impact?setupId=X endpoint Why it's wrong: Setup weight totals are already fetched by useSetup(setupId) which returns items[] with weights. The impact is additive arithmetic. Do this instead: Sum setup.items[].weightGrams client-side, add candidate.weightGrams. No round-trip.

Anti-Pattern 3: Storing Rank as a Linked List

What people do: Store prevId/nextId on each candidate for ordering Why it's wrong: Linked list ordering is complex to maintain transactionally, especially for batch reorders. Queries become multi-step. Do this instead: Use a plain sort_order integer (0-indexed). On reorder, update all affected rows in a single transaction loop. Integer order is simple, fast, and trivially queryable with ORDER BY sort_order.

Anti-Pattern 4: External DnD Library When framer-motion is Already Present

What people do: Install @dnd-kit/sortable or react-beautiful-dnd for drag reorder Why it's wrong: framer-motion is already in package.json at v12.37.0 and includes Reorder.Group / Reorder.Item components designed exactly for this use case. Adding another DnD library duplicates functionality and bloats the bundle. Do this instead: Use framer-motion's Reorder components. import { Reorder } from "framer-motion" -- no new dependency.

Anti-Pattern 5: Full Optimistic Update with Rollback for Reorder

What people do: Implement onMutate with cache snapshot and onError rollback in the reorder mutation Why it's wrong for this app: Single-user SQLite on localhost. The reorder endpoint will not fail under any realistic condition. Full optimistic update infrastructure (snapshot, rollback, error handling) is meaningful in multi-user or network-failure scenarios. Do this instead: Local useState provides immediate visual feedback. The mutation runs fire-and-forget style. If it somehow fails, onError can invalidateQueries to restore server state. No manual rollback needed.

Anti-Pattern 6: Pros/Cons as Separate Database Tables

What people do: Create a candidate_annotations table with type: "pro"|"con" rows Why it's wrong: Pros/cons are simple text fields per candidate, edited as textarea inputs. Modeling them as a separate table with individual row creation/deletion adds CRUD complexity for zero benefit at this scale. Do this instead: Two text columns (pros TEXT, cons TEXT) on thread_candidates. Store multi-line text directly. Simple, fast, and fits the existing update mutation pattern.


Integration Summary Table

New Feature API Changes Schema Changes New Components Key Modified Files
Side-by-side comparison None None CandidateCompare.tsx $threadId.tsx, uiStore.ts
Setup impact preview None None SetupImpactRow.tsx $threadId.tsx, uiStore.ts
Ranking (DnD) PATCH /threads/:id/candidates/reorder sort_order on thread_candidates None (uses Reorder.Group) $threadId.tsx, thread.service.ts, routes/threads.ts, useCandidates.ts
Pros/Cons fields Extend existing PUT candidate pros, cons on thread_candidates None CandidateForm.tsx, CandidateCard.tsx, thread.service.ts, schemas.ts

Sources


Architecture research for: GearBox v1.3 Research & Decision Tools Researched: 2026-03-16