From 4689d49b933a5f28966b141208258ac422bc93f6 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 16 Mar 2026 21:08:09 +0100 Subject: [PATCH] docs: complete project research --- .planning/research/ARCHITECTURE.md | 938 +++++++++++++---------------- .planning/research/FEATURES.md | 434 ++++++------- .planning/research/PITFALLS.md | 378 +++++++----- .planning/research/STACK.md | 267 ++++---- .planning/research/SUMMARY.md | 238 ++++---- 5 files changed, 1106 insertions(+), 1149 deletions(-) diff --git a/.planning/research/ARCHITECTURE.md b/.planning/research/ARCHITECTURE.md index e2d341a..271aeae 100644 --- a/.planning/research/ARCHITECTURE.md +++ b/.planning/research/ARCHITECTURE.md @@ -1,65 +1,76 @@ # Architecture Research -**Domain:** Gear management app -- v1.2 feature integration (search/filter, weight classification, weight distribution charts, candidate status tracking, weight unit selection) +**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.2 features integrate across all existing layers. This diagram shows where new components slot in relative to the current architecture. +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 | -| +------------+ +------------+ +------------+ | -| | /collection| | /threads/$ | | /setups/$ | | -| | [MODIFIED] | | [MODIFIED] | | [MODIFIED] | | -| +------+-----+ +------+-----+ +------+-----+ | -| | | | | +| +-------------------+ +--------------------+ | +| | /threads/$threadId| | (existing routes) | | +| | [MODIFIED] | | [NO CHANGE] | | +| +--------+----------+ +--------------------+ | +| | | | Components (NEW) | -| +------------+ +--------------+ +-------------+ | -| | SearchBar | | WeightChart | | UnitSelector| | -| +------------+ +--------------+ +-------------+ | +| +----------------------+ +---------------------------+ | +| | CandidateCompare | | SetupImpactSelector | | +| | (side-by-side table) | | (setup picker + delta row)| | +| +----------------------+ +---------------------------+ | +| +----------------------+ | +| | CandidateRankList | | +| | (drag-to-reorder) | | +| +----------------------+ | | | | Components (MODIFIED) | -| +------------+ +--------------+ +-------------+ | -| | ItemCard | | CandidateCard| | TotalsBar | | -| | ItemForm | | CandidateForm| | CategoryHdr | | -| +------------+ +--------------+ +-------------+ | +| +----------------------+ +---------------------------+ | +| | CandidateCard | | CandidateForm | | +| | +rank badge | | +pros/cons fields | | +| | +pros/cons display | +---------------------------+ | +| +----------------------+ | | | -| Hooks (NEW) Hooks (MODIFIED) | -| +------------------+ +------------------+ | -| | useFormatWeight | | useSetups | | -| +------------------+ | useThreads | | -| +------------------+ | +| Hooks (NEW) Hooks (MODIFIED) | +| +---------------------+ +---------------------+ | +| | useReorderCandidates| | useCandidates.ts | | +| | useSetupImpact | | +reorder mutation | | +| +---------------------+ | +pros/cons in update| | +| +---------------------+ | | | -| Lib (MODIFIED) Stores (NO CHANGE) | -| +------------------+ +------------------+ | -| | formatters.ts | | uiStore.ts | | -| +------------------+ +------------------+ | +| Stores (MODIFIED) | +| +---------------------+ | +| | uiStore.ts | | +| | +compareMode bool | | +| | +selectedSetupId | | +| +---------------------+ | +-----------------------------------------------------------------+ | API Layer: lib/api.ts -- NO CHANGE | +-----------------------------------------------------------------+ SERVER LAYER | Routes (MODIFIED) | -| +------------+ +------------+ +------------+ | -| | items.ts | | threads.ts | | setups.ts | | -| | (no change)| | (no change)| | +PATCH item| | -| +------+-----+ +------+-----+ +------+-----+ | -| | | | | +| +--------------------+ | +| | threads.ts | | +| | +PATCH /reorder | | +| +--------------------+ | +| | | Services (MODIFIED) | -| +------------+ +--------------+ +--------------+ | -| | item.svc | | thread.svc | | setup.svc | | -| | (no change)| | +cand.status | | +weightClass | | -| +------+-----+ +------+-------+ +------+-------+ | -+---------+----------------+----------------+---------------------+ +| +--------------------+ | +| | thread.service.ts | | +| | +reorderCandidates | | +| | +pros/cons in CRUD | | +| +--------------------+ | ++---------+---------------------------------------------------+---+ DATABASE LAYER | schema.ts (MODIFIED) | | +----------------------------------------------------------+ | -| | setup_items: +weight_class TEXT DEFAULT 'base' | | -| | thread_candidates: +status TEXT DEFAULT 'researching' | | -| | settings: weightUnit row (uses existing key-value table) | | +| | thread_candidates: | | +| | +sort_order INTEGER NOT NULL DEFAULT 0 | | +| | +pros TEXT | | +| | +cons TEXT | | | +----------------------------------------------------------+ | | | | tests/helpers/db.ts (MODIFIED -- add new columns) | @@ -68,610 +79,499 @@ DATABASE LAYER ## Feature-by-Feature Integration -### Feature 1: Search Items and Filter by Category +### Feature 1: Side-by-Side Candidate Comparison -**Scope:** Client-side filtering of already-fetched data. No server changes needed -- the collection is small enough (single user) that client-side filtering is both simpler and faster. +**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/collection/index.tsx` | MODIFY | Add search input and category filter dropdown above the gear grid in `CollectionView` | -| Client | NEW `components/SearchBar.tsx` | NEW | Reusable search input component with clear button | -| Client | `hooks/useItems.ts` | NO CHANGE | Already returns all items; filtering happens in the route | +| 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:** ``` -CollectionView (owns search/filter state via useState) +useThread(threadId) -> thread.candidates[] (already fetched) | - +-- SearchBar (controlled input, calls setSearchTerm) - +-- CategoryFilter (dropdown from useCategories, calls setCategoryFilter) - | - +-- Items = useItems().data - .filter(item => matchesSearch(item.name, searchTerm)) - .filter(item => !categoryFilter || item.categoryId === categoryFilter) - | - +-- Grouped by category -> rendered as before + +-- 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 ``` -**Why client-side:** The `useItems()` hook already fetches all items. For a single-user app, even 500 items is trivially fast to filter in memory. Adding server-side search would mean new API parameters, new query logic, and pagination -- all unnecessary complexity. If the collection grows beyond ~2000 items someday, server-side search can be added to the existing `getAllItems` service function by accepting optional `search` and `categoryId` parameters and adding Drizzle `like()` + `eq()` conditions. +**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. -**Pattern -- filtered items with useMemo:** +**CandidateCompare component structure:** ```typescript -// In CollectionView component -const [searchTerm, setSearchTerm] = useState(""); -const [categoryFilter, setCategoryFilter] = useState(null); +// src/client/components/CandidateCompare.tsx +interface CandidateCompareProps { + candidates: CandidateWithCategory[]; + isActive: boolean; +} -const filteredItems = useMemo(() => { - if (!items) return []; - return items - .filter(item => { - if (!searchTerm) return true; - return item.name.toLowerCase().includes(searchTerm.toLowerCase()); - }) - .filter(item => { - if (!categoryFilter) return true; - return item.categoryId === categoryFilter; - }); -}, [items, searchTerm, categoryFilter]); +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 +} ``` -No debounce library needed -- `useMemo` re-computes on keystroke, and filtering an in-memory array of <1000 items is sub-millisecond. Debounce is only needed if triggering API calls. +**Weight/price delta display:** -**The category filter already exists** in `PlanningView` (lines 191-209 and 277-290 in `collection/index.tsx`). The same pattern should be reused for the gear tab with an icon-aware dropdown replacing the plain `` in PlanningView should be upgraded to an icon-aware dropdown that shows Lucide icons next to category names. This is a shared component that both the gear tab filter and the planning tab filter can use. +// 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: Weight Classification (Base / Worn / Consumable) +### Feature 2: Setup Impact Preview -**Scope:** Per-item-per-setup classification. An item's classification depends on the setup context (a rain jacket might be "worn" in one setup and "base" in another). This means the classification lives on the `setup_items` join table, not on the `items` table. +**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 | |-------|------|-------------|---------| -| DB | `schema.ts` | MODIFY | Add `weightClass` column to `setup_items` | -| DB | Drizzle migration | NEW | `ALTER TABLE setup_items ADD COLUMN weight_class TEXT NOT NULL DEFAULT 'base'` | -| Shared | `schemas.ts` | MODIFY | Add `weightClass` to sync schema, add update schema | -| Shared | `types.ts` | NO CHANGE | Types auto-infer from Drizzle schema | -| Server | `setup.service.ts` | MODIFY | `getSetupWithItems` returns `weightClass`; add `updateSetupItemClass` function | -| Server | `routes/setups.ts` | MODIFY | Add `PATCH /:id/items/:itemId` for classification update | -| Client | `hooks/useSetups.ts` | MODIFY | `SetupItemWithCategory` type adds `weightClass`; add `useUpdateSetupItemClass` mutation | -| Client | `routes/setups/$setupId.tsx` | MODIFY | Show classification badges, add toggle UI, compute classification totals | -| Client | `components/ItemCard.tsx` | MODIFY | Accept optional `weightClass` prop for setup context | -| Test | `tests/helpers/db.ts` | MODIFY | Add `weight_class` column to `setup_items` CREATE TABLE | +| 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 schema.ts -- setup_items table -export const setupItems = sqliteTable("setup_items", { - id: integer("id").primaryKey({ autoIncrement: true }), - setupId: integer("setup_id") - .notNull() - .references(() => setups.id, { onDelete: "cascade" }), - itemId: integer("item_id") - .notNull() - .references(() => items.id, { onDelete: "cascade" }), - weightClass: text("weight_class").notNull().default("base"), - // Values: "base" | "worn" | "consumable" +// 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"), }); ``` -**Why on setup_items, not items:** LighterPack and all serious gear tracking tools classify items per-loadout. A sleeping bag is "base weight" in a backpacking setup but might not be in a day hike setup. The same pair of hiking boots is "worn weight" in every setup, but this is a user choice per context. Storing on the join table preserves this flexibility at zero additional complexity -- the `setup_items` table already exists. - -**New endpoint for classification update:** - -The existing sync pattern (delete-all + re-insert) would destroy classification data on every item add/remove. Instead, add a targeted update endpoint: +**Sort order in service:** ```typescript -// In setup.service.ts -export function updateSetupItemClass( - db: Db, - setupId: number, - itemId: number, - weightClass: "base" | "worn" | "consumable", -) { - return db - .update(setupItems) - .set({ weightClass }) - .where( - sql`${setupItems.setupId} = ${setupId} AND ${setupItems.itemId} = ${itemId}`, - ) - .run(); -} +// 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(); ``` -```typescript -// In routes/setups.ts -- new PATCH route -app.patch("/:setupId/items/:itemId", zValidator("json", updateSetupItemClassSchema), (c) => { - const db = c.get("db"); - const setupId = Number(c.req.param("setupId")); - const itemId = Number(c.req.param("itemId")); - const { weightClass } = c.req.valid("json"); - updateSetupItemClass(db, setupId, itemId, weightClass); - return c.json({ success: true }); -}); -``` - -**Also update syncSetupItems** to preserve existing classifications or accept them: +**New service function for batch reorder:** ```typescript -// Updated syncSetupItems to accept optional weightClass -export function syncSetupItems( - db: Db, - setupId: number, - items: Array<{ itemId: number; weightClass?: string }>, +// In thread.service.ts +export function reorderCandidates( + db: Db = prodDb, + threadId: number, + orderedIds: number[], // candidate IDs in new rank order ) { return db.transaction((tx) => { - tx.delete(setupItems).where(eq(setupItems.setupId, setupId)).run(); - for (const item of items) { - tx.insert(setupItems) - .values({ - setupId, - itemId: item.itemId, - weightClass: item.weightClass ?? "base", - }) + 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(); } }); } ``` -**Sync schema update:** +**New API endpoint:** ```typescript -export const syncSetupItemsSchema = z.object({ - items: z.array(z.object({ - itemId: z.number().int().positive(), - weightClass: z.enum(["base", "worn", "consumable"]).default("base"), - })), +// 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 }); }); ``` -This is a **breaking change** to the sync API shape (from `{ itemIds: number[] }` to `{ items: [...] }`). The single call site is `useSyncSetupItems` in `useSetups.ts`, called from `ItemPicker.tsx`. - -**Client-side classification totals** are computed from the setup items array, not from a separate API: +**Client-side drag with framer-motion Reorder:** ```typescript -const baseWeight = setup.items - .filter(i => i.weightClass === "base") - .reduce((sum, i) => sum + (i.weightGrams ?? 0), 0); +// In routes/threads/$threadId.tsx +import { Reorder } from "framer-motion"; -const wornWeight = setup.items - .filter(i => i.weightClass === "worn") - .reduce((sum, i) => sum + (i.weightGrams ?? 0), 0); +// Local state tracks optimistic order +const [orderedCandidates, setOrderedCandidates] = useState(thread.candidates); -const consumableWeight = setup.items - .filter(i => i.weightClass === "consumable") - .reduce((sum, i) => sum + (i.weightGrams ?? 0), 0); -``` +// Sync with server data when it changes +useEffect(() => { + setOrderedCandidates(thread.candidates); +}, [thread.candidates]); -**UI for classification toggle:** A three-segment toggle on each item card within the setup detail view. Clicking a segment calls `useUpdateSetupItemClass`. The three segments use the same pill-tab pattern already used for Active/Resolved in PlanningView. +// On drag end, persist to server +const reorderCandidates = useReorderCandidates(threadId); ---- - -### Feature 3: Weight Distribution Visualization - -**Scope:** Donut chart showing weight breakdown by category (on collection page) and by classification (on setup detail page). Uses `react-minimal-pie-chart` (~2kB gzipped) instead of Recharts (~45kB) because this is the only chart in the app. - -**Integration points:** - -| Layer | File | Change Type | Details | -|-------|------|-------------|---------| -| Package | `package.json` | MODIFY | Add `react-minimal-pie-chart` dependency | -| Client | NEW `components/WeightChart.tsx` | NEW | Reusable donut chart component | -| Client | `routes/collection/index.tsx` | MODIFY | Add chart above category list in gear tab | -| Client | `routes/setups/$setupId.tsx` | MODIFY | Add classification breakdown chart | -| Client | `hooks/useTotals.ts` | NO CHANGE | Already returns `CategoryTotals[]` with weights | - -**Why react-minimal-pie-chart over Recharts:** The app needs exactly one chart type (donut/pie). Recharts adds ~45kB gzipped for a full charting library when only the PieChart component is used. `react-minimal-pie-chart` is <3kB gzipped, has zero dependencies beyond React, supports donut charts via `lineWidth` prop, includes animation, and provides label support. It is the right tool for a focused need. - -**Chart component pattern:** - -```typescript -// components/WeightChart.tsx -import { PieChart } from "react-minimal-pie-chart"; - -interface WeightChartProps { - segments: Array<{ - label: string; - value: number; // weight in grams (always grams internally) - color: string; - }>; - size?: number; +function handleReorderEnd() { + reorderCandidates.mutate(orderedCandidates.map(c => c.id)); } -export function WeightChart({ segments, size = 200 }: WeightChartProps) { - const filtered = segments.filter(s => s.value > 0); - if (filtered.length === 0) return null; - - return ( - ({ - title: s.label, - value: s.value, - color: s.color, - }))} - lineWidth={35} // donut style - paddingAngle={2} - rounded - animate - animationDuration={500} - style={{ height: size, width: size }} - /> - ); -} +// Render + + {orderedCandidates.map((candidate, index) => ( + + + + ))} + ``` -**Two usage contexts:** - -1. **Collection page** -- weight by category. Data source: `useTotals().data.categories`. Each `CategoryTotals` already has `totalWeight` and `categoryName`. Assign a consistent color per category (use category index mapped to a palette array). - -2. **Setup detail page** -- weight by classification. Data source: computed from `setup.items` grouping by `weightClass`. Three fixed colors for base/worn/consumable. - -**Color palette for categories:** +**Rank badge on CandidateCard:** ```typescript -const CATEGORY_COLORS = [ - "#6B7280", "#3B82F6", "#10B981", "#F59E0B", - "#EF4444", "#8B5CF6", "#EC4899", "#14B8A6", - "#F97316", "#6366F1", "#84CC16", "#06B6D4", -]; - -function getCategoryColor(index: number): string { - return CATEGORY_COLORS[index % CATEGORY_COLORS.length]; -} -``` - -**Classification colors (matching the app's muted palette):** - -```typescript -const CLASSIFICATION_COLORS = { - base: "#6B7280", // gray -- the core pack weight - worn: "#3B82F6", // blue -- on your body - consumable: "#F59E0B", // amber -- gets used up +// 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 }; ``` -**Chart placement:** On the collection page, the chart appears as a compact summary card above the category-grouped items, alongside the global totals. On the setup detail page, it appears in the sticky sub-bar area or as a collapsible section showing base/worn/consumable breakdown with a legend. Keep it compact -- this is a supplementary visualization, not the primary UI. +**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. ---- - -### Feature 4: Candidate Status Tracking - -**Scope:** Track candidate lifecycle from "researching" through "ordered" to "arrived". This is a column on the `thread_candidates` table, displayed as a badge on `CandidateCard`, and editable inline. - -**Integration points:** - -| Layer | File | Change Type | Details | -|-------|------|-------------|---------| -| DB | `schema.ts` | MODIFY | Add `status` column to `thread_candidates` | -| DB | Drizzle migration | NEW | `ALTER TABLE thread_candidates ADD COLUMN status TEXT NOT NULL DEFAULT 'researching'` | -| Shared | `schemas.ts` | MODIFY | Add `status` to candidate schemas | -| Server | `thread.service.ts` | MODIFY | Include `status` in candidate creates and updates | -| Server | `routes/threads.ts` | NO CHANGE | Already passes through all candidate fields | -| Client | `hooks/useThreads.ts` | MODIFY | `CandidateWithCategory` type adds `status` | -| Client | `hooks/useCandidates.ts` | NO CHANGE | `useUpdateCandidate` already handles partial updates | -| Client | `components/CandidateCard.tsx` | MODIFY | Show status badge, add click-to-cycle | -| Client | `components/CandidateForm.tsx` | MODIFY | Add status selector to form | -| Test | `tests/helpers/db.ts` | MODIFY | Add `status` column to `thread_candidates` CREATE TABLE | - -**Schema change:** +**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 schema.ts -- thread_candidates table -export const threadCandidates = sqliteTable("thread_candidates", { - // ... existing fields ... - status: text("status").notNull().default("researching"), - // Values: "researching" | "ordered" | "arrived" -}); +// 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) ``` -**Status badge colors (matching app's muted palette from v1.1):** - -```typescript -const CANDIDATE_STATUS_STYLES = { - researching: "bg-gray-100 text-gray-600", - ordered: "bg-amber-50 text-amber-600", - arrived: "bg-green-50 text-green-600", -} as const; -``` - -**Inline status cycling:** On `CandidateCard`, clicking the status badge cycles to the next state (researching -> ordered -> arrived). This calls the existing `useUpdateCandidate` mutation with just the status field. No new endpoint needed -- the `updateCandidate` service already accepts partial updates via `updateCandidateSchema.partial()`. - -```typescript -// In CandidateCard -const STATUS_ORDER = ["researching", "ordered", "arrived"] as const; - -function cycleStatus(current: string) { - const idx = STATUS_ORDER.indexOf(current as any); - return STATUS_ORDER[(idx + 1) % STATUS_ORDER.length]; -} - -// onClick handler for status badge: -updateCandidate.mutate({ - candidateId: id, - status: cycleStatus(status), -}); -``` - -**Candidate creation default:** New candidates default to "researching". The `createCandidateSchema` includes `status` as optional with default. - -```typescript -export const createCandidateSchema = z.object({ - // ... existing fields ... - status: z.enum(["researching", "ordered", "arrived"]).default("researching"), -}); -``` - ---- - -### Feature 5: Weight Unit Selection - -**Scope:** User preference stored in the `settings` table, applied globally across all weight displays. The database always stores grams -- unit conversion is a display-only concern handled in the client formatter. - -**Integration points:** - -| Layer | File | Change Type | Details | -|-------|------|-------------|---------| -| DB | `settings` table | NO SCHEMA CHANGE | Uses existing key-value `settings` table: `{ key: "weightUnit", value: "g" }` | -| Server | Settings routes | NO CHANGE | Existing `GET/PUT /api/settings/:key` handles this | -| Client | `hooks/useSettings.ts` | MODIFY | Add `useWeightUnit` convenience hook | -| Client | `lib/formatters.ts` | MODIFY | `formatWeight` accepts unit parameter | -| Client | NEW `hooks/useFormatWeight.ts` | NEW | Hook combining weight unit setting + formatter | -| Client | ALL components showing weight | MODIFY | Use new formatting approach | -| Client | `components/ItemForm.tsx` | MODIFY | Weight input label shows current unit, converts on submit | -| Client | `components/CandidateForm.tsx` | MODIFY | Same as ItemForm | -| Client | NEW `components/UnitSelector.tsx` | NEW | Unit picker UI (segmented control or dropdown) | - -**Settings approach -- why not a new table:** - -The `settings` table already exists with a `key/value` pattern, and `useSettings.ts` already has `useSetting(key)` and `useUpdateSetting`. Adding weight unit is: - -```typescript -// In useSettings.ts -export function useWeightUnit() { - return useSetting("weightUnit"); // Returns "g" | "oz" | "lb" | "kg" or null (default to "g") -} -``` - -**Conversion constants:** - -```typescript -const GRAMS_PER_UNIT = { - g: 1, - oz: 28.3495, - lb: 453.592, - kg: 1000, -} as const; - -type WeightUnit = keyof typeof GRAMS_PER_UNIT; -``` - -**Modified formatWeight:** - -```typescript -export function formatWeight( - grams: number | null | undefined, - unit: WeightUnit = "g", -): string { - if (grams == null) return "--"; - const converted = grams / GRAMS_PER_UNIT[unit]; - const decimals = unit === "g" ? 0 : unit === "kg" ? 2 : 1; - return `${converted.toFixed(decimals)} ${unit}`; -} -``` - -**Threading unit through components -- custom hook approach:** - -Create a `useFormatWeight()` hook. Components call it to get a unit-aware formatter. No React Context needed -- `useSetting()` already provides reactive data through React Query. - -```typescript -// hooks/useFormatWeight.ts -import { useSetting } from "./useSettings"; -import { formatWeight as rawFormat, type WeightUnit } from "../lib/formatters"; - -export function useFormatWeight() { - const { data: unitSetting } = useSetting("weightUnit"); - const unit = (unitSetting ?? "g") as WeightUnit; - - return { - unit, - formatWeight: (grams: number | null | undefined) => rawFormat(grams, unit), - }; -} -``` - -Components that display weight (ItemCard, CandidateCard, CategoryHeader, TotalsBar, SetupDetailPage) call `const { formatWeight } = useFormatWeight()` instead of importing `formatWeight` directly from `lib/formatters.ts`. This is 6-8 call sites to update. - -**Weight input handling:** When the user enters weight in the form, the input accepts the selected unit and converts to grams before sending to the API. The label changes from "Weight (g)" to "Weight (oz)" etc. - -```typescript -// In ItemForm, the label reads from the hook -const { unit } = useFormatWeight(); -// Label: `Weight (${unit})` - -// On submit, before payload construction: -const weightGrams = form.weightValue - ? Number(form.weightValue) * GRAMS_PER_UNIT[unit] - : undefined; -``` - -**When editing an existing item**, the form pre-fills by converting stored grams back to the display unit: - -```typescript -const displayWeight = item.weightGrams != null - ? (item.weightGrams / GRAMS_PER_UNIT[unit]).toFixed(unit === "g" ? 0 : unit === "kg" ? 2 : 1) - : ""; -``` - -**Unit selector placement:** In the TotalsBar component. The user sees the unit right where weights are displayed and can switch inline. A small segmented control or dropdown next to the weight display in the top bar. - --- ## New vs Modified Files -- Complete Inventory -### New Files (5) +### New Files (3) | File | Purpose | |------|---------| -| `src/client/components/SearchBar.tsx` | Reusable search input with clear button | -| `src/client/components/WeightChart.tsx` | Donut chart wrapper around react-minimal-pie-chart | -| `src/client/components/UnitSelector.tsx` | Weight unit segmented control / dropdown | -| `src/client/hooks/useFormatWeight.ts` | Hook combining weight unit setting + formatter | -| `src/db/migrations/XXXX_v1.2_columns.sql` | Drizzle migration for new columns | +| `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 (15) +### Modified Files (12) | File | What Changes | |------|-------------| -| `package.json` | Add `react-minimal-pie-chart` dependency | -| `src/db/schema.ts` | Add `weightClass` to setup_items, `status` to thread_candidates | -| `src/shared/schemas.ts` | Add `status` to candidate schemas, update sync schema | -| `src/server/services/setup.service.ts` | Return `weightClass`, add `updateSetupItemClass`, update `syncSetupItems` | -| `src/server/services/thread.service.ts` | Include `status` in candidate create/update | -| `src/server/routes/setups.ts` | Add `PATCH /:id/items/:itemId` for classification | -| `src/client/lib/formatters.ts` | `formatWeight` accepts unit param, add conversion constants | -| `src/client/hooks/useSetups.ts` | `SetupItemWithCategory` adds `weightClass`, update sync mutation, add classification mutation | -| `src/client/hooks/useThreads.ts` | `CandidateWithCategory` adds `status` field | -| `src/client/hooks/useSettings.ts` | Add `useWeightUnit` convenience export | -| `src/client/routes/collection/index.tsx` | Add SearchBar + category filter to gear tab, add weight chart | -| `src/client/routes/setups/$setupId.tsx` | Classification toggles per item, classification chart, updated totals | -| `src/client/components/ItemCard.tsx` | Optional `weightClass` badge in setup context | -| `src/client/components/CandidateCard.tsx` | Status badge + click-to-cycle behavior | -| `tests/helpers/db.ts` | Add `weight_class` and `status` columns to CREATE TABLE statements | +| `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 fetch wrappers handle all new API shapes | -| `src/client/stores/uiStore.ts` | No new panel/dialog state needed | -| `src/server/routes/items.ts` | Search is client-side | -| `src/server/services/item.service.ts` | No query changes needed | -| `src/server/services/totals.service.ts` | Category totals unchanged; classification totals computed client-side | +| `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 | -| `src/server/index.ts` | No new route registrations (setups routes already registered) | +| `package.json` | `framer-motion` already installed at v12.37.0 | -## Build Order (Dependency-Aware) - -The features have specific dependencies that dictate build order. - -``` -Phase 1: Weight Unit Selection - +-- Modifies formatWeight which is used everywhere - +-- Must be done first so subsequent weight displays use the new formatter - +-- Dependencies: none (uses existing settings infrastructure) - -Phase 2: Search/Filter - +-- Pure client-side addition, no schema changes - +-- Can be built independently - +-- Dependencies: none - -Phase 3: Candidate Status Tracking - +-- Schema migration (simple column add) - +-- Minimal integration surface - +-- Dependencies: none (but batch schema migration with Phase 4) - -Phase 4: Weight Classification - +-- Schema migration + sync API change + new PATCH endpoint - +-- Requires weight unit work to be done (displays classification totals) - +-- Dependencies: Phase 1 (weight formatting) - -Phase 5: Weight Distribution Charts - +-- Depends on weight classification (for setup breakdown chart) - +-- Depends on weight unit (chart labels need formatted weights) - +-- Dependencies: Phase 1 + Phase 4 - +-- npm dependency: react-minimal-pie-chart -``` - -**Batch Phase 3 and Phase 4 schema migrations into one Drizzle migration** since they both add columns. Run `bun run db:generate` once after both schema changes are made. +--- ## Data Flow Changes Summary -### Current Data Flows (unchanged) +### Existing Data Flows (unchanged) ``` -useItems() -> GET /api/items -> getAllItems(db) -> items JOIN categories -useThreads() -> GET /api/threads -> getAllThreads(db) -> threads JOIN categories -useSetups() -> GET /api/setups -> getAllSetups(db) -> setups + subqueries -useTotals() -> GET /api/totals -> getCategoryTotals -> items GROUP BY categoryId +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/Modified Data Flows +### New Data Flows ``` -Search/Filter: - CollectionView local state (searchTerm, categoryFilter) - -> useMemo over useItems().data - -> no API change +Side-by-Side Comparison (client-only): + thread.candidates[] (already in cache) + -> uiStore.compareMode == true + -> CandidateCompare component + -> renders column-per-candidate table + -> no API call -Weight Unit: - useFormatWeight() -> useSetting("weightUnit") -> GET /api/settings/weightUnit - -> formatWeight(grams, unit) -> display string +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 Status: - CandidateCard click -> useUpdateCandidate({ status: "ordered" }) - -> PUT /api/threads/:id/candidates/:cid -> updateCandidate(db, cid, { status }) - -Weight Classification: - Setup detail -> getSetupWithItems now returns weightClass per item - -> client groups by weightClass for totals - -> PATCH /api/setups/:id/items/:itemId updates classification - -Weight Chart: - Collection: useTotals().data.categories -> WeightChart segments - Setup: setup.items grouped by weightClass -> WeightChart segments +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-Side Search for Small Collections +### Anti-Pattern 1: Server Endpoint for Comparison Deltas -**What people do:** Build a search API with pagination, debounced requests, loading states -**Why it's wrong for this app:** Single-user app with <1000 items. Server round-trips add latency and complexity for zero benefit. Client already has all items in React Query cache. -**Do this instead:** Filter in-memory using `useMemo` over the cached items array. +**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: Weight Classification on the Items Table +### Anti-Pattern 2: Server Endpoint for Impact Preview -**What people do:** Add `weightClass` column to `items` table -**Why it's wrong:** An item's classification is context-dependent -- the same item can be "base" in one setup and not present in another. Putting it on `items` forces a single global classification. -**Do this instead:** Put `weightClass` on `setup_items` join table. This is how LighterPack and every serious gear tracker works. +**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: Converting Stored Values to User's Unit +### Anti-Pattern 3: Storing Rank as a Linked List -**What people do:** Store weights in the user's preferred unit, or convert on the server before sending -**Why it's wrong:** Changing the unit preference would require re-interpreting all stored data. Different users (future multi-user) might prefer different units from the same data. -**Do this instead:** Always store grams in the database. Convert to display unit only in the client formatter. The conversion is a pure function with no side effects. +**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: Heavy Charting Library for One Chart Type +### Anti-Pattern 4: External DnD Library When framer-motion is Already Present -**What people do:** Install Recharts (~45kB) or Chart.js (~67kB) for a single donut chart -**Why it's wrong:** Massive bundle size overhead for minimal usage. These libraries are designed for dashboards with many chart types. -**Do this instead:** Use `react-minimal-pie-chart` (<3kB) which does exactly donut/pie charts with zero dependencies. +**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: React Context Provider for Weight Unit +### Anti-Pattern 5: Full Optimistic Update with Rollback for Reorder -**What people do:** Build a full React Context provider with `createContext`, `useContext`, a Provider wrapper component -**Why it's excessive here:** The `useSetting("weightUnit")` hook already provides reactive data through React Query. Adding a Context layer on top adds indirection for no benefit. -**Do this instead:** Create a simple custom hook `useFormatWeight()` that internally calls `useSetting("weightUnit")`. React Query already handles caching and reactivity. +**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 -- [Drizzle ORM Filters Documentation](https://orm.drizzle.team/docs/operators) -- like, and, or operators for SQLite -- [Drizzle ORM Conditional Filters Guide](https://orm.drizzle.team/docs/guides/conditional-filters-in-query) -- dynamic filter patterns -- [SQLite LIKE case sensitivity with Drizzle](https://github.com/drizzle-team/drizzle-orm-docs/issues/239) -- SQLite LIKE is case-insensitive for ASCII -- [react-minimal-pie-chart npm](https://www.npmjs.com/package/react-minimal-pie-chart) -- lightweight pie/donut chart, <3kB gzipped -- [react-minimal-pie-chart GitHub](https://github.com/toomuchdesign/react-minimal-pie-chart) -- API docs and examples -- [LighterPack Tutorial - 99Boulders](https://www.99boulders.com/lighterpack-tutorial) -- base/worn/consumable weight classification standard -- [Pack Weight Categories](https://hikertimes.com/difference-between-base-weight-and-total-weight/) -- base weight vs total weight definitions -- [Pack Weight Calculator](https://backpackpeek.com/blog/pack-weight-calculator-base-weight-guide) -- weight classification guide +- [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.2 Collection Power-Ups* +*Architecture research for: GearBox v1.3 Research & Decision Tools* *Researched: 2026-03-16* diff --git a/.planning/research/FEATURES.md b/.planning/research/FEATURES.md index 506c5b6..81e59ae 100644 --- a/.planning/research/FEATURES.md +++ b/.planning/research/FEATURES.md @@ -1,244 +1,262 @@ -# Feature Research: v1.2 Collection Power-Ups +# Feature Research -**Domain:** Gear management -- search/filter, weight classification, weight visualization, candidate status tracking, weight unit selection +**Domain:** Gear management — candidate comparison, setup impact preview, and candidate ranking **Researched:** 2026-03-16 -**Confidence:** HIGH -**Scope:** New features only. v1.0/v1.1 features (item CRUD, categories, threads, setups, dashboard, onboarding, images, icons) are already shipped. +**Confidence:** HIGH (existing codebase fully understood; UX patterns verified via multiple sources) -## Table Stakes +--- -Features that gear management users expect. Missing these makes the app feel incomplete for collections beyond ~20 items. +## Context -| Feature | Why Expected | Complexity | Dependencies on Existing | -|---------|--------------|------------|--------------------------| -| Search items by name | Every competitor with an inventory concept has search. Hikt highlights "searchable digital closet." PackLight Supporter Edition has inventory search. Once a collection exceeds 30 items, scrolling to find something is painful. LighterPack notably lacks this and users complain. | LOW | Items query (`useItems`), collection view. Client-side only -- no API changes needed for <500 items. | -| Filter items by category | Already partially exists in Planning view (category dropdown for threads). Collection view groups by category visually but has no filter. Users need to quickly narrow to "show me just my shelter items." | LOW | Categories query (`useCategories`), collection view. Client-side filtering of already-fetched items. | -| Weight unit selection (g, oz, lb, kg) | Universal across all competitors. LighterPack supports toggling between g/oz/lb/kg. Packrat offers per-item input in any unit with display conversion. Backpacking Light forum users specifically praise apps that let you "enter item weights in grams and switch the entire display to lbs & oz." Gear specs come in mixed units -- a sleeping bag in lbs/oz, a fuel canister in grams. | LOW | `formatWeight()` in `lib/formatters.ts`, `settings` table (already exists with key/value store), TotalsBar, ItemCard, CandidateCard, SetupCard -- every weight display. | -| Weight classification (base/worn/consumable) | LighterPack pioneered this three-way split and it is now universal. Hikt, PackLight, Packstack, HikeLite, 99Boulders spreadsheet -- all support it. "Base weight" is the core metric of the ultralight community. Without classification, weight totals are a single number with no actionable insight. | MEDIUM | `setup_items` join table (needs new column), setup detail view, setup service, totals computation. Schema migration required. | +This is a subsequent milestone research file for **v1.3 Research & Decision Tools**. +The features below are **additive** to v1.2. All three features operate within the existing +`threads/$threadId` page and its data model. -## Differentiators +**Existing data model relevant to this milestone:** +- `threadCandidates`: id, threadId, name, weightGrams, priceCents, categoryId, notes, productUrl, imageFilename, status — no rank, pros, or cons columns yet +- `setups` + `setupItems`: stores weight/cost per setup item with classification (base/worn/consumable) +- `getSetupWithItems` already returns `classification` per item — available for impact preview -Features that set GearBox apart or add meaningful value beyond what competitors offer. +--- -| Feature | Value Proposition | Complexity | Dependencies on Existing | -|---------|-------------------|------------|--------------------------| -| Weight distribution visualization (donut/pie chart) | LighterPack's pie chart is iconic and widely cited as its best feature. "The pie chart at the top is a great way to visualize how your pack weight breaks down by category." PackLight uses bar graphs. GearBox can do this per-setup with a modern donut chart that also shows base/worn/consumable breakdown -- a combination no competitor offers cleanly. | MEDIUM | Totals data (already computed server-side per category), weight classification (new), a chart library (react-minimal-pie-chart at 2kB or Recharts). | -| Candidate status tracking (researching/ordered/arrived) | No competitor has this. Research confirmed: the specific workflow of tracking purchase status through stages does not exist in any gear management app. This is unique to GearBox's planning thread concept. It makes threads a living document of the purchase lifecycle, not just a comparison tool. | LOW | `thread_candidates` table (needs new `status` column), CandidateCard, CandidateForm. Simple text field migration. | -| Planning category filter with icon-aware dropdown | Already partially built as a plain `` without icons. Since categories now have Lucide icons (v1.1), the filter should show them. - -**Recommended implementation:** -- Replace the native `` with a custom dropdown component. +--- ## Feature Dependencies ``` -[Weight Unit Selection] --independent-- (affects all displays, no schema changes) - | - +-- should ship first (all other features benefit from correct unit display) +[Side-by-side comparison view] + └──requires──> [All candidate fields visible in UI] + (weightGrams, priceCents, notes, productUrl already in schema) + └──enhances──> [Pros/cons fields] (displayed as comparison rows) + └──enhances──> [Drag-to-rank] (rank number shown as position in comparison columns) + └──enhances──> [Impact preview] (delta displayed per-column inline) -[Search & Filter] --independent-- (pure client-side, no schema changes) - | - +-- no dependencies on other v1.2 features +[Impact preview (weight + cost delta)] + └──requires──> [Setup selector] (user picks which setup to compute delta against) + └──requires──> [Setup data client-side] (useSetup hook already exists, no new API) + └──requires──> [Candidate weight/price data] (already in threadCandidates schema) -[Candidate Status Tracking] --independent-- (schema change on thread_candidates only) - | - +-- no dependencies on other v1.2 features +[Setup selector] + └──requires──> [useSetups() hook] (already exists in src/client/hooks/useSetups.ts) + └──requires──> [useSetup(id) hook] (already exists, loads items with classification) -[Weight Classification] --depends-on--> [existing setup_items table] - | - +-- schema migration on setup_items - +-- enables [Weight Distribution Visualization] +[Drag-to-rank] + └──requires──> [rank INTEGER column on threadCandidates] (new — schema migration) + └──requires──> [PATCH /api/threads/:id/candidates/rank endpoint] (new API endpoint) + └──enhances──> [Side-by-side comparison] (rank visible as position indicator) + └──enhances──> [Card grid view] (rank badge on each CandidateCard) -[Weight Distribution Visualization] --depends-on--> [Weight Classification] - | - +-- needs classification data to show base/worn/consumable breakdown - +-- can show by-category chart without classification (partial value) - +-- new dependency: react-minimal-pie-chart - -[Planning Category Filter Icons] --depends-on--> [existing CategoryPicker pattern] - | - +-- pure UI enhancement +[Pros/cons fields] + └──requires──> [pros TEXT column on threadCandidates] (new — schema migration) + └──requires──> [cons TEXT column on threadCandidates] (new — schema migration) + └──requires──> [updateCandidateSchema extended] (add pros/cons to Zod schema) + └──enhances──> [CandidateForm edit panel] (new textarea fields) + └──enhances──> [Side-by-side comparison] (pros/cons rows in comparison table) ``` -### Implementation Order Rationale +### Dependency Notes -1. **Weight Unit Selection** first -- touches formatting everywhere, foundational for all subsequent weight displays -2. **Search & Filter** second -- standalone, immediately useful, low risk -3. **Candidate Status Tracking** third -- standalone schema change, simple -4. **Planning Category Filter** fourth -- quick UI polish -5. **Weight Classification** fifth -- most complex schema change, affects setup data model -6. **Weight Distribution Visualization** last -- depends on classification, needs chart library, highest UI complexity +- **Side-by-side comparison is independent of schema changes.** It can be built using + existing candidate data. No migrations required. Delivers value immediately. +- **Impact preview is independent of schema changes.** Uses existing `useSetups` and + `useSetup` hooks client-side. Delta computation is pure math in the component. + No new API endpoint needed for MVP. +- **Drag-to-rank requires schema migration.** `rank` column must be added to + `threadCandidates`. Default ordering on migration = `createdAt` ascending. +- **Pros/cons requires schema migration.** Two nullable `text` columns on + `threadCandidates`. Low risk — nullable, backwards compatible. +- **Comparison view enhances everything.** Best delivered after rank and pros/cons + schema work is done so the full table is useful from day one. -## Complexity Summary +--- -| Feature | Schema Change | API Change | New Dependency | UI Scope | Overall | -|---------|---------------|------------|----------------|----------|---------| -| Search & Filter | None | None | None | Collection view only | LOW | -| Weight Unit Selection | None (uses settings) | None (settings API exists) | None | All weight displays (~8 components) | LOW | -| Candidate Status Tracking | `thread_candidates.status` column | Update candidate CRUD | None | CandidateCard, CandidateForm | LOW | -| Planning Category Filter | None | None | None | PlanningView dropdown | LOW | -| Weight Classification | `setup_items.classification` column | Update setup sync + detail endpoints | None | Setup detail view | MEDIUM | -| Weight Distribution Chart | None | Possibly new totals endpoint | react-minimal-pie-chart (~2kB) | New chart component | MEDIUM | +## MVP Definition + +### Launch With (v1.3 milestone) + +- [ ] **Side-by-side comparison view** — Core deliverable. Replace mental juggling of the card + grid with a scannable table. No schema changes. Highest ROI, lowest risk. +- [ ] **Impact preview: flat weight + cost delta per candidate** — Shows `+/- X g` and + `+/- $Y` vs. the selected setup. Pure client-side math. No schema changes. +- [ ] **Setup selector** — Dropdown of user's setups. Required for impact preview. One + interaction: pick a setup, see deltas update. +- [ ] **Drag-to-rank** — Requires `rank` column migration. `@dnd-kit/sortable` handles + the drag UX. Persist via new PATCH endpoint. +- [ ] **Pros/cons text fields** — Requires `pros` + `cons` column migration. Trivially low + implementation complexity once schema is in place. + +### Add After Validation (v1.x) + +- [ ] **Classification-aware impact preview** — Delta broken down by base/worn/consumable. + Higher complexity UI. Add once flat delta is validated as useful. + Trigger: user feedback requests "which classification does this affect?" +- [ ] **Rank indicator on card grid** — Small "1st", "2nd" badge on CandidateCard. + Trigger: users express confusion about which candidate is ranked first without entering + comparison view. +- [ ] **Comparison view on mobile** — Horizontal scroll works but is not ideal. Consider + attribute-focus swipe view. Trigger: usage data shows mobile traffic on thread pages. + +### Future Consideration (v2+) + +- [ ] **Comparison permalink** — Requires auth/multi-user work first. +- [ ] **Auto-fill from product URL** — Fragile scraping, rejected in PROJECT.md. +- [ ] **Custom comparison attributes** — Explicitly rejected in PROJECT.md. + +--- + +## Feature Prioritization Matrix + +| Feature | User Value | Implementation Cost | Priority | +|---------|------------|---------------------|----------| +| Side-by-side comparison view | HIGH | MEDIUM | P1 | +| Setup impact preview (flat delta) | HIGH | LOW | P1 | +| Setup selector for impact preview | HIGH | LOW | P1 | +| Drag-to-rank ordering | MEDIUM | MEDIUM | P1 | +| Pros/cons text fields | MEDIUM | LOW | P1 | +| Classification-aware impact preview | MEDIUM | HIGH | P2 | +| Rank indicator on card grid | LOW | LOW | P2 | +| Mobile-optimized comparison view | LOW | MEDIUM | P3 | + +**Priority key:** +- P1: Must have for this milestone launch +- P2: Should have, add when possible +- P3: Nice to have, future consideration + +--- + +## Competitor Feature Analysis + +| Feature | LighterPack | GearGrams | OutPack | Our Approach | +|---------|-------------|-----------|---------|--------------| +| Side-by-side candidate comparison | None (list only) | None (library + trip list) | None | Inline comparison table on thread detail page, toggle from grid view | +| Impact preview / weight delta | None (duplicate lists manually to compare) | None (no delta concept) | None | Per-candidate delta vs. selected setup, computed client-side | +| Candidate ranking | None | None | None | Drag-to-rank with persisted `rank` column | +| Pros/cons annotation | None (notes field only) | None (notes field only) | None | Dedicated `pros` and `cons` fields separate from general notes | +| Status tracking | None | "wish list" item flag only | None | Already built in v1.2 (researching/ordered/arrived) | + +**Key insight:** No existing gear management tool has a comparison view, delta preview, or +ranking system for candidates within a research thread. This is an unmet-need gap. +The features are adapted from general product comparison UX (e-commerce) to the gear domain. + +--- + +## Implementation Notes by Feature + +### Side-by-side Comparison View + +- Rendered as a transposed table: rows = attribute labels, columns = candidates. +- Rows: Image (thumbnail), Name, Weight, Price, Status, Notes, Link, Pros, Cons, Rank, Impact Delta (weight), Impact Delta (cost). +- Sticky first column (attribute label) while candidate columns scroll horizontally for 3+. +- Candidate images at reduced aspect ratio (square thumbnail ~80px). +- Weight/price cells use existing `formatWeight` / `formatPrice` formatters with the user's preferred unit. +- Status cell reuses existing `StatusBadge` component. +- "Pick as winner" action available per column (reuses existing `openResolveDialog`). +- Toggle between grid view (current) and table view. Preserve both modes. Default to grid; + user activates comparison mode explicitly. +- Comparison mode is a UI state only (Zustand or local component state) — no URL change needed. + +### Impact Preview + +- Setup selector: `