Files
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

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
  • 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

// 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 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

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

  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)