578 lines
28 KiB
Markdown
578 lines
28 KiB
Markdown
# 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
|
|
<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:**
|
|
|
|
```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<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.
|
|
|
|
```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*
|