# 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:** ```typescript // 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:** ```typescript // 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 | null`, `setImpactSetupId(id)` | | 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:** ```typescript // 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:** ```typescript // 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:** ```typescript // 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:** ```typescript // 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:** ```typescript // 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:** ```typescript // 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 {orderedCandidates.map((candidate, index) => ( ))} ``` **Rank badge on CandidateCard:** ```typescript // 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): ```typescript // In createCandidate service const existingCount = db .select({ count: sql`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. ```typescript // 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. ```typescript // 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](https://www.framer.com/motion/reorder/) -- `Reorder.Group` and `Reorder.Item` API for drag-to-reorder lists - [framer-motion v12 changelog](https://github.com/framer/motion/releases) -- confirms `Reorder` available in v12 (already installed) - [Drizzle ORM orderBy documentation](https://orm.drizzle.team/docs/select#order-by) -- `asc()` for sort_order ordering - [TanStack React Query optimistic updates](https://tanstack.com/query/latest/docs/framework/react/guides/optimistic-updates) -- pattern reference for the reorder mutation approach - [Zustand documentation](https://zustand.docs.pmnd.rs/) -- confirms store extend pattern for new UI state slices --- *Architecture research for: GearBox v1.3 Research & Decision Tools* *Researched: 2026-03-16*