From 2424ecc0c23f1b69ade920c07d51dd4bb46c2af4 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 15 Mar 2026 12:26:27 +0100 Subject: [PATCH] docs(03): research phase domain --- .../03-setups-and-dashboard/03-RESEARCH.md | 540 ++++++++++++++++++ 1 file changed, 540 insertions(+) create mode 100644 .planning/phases/03-setups-and-dashboard/03-RESEARCH.md diff --git a/.planning/phases/03-setups-and-dashboard/03-RESEARCH.md b/.planning/phases/03-setups-and-dashboard/03-RESEARCH.md new file mode 100644 index 0000000..2ae86cc --- /dev/null +++ b/.planning/phases/03-setups-and-dashboard/03-RESEARCH.md @@ -0,0 +1,540 @@ +# Phase 3: Setups and Dashboard - Research + +**Researched:** 2026-03-15 +**Domain:** Full-stack CRUD (Drizzle + Hono + React) with navigation restructure +**Confidence:** HIGH + +## Summary + +Phase 3 adds two features: (1) named setups (loadouts) that compose collection items with live weight/cost totals, and (2) a dashboard home page with summary cards. The codebase has strong established patterns from Phases 1 and 2 -- database schema in Drizzle, service layer with DB injection, Hono routes with Zod validation, TanStack Query hooks, and Zustand UI state. This phase follows identical patterns with one significant difference: the many-to-many relationship between items and setups via a junction table (`setup_items`). + +The navigation restructure moves the current `/` (gear + planning tabs) to `/collection` and replaces `/` with a dashboard. This requires moving the existing `index.tsx` route content to a new `collection/index.tsx` route, creating new `/setups` routes, and making TotalsBar route-aware for contextual stats. + +**Primary recommendation:** Follow the exact thread CRUD pattern for setups (schema, service, routes, hooks, components), add a `setup_items` junction table for the many-to-many relationship, compute setup totals server-side via SQL aggregation, and restructure routes with TanStack Router file-based routing. + + +## User Constraints (from CONTEXT.md) + +### Locked Decisions +- Setup item selection: Checklist picker in SlideOutPanel, items grouped by category with emoji headers, toggle via checkboxes, "Done" button to confirm, items shared across setups +- Setup creation: Inline name input + create button (same pattern as thread creation) +- Setup display: Card grid grouped by category reusing ItemCard with small x remove icon, per-category subtotals in CategoryHeader +- Setup totals: Sticky bar at top showing setup name, item count, total weight, total cost (reuses TotalsBar pattern) +- Dashboard cards: Three equal-width cards (Collection, Planning, Setups) side by side on desktop, stacking on mobile, with summary stats on each card +- Dashboard header: "GearBox" title only, no welcome message +- Navigation: `/` = Dashboard, `/collection` = Gear|Planning tabs, `/setups` = Setups list, `/setups/:id` = Setup detail +- "GearBox" title in TotalsBar is always a clickable link back to dashboard +- No breadcrumbs or back arrows -- GearBox title link is the only back navigation + +### Claude's Discretion +- Setup list card design (stats/info per setup card beyond name and totals) +- Exact Tailwind styling, spacing, and transitions for dashboard cards +- Setup detail page layout specifics beyond card grid + sticky totals +- How checklist picker handles large number of items (scroll behavior) +- Error states and loading skeletons + +### Deferred Ideas (OUT OF SCOPE) +None -- discussion stayed within phase scope + + + +## Phase Requirements + +| ID | Description | Research Support | +|----|-------------|-----------------| +| SETP-01 | User can create named setups (e.g. "Summer Bikepacking") | Setup CRUD: schema, service, routes, hooks -- follows thread creation pattern exactly | +| SETP-02 | User can add/remove collection items to a setup | Junction table `setup_items`, checklist picker in SlideOutPanel, batch sync endpoint | +| SETP-03 | User can see total weight and cost for a setup | Server-side SQL aggregation via `setup_items` JOIN `items`, setup totals endpoint | +| DASH-01 | User sees dashboard home page with cards linking to collection, threads, and setups | New `/` route with three summary cards, existing content moves to `/collection` | + + +## Standard Stack + +### Core (already installed, no new dependencies) +| Library | Version | Purpose | Why Standard | +|---------|---------|---------|--------------| +| drizzle-orm | 0.45.1 | Database schema + queries | Already used for items, threads | +| hono | 4.12.8 | API routes | Already used for all server routes | +| @hono/zod-validator | 0.7.6 | Request validation | Already used on all routes | +| zod | 4.3.6 | Schema validation | Already used in shared schemas | +| @tanstack/react-query | 5.90.21 | Data fetching + cache | Already used for items, threads, totals | +| @tanstack/react-router | 1.167.0 | File-based routing | Already used, auto-generates route tree | +| zustand | 5.0.11 | UI state | Already used for panel/dialog state | +| tailwindcss | 4.2.1 | Styling | Already used throughout | + +### Supporting +No new libraries needed. Phase 3 uses only existing dependencies. + +### Alternatives Considered +None -- all decisions are locked to existing stack patterns. + +**Installation:** +```bash +# No new packages needed +``` + +## Architecture Patterns + +### New Files Structure +``` +src/ + db/ + schema.ts # ADD: setups + setup_items tables + shared/ + schemas.ts # ADD: setup Zod schemas + types.ts # ADD: Setup + SetupItem types + server/ + routes/ + setups.ts # NEW: setup CRUD routes + services/ + setup.service.ts # NEW: setup business logic + index.ts # UPDATE: mount setup routes + client/ + routes/ + index.tsx # REWRITE: dashboard page + collection/ + index.tsx # NEW: moved from current index.tsx (gear + planning tabs) + setups/ + index.tsx # NEW: setups list page + $setupId.tsx # NEW: setup detail page + hooks/ + useSetups.ts # NEW: setup query/mutation hooks + components/ + SetupCard.tsx # NEW: setup list card + ItemPicker.tsx # NEW: checklist picker for SlideOutPanel + DashboardCard.tsx # NEW: dashboard summary card + stores/ + uiStore.ts # UPDATE: add setup-related UI state +tests/ + helpers/ + db.ts # UPDATE: add setups + setup_items tables + services/ + setup.service.test.ts # NEW: setup service tests + routes/ + setups.test.ts # NEW: setup route tests +``` + +### Pattern 1: Many-to-Many Junction Table +**What:** `setup_items` links setups to items (items can belong to multiple setups) +**When to use:** This is the only new DB pattern in this phase + +```typescript +// In src/db/schema.ts +export const setups = sqliteTable("setups", { + id: integer("id").primaryKey({ autoIncrement: true }), + name: text("name").notNull(), + createdAt: integer("created_at", { mode: "timestamp" }) + .notNull() + .$defaultFn(() => new Date()), + updatedAt: integer("updated_at", { mode: "timestamp" }) + .notNull() + .$defaultFn(() => new Date()), +}); + +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" }), + addedAt: integer("added_at", { mode: "timestamp" }) + .notNull() + .$defaultFn(() => new Date()), +}); +``` + +**Key design decisions:** +- `onDelete: "cascade"` on both FKs: deleting a setup removes its setup_items; deleting a collection item removes it from all setups +- No unique constraint on (setupId, itemId) at DB level -- enforce in service layer for better error messages +- `addedAt` for potential future ordering, but not critical for v1 + +### Pattern 2: Batch Sync for Setup Items +**What:** Instead of individual add/remove endpoints, use a single "sync" endpoint that receives the full list of selected item IDs +**When to use:** When the checklist picker submits all selections at once via "Done" button + +```typescript +// In setup.service.ts +export function syncSetupItems(db: Db = prodDb, setupId: number, itemIds: number[]) { + return db.transaction((tx) => { + // Delete all existing setup_items for this setup + tx.delete(setupItems).where(eq(setupItems.setupId, setupId)).run(); + // Insert new ones + if (itemIds.length > 0) { + tx.insert(setupItems) + .values(itemIds.map(itemId => ({ setupId, itemId }))) + .run(); + } + }); +} +``` + +**Why batch sync over individual add/remove:** +- The checklist picker has a "Done" button that submits all at once +- Simpler than tracking individual toggles +- Single transaction = atomic operation +- Still need a single-item remove for the x button on cards (separate endpoint) + +### Pattern 3: Setup Totals via SQL Aggregation +**What:** Compute setup weight/cost totals server-side by joining `setup_items` with `items` +**When to use:** For the setup detail page totals bar and setup list cards + +```typescript +// In setup.service.ts +export function getSetupWithItems(db: Db = prodDb, setupId: number) { + const setup = db.select().from(setups).where(eq(setups.id, setupId)).get(); + if (!setup) return null; + + const itemList = db + .select({ + id: items.id, + name: items.name, + weightGrams: items.weightGrams, + priceCents: items.priceCents, + categoryId: items.categoryId, + notes: items.notes, + productUrl: items.productUrl, + imageFilename: items.imageFilename, + createdAt: items.createdAt, + updatedAt: items.updatedAt, + categoryName: categories.name, + categoryEmoji: categories.emoji, + }) + .from(setupItems) + .innerJoin(items, eq(setupItems.itemId, items.id)) + .innerJoin(categories, eq(items.categoryId, categories.id)) + .where(eq(setupItems.setupId, setupId)) + .all(); + + return { ...setup, items: itemList }; +} +``` + +**Totals are computed client-side from the items array** (not a separate endpoint) since the setup detail page already fetches all items. This avoids an extra API call and keeps totals always in sync with displayed data. + +For the setup list cards (showing totals per setup), use a SQL subquery: + +```typescript +export function getAllSetups(db: Db = prodDb) { + return db + .select({ + id: setups.id, + name: setups.name, + createdAt: setups.createdAt, + updatedAt: setups.updatedAt, + itemCount: sql`( + SELECT COUNT(*) FROM setup_items + WHERE setup_items.setup_id = setups.id + )`.as("item_count"), + totalWeight: sql`COALESCE(( + SELECT SUM(items.weight_grams) FROM setup_items + INNER JOIN items ON setup_items.item_id = items.id + WHERE setup_items.setup_id = setups.id + ), 0)`.as("total_weight"), + totalCost: sql`COALESCE(( + SELECT SUM(items.price_cents) FROM setup_items + INNER JOIN items ON setup_items.item_id = items.id + WHERE setup_items.setup_id = setups.id + ), 0)`.as("total_cost"), + }) + .from(setups) + .orderBy(desc(setups.updatedAt)) + .all(); +} +``` + +### Pattern 4: Route-Aware TotalsBar +**What:** Make TotalsBar show different content based on the current route +**When to use:** Dashboard shows "GearBox" title only; collection shows global totals; setup detail shows setup-specific totals + +```typescript +// TotalsBar accepts optional props to override default behavior +interface TotalsBarProps { + title?: string; // Override the title text + stats?: TotalsStat[]; // Override stats display (empty = title only) + linkTo?: string; // Make title a link (defaults to "/") +} +``` + +**Approach:** Rather than making TotalsBar read route state internally, have each page pass the appropriate stats. This keeps TotalsBar a pure presentational component. + +- Dashboard page: `` (title only, no stats, no link since already on dashboard) +- Collection page: `` (current behavior) +- Setup detail: `` +- Thread detail: keep current behavior + +The "GearBox" title becomes a `` on all pages except the dashboard itself. + +### Pattern 5: TanStack Router File-Based Routing +**What:** New route files auto-register via TanStack Router plugin +**When to use:** Creating `/collection`, `/setups`, `/setups/:id` routes + +``` +src/client/routes/ + __root.tsx # Existing root layout + index.tsx # REWRITE: Dashboard + collection/ + index.tsx # NEW: current index.tsx content moves here + setups/ + index.tsx # NEW: setups list + $setupId.tsx # NEW: setup detail + threads/ + $threadId.tsx # Existing, unchanged +``` + +The TanStack Router plugin will auto-generate `routeTree.gen.ts` with the new routes. Route files use `createFileRoute("/path")` -- the path must match the file location. + +### Pattern 6: Dashboard Summary Stats +**What:** Dashboard cards need aggregate data from multiple domains +**When to use:** The dashboard page + +The dashboard needs: collection item count + total weight + total cost, active thread count, setup count. Two approaches: + +**Recommended: Aggregate on client from existing hooks** +- `useTotals()` already provides collection stats +- `useThreads()` provides thread list (count from `.length`) +- New `useSetups()` provides setup list (count from `.length`) + +This avoids a new dashboard-specific API endpoint. Three parallel queries that TanStack Query handles efficiently with its deduplication. + +### Anti-Patterns to Avoid +- **Don't add setup state to Zustand beyond UI concerns:** Setup data belongs in TanStack Query cache, not Zustand. Zustand is only for panel open/close state. +- **Don't compute totals in the component loop:** Use SQL aggregation for list views, and derive from the fetched items array for detail views. +- **Don't create a separate "dashboard totals" API:** Reuse existing totals endpoint + new setup/thread counts from their list endpoints. + +## Don't Hand-Roll + +| Problem | Don't Build | Use Instead | Why | +|---------|-------------|-------------|-----| +| Many-to-many sync | Custom diff logic | Delete-all + re-insert in transaction | Simpler, atomic, handles edge cases | +| Route generation | Manual route registration | TanStack Router file-based plugin | Already configured, auto-generates types | +| Data fetching cache | Custom cache | TanStack Query | Already used, handles invalidation | +| SQL totals aggregation | Client-side loops over raw data | SQL COALESCE + SUM subqueries | Consistent with existing totals.service.ts pattern | + +**Key insight:** Every pattern in this phase has a direct precedent in Phases 1-2. The only new concept is the junction table. + +## Common Pitfalls + +### Pitfall 1: Stale Setup Totals After Item Edit +**What goes wrong:** User edits a collection item's weight/price, but setup detail page shows old totals +**Why it happens:** Setup query cache not invalidated when items change +**How to avoid:** In `useUpdateItem` and `useDeleteItem` mutation `onSuccess`, also invalidate `["setups"]` query key +**Warning signs:** Totals don't update until page refresh + +### Pitfall 2: Orphaned Setup Items After Collection Item Deletion +**What goes wrong:** Deleting a collection item leaves dangling references in `setup_items` +**Why it happens:** Missing cascade or no FK constraint +**How to avoid:** `onDelete: "cascade"` on `setupItems.itemId` FK -- already specified in schema pattern above +**Warning signs:** Setup shows items that no longer exist in collection + +### Pitfall 3: Route Migration Breaking Existing Links +**What goes wrong:** Moving `/` content to `/collection` breaks hardcoded links like the "Back to planning" link in thread detail +**Why it happens:** Thread detail page currently links to `{ to: "/", search: { tab: "planning" } }` +**How to avoid:** Update ALL internal links: thread detail back link, resolution dialog redirect, floating add button visibility check +**Warning signs:** Clicking links after restructure navigates to wrong page + +### Pitfall 4: TanStack Router Route Tree Not Regenerating +**What goes wrong:** New route files exist but routes 404 +**Why it happens:** Vite dev server needs restart, or route file doesn't export `Route` correctly +**How to avoid:** Use `createFileRoute("/correct/path")` matching the file location. Restart dev server after adding new route directories. +**Warning signs:** `routeTree.gen.ts` doesn't include new routes + +### Pitfall 5: Floating Add Button Showing on Wrong Pages +**What goes wrong:** The floating "+" button (for adding items) appears on dashboard or setups pages +**Why it happens:** Current logic only hides it on thread pages (`!threadMatch`) +**How to avoid:** Update __root.tsx to only show the floating add button on `/collection` route (gear tab) +**Warning signs:** "+" button visible on dashboard or setup pages + +### Pitfall 6: TotalsBar in Root Layout vs Per-Page +**What goes wrong:** TotalsBar in `__root.tsx` shows global stats on every page including dashboard +**Why it happens:** TotalsBar is currently rendered unconditionally in root layout +**How to avoid:** Either (a) make TotalsBar route-aware via props from root, or (b) move TotalsBar out of root layout and render per-page. Option (a) is simpler -- pass a mode/props based on route matching. +**Warning signs:** Dashboard shows stats in TotalsBar instead of just the title + +## Code Examples + +### Setup Zod Schemas +```typescript +// In src/shared/schemas.ts +export const createSetupSchema = z.object({ + name: z.string().min(1, "Setup name is required"), +}); + +export const updateSetupSchema = z.object({ + name: z.string().min(1).optional(), +}); + +export const syncSetupItemsSchema = z.object({ + itemIds: z.array(z.number().int().positive()), +}); +``` + +### Setup Hooks Pattern +```typescript +// In src/client/hooks/useSetups.ts -- follows useThreads.ts pattern exactly +export function useSetups() { + return useQuery({ + queryKey: ["setups"], + queryFn: () => apiGet("/api/setups"), + }); +} + +export function useSetup(setupId: number | null) { + return useQuery({ + queryKey: ["setups", setupId], + queryFn: () => apiGet(`/api/setups/${setupId}`), + enabled: setupId != null, + }); +} + +export function useSyncSetupItems(setupId: number) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (itemIds: number[]) => + apiPut<{ success: boolean }>(`/api/setups/${setupId}/items`, { itemIds }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["setups"] }); + }, + }); +} +``` + +### Remove Single Item from Setup +```typescript +// Separate from batch sync -- used by the x button on item cards in setup detail +export function useRemoveSetupItem(setupId: number) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (itemId: number) => + apiDelete<{ success: boolean }>(`/api/setups/${setupId}/items/${itemId}`), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["setups"] }); + }, + }); +} +``` + +### Dashboard Route +```typescript +// src/client/routes/index.tsx -- new dashboard +import { createFileRoute, Link } from "@tanstack/react-router"; + +export const Route = createFileRoute("/")({ + component: DashboardPage, +}); + +function DashboardPage() { + // Three hooks in parallel -- TanStack Query deduplicates + const { data: totals } = useTotals(); + const { data: threads } = useThreads(); + const { data: setups } = useSetups(); + + const activeThreadCount = threads?.filter(t => t.status === "active").length ?? 0; + + return ( +
+
+ + + +
+
+ ); +} +``` + +### Collection Route (moved from current index.tsx) +```typescript +// src/client/routes/collection/index.tsx +import { createFileRoute } from "@tanstack/react-router"; +import { z } from "zod"; + +const searchSchema = z.object({ + tab: z.enum(["gear", "planning"]).catch("gear"), +}); + +export const Route = createFileRoute("/collection/")({ + validateSearch: searchSchema, + component: CollectionPage, +}); + +function CollectionPage() { + // Exact same content as current HomePage in src/client/routes/index.tsx + // Just update navigation targets (e.g., handleTabChange navigates to "/collection") +} +``` + +## State of the Art + +| Old Approach | Current Approach | When Changed | Impact | +|--------------|------------------|--------------|--------| +| Current `/` has gear+planning | `/` becomes dashboard, content moves to `/collection` | Phase 3 | All internal links must update | +| TotalsBar always shows global stats | TotalsBar becomes route-aware with contextual stats | Phase 3 | Root layout needs route matching logic | +| No many-to-many relationships | `setup_items` junction table | Phase 3 | New Drizzle pattern for this project | + +## Open Questions + +1. **Should setup deletion require confirmation?** + - What we know: CONTEXT.md mentions using ConfirmDialog for setup deletion + - What's unclear: Whether to also confirm when removing all items from a setup + - Recommendation: Use ConfirmDialog for setup deletion (destructive). No confirmation for removing individual items from setup (non-destructive, per CONTEXT.md decision). + +2. **Should `useThreads` on dashboard include resolved threads for the count?** + - What we know: Dashboard "Planning" card shows active thread count + - What's unclear: Whether to show "3 active" or "3 active / 5 total" + - Recommendation: Show only active count for simplicity. `useThreads(false)` already filters to active. + +## Validation Architecture + +### Test Framework +| Property | Value | +|----------|-------| +| Framework | bun:test (built into Bun) | +| Config file | None (Bun built-in, runs from `package.json` `"test": "bun test"`) | +| Quick run command | `bun test tests/services/setup.service.test.ts` | +| Full suite command | `bun test` | + +### Phase Requirements to Test Map +| Req ID | Behavior | Test Type | Automated Command | File Exists? | +|--------|----------|-----------|-------------------|-------------| +| SETP-01 | Create/list/delete named setups | unit + integration | `bun test tests/services/setup.service.test.ts` | No - Wave 0 | +| SETP-01 | Setup CRUD API routes | integration | `bun test tests/routes/setups.test.ts` | No - Wave 0 | +| SETP-02 | Add/remove items to setup (junction table) | unit | `bun test tests/services/setup.service.test.ts` | No - Wave 0 | +| SETP-02 | Setup items sync API route | integration | `bun test tests/routes/setups.test.ts` | No - Wave 0 | +| SETP-03 | Setup totals (weight/cost aggregation) | unit | `bun test tests/services/setup.service.test.ts` | No - Wave 0 | +| DASH-01 | Dashboard summary data | manual-only | Manual browser verification | N/A (UI-only, data from existing endpoints) | + +### Sampling Rate +- **Per task commit:** `bun test tests/services/setup.service.test.ts && bun test tests/routes/setups.test.ts` +- **Per wave merge:** `bun test` +- **Phase gate:** Full suite green before `/gsd:verify-work` + +### Wave 0 Gaps +- [ ] `tests/services/setup.service.test.ts` -- covers SETP-01, SETP-02, SETP-03 +- [ ] `tests/routes/setups.test.ts` -- covers SETP-01, SETP-02 API layer +- [ ] `tests/helpers/db.ts` -- needs `setups` and `setup_items` CREATE TABLE statements added + +## Sources + +### Primary (HIGH confidence) +- Existing codebase: `src/db/schema.ts`, `src/server/services/thread.service.ts`, `src/server/routes/threads.ts` -- direct pattern references +- Existing codebase: `src/client/hooks/useThreads.ts`, `src/client/stores/uiStore.ts` -- client-side patterns +- Existing codebase: `tests/services/thread.service.test.ts`, `tests/helpers/db.ts` -- test infrastructure patterns +- Existing codebase: `src/client/routes/__root.tsx`, `src/client/routes/index.tsx` -- routing patterns + +### Secondary (MEDIUM confidence) +- TanStack Router file-based routing conventions -- verified against existing `routeTree.gen.ts` auto-generation + +### Tertiary (LOW confidence) +- None + +## Metadata + +**Confidence breakdown:** +- Standard stack: HIGH -- no new dependencies, all patterns established in Phases 1-2 +- Architecture: HIGH -- direct 1:1 mapping from thread patterns to setup patterns, only new concept is junction table +- Pitfalls: HIGH -- identified from direct codebase analysis (hardcoded links, TotalsBar in root, cascade behavior) + +**Research date:** 2026-03-15 +**Valid until:** 2026-04-15 (stable -- no external dependencies changing)