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
- framer-motion Reorder documentation --
Reorder.GroupandReorder.ItemAPI for drag-to-reorder lists - framer-motion v12 changelog -- confirms
Reorderavailable in v12 (already installed) - Drizzle ORM orderBy documentation --
asc()for sort_order ordering - TanStack React Query optimistic updates -- pattern reference for the reorder mutation approach
- Zustand documentation -- confirms store extend pattern for new UI state slices
Architecture research for: GearBox v1.3 Research & Decision Tools Researched: 2026-03-16