From 37c4272c08b1b20db8dfae0c6de52d8c3dd60d2f Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 15 Mar 2026 18:57:40 +0100 Subject: [PATCH] chore: add CLAUDE.md, initial Drizzle migration, and update gitignore Add project instructions for Claude Code, the initial database migration, and ignore the .claude/ local config directory. Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 3 + CLAUDE.md | 70 +++++ drizzle/0000_bitter_luckman.sql | 68 +++++ drizzle/meta/0000_snapshot.json | 467 ++++++++++++++++++++++++++++++++ 4 files changed, 608 insertions(+) create mode 100644 CLAUDE.md create mode 100644 drizzle/0000_bitter_luckman.sql create mode 100644 drizzle/meta/0000_snapshot.json diff --git a/.gitignore b/.gitignore index f9aa9b8..60c686a 100644 --- a/.gitignore +++ b/.gitignore @@ -223,3 +223,6 @@ dist/ uploads/* !uploads/.gitkeep +# Claude Code +.claude/ + diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..8373622 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,70 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +GearBox is a single-user web app for managing gear collections (bikepacking, sim racing, etc.), tracking weight/price, and planning purchases through research threads. Full-stack TypeScript monolith running on Bun. + +## Commands + +```bash +# Development (run both in separate terminals) +bun run dev:client # Vite dev server on :5173 (proxies /api to :3000) +bun run dev:server # Hono server on :3000 with hot reload + +# Database +bun run db:generate # Generate Drizzle migration from schema changes +bun run db:push # Apply migrations to gearbox.db + +# Testing +bun test # Run all tests +bun test tests/services/item.service.test.ts # Run single test file + +# Lint & Format +bun run lint # Biome check (tabs, double quotes, organized imports) + +# Build +bun run build # Vite build → dist/client/ +``` + +## Architecture + +**Stack**: React 19 + Hono + Drizzle ORM + SQLite, all running on Bun. + +### Client (`src/client/`) +- **Routing**: TanStack Router with file-based routes in `src/client/routes/`. Route tree auto-generated to `routeTree.gen.ts` — never edit manually. +- **Data fetching**: TanStack React Query via custom hooks in `src/client/hooks/` (e.g., `useItems`, `useThreads`, `useSetups`). Mutations invalidate related query keys. +- **UI state**: Zustand store (`stores/uiStore.ts`) for panel/dialog state only — server data lives in React Query. +- **API calls**: Thin fetch wrapper in `lib/api.ts` (`apiGet`, `apiPost`, `apiPut`, `apiDelete`, `apiUpload`). +- **Styling**: Tailwind CSS v4. + +### Server (`src/server/`) +- **Routes** (`routes/`): Hono handlers with Zod validation via `@hono/zod-validator`. Delegate to services. +- **Services** (`services/`): Pure business logic functions that take a db instance. No HTTP awareness — testable without mocking. +- Route registration in `src/server/index.ts` via `app.route("/api/...", routes)`. + +### Shared (`src/shared/`) +- **`schemas.ts`**: Zod schemas for API request validation (source of truth for types). +- **`types.ts`**: Types inferred from Zod schemas + Drizzle table definitions. No manual type duplication. + +### Database (`src/db/`) +- **Schema**: `schema.ts` — Drizzle table definitions for SQLite. +- **Prices stored as cents** (`priceCents: integer`) to avoid float rounding. +- **Timestamps**: stored as integers (unix epoch) with `{ mode: "timestamp" }`. +- Tables: `categories`, `items`, `threads`, `threadCandidates`, `setups`, `setupItems`, `settings`. + +### Testing (`tests/`) +- Bun test runner. Tests at service level and route level. +- `tests/helpers/db.ts`: `createTestDb()` creates in-memory SQLite with full schema and seeds an "Uncategorized" category. When adding schema columns, update both `src/db/schema.ts` and the test helper's CREATE TABLE statements. + +## Path Alias + +`@/*` maps to `./src/*` (configured in tsconfig.json). + +## Key Patterns + +- **Thread resolution**: Resolving a thread copies the winning candidate's data into a new item in the collection, sets `resolvedCandidateId`, and changes status to "resolved". +- **Setup item sync**: `PUT /api/setups/:id/items` replaces all setup_items atomically (delete all, re-insert). +- **Image uploads**: `POST /api/images` saves to `./uploads/` with UUID filename, returned as `imageFilename` on item/candidate records. +- **Aggregates** (weight/cost totals): Computed via SQL on read, not stored on records. diff --git a/drizzle/0000_bitter_luckman.sql b/drizzle/0000_bitter_luckman.sql new file mode 100644 index 0000000..30f1c7b --- /dev/null +++ b/drizzle/0000_bitter_luckman.sql @@ -0,0 +1,68 @@ +CREATE TABLE `categories` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `name` text NOT NULL, + `emoji` text DEFAULT '📦' NOT NULL, + `created_at` integer NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `categories_name_unique` ON `categories` (`name`);--> statement-breakpoint +CREATE TABLE `items` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `name` text NOT NULL, + `weight_grams` real, + `price_cents` integer, + `category_id` integer NOT NULL, + `notes` text, + `product_url` text, + `image_filename` text, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL, + FOREIGN KEY (`category_id`) REFERENCES `categories`(`id`) ON UPDATE no action ON DELETE no action +); +--> statement-breakpoint +CREATE TABLE `settings` ( + `key` text PRIMARY KEY NOT NULL, + `value` text NOT NULL +); +--> statement-breakpoint +CREATE TABLE `setup_items` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `setup_id` integer NOT NULL, + `item_id` integer NOT NULL, + FOREIGN KEY (`setup_id`) REFERENCES `setups`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`item_id`) REFERENCES `items`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `setups` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `name` text NOT NULL, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL +); +--> statement-breakpoint +CREATE TABLE `thread_candidates` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `thread_id` integer NOT NULL, + `name` text NOT NULL, + `weight_grams` real, + `price_cents` integer, + `category_id` integer NOT NULL, + `notes` text, + `product_url` text, + `image_filename` text, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL, + FOREIGN KEY (`thread_id`) REFERENCES `threads`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`category_id`) REFERENCES `categories`(`id`) ON UPDATE no action ON DELETE no action +); +--> statement-breakpoint +CREATE TABLE `threads` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `name` text NOT NULL, + `status` text DEFAULT 'active' NOT NULL, + `resolved_candidate_id` integer, + `category_id` integer NOT NULL, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL, + FOREIGN KEY (`category_id`) REFERENCES `categories`(`id`) ON UPDATE no action ON DELETE no action +); diff --git a/drizzle/meta/0000_snapshot.json b/drizzle/meta/0000_snapshot.json new file mode 100644 index 0000000..0bc483f --- /dev/null +++ b/drizzle/meta/0000_snapshot.json @@ -0,0 +1,467 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "78e5f5c8-f8f0-43f4-93f8-5ef68154ed17", + "prevId": "00000000-0000-0000-0000-000000000000", + "tables": { + "categories": { + "name": "categories", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "emoji": { + "name": "emoji", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'📦'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "categories_name_unique": { + "name": "categories_name_unique", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "items": { + "name": "items", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "weight_grams": { + "name": "weight_grams", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "price_cents": { + "name": "price_cents", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "category_id": { + "name": "category_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "product_url": { + "name": "product_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "image_filename": { + "name": "image_filename", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "items_category_id_categories_id_fk": { + "name": "items_category_id_categories_id_fk", + "tableFrom": "items", + "tableTo": "categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "settings": { + "name": "settings", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "setup_items": { + "name": "setup_items", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "setup_id": { + "name": "setup_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "item_id": { + "name": "item_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "setup_items_setup_id_setups_id_fk": { + "name": "setup_items_setup_id_setups_id_fk", + "tableFrom": "setup_items", + "tableTo": "setups", + "columnsFrom": [ + "setup_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "setup_items_item_id_items_id_fk": { + "name": "setup_items_item_id_items_id_fk", + "tableFrom": "setup_items", + "tableTo": "items", + "columnsFrom": [ + "item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "setups": { + "name": "setups", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "thread_candidates": { + "name": "thread_candidates", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "thread_id": { + "name": "thread_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "weight_grams": { + "name": "weight_grams", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "price_cents": { + "name": "price_cents", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "category_id": { + "name": "category_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "product_url": { + "name": "product_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "image_filename": { + "name": "image_filename", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "thread_candidates_thread_id_threads_id_fk": { + "name": "thread_candidates_thread_id_threads_id_fk", + "tableFrom": "thread_candidates", + "tableTo": "threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "thread_candidates_category_id_categories_id_fk": { + "name": "thread_candidates_category_id_categories_id_fk", + "tableFrom": "thread_candidates", + "tableTo": "categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "threads": { + "name": "threads", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "resolved_candidate_id": { + "name": "resolved_candidate_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "category_id": { + "name": "category_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "threads_category_id_categories_id_fk": { + "name": "threads_category_id_categories_id_fk", + "tableFrom": "threads", + "tableTo": "categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file