Files
GearBox/.planning/milestones/v1.0-phases/01-foundation-and-collection/01-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

31 KiB

Phase 1: Foundation and Collection - Research

Researched: 2026-03-14 Domain: Full-stack web app scaffolding, SQLite CRUD, React SPA with collection management Confidence: HIGH

Summary

Phase 1 is a greenfield build establishing the entire project stack: Bun runtime with Hono API server, React 19 SPA via Vite with TanStack Router, Drizzle ORM over bun:sqlite, and Tailwind v4 styling. The phase delivers complete gear collection CRUD (items and categories) with aggregate weight/cost totals, a slide-out panel for add/edit, a card grid grouped by category, and a first-run onboarding wizard.

The critical architectural decision is using Vite as the frontend dev server (required by TanStack Router's file-based routing plugin) with Hono on Bun as the backend, connected via Vite's dev proxy. This is NOT Bun's native fullstack HTML entrypoint pattern -- TanStack Router requires the Vite plugin, which means Vite owns the frontend build pipeline. In production, Hono serves the Vite-built static assets alongside API routes from a single Bun process.

A key blocker from STATE.md has been resolved: @hono/zod-validator now supports Zod 4 (merged May 2025, PR #1173). The project can use Zod 4.x without pinning to 3.x.

Primary recommendation: Scaffold with Vite + TanStack Router for frontend, Hono + Drizzle on Bun for backend, with categories as a first-class table (not just a text field on items) to support emoji icons, rename, and delete-with-reassignment.

<user_constraints>

User Constraints (from CONTEXT.md)

Locked Decisions

  • Card grid layout, grouped by category headers
  • Each card shows: item name (prominent), then tag-style chips for weight, price, and category
  • Item image displayed on the card for visual identification
  • Items grouped under category headers with per-category weight/cost subtotals
  • Global sticky totals bar at the top showing total items, weight, and cost
  • Empty categories are hidden from the collection view
  • Slide-out panel from the right side for both adding and editing items
  • Same panel component for add (empty) and edit (pre-filled)
  • Collection remains visible behind the panel for context
  • Confirmation dialog before deleting items
  • Single-level categories only (no subcategories)
  • Searchable category picker in the item form -- type to find existing or create new
  • Categories editable from the collection overview (rename, delete, change icon)
  • Each category gets an emoji/icon for visual distinction
  • Deleting a category moves its items to "Uncategorized" default category
  • Step-by-step onboarding wizard for first-time users (guides through: create first category, add first item)
  • Cards should feel clean and minimal -- "light and airy" aesthetic
  • Item info displayed as tag-style chips (compact, scannable)
  • Category picker works like a combobox: type to search, select existing, or create new inline
  • Photos on cards are important for visual identification even in v1

Claude's Discretion

  • Form layout for item add/edit panel (all fields visible vs grouped sections)
  • Loading states and skeleton design
  • Exact spacing, typography, and Tailwind styling choices
  • Error state handling and validation feedback
  • Weight unit storage (grams internally, display in user's preferred unit can be deferred to v2)

Deferred Ideas (OUT OF SCOPE)

  • Subcategories (e.g. "Bags" -> "Handlebar Bag")
  • Full photo management is v2 (basic image upload for cards IS in scope)

</user_constraints>

<phase_requirements>

Phase Requirements

ID Description Research Support
COLL-01 User can add gear items with name, weight, price, category, notes, and product link Drizzle schema for items table, Hono POST endpoint, React slide-out panel with Zod-validated form, image upload to local filesystem
COLL-02 User can edit and delete gear items Hono PUT/DELETE endpoints, same slide-out panel pre-filled for edit, confirmation dialog for delete, image cleanup on item delete
COLL-03 User can organize items into user-defined categories Separate categories table with emoji field, combobox category picker, category CRUD endpoints, "Uncategorized" default category, reassignment on category delete
COLL-04 User can see automatic weight and cost totals by category and overall SQL SUM aggregates via Drizzle, computed on read (never cached), sticky totals bar component, per-category subtotals in group headers

</phase_requirements>

Standard Stack

Core

Library Version Purpose Why Standard
Bun 1.3.x Runtime, package manager Built-in SQLite, native TS, fast installs
React 19.2.x UI framework Locked in CONTEXT.md
Vite 8.x Frontend dev server + production builds Required by TanStack Router plugin for file-based routing
Hono 4.12.x Backend API framework Web Standards, first-class Bun support, tiny footprint
Drizzle ORM 0.45.x Database ORM + migrations Type-safe SQL, native bun:sqlite driver, built-in migration tooling
Tailwind CSS 4.2.x Styling CSS-native config, auto content detection, microsecond incremental builds
TanStack Router 1.x Client-side routing Type-safe routing with file-based route generation via Vite plugin
TanStack Query 5.x Server state management Handles fetching, caching, cache invalidation on mutations
Zustand 5.x Client state management UI state: panel open/close, active filters, onboarding step
Zod 4.x Schema validation Shared between client forms and Hono API validation. Zod 4 confirmed compatible with @hono/zod-validator (PR #1173, May 2025)
TypeScript 5.x Type safety Bun transpiles natively, required by Drizzle and TanStack Router

Supporting

Library Version Purpose When to Use
@tanstack/router-plugin latest Vite plugin for file-based routing Required in vite.config.ts, must be listed BEFORE @vitejs/plugin-react
@hono/zod-validator 0.7.6+ Request validation middleware Validate API request bodies/params using Zod schemas
drizzle-kit latest DB migrations CLI bunx drizzle-kit generate and bunx drizzle-kit push for schema changes
clsx 2.x Conditional class names Building components with variant styles
@vitejs/plugin-react latest (Vite 8 compatible) React HMR/JSX Required in vite.config.ts for Fast Refresh
@tailwindcss/vite latest Tailwind Vite plugin Required in vite.config.ts for Tailwind v4
@biomejs/biome latest Linter + formatter Single tool replacing ESLint + Prettier

Alternatives Considered

Instead of Could Use Tradeoff
Vite + Hono Bun fullstack (HTML entrypoints) Bun fullstack is simpler but incompatible with TanStack Router file-based routing which requires the Vite plugin
Zod 4.x Zod 3.23.x No need to pin -- @hono/zod-validator supports Zod 4 as of May 2025
Separate categories table Category as text field on items Text field cannot store emoji/icon, cannot rename without updating all items, cannot enforce "Uncategorized" default cleanly

Installation:

# Initialize
bun init

# Core frontend
bun add react react-dom @tanstack/react-router @tanstack/react-query zustand zod clsx

# Core backend
bun add hono @hono/zod-validator drizzle-orm

# Styling
bun add tailwindcss @tailwindcss/vite

# Build tooling
bun add -d vite @vitejs/plugin-react @tanstack/router-plugin typescript @types/react @types/react-dom

# Database tooling
bun add -d drizzle-kit

# Linting + formatting
bun add -d @biomejs/biome

# Dev tools
bun add -d @tanstack/react-query-devtools @tanstack/react-router-devtools

Architecture Patterns

src/
  client/                # React SPA (Vite entry point)
    routes/              # TanStack Router file-based routes
      __root.tsx         # Root layout with sticky totals bar
      index.tsx          # Collection page (default route)
    components/          # Shared UI components
      ItemCard.tsx       # Gear item card with chips
      CategoryHeader.tsx # Category group header with subtotals
      SlideOutPanel.tsx  # Right slide-out panel for add/edit
      CategoryPicker.tsx # Combobox: search, select, or create category
      TotalsBar.tsx      # Sticky global totals bar
      OnboardingWizard.tsx # First-run step-by-step guide
      ConfirmDialog.tsx  # Delete confirmation
    hooks/               # TanStack Query hooks
      useItems.ts        # CRUD operations for items
      useCategories.ts   # CRUD operations for categories
      useTotals.ts       # Aggregate totals query
    stores/              # Zustand stores
      uiStore.ts         # Panel state, onboarding state
    lib/                 # Client utilities
      api.ts             # Fetch wrapper for API calls
      formatters.ts      # Weight/cost display formatting
  server/                # Hono API server
    index.ts             # Hono app instance, route registration
    routes/              # API route handlers
      items.ts           # /api/items CRUD
      categories.ts      # /api/categories CRUD
      totals.ts          # /api/totals aggregates
      images.ts          # /api/images upload
    services/            # Business logic
      item.service.ts    # Item CRUD logic
      category.service.ts # Category management with reassignment
  db/                    # Database layer
    schema.ts            # Drizzle table definitions
    index.ts             # Database connection singleton (WAL mode, foreign keys)
    seed.ts              # Seed "Uncategorized" default category
    migrations/          # Drizzle Kit generated migrations
  shared/                # Zod schemas shared between client and server
    schemas.ts           # Item, category validation schemas
    types.ts             # Inferred TypeScript types
public/                  # Static assets
uploads/                 # Gear photos (gitignored)
index.html               # Vite SPA entry point
vite.config.ts           # Vite + TanStack Router plugin + Tailwind plugin
drizzle.config.ts        # Drizzle Kit config

Pattern 1: Vite Frontend + Hono Backend (Dev Proxy)

What: Vite runs the frontend dev server with HMR. Hono runs on Bun as the API server on a separate port. Vite's server.proxy forwards /api/* to Hono. In production, Hono serves Vite's built output as static files. When to use: When TanStack Router (or any Vite plugin) is required for the frontend. Example:

// vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
import { tanstackRouter } from "@tanstack/router-plugin/vite";

export default defineConfig({
  plugins: [
    tanstackRouter({ target: "react", autoCodeSplitting: true }),
    react(),
    tailwindcss(),
  ],
  server: {
    proxy: {
      "/api": "http://localhost:3000",
      "/uploads": "http://localhost:3000",
    },
  },
  build: {
    outDir: "dist/client",
  },
});
// src/server/index.ts
import { Hono } from "hono";
import { serveStatic } from "hono/bun";
import { itemRoutes } from "./routes/items";
import { categoryRoutes } from "./routes/categories";

const app = new Hono();

// API routes
app.route("/api/items", itemRoutes);
app.route("/api/categories", categoryRoutes);

// Serve uploaded images
app.use("/uploads/*", serveStatic({ root: "./" }));

// Serve Vite-built SPA in production
if (process.env.NODE_ENV === "production") {
  app.use("/*", serveStatic({ root: "./dist/client" }));
  app.get("*", serveStatic({ path: "./dist/client/index.html" }));
}

export default { port: 3000, fetch: app.fetch };

Pattern 2: Categories as a First-Class Table

What: Categories are a separate table with id, name, and emoji fields. Items reference categories via foreign key. An "Uncategorized" category with a known ID (1) is seeded on DB init. When to use: When categories need independent properties (emoji/icon), rename support, and delete-with-reassignment. Example:

// db/schema.ts
import { sqliteTable, text, integer, real } from "drizzle-orm/sqlite-core";

export const categories = sqliteTable("categories", {
  id: integer("id").primaryKey({ autoIncrement: true }),
  name: text("name").notNull().unique(),
  emoji: text("emoji").notNull().default("📦"),
  createdAt: integer("created_at", { mode: "timestamp" }).notNull()
    .$defaultFn(() => new Date()),
});

export const items = sqliteTable("items", {
  id: integer("id").primaryKey({ autoIncrement: true }),
  name: text("name").notNull(),
  weightGrams: real("weight_grams"),
  priceCents: integer("price_cents"),
  categoryId: integer("category_id").notNull().references(() => categories.id),
  notes: text("notes"),
  productUrl: text("product_url"),
  imageFilename: text("image_filename"),
  createdAt: integer("created_at", { mode: "timestamp" }).notNull()
    .$defaultFn(() => new Date()),
  updatedAt: integer("updated_at", { mode: "timestamp" }).notNull()
    .$defaultFn(() => new Date()),
});

Pattern 3: Slide-Out Panel with Shared Component

What: A single SlideOutPanel component serves both add and edit flows. When adding, fields are empty. When editing, fields are pre-filled from the existing item. The panel slides in from the right, overlaying (not replacing) the collection view. When to use: Per CONTEXT.md locked decision. State management:

// stores/uiStore.ts
import { create } from "zustand";

interface UIState {
  panelMode: "closed" | "add" | "edit";
  editingItemId: number | null;
  openAddPanel: () => void;
  openEditPanel: (itemId: number) => void;
  closePanel: () => void;
}

export const useUIStore = create<UIState>((set) => ({
  panelMode: "closed",
  editingItemId: null,
  openAddPanel: () => set({ panelMode: "add", editingItemId: null }),
  openEditPanel: (itemId) => set({ panelMode: "edit", editingItemId: itemId }),
  closePanel: () => set({ panelMode: "closed", editingItemId: null }),
}));

Pattern 4: Computed Totals (Never Cached)

What: Weight and cost totals are computed on every read via SQL aggregates. Never store totals as columns. Why: Avoids stale data bugs when items are added, edited, or deleted. Example:

// server/services/item.service.ts
import { db } from "../../db";
import { items, categories } from "../../db/schema";
import { eq, sql } from "drizzle-orm";

export function getCategoryTotals() {
  return db
    .select({
      categoryId: items.categoryId,
      categoryName: categories.name,
      categoryEmoji: categories.emoji,
      totalWeight: sql<number>`COALESCE(SUM(${items.weightGrams}), 0)`,
      totalCost: sql<number>`COALESCE(SUM(${items.priceCents}), 0)`,
      itemCount: sql<number>`COUNT(*)`,
    })
    .from(items)
    .innerJoin(categories, eq(items.categoryId, categories.id))
    .groupBy(items.categoryId)
    .all();
}

export function getGlobalTotals() {
  return db
    .select({
      totalWeight: sql<number>`COALESCE(SUM(${items.weightGrams}), 0)`,
      totalCost: sql<number>`COALESCE(SUM(${items.priceCents}), 0)`,
      itemCount: sql<number>`COUNT(*)`,
    })
    .from(items)
    .get();
}

Anti-Patterns to Avoid

  • Storing money as floats: Use integer cents (priceCents). Format to dollars only in the display layer. 0.1 + 0.2 !== 0.3 in JavaScript.
  • Category as a text field on items: Cannot store emoji, cannot rename without updating all items, cannot enforce default category on delete.
  • Caching totals in the database: Always compute from source data. SQLite SUM() over hundreds of items is sub-millisecond.
  • Absolute paths for images: Store relative paths only (uploads/{filename}). Absolute paths break on deployment or directory changes.
  • Requiring all fields to add an item: Only require name. Weight, price, category, etc. should be optional. Users fill in details over time.

Don't Hand-Roll

Problem Don't Build Use Instead Why
Database migrations Custom SQL scripts Drizzle Kit (drizzle-kit generate/push) Migration ordering, conflict detection, rollback support
Form validation Manual if/else checks Zod schemas shared between client and server Single source of truth, type inference, consistent error messages
API data fetching/caching useState + useEffect + fetch TanStack Query hooks Handles loading/error states, cache invalidation, refetching, deduplication
Combobox/autocomplete Custom input with dropdown Headless UI pattern (build from primitives with proper ARIA) or a lightweight combobox library Keyboard navigation, screen reader support, focus management are deceptively hard
Slide-out panel animation CSS transitions from scratch Tailwind transition-transform + translate-x utilities Consistent timing, GPU-accelerated, respects prefers-reduced-motion
Image resizing on upload Custom canvas manipulation Sharp library or accept-and-store (resize deferred to v2) Sharp handles EXIF rotation, format conversion, memory management

Key insight: For Phase 1, defer image resizing/thumbnailing. Accept and store the uploaded image as-is. Thumbnail generation can be added in v2 without schema changes (imageFilename stays the same, just generate a thumb variant).

Common Pitfalls

Pitfall 1: Bun Fullstack vs Vite Confusion

What goes wrong: Attempting to use Bun's native Bun.serve() with HTML entrypoints AND TanStack Router, which requires Vite's build pipeline. Why it happens: Bun's fullstack dev server is compelling but incompatible with TanStack Router's file-based routing Vite plugin. How to avoid: Use Vite for frontend (with TanStack Router plugin). Use Hono on Bun for backend. Connect via Vite proxy in dev, static file serving in prod. Warning signs: Import errors from @tanstack/router-plugin/vite, missing route tree generation file.

Pitfall 2: Category Delete Without Reassignment

What goes wrong: Deleting a category with foreign key constraints either fails (FK violation) or cascades (deletes all items in that category). Why it happens: Using ON DELETE CASCADE or not handling FK constraints at all. How to avoid: Before deleting a category, reassign all its items to the "Uncategorized" default category (id=1). Then delete. This is a two-step transaction. Warning signs: FK constraint errors on category delete, or silent item deletion.

Pitfall 3: Onboarding State Persistence

What goes wrong: User completes onboarding, refreshes the page, and sees the wizard again. Why it happens: Storing onboarding completion state only in Zustand (memory). State is lost on page refresh. How to avoid: Store onboardingComplete as a flag in SQLite (a simple settings table or a dedicated endpoint). Check on app load. Warning signs: Onboarding wizard appears on every fresh page load.

Pitfall 4: Image Upload Without Cleanup

What goes wrong: Deleting an item leaves its image file on disk. Over time, orphaned images accumulate. Why it happens: DELETE endpoint removes the DB record but forgets to unlink the file. How to avoid: In the item delete service, check imageFilename, unlink the file from uploads/ before or after DB delete. Wrap in try/catch -- file missing is not an error worth failing the delete over. Warning signs: uploads/ directory grows larger than expected, files with no matching item records.

Pitfall 5: TanStack Router Plugin Order in Vite Config

What goes wrong: File-based routes are not generated, routeTree.gen.ts is missing or stale. Why it happens: TanStack Router plugin must be listed BEFORE @vitejs/plugin-react in the Vite plugins array. How to avoid: Always order: tanstackRouter(), then react(), then tailwindcss(). Warning signs: Missing routeTree.gen.ts, type errors on route imports.

Pitfall 6: Forgetting PRAGMA foreign_keys = ON

What goes wrong: Foreign key constraints between items and categories are silently ignored. Items can reference non-existent categories. Why it happens: SQLite has foreign key support but it is OFF by default. Must be enabled per connection. How to avoid: Run PRAGMA foreign_keys = ON immediately after opening the database connection, before any queries. Warning signs: Items with categoryId pointing to deleted categories, no errors on invalid inserts.

Code Examples

Database Connection Singleton

// src/db/index.ts
import { Database } from "bun:sqlite";
import { drizzle } from "drizzle-orm/bun-sqlite";
import * as schema from "./schema";

const sqlite = new Database("gearbox.db");
sqlite.run("PRAGMA journal_mode = WAL");
sqlite.run("PRAGMA foreign_keys = ON");

export const db = drizzle(sqlite, { schema });

Drizzle Config

// drizzle.config.ts
import { defineConfig } from "drizzle-kit";

export default defineConfig({
  out: "./drizzle",
  schema: "./src/db/schema.ts",
  dialect: "sqlite",
  dbCredentials: {
    url: "gearbox.db",
  },
});

Shared Zod Schemas

// src/shared/schemas.ts
import { z } from "zod";

export const createItemSchema = z.object({
  name: z.string().min(1, "Name is required"),
  weightGrams: z.number().nonnegative().optional(),
  priceCents: z.number().int().nonnegative().optional(),
  categoryId: z.number().int().positive(),
  notes: z.string().optional(),
  productUrl: z.string().url().optional().or(z.literal("")),
});

export const updateItemSchema = createItemSchema.partial().extend({
  id: z.number().int().positive(),
});

export const createCategorySchema = z.object({
  name: z.string().min(1, "Category name is required"),
  emoji: z.string().min(1).max(4).default("📦"),
});

export const updateCategorySchema = z.object({
  id: z.number().int().positive(),
  name: z.string().min(1).optional(),
  emoji: z.string().min(1).max(4).optional(),
});

export type CreateItem = z.infer<typeof createItemSchema>;
export type UpdateItem = z.infer<typeof updateItemSchema>;
export type CreateCategory = z.infer<typeof createCategorySchema>;

Hono Item Routes with Zod Validation

// src/server/routes/items.ts
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { createItemSchema, updateItemSchema } from "../../shared/schemas";
import { db } from "../../db";
import { items } from "../../db/schema";
import { eq } from "drizzle-orm";

const app = new Hono();

app.get("/", async (c) => {
  const allItems = db.select().from(items).all();
  return c.json(allItems);
});

app.post("/", zValidator("json", createItemSchema), async (c) => {
  const data = c.req.valid("json");
  const result = db.insert(items).values(data).returning().get();
  return c.json(result, 201);
});

app.put("/:id", zValidator("json", updateItemSchema), async (c) => {
  const id = Number(c.req.param("id"));
  const data = c.req.valid("json");
  const result = db.update(items).set({ ...data, updatedAt: new Date() })
    .where(eq(items.id, id)).returning().get();
  if (!result) return c.json({ error: "Item not found" }, 404);
  return c.json(result);
});

app.delete("/:id", async (c) => {
  const id = Number(c.req.param("id"));
  // Clean up image file if exists
  const item = db.select().from(items).where(eq(items.id, id)).get();
  if (!item) return c.json({ error: "Item not found" }, 404);
  if (item.imageFilename) {
    try { await Bun.file(`uploads/${item.imageFilename}`).exists() &&
      await Bun.$`rm uploads/${item.imageFilename}`; } catch {}
  }
  db.delete(items).where(eq(items.id, id)).run();
  return c.json({ success: true });
});

export { app as itemRoutes };

TanStack Query Hook for Items

// src/client/hooks/useItems.ts
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import type { CreateItem, UpdateItem } from "../../shared/schemas";

const API = "/api/items";

export function useItems() {
  return useQuery({
    queryKey: ["items"],
    queryFn: async () => {
      const res = await fetch(API);
      if (!res.ok) throw new Error("Failed to fetch items");
      return res.json();
    },
  });
}

export function useCreateItem() {
  const queryClient = useQueryClient();
  return useMutation({
    mutationFn: async (data: CreateItem) => {
      const res = await fetch(API, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(data),
      });
      if (!res.ok) throw new Error("Failed to create item");
      return res.json();
    },
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ["items"] });
      queryClient.invalidateQueries({ queryKey: ["totals"] });
    },
  });
}

Seed Default Category

// src/db/seed.ts
import { db } from "./index";
import { categories } from "./schema";

export function seedDefaults() {
  const existing = db.select().from(categories).all();
  if (existing.length === 0) {
    db.insert(categories).values({
      name: "Uncategorized",
      emoji: "📦",
    }).run();
  }
}

State of the Art

Old Approach Current Approach When Changed Impact
Zod 3.x + @hono/zod-validator Zod 4.x fully supported May 2025 (PR #1173) No need to pin Zod 3.x. Resolves STATE.md blocker.
Tailwind config via JS Tailwind v4 CSS-native config Jan 2025 No tailwind.config.js file. Theme defined in CSS via @theme directive.
Vite 7 (esbuild/Rollup) Vite 8 (Rolldown-based) 2025 5-30x faster builds. Same config API.
React Router v6/v7 TanStack Router v1 2024 Type-safe params, file-based routes, better SPA experience
bun:sqlite manual SQL Drizzle ORM 0.45.x Ongoing Type-safe queries, migration tooling, schema-as-code

Deprecated/outdated:

  • tailwind.config.js: Use CSS @theme directive in Tailwind v4
  • better-sqlite3: Use bun:sqlite (built-in, 3-6x faster)
  • Vite server.proxy syntax: Verify correct format for Vite 8 (string shorthand still works)

Open Questions

  1. Image upload size limit and accepted formats

    • What we know: CONTEXT.md says photos on cards are important for visual identification
    • What's unclear: Maximum file size, accepted formats (jpg/png/webp), whether to resize on upload or defer to v2
    • Recommendation: Accept jpg/png/webp up to 5MB. Store as-is in uploads/. Defer resizing/thumbnailing to v2. Use object-fit: cover in CSS for consistent card display.
  2. Onboarding wizard scope

    • What we know: Step-by-step guide through "create first category, add first item"
    • What's unclear: Exact number of steps, whether it is a modal overlay or a full-page takeover
    • Recommendation: 2-3 step modal overlay. Step 1: Welcome + create first category (with emoji picker). Step 2: Add first item to that category. Step 3: Done, show collection. Store completion flag in a settings table.
  3. Weight input UX

    • What we know: Store grams internally. Display unit deferred to v2.
    • What's unclear: Should the input field accept grams only, or allow free-text with unit suffix?
    • Recommendation: For v1, use a numeric input labeled "Weight (g)". Clean and simple. V2 adds unit selector.

Validation Architecture

Test Framework

Property Value
Framework Bun test runner (built-in, Jest-compatible API)
Config file None needed (Bun detects test files automatically)
Quick run command bun test --bail
Full suite command bun test

Phase Requirements -> Test Map

Req ID Behavior Test Type Automated Command File Exists?
COLL-01 Create item with all fields unit bun test tests/services/item.service.test.ts -t "create" No - Wave 0
COLL-01 POST /api/items validates input integration bun test tests/routes/items.test.ts -t "create" No - Wave 0
COLL-02 Update item fields unit bun test tests/services/item.service.test.ts -t "update" No - Wave 0
COLL-02 Delete item cleans up image unit bun test tests/services/item.service.test.ts -t "delete" No - Wave 0
COLL-03 Create/rename/delete category unit bun test tests/services/category.service.test.ts No - Wave 0
COLL-03 Delete category reassigns items to Uncategorized unit bun test tests/services/category.service.test.ts -t "reassign" No - Wave 0
COLL-04 Compute per-category totals unit bun test tests/services/totals.test.ts -t "category" No - Wave 0
COLL-04 Compute global totals unit bun test tests/services/totals.test.ts -t "global" No - Wave 0

Sampling Rate

  • Per task commit: bun test --bail
  • Per wave merge: bun test
  • Phase gate: Full suite green before /gsd:verify-work

Wave 0 Gaps

  • tests/services/item.service.test.ts -- covers COLL-01, COLL-02
  • tests/services/category.service.test.ts -- covers COLL-03
  • tests/services/totals.test.ts -- covers COLL-04
  • tests/routes/items.test.ts -- integration tests for item API endpoints
  • tests/routes/categories.test.ts -- integration tests for category API endpoints
  • tests/helpers/db.ts -- shared test helper: in-memory SQLite instance with migrations applied
  • Biome config: bunx @biomejs/biome init

Sources

Primary (HIGH confidence)

Secondary (MEDIUM confidence)

Tertiary (LOW confidence)

  • Image upload best practices for Bun -- needs validation during implementation (file size limits, multipart handling)

Metadata

Confidence breakdown:

  • Standard stack: HIGH -- all libraries verified via official docs, version compatibility confirmed, Zod 4 blocker resolved
  • Architecture: HIGH -- Vite + Hono pattern well-documented, TanStack Router plugin requirement verified
  • Pitfalls: HIGH -- drawn from PITFALLS.md research and verified against stack specifics
  • Database schema: HIGH -- Drizzle + bun:sqlite pattern verified via official docs

Research date: 2026-03-14 Valid until: 2026-04-14 (stable ecosystem, no fast-moving dependencies)