Files
GearBox/.planning/milestones/v1.0-phases/03-setups-and-dashboard/03-RESEARCH.md
Jean-Luc Makiola 261c1f9d02 chore: complete v1.0 MVP milestone
Archive roadmap, requirements, and phase directories to milestones/.
Evolve PROJECT.md with validated requirements and key decisions.
Reorganize ROADMAP.md with milestone grouping.
Delete REQUIREMENTS.md (fresh for next milestone).
2026-03-15 15:49:45 +01:00

541 lines
24 KiB
Markdown

# 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>
## 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
</user_constraints>
<phase_requirements>
## 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` |
</phase_requirements>
## 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<number>`(
SELECT COUNT(*) FROM setup_items
WHERE setup_items.setup_id = setups.id
)`.as("item_count"),
totalWeight: sql<number>`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<number>`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: `<TotalsBar />` (title only, no stats, no link since already on dashboard)
- Collection page: `<TotalsBar stats={globalStats} />` (current behavior)
- Setup detail: `<TotalsBar title={setupName} stats={setupStats} />`
- Thread detail: keep current behavior
The "GearBox" title becomes a `<Link to="/">` 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<SetupListItem[]>("/api/setups"),
});
}
export function useSetup(setupId: number | null) {
return useQuery({
queryKey: ["setups", setupId],
queryFn: () => apiGet<SetupWithItems>(`/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 (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<DashboardCard to="/collection" ... />
<DashboardCard to="/collection?tab=planning" ... />
<DashboardCard to="/setups" ... />
</div>
</div>
);
}
```
### 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)