24 KiB
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:
# 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
// 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
addedAtfor 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
// 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
// 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:
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
// 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 statsuseThreads()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
// 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
// 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
// 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
// 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)
// 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
-
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).
-
Should
useThreadson 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-03tests/routes/setups.test.ts-- covers SETP-01, SETP-02 API layertests/helpers/db.ts-- needssetupsandsetup_itemsCREATE 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.tsauto-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)