24 Commits

Author SHA1 Message Date
e1051e022b docs(phase-1): complete phase execution 2026-03-14 22:58:55 +01:00
55d47d4e33 fix(01): align image upload field name and wrap category delete in transaction 2026-03-14 22:58:41 +01:00
950bf2c287 docs(01-04): complete onboarding wizard and visual verification plan
- Phase 1 (Foundation and Collection) fully complete: 4/4 plans done
- Onboarding wizard with settings API and human-verified collection experience

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 22:54:05 +01:00
9fcbf0bab5 feat(01-04): add onboarding wizard with settings API and persisted state
- Settings API: GET/PUT /api/settings/:key with SQLite persistence
- useSettings hook with TanStack Query for settings CRUD
- OnboardingWizard: 3-step modal overlay (welcome, create category, add item)
- Root layout checks onboarding completion flag before rendering wizard
- Skip option available at every step, all paths persist completion to DB

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 22:51:25 +01:00
0084cc0608 docs(01-03): complete frontend collection UI plan
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 22:48:09 +01:00
12fd14ff41 feat(01-03): add slide-out panel, item form, category picker, and collection page
- CategoryPicker combobox with search, select, and inline create
- SlideOutPanel with backdrop, escape key, and slide animation
- ItemForm with all fields, validation, dollar-to-cents conversion
- Root layout with TotalsBar, panel, confirm dialog, and floating add button
- Collection page with card grid grouped by category, empty state

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 22:46:24 +01:00
b099a47eb4 feat(01-03): add data hooks, utilities, UI store, and foundational components
- API fetch wrapper with error handling and multipart upload
- Weight/price formatters for display
- TanStack Query hooks for items, categories, and totals with cache invalidation
- Zustand UI store for panel and confirm dialog state
- TotalsBar, CategoryHeader, ItemCard, ConfirmDialog, ImageUpload components

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 22:44:48 +01:00
a5df33a2d8 docs(01-02): complete backend API plan
- SUMMARY.md with 30 passing tests, 13 files, 3min duration
- STATE.md updated to plan 2/4 at 50% progress
- ROADMAP.md and REQUIREMENTS.md updated with COLL-01 through COLL-04

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 22:42:22 +01:00
029adf4dca feat(01-02): add Hono API routes with validation, image upload, and integration tests
- Item routes: GET, POST, PUT, DELETE with Zod validation and image cleanup
- Category routes: GET, POST, PUT, DELETE with Uncategorized protection
- Totals route: per-category and global aggregates
- Image upload: multipart file handling with type/size validation
- Routes use DI via Hono context variables for testability
- Integration tests: 10 tests covering all endpoints and edge cases
- All 30 tests passing

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 22:40:49 +01:00
22757a8aef feat(01-02): implement item, category, and totals service layers
- Item CRUD: getAllItems with category join, getById, create, update, delete
- Category CRUD: getAll ordered by name, create, update, delete with reassignment
- Totals: per-category aggregates and global totals via SQL SUM/COUNT
- All 20 service tests passing

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 22:39:09 +01:00
f90677988d test(01-02): add failing tests for item, category, and totals services
- Item CRUD tests: create, getAll, getById, update, delete
- Category CRUD tests: create, getAll, update, delete with reassignment
- Totals tests: per-category and global aggregates

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 22:38:40 +01:00
2d4f363823 docs(01-01): complete project scaffolding plan
- Add 01-01-SUMMARY.md with execution results
- Update STATE.md with position, decisions, and resolved blockers
- Update ROADMAP.md with plan progress
- Mark COLL-01 and COLL-03 requirements complete

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 22:36:52 +01:00
7412ef1d86 feat(01-01): add database schema, shared Zod schemas, seed, and test infrastructure
- Create Drizzle schema with items, categories, and settings tables
- Set up database connection singleton with WAL mode and foreign keys
- Add seed script for default Uncategorized category
- Create shared Zod validation schemas for items and categories
- Export TypeScript types inferred from Zod and Drizzle schemas
- Add in-memory SQLite test helper for isolated test databases
- Wire seed into Hono server startup

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 22:34:53 +01:00
67ff86039f feat(01-01): scaffold project with Vite, Hono, TanStack Router, Tailwind, and Drizzle config
- Initialize bun project with all frontend/backend dependencies
- Configure Vite with TanStack Router plugin, React, and Tailwind v4
- Create Hono server with health check and static file serving
- Set up TanStack Router file-based routes with root layout
- Add Drizzle config, Biome linter, and proper .gitignore

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 22:33:28 +01:00
5558381e09 docs(01): create phase plan 2026-03-14 22:27:00 +01:00
bbe4ac2b29 docs(phase-1): add validation strategy 2026-03-14 22:19:09 +01:00
4bd70cd4e5 docs(01): research phase domain 2026-03-14 22:18:09 +01:00
aae4e14a8f docs(state): record phase 1 context session 2026-03-14 22:10:46 +01:00
8f8c31ec0d docs(01): capture phase context 2026-03-14 22:10:37 +01:00
febae3498a docs: create roadmap (3 phases) 2026-03-14 21:53:13 +01:00
1886ac1abd docs: define v1 requirements 2026-03-14 21:50:52 +01:00
8f1647d557 docs: complete project research 2026-03-14 21:46:42 +01:00
632e45d294 chore: add project config 2026-03-14 21:39:27 +01:00
227239ce41 docs: initialize project 2026-03-14 21:38:16 +01:00
72 changed files with 7939 additions and 0 deletions

8
.gitignore vendored
View File

@@ -215,3 +215,11 @@ dist
.yarn/install-state.gz
.pnp.*
# GearBox
gearbox.db
gearbox.db-*
dist/
.tanstack/
uploads/*
!uploads/.gitkeep

63
.planning/PROJECT.md Normal file
View File

@@ -0,0 +1,63 @@
# GearBox
## What This Is
A web-based gear management and purchase planning app. Users can catalog their gear collections (bikepacking, sim racing, or any hobby), track details like weight, price, and source, and use planning threads to research and compare new purchases against their existing setup. Built as a single-user app with a clean, minimalist interface.
## Core Value
Make it effortless to manage gear and plan new purchases — see how a potential buy affects your total setup weight and cost before committing.
## Requirements
### Validated
<!-- Shipped and confirmed valuable. -->
(None yet — ship to validate)
### Active
<!-- Current scope. Building toward these. -->
- [ ] Gear collection with items including weight, price, purchase source, category, photos, product links, and notes
- [ ] Planning threads for researching purchases — add candidate products, compare side-by-side
- [ ] See how candidates affect overall setup (total weight/cost impact)
- [ ] Named setups (e.g. "Summer Bikepacking") composed from collection items with total weight/cost
- [ ] Thread resolution — pick a winner, it moves to your collection, thread closes
- [ ] Status tracking on thread items (researching → ordered → arrived)
- [ ] Priority/ranking within threads to mark favorites
- [ ] Dashboard home page with cards linking to collection, threads, and setups
### Out of Scope
- Authentication / multi-user — single user for v1, no login needed
- Custom comparison parameters — future enhancement, not v1
- Mobile app — web-first
- Social/sharing features — may add later
## Context
- Primary use case is bikepacking gear, but the data model should be generic enough for any hobby/collection type
- Replaces a spreadsheet-based workflow for tracking gear and planning purchases
- Single user, no auth — simplest possible setup
- User prefers Bun over npm as package manager/runtime
## Constraints
- **Runtime**: Bun — used as package manager and runtime
- **Design**: Light, airy, minimalist — white/light backgrounds, lots of whitespace, no visual clutter
- **Navigation**: Dashboard-based home page, not sidebar or top-nav tabs
- **Scope**: No auth, single user for v1
## Key Decisions
| Decision | Rationale | Outcome |
|----------|-----------|---------|
| No auth for v1 | Single user, simplicity first | — Pending |
| Generic data model | Support any hobby, not just bikepacking | — Pending |
| Dashboard navigation | Clean entry point, not persistent nav | — Pending |
| Bun runtime | User preference | — Pending |
---
*Last updated: 2026-03-14 after initialization*

97
.planning/REQUIREMENTS.md Normal file
View File

@@ -0,0 +1,97 @@
# Requirements: GearBox
**Defined:** 2026-03-14
**Core Value:** Make it effortless to manage gear and plan new purchases — see how a potential buy affects your total setup weight and cost before committing.
## v1 Requirements
Requirements for initial release. Each maps to roadmap phases.
### Collection
- [x] **COLL-01**: User can add gear items with name, weight, price, category, notes, and product link
- [x] **COLL-02**: User can edit and delete gear items
- [x] **COLL-03**: User can organize items into user-defined categories
- [x] **COLL-04**: User can see automatic weight and cost totals by category and overall
### Planning Threads
- [ ] **THRD-01**: User can create a planning thread with a name (e.g. "Helmet")
- [ ] **THRD-02**: User can add candidate products to a thread with weight, price, notes, and product link
- [ ] **THRD-03**: User can edit and remove candidates from a thread
- [ ] **THRD-04**: User can resolve a thread by picking a winner, which moves to their collection
### Setups
- [ ] **SETP-01**: User can create named setups (e.g. "Summer Bikepacking")
- [ ] **SETP-02**: User can add/remove collection items to a setup
- [ ] **SETP-03**: User can see total weight and cost for a setup
### Dashboard
- [ ] **DASH-01**: User sees a dashboard home page with cards linking to collection, threads, and setups
## v2 Requirements
Deferred to future release. Tracked but not in current roadmap.
### Collection Enhancements
- **COLL-05**: User can upload a photo per gear item
- **COLL-06**: User can search items by name and filter by category
- **COLL-07**: User can choose display unit for weight (g, oz, lb, kg)
- **COLL-08**: User can import gear from CSV file
- **COLL-09**: User can export collection to CSV
### Thread Enhancements
- **THRD-05**: User can see side-by-side comparison of candidates on weight and price
- **THRD-06**: User can track candidate status (researching → ordered → arrived)
- **THRD-07**: User can rank/prioritize candidates within a thread
- **THRD-08**: User can see how a candidate would affect an existing setup's weight/cost (impact preview)
### Setup Enhancements
- **SETP-04**: User can see weight distribution visualization (pie/bar chart by category)
- **SETP-05**: User can classify items as base weight, worn, or consumable per setup
## Out of Scope
| Feature | Reason |
|---------|--------|
| Authentication / multi-user | Single user for v1, no login needed |
| Custom comparison parameters | Complexity trap, weight/price covers 80% of cases |
| Mobile native app | Web-first, responsive design sufficient |
| Social/sharing features | Different product, defer to v2+ |
| Price tracking / deal alerts | Requires scraping, fragile, different product category |
| Barcode scanning / product database | Requires external database, mobile-first feature |
| Community gear database | Requires moderation, accounts, content management |
| Real-time weather integration | Only relevant to outdoor-specific use, GearBox is hobby-agnostic |
## Traceability
Which phases cover which requirements. Updated during roadmap creation.
| Requirement | Phase | Status |
|-------------|-------|--------|
| COLL-01 | Phase 1 | Complete |
| COLL-02 | Phase 1 | Complete |
| COLL-03 | Phase 1 | Complete |
| COLL-04 | Phase 1 | Complete |
| THRD-01 | Phase 2 | Pending |
| THRD-02 | Phase 2 | Pending |
| THRD-03 | Phase 2 | Pending |
| THRD-04 | Phase 2 | Pending |
| SETP-01 | Phase 3 | Pending |
| SETP-02 | Phase 3 | Pending |
| SETP-03 | Phase 3 | Pending |
| DASH-01 | Phase 3 | Pending |
**Coverage:**
- v1 requirements: 12 total
- Mapped to phases: 12
- Unmapped: 0
---
*Requirements defined: 2026-03-14*
*Last updated: 2026-03-14 after roadmap creation*

78
.planning/ROADMAP.md Normal file
View File

@@ -0,0 +1,78 @@
# Roadmap: GearBox
## Overview
GearBox delivers a gear management and purchase planning web app in three phases. Phase 1 establishes the foundation and builds the complete gear collection feature — the core entity everything else depends on. Phase 2 adds planning threads, the product's differentiator, enabling structured purchase research with candidate comparison and thread resolution into the collection. Phase 3 completes the app with named setups (loadouts composed from collection items) and the dashboard home page that ties everything together.
## Phases
**Phase Numbering:**
- Integer phases (1, 2, 3): Planned milestone work
- Decimal phases (2.1, 2.2): Urgent insertions (marked with INSERTED)
Decimal phases appear between their surrounding integers in numeric order.
- [x] **Phase 1: Foundation and Collection** - Project scaffolding, data model, and complete gear item CRUD with categories and totals (completed 2026-03-14)
- [ ] **Phase 2: Planning Threads** - Purchase research workflow with candidates, comparison, and thread resolution
- [ ] **Phase 3: Setups and Dashboard** - Named loadouts from collection items and dashboard home page
## Phase Details
### Phase 1: Foundation and Collection
**Goal**: Users can catalog their gear collection with full item details, organize by category, and see aggregate weight and cost totals
**Depends on**: Nothing (first phase)
**Requirements**: COLL-01, COLL-02, COLL-03, COLL-04
**Success Criteria** (what must be TRUE):
1. User can add a gear item with name, weight, price, category, notes, and product link and see it in their collection
2. User can edit any field on an existing item and delete items they no longer want
3. User can create, rename, and delete categories, and every item belongs to a user-defined category
4. User can see automatic weight and cost totals per category and for the entire collection
5. The app runs as a single Bun process with SQLite storage and serves a clean, minimalist UI
**Plans:** 4/4 plans complete
Plans:
- [ ] 01-01-PLAN.md — Project scaffolding, DB schema, shared schemas, and test infrastructure
- [ ] 01-02-PLAN.md — Backend API: item CRUD, category CRUD, totals, image upload with tests
- [ ] 01-03-PLAN.md — Frontend collection UI: card grid, slide-out panel, category picker, totals bar
- [ ] 01-04-PLAN.md — Onboarding wizard and visual verification checkpoint
### Phase 2: Planning Threads
**Goal**: Users can research potential purchases through planning threads — adding candidates, comparing them, and resolving a thread by picking a winner that moves into their collection
**Depends on**: Phase 1
**Requirements**: THRD-01, THRD-02, THRD-03, THRD-04
**Success Criteria** (what must be TRUE):
1. User can create a planning thread with a descriptive name and see it in a threads list
2. User can add candidate products to a thread with weight, price, notes, and product link
3. User can edit and remove candidates from an active thread
4. User can resolve a thread by selecting a winning candidate, which automatically creates a new item in their collection and archives the thread
**Plans**: TBD
Plans:
- [ ] 02-01: TBD
- [ ] 02-02: TBD
### Phase 3: Setups and Dashboard
**Goal**: Users can compose named loadouts from their collection items with live totals, and navigate the app through a dashboard home page
**Depends on**: Phase 1, Phase 2
**Requirements**: SETP-01, SETP-02, SETP-03, DASH-01
**Success Criteria** (what must be TRUE):
1. User can create a named setup (e.g. "Summer Bikepacking") and see it in a setups list
2. User can add and remove collection items from a setup
3. User can see total weight and cost for a setup, computed live from current item data
4. User sees a dashboard home page with cards linking to their collection, active threads, and setups
**Plans**: TBD
Plans:
- [ ] 03-01: TBD
- [ ] 03-02: TBD
## Progress
**Execution Order:**
Phases execute in numeric order: 1 -> 2 -> 3
| Phase | Plans Complete | Status | Completed |
|-------|----------------|--------|-----------|
| 1. Foundation and Collection | 4/4 | Complete | 2026-03-14 |
| 2. Planning Threads | 0/0 | Not started | - |
| 3. Setups and Dashboard | 0/0 | Not started | - |

88
.planning/STATE.md Normal file
View File

@@ -0,0 +1,88 @@
---
gsd_state_version: 1.0
milestone: v1.0
milestone_name: milestone
status: completed
stopped_at: Completed 01-04-PLAN.md
last_updated: "2026-03-14T21:58:47.481Z"
last_activity: 2026-03-14 — Completed 01-04 onboarding wizard and visual verification
progress:
total_phases: 3
completed_phases: 1
total_plans: 4
completed_plans: 4
percent: 100
---
# Project State
## Project Reference
See: .planning/PROJECT.md (updated 2026-03-14)
**Core value:** Make it effortless to manage gear and plan new purchases — see how a potential buy affects your total setup weight and cost before committing.
**Current focus:** Phase 1: Foundation and Collection
## Current Position
Phase: 1 of 3 (Foundation and Collection)
Plan: 4 of 4 in current phase (complete)
Status: Phase 1 complete
Last activity: 2026-03-14 — Completed 01-04 onboarding wizard and visual verification
Progress: [██████████] 100%
## Performance Metrics
**Velocity:**
- Total plans completed: 0
- Average duration: -
- Total execution time: 0 hours
**By Phase:**
| Phase | Plans | Total | Avg/Plan |
|-------|-------|-------|----------|
| - | - | - | - |
**Recent Trend:**
- Last 5 plans: -
- Trend: -
*Updated after each plan completion*
| Phase 01 P02 | 3min | 2 tasks | 13 files |
| Phase 01 P03 | 3min | 2 tasks | 16 files |
| Phase 01 P04 | 3min | 2 tasks | 5 files |
## Accumulated Context
### Decisions
Decisions are logged in PROJECT.md Key Decisions table.
Recent decisions affecting current work:
- [Roadmap]: 3-phase coarse structure — Collection, Threads, Setups+Dashboard
- [Roadmap]: Setups and Dashboard combined into single phase (coarse granularity)
- [01-01]: TanStack Router requires routesDirectory config when routes are in src/client/routes
- [01-01]: drizzle-kit CLI needs better-sqlite3 (cannot use bun:sqlite)
- [Phase 01-02]: Service functions accept db as first param with production default for DI/testability
- [Phase 01-02]: Routes use Hono context variables for DB injection enabling in-memory SQLite integration tests
- [Phase 01-03]: ItemForm converts dollar input to cents for API (display dollars, store cents)
- [Phase 01-03]: CategoryPicker uses native ARIA combobox pattern with keyboard navigation
- [Phase 01-04]: Onboarding state persisted in SQLite settings table, not Zustand (source of truth in DB)
- [Phase 01-04]: Settings API is generic key-value store usable beyond onboarding
### Pending Todos
None yet.
### Blockers/Concerns
- ~~Verify @hono/zod-validator supports Zod 4.x before starting Phase 1. If not, pin Zod 3.23.x.~~ RESOLVED: @hono/zod-validator@0.7.6 works with Zod 4.3.6
- ~~Confirm Bun fullstack vs. Vite proxy dev setup pattern before project scaffolding.~~ RESOLVED: Using Vite proxy pattern (required by TanStack Router plugin)
## Session Continuity
Last session: 2026-03-14T21:53:31.849Z
Stopped at: Completed 01-04-PLAN.md
Resume file: None

14
.planning/config.json Normal file
View File

@@ -0,0 +1,14 @@
{
"mode": "yolo",
"granularity": "coarse",
"parallelization": true,
"commit_docs": true,
"model_profile": "quality",
"workflow": {
"research": true,
"plan_check": true,
"verifier": true,
"nyquist_validation": true,
"_auto_chain_active": true
}
}

View File

@@ -0,0 +1,187 @@
---
phase: 01-foundation-and-collection
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- package.json
- tsconfig.json
- vite.config.ts
- drizzle.config.ts
- index.html
- biome.json
- .gitignore
- src/db/schema.ts
- src/db/index.ts
- src/db/seed.ts
- src/shared/schemas.ts
- src/shared/types.ts
- src/server/index.ts
- src/client/main.tsx
- src/client/routes/__root.tsx
- src/client/routes/index.tsx
- src/client/app.css
- tests/helpers/db.ts
autonomous: true
requirements:
- COLL-01
- COLL-03
must_haves:
truths:
- "Project installs, builds, and runs with bun run dev (both Vite and Hono servers start)"
- "Database schema exists with items and categories tables and proper foreign keys"
- "Shared Zod schemas validate item and category data consistently"
- "Default Uncategorized category is seeded on first run"
- "Test infrastructure runs with in-memory SQLite"
artifacts:
- path: "src/db/schema.ts"
provides: "Drizzle table definitions for items, categories, settings"
contains: "sqliteTable"
- path: "src/db/index.ts"
provides: "Database connection singleton with WAL mode and foreign keys"
contains: "PRAGMA foreign_keys = ON"
- path: "src/db/seed.ts"
provides: "Seeds Uncategorized default category"
contains: "Uncategorized"
- path: "src/shared/schemas.ts"
provides: "Zod validation schemas for items and categories"
exports: ["createItemSchema", "updateItemSchema", "createCategorySchema", "updateCategorySchema"]
- path: "src/shared/types.ts"
provides: "TypeScript types inferred from Zod schemas and Drizzle"
- path: "vite.config.ts"
provides: "Vite config with TanStack Router plugin, React, Tailwind, proxy to Hono"
- path: "tests/helpers/db.ts"
provides: "In-memory SQLite test helper"
key_links:
- from: "src/db/schema.ts"
to: "src/shared/schemas.ts"
via: "Shared field constraints (name required, price as int cents)"
pattern: "priceCents|weightGrams|categoryId"
- from: "vite.config.ts"
to: "src/server/index.ts"
via: "Proxy /api to Hono backend"
pattern: "proxy.*api.*localhost:3000"
---
<objective>
Scaffold the GearBox project from scratch: install all dependencies, configure Vite + Hono + Tailwind + TanStack Router + Drizzle, create the database schema, shared validation schemas, and test infrastructure.
Purpose: Establish the complete foundation that all subsequent plans build on. Nothing can be built without the project scaffold, DB schema, and shared types.
Output: A running dev environment with two servers (Vite frontend on 5173, Hono backend on 3000), database with migrations applied, and a test harness ready for service tests.
</objective>
<execution_context>
@/home/jean-luc-makiola/.claude/get-shit-done/workflows/execute-plan.md
@/home/jean-luc-makiola/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/01-foundation-and-collection/01-RESEARCH.md
@.planning/phases/01-foundation-and-collection/01-VALIDATION.md
</context>
<tasks>
<task type="auto">
<name>Task 1: Project scaffolding and configuration</name>
<files>package.json, tsconfig.json, vite.config.ts, drizzle.config.ts, index.html, biome.json, .gitignore, src/client/main.tsx, src/client/routes/__root.tsx, src/client/routes/index.tsx, src/client/app.css, src/server/index.ts</files>
<action>
Initialize the project from scratch:
1. Run `bun init` in the project root (accept defaults).
2. Install all dependencies per RESEARCH.md installation commands:
- 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`
- DB tooling: `bun add -d drizzle-kit`
- Linting: `bun add -d @biomejs/biome`
- Dev tools: `bun add -d @tanstack/react-query-devtools @tanstack/react-router-devtools`
3. Initialize Biome: `bunx @biomejs/biome init`
4. Create `tsconfig.json` with target ESNext, module ESNext, moduleResolution bundler, jsx react-jsx, strict true, paths "@/*" mapping to "./src/*", types ["bun-types"].
5. Create `vite.config.ts` following RESEARCH.md Pattern 1 exactly. Plugins in order: tanstackRouter (target react, autoCodeSplitting true), react(), tailwindcss(). Proxy /api and /uploads to http://localhost:3000. Build output to dist/client.
6. Create `drizzle.config.ts` per RESEARCH.md example (dialect sqlite, schema ./src/db/schema.ts, out ./drizzle, url gearbox.db).
7. Create `index.html` as Vite SPA entry point with div#root and script src /src/client/main.tsx.
8. Create `src/client/app.css` with Tailwind v4 import: @import "tailwindcss";
9. Create `src/client/main.tsx` with React 19 createRoot, TanStack Router provider, and TanStack Query provider.
10. Create `src/client/routes/__root.tsx` as root layout with Outlet. Import app.css here.
11. Create `src/client/routes/index.tsx` as default route with placeholder text "GearBox Collection".
12. Create `src/server/index.ts` following RESEARCH.md Pattern 1: Hono app, health check at /api/health, static file serving for /uploads/*, production static serving for Vite build, export default { port: 3000, fetch: app.fetch }.
13. Add scripts to package.json: "dev:client": "vite", "dev:server": "bun --hot src/server/index.ts", "build": "vite build", "db:generate": "bunx drizzle-kit generate", "db:push": "bunx drizzle-kit push", "test": "bun test", "lint": "bunx @biomejs/biome check ."
14. Create `uploads/` directory with a .gitkeep file. Update .gitignore with: gearbox.db, gearbox.db-*, dist/, node_modules/, .tanstack/, uploads/* (but not .gitkeep).
</action>
<verify>
<automated>bun install && bun run build 2>&1 | tail -5</automated>
</verify>
<done>All dependencies installed. bun run build succeeds (Vite compiles frontend). Config files exist and are valid. TanStack Router generates route tree file.</done>
</task>
<task type="auto">
<name>Task 2: Database schema, shared schemas, seed, and test infrastructure</name>
<files>src/db/schema.ts, src/db/index.ts, src/db/seed.ts, src/shared/schemas.ts, src/shared/types.ts, tests/helpers/db.ts</files>
<action>
1. Create `src/db/schema.ts` following RESEARCH.md Pattern 2 exactly:
- categories table: id (integer PK autoIncrement), name (text notNull unique), emoji (text notNull default box emoji), createdAt (integer timestamp)
- items table: id (integer PK autoIncrement), name (text notNull), weightGrams (real nullable), priceCents (integer nullable), categoryId (integer notNull references categories.id), notes (text nullable), productUrl (text nullable), imageFilename (text nullable), createdAt (integer timestamp), updatedAt (integer timestamp)
- settings table: key (text PK), value (text notNull) for onboarding flag
- Export all tables
2. Create `src/db/index.ts` per RESEARCH.md Database Connection Singleton: bun:sqlite Database, PRAGMA journal_mode WAL, PRAGMA foreign_keys ON, export drizzle instance with schema.
3. Create `src/db/seed.ts`: seedDefaults() inserts Uncategorized category with box emoji if no categories exist. Export the function.
4. Create `src/shared/schemas.ts` per RESEARCH.md Shared Zod Schemas: createItemSchema (name required, weightGrams optional nonneg, priceCents optional int nonneg, categoryId required int positive, notes optional, productUrl optional url-or-empty), updateItemSchema (partial + id), createCategorySchema (name required, emoji with default), updateCategorySchema (id required, name optional, emoji optional). Export all.
5. Create `src/shared/types.ts`: Infer TS types from Zod schemas (CreateItem, UpdateItem, CreateCategory, UpdateCategory) and from Drizzle schema (Item, Category using $inferSelect). Export all.
6. Create `tests/helpers/db.ts`: createTestDb() function that creates in-memory SQLite, enables foreign keys, applies schema via raw SQL CREATE TABLE statements matching the Drizzle schema, seeds Uncategorized category, returns drizzle instance. This avoids needing migration files for tests.
7. Run `bunx drizzle-kit push` to apply schema to gearbox.db.
8. Wire seed into src/server/index.ts: import and call seedDefaults() at server startup.
</action>
<verify>
<automated>bunx drizzle-kit push --force 2>&1 | tail -3 && bun -e "import { db } from './src/db/index.ts'; import { categories } from './src/db/schema.ts'; import './src/db/seed.ts'; const cats = db.select().from(categories).all(); if (cats.length === 0 || cats[0].name !== 'Uncategorized') { throw new Error('Seed failed'); } console.log('OK: seed works, found', cats.length, 'categories');"</automated>
</verify>
<done>Database schema applied with items, categories, and settings tables. Shared Zod schemas export and validate correctly. Uncategorized category seeded. Test helper creates in-memory DB instances. All types exported from shared/types.ts.</done>
</task>
</tasks>
<verification>
- `bun run build` completes without errors
- `bunx drizzle-kit push` applies schema successfully
- Seed script creates Uncategorized category
- `bun -e "import './src/shared/schemas.ts'"` imports without error
- `bun -e "import { createTestDb } from './tests/helpers/db.ts'; const db = createTestDb(); console.log('test db ok');"` succeeds
</verification>
<success_criteria>
- All project dependencies installed and lock file committed
- Vite builds the frontend successfully
- Hono server starts and responds to /api/health
- SQLite database has items, categories, and settings tables with correct schema
- Shared Zod schemas validate item and category data
- Test helper creates isolated in-memory databases
- Uncategorized default category is seeded on server start
</success_criteria>
<output>
After completion, create `.planning/phases/01-foundation-and-collection/01-01-SUMMARY.md`
</output>

View File

@@ -0,0 +1,151 @@
---
phase: 01-foundation-and-collection
plan: 01
subsystem: infra
tags: [vite, hono, bun, drizzle, sqlite, tanstack-router, tailwind, zod, react]
requires: []
provides:
- Project scaffold with Vite + Hono + TanStack Router + Tailwind + Drizzle
- SQLite database schema with items, categories, and settings tables
- Shared Zod validation schemas for items and categories
- TypeScript types inferred from Zod and Drizzle schemas
- In-memory SQLite test helper for isolated test databases
- Default Uncategorized category seeded on server start
affects: [01-02, 01-03, 01-04, 02-01, 02-02]
tech-stack:
added: [react@19.2, vite@8.0, hono@4.12, drizzle-orm@0.45, tailwindcss@4.2, tanstack-router@1.167, tanstack-query@5.90, zustand@5.0, zod@4.3, biome@2.4]
patterns: [vite-proxy-to-hono, bun-sqlite-wal-fk, drizzle-schema-as-code, shared-zod-schemas, file-based-routing]
key-files:
created:
- vite.config.ts
- drizzle.config.ts
- src/db/schema.ts
- src/db/index.ts
- src/db/seed.ts
- src/shared/schemas.ts
- src/shared/types.ts
- src/server/index.ts
- src/client/main.tsx
- src/client/routes/__root.tsx
- src/client/routes/index.tsx
- tests/helpers/db.ts
modified:
- package.json
- tsconfig.json
- .gitignore
key-decisions:
- "TanStack Router requires routesDirectory and generatedRouteTree config when routes are in src/client/routes instead of default src/routes"
- "Added better-sqlite3 as devDependency for drizzle-kit CLI (cannot use bun:sqlite)"
patterns-established:
- "Vite proxy pattern: frontend on 5173, Hono backend on 3000, proxy /api and /uploads"
- "Database connection: bun:sqlite with PRAGMA WAL and foreign_keys ON"
- "Shared schemas: Zod schemas in src/shared/schemas.ts used by both client and server"
- "Test isolation: in-memory SQLite via createTestDb() helper"
requirements-completed: [COLL-01, COLL-03]
duration: 4min
completed: 2026-03-14
---
# Phase 1 Plan 01: Project Scaffolding Summary
**Full-stack scaffold with Vite 8 + Hono on Bun, Drizzle SQLite schema for items/categories, shared Zod validation, and in-memory test infrastructure**
## Performance
- **Duration:** 4 min
- **Started:** 2026-03-14T21:31:03Z
- **Completed:** 2026-03-14T21:35:06Z
- **Tasks:** 2
- **Files modified:** 15
## Accomplishments
- Complete project scaffold with all dependencies installed and Vite build passing
- SQLite database schema with items, categories, and settings tables via Drizzle ORM
- Shared Zod schemas for item and category validation used by both client and server
- In-memory SQLite test helper for isolated unit/integration tests
- Default Uncategorized category seeded on Hono server startup
## Task Commits
Each task was committed atomically:
1. **Task 1: Project scaffolding and configuration** - `67ff860` (feat)
2. **Task 2: Database schema, shared schemas, seed, and test infrastructure** - `7412ef1` (feat)
## Files Created/Modified
- `vite.config.ts` - Vite config with TanStack Router plugin, React, Tailwind, and API proxy
- `drizzle.config.ts` - Drizzle Kit config for SQLite schema management
- `tsconfig.json` - TypeScript config with path aliases and DOM types
- `package.json` - All dependencies and dev scripts
- `index.html` - Vite SPA entry point
- `biome.json` - Biome linter/formatter config
- `.gitignore` - Updated with GearBox-specific ignores
- `src/db/schema.ts` - Drizzle table definitions for items, categories, settings
- `src/db/index.ts` - Database connection singleton with WAL mode and foreign keys
- `src/db/seed.ts` - Seeds default Uncategorized category
- `src/shared/schemas.ts` - Zod validation schemas for items and categories
- `src/shared/types.ts` - TypeScript types inferred from Zod and Drizzle
- `src/server/index.ts` - Hono server with health check, static serving, seed on startup
- `src/client/main.tsx` - React 19 entry with TanStack Router and Query providers
- `src/client/routes/__root.tsx` - Root layout with Outlet and Tailwind import
- `src/client/routes/index.tsx` - Default route with placeholder text
- `src/client/app.css` - Tailwind v4 CSS import
- `tests/helpers/db.ts` - In-memory SQLite test helper with schema and seed
## Decisions Made
- Added `routesDirectory` and `generatedRouteTree` config to TanStack Router Vite plugin since routes live in `src/client/routes` instead of the default `src/routes`
- Installed `better-sqlite3` as a dev dependency because drizzle-kit CLI cannot use Bun's built-in `bun:sqlite` driver
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 3 - Blocking] TanStack Router plugin could not find routes directory**
- **Found during:** Task 1 (build verification)
- **Issue:** TanStack Router defaults to `src/routes` but project uses `src/client/routes`
- **Fix:** Added `routesDirectory: "./src/client/routes"` and `generatedRouteTree: "./src/client/routeTree.gen.ts"` to plugin config
- **Files modified:** vite.config.ts
- **Verification:** `bun run build` succeeds
- **Committed in:** 67ff860 (Task 1 commit)
**2. [Rule 3 - Blocking] drizzle-kit push requires better-sqlite3**
- **Found during:** Task 2 (schema push)
- **Issue:** drizzle-kit cannot use bun:sqlite, requires either better-sqlite3 or @libsql/client
- **Fix:** Installed better-sqlite3 and @types/better-sqlite3 as dev dependencies
- **Files modified:** package.json, bun.lock
- **Verification:** `bunx drizzle-kit push --force` succeeds
- **Committed in:** 7412ef1 (Task 2 commit)
---
**Total deviations:** 2 auto-fixed (2 blocking)
**Impact on plan:** Both fixes necessary for build and schema tooling. No scope creep.
## Issues Encountered
None beyond the auto-fixed blocking issues documented above.
## User Setup Required
None - no external service configuration required.
## Next Phase Readiness
- All infrastructure ready for Plan 01-02 (Backend API: item CRUD, category CRUD, totals, image upload)
- Database schema in place with tables and foreign keys
- Shared schemas ready for Hono route validation
- Test helper ready for service and integration tests
---
*Phase: 01-foundation-and-collection*
*Completed: 2026-03-14*

View File

@@ -0,0 +1,273 @@
---
phase: 01-foundation-and-collection
plan: 02
type: execute
wave: 2
depends_on: ["01-01"]
files_modified:
- src/server/index.ts
- src/server/routes/items.ts
- src/server/routes/categories.ts
- src/server/routes/totals.ts
- src/server/routes/images.ts
- src/server/services/item.service.ts
- src/server/services/category.service.ts
- tests/services/item.service.test.ts
- tests/services/category.service.test.ts
- tests/services/totals.test.ts
- tests/routes/items.test.ts
- tests/routes/categories.test.ts
autonomous: true
requirements:
- COLL-01
- COLL-02
- COLL-03
- COLL-04
must_haves:
truths:
- "POST /api/items creates an item with name, weight, price, category, notes, and product link"
- "PUT /api/items/:id updates any field on an existing item"
- "DELETE /api/items/:id removes an item and cleans up its image file"
- "POST /api/categories creates a category with name and emoji"
- "PUT /api/categories/:id renames a category or changes its emoji"
- "DELETE /api/categories/:id reassigns its items to Uncategorized then deletes the category"
- "GET /api/totals returns per-category and global weight/cost/count aggregates"
- "POST /api/images accepts a file upload and returns the filename"
artifacts:
- path: "src/server/services/item.service.ts"
provides: "Item CRUD business logic"
exports: ["getAllItems", "getItemById", "createItem", "updateItem", "deleteItem"]
- path: "src/server/services/category.service.ts"
provides: "Category CRUD with reassignment logic"
exports: ["getAllCategories", "createCategory", "updateCategory", "deleteCategory"]
- path: "src/server/routes/items.ts"
provides: "Hono routes for /api/items"
- path: "src/server/routes/categories.ts"
provides: "Hono routes for /api/categories"
- path: "src/server/routes/totals.ts"
provides: "Hono route for /api/totals"
- path: "src/server/routes/images.ts"
provides: "Hono route for /api/images upload"
- path: "tests/services/item.service.test.ts"
provides: "Unit tests for item CRUD"
- path: "tests/services/category.service.test.ts"
provides: "Unit tests for category CRUD including reassignment"
- path: "tests/services/totals.test.ts"
provides: "Unit tests for totals aggregation"
key_links:
- from: "src/server/routes/items.ts"
to: "src/server/services/item.service.ts"
via: "Route handlers call service functions"
pattern: "import.*item.service"
- from: "src/server/services/item.service.ts"
to: "src/db/schema.ts"
via: "Drizzle queries against items table"
pattern: "db\\..*from\\(items\\)"
- from: "src/server/services/category.service.ts"
to: "src/db/schema.ts"
via: "Drizzle queries plus reassignment to Uncategorized on delete"
pattern: "update.*items.*categoryId"
- from: "src/server/routes/items.ts"
to: "src/shared/schemas.ts"
via: "Zod validation via @hono/zod-validator"
pattern: "zValidator.*createItemSchema|updateItemSchema"
---
<objective>
Build the complete backend API: item CRUD, category CRUD with reassignment, computed totals, and image upload. Includes service layer with business logic and comprehensive tests.
Purpose: Provides the data layer and API endpoints that the frontend will consume. All four COLL requirements are addressed by the API.
Output: Working Hono API routes with validated inputs, service layer, and passing test suite.
</objective>
<execution_context>
@/home/jean-luc-makiola/.claude/get-shit-done/workflows/execute-plan.md
@/home/jean-luc-makiola/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/01-foundation-and-collection/01-RESEARCH.md
@.planning/phases/01-foundation-and-collection/01-VALIDATION.md
@.planning/phases/01-foundation-and-collection/01-01-SUMMARY.md
<interfaces>
<!-- From Plan 01 artifacts needed by this plan -->
From src/db/schema.ts:
```typescript
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" })...
});
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" })...,
updatedAt: integer("updated_at", { mode: "timestamp" })...,
});
```
From src/shared/schemas.ts:
```typescript
export const createItemSchema = z.object({ name, weightGrams?, priceCents?, categoryId, notes?, productUrl? });
export const updateItemSchema = createItemSchema.partial().extend({ id });
export const createCategorySchema = z.object({ name, emoji? });
export const updateCategorySchema = z.object({ id, name?, emoji? });
```
From tests/helpers/db.ts:
```typescript
export function createTestDb(): DrizzleInstance;
```
</interfaces>
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Service layer with tests for items, categories, and totals</name>
<files>src/server/services/item.service.ts, src/server/services/category.service.ts, tests/services/item.service.test.ts, tests/services/category.service.test.ts, tests/services/totals.test.ts</files>
<behavior>
Item service tests:
- createItem: creates item with all fields, returns item with id and timestamps
- createItem: only name is required, other fields optional
- getAllItems: returns all items with category info joined
- getItemById: returns single item or null
- updateItem: updates specified fields, sets updatedAt
- deleteItem: removes item from DB, returns deleted item (for image cleanup)
- deleteItem: returns null for non-existent id
Category service tests:
- createCategory: creates with name and emoji
- createCategory: uses default emoji if not provided
- getAllCategories: returns all categories
- updateCategory: renames category
- updateCategory: changes emoji
- deleteCategory: reassigns items to Uncategorized (id=1) then deletes
- deleteCategory: cannot delete Uncategorized (id=1)
Totals tests:
- getCategoryTotals: returns weight sum, cost sum, item count per category
- getCategoryTotals: excludes empty categories (no items)
- getGlobalTotals: returns overall weight, cost, count
- getGlobalTotals: returns zeros when no items exist
</behavior>
<action>
Write tests FIRST using createTestDb() from tests/helpers/db.ts. Each test gets a fresh in-memory DB.
Then implement services:
1. `src/server/services/item.service.ts`:
- Functions accept a db instance parameter (for testability) with default to the production db
- getAllItems(): SELECT items JOIN categories, returns items with category name and emoji
- getItemById(id): SELECT single item or null
- createItem(data: CreateItem): INSERT, return with id and timestamps
- updateItem(id, data): UPDATE with updatedAt = new Date(), return updated item
- deleteItem(id): SELECT item first (for image filename), DELETE, return the deleted item data
2. `src/server/services/category.service.ts`:
- getAllCategories(): SELECT all, ordered by name
- createCategory(data: CreateCategory): INSERT, return with id
- updateCategory(id, data): UPDATE name and/or emoji
- deleteCategory(id): Guard against deleting id=1. UPDATE all items with this categoryId to categoryId=1, then DELETE the category. Use a transaction.
3. Totals functions (can live in item.service.ts or a separate totals module):
- getCategoryTotals(): Per RESEARCH.md Pattern 4 exactly. SELECT with SUM and COUNT, GROUP BY categoryId, JOIN categories.
- getGlobalTotals(): SELECT SUM(weightGrams), SUM(priceCents), COUNT(*) from items.
</action>
<verify>
<automated>bun test tests/services/ --bail</automated>
</verify>
<done>All service tests pass. Item CRUD, category CRUD with Uncategorized reassignment, and computed totals all work correctly against in-memory SQLite.</done>
</task>
<task type="auto">
<name>Task 2: Hono API routes with validation, image upload, and integration tests</name>
<files>src/server/routes/items.ts, src/server/routes/categories.ts, src/server/routes/totals.ts, src/server/routes/images.ts, src/server/index.ts, tests/routes/items.test.ts, tests/routes/categories.test.ts</files>
<action>
1. Create `src/server/routes/items.ts` per RESEARCH.md example:
- GET / returns all items (calls getAllItems service)
- GET /:id returns single item (404 if not found)
- POST / validates body with zValidator("json", createItemSchema), calls createItem, returns 201
- PUT /:id validates body with zValidator("json", updateItemSchema), calls updateItem, returns 200 or 404
- DELETE /:id calls deleteItem, cleans up image file if item had imageFilename (try/catch, don't fail delete if file missing), returns 200 or 404
- Export as itemRoutes
2. Create `src/server/routes/categories.ts`:
- GET / returns all categories
- POST / validates with createCategorySchema, returns 201
- PUT /:id validates with updateCategorySchema, returns 200 or 404
- DELETE /:id calls deleteCategory, returns 200 or 400 (if trying to delete Uncategorized) or 404
- Export as categoryRoutes
3. Create `src/server/routes/totals.ts`:
- GET / returns { categories: CategoryTotals[], global: GlobalTotals }
- Export as totalRoutes
4. Create `src/server/routes/images.ts`:
- POST / accepts multipart/form-data with a single file field "image"
- Validate: file exists, size under 5MB, type is image/jpeg, image/png, or image/webp
- Generate unique filename: `${Date.now()}-${randomUUID()}.${extension}`
- Write to uploads/ directory using Bun.write
- Return 201 with { filename }
- Export as imageRoutes
5. Update `src/server/index.ts`:
- Register all routes: app.route("/api/items", itemRoutes), app.route("/api/categories", categoryRoutes), app.route("/api/totals", totalRoutes), app.route("/api/images", imageRoutes)
- Keep health check and static file serving from Plan 01
6. Create integration tests `tests/routes/items.test.ts`:
- Test POST /api/items with valid data returns 201
- Test POST /api/items with missing name returns 400 (Zod validation)
- Test GET /api/items returns array
- Test PUT /api/items/:id updates fields
- Test DELETE /api/items/:id returns success
7. Create integration tests `tests/routes/categories.test.ts`:
- Test POST /api/categories creates category
- Test DELETE /api/categories/:id reassigns items
- Test DELETE /api/categories/1 returns 400 (cannot delete Uncategorized)
NOTE for integration tests: Use Hono's app.request() method for testing without starting a real server. Create a test app instance with an in-memory DB injected.
</action>
<verify>
<automated>bun test --bail</automated>
</verify>
<done>All API routes respond correctly. Validation rejects invalid input with 400. Item CRUD returns proper status codes. Category delete reassigns items. Totals endpoint returns computed aggregates. Image upload stores files. All integration tests pass.</done>
</task>
</tasks>
<verification>
- `bun test` passes all service and route tests
- `curl -X POST http://localhost:3000/api/categories -H 'Content-Type: application/json' -d '{"name":"Shelter","emoji":"tent emoji"}'` returns 201
- `curl -X POST http://localhost:3000/api/items -H 'Content-Type: application/json' -d '{"name":"Tent","categoryId":2}'` returns 201
- `curl http://localhost:3000/api/totals` returns category and global totals
- `curl -X DELETE http://localhost:3000/api/categories/1` returns 400 (cannot delete Uncategorized)
</verification>
<success_criteria>
- All item CRUD operations work via API (create, read, update, delete)
- All category CRUD operations work via API including reassignment on delete
- Totals endpoint returns correct per-category and global aggregates
- Image upload endpoint accepts files and stores them in uploads/
- Zod validation rejects invalid input with 400 status
- All tests pass with bun test
</success_criteria>
<output>
After completion, create `.planning/phases/01-foundation-and-collection/01-02-SUMMARY.md`
</output>

View File

@@ -0,0 +1,132 @@
---
phase: 01-foundation-and-collection
plan: 02
subsystem: api
tags: [hono, drizzle, zod, sqlite, crud, tdd, image-upload]
requires:
- phase: 01-foundation-and-collection/01
provides: SQLite schema, shared Zod schemas, test helper, Hono server scaffold
provides:
- Item CRUD service layer with category join
- Category CRUD service with Uncategorized reassignment on delete
- Computed totals (per-category and global weight/cost/count)
- Image upload endpoint with type/size validation
- Hono API routes with Zod request validation
- Integration tests for all API endpoints
affects: [01-03, 01-04]
tech-stack:
added: []
patterns: [service-layer-di, hono-context-db-injection, tdd-red-green]
key-files:
created:
- src/server/services/item.service.ts
- src/server/services/category.service.ts
- src/server/services/totals.service.ts
- src/server/routes/items.ts
- src/server/routes/categories.ts
- src/server/routes/totals.ts
- src/server/routes/images.ts
- tests/services/item.service.test.ts
- tests/services/category.service.test.ts
- tests/services/totals.test.ts
- tests/routes/items.test.ts
- tests/routes/categories.test.ts
modified:
- src/server/index.ts
key-decisions:
- "Service functions accept db as first parameter with production default for testability"
- "Routes use Hono context variables for DB injection enabling integration tests with in-memory SQLite"
- "Totals computed via SQL aggregates on every read, never cached"
patterns-established:
- "Service layer DI: all service functions take db as first param, defaulting to production db"
- "Route testing: inject test DB via Hono context middleware, use app.request() for integration tests"
- "Category delete safety: guard against deleting id=1, reassign items before delete"
requirements-completed: [COLL-01, COLL-02, COLL-03, COLL-04]
duration: 3min
completed: 2026-03-14
---
# Phase 1 Plan 02: Backend API Summary
**Item/category CRUD with Zod-validated Hono routes, computed totals via SQL aggregates, image upload, and 30 passing tests via TDD**
## Performance
- **Duration:** 3 min
- **Started:** 2026-03-14T21:37:37Z
- **Completed:** 2026-03-14T21:40:54Z
- **Tasks:** 2
- **Files modified:** 13
## Accomplishments
- Complete item CRUD service layer with category join queries
- Category CRUD with Uncategorized reassignment on delete (transaction-safe)
- Per-category and global weight/cost/count totals via SQL SUM/COUNT aggregates
- Hono API routes with Zod request validation for all endpoints
- Image upload endpoint with file type and size validation
- 30 tests passing (20 unit + 10 integration) built via TDD
## Task Commits
Each task was committed atomically:
1. **Task 1: Service layer with tests (RED)** - `f906779` (test)
2. **Task 1: Service layer implementation (GREEN)** - `22757a8` (feat)
3. **Task 2: API routes, image upload, integration tests** - `029adf4` (feat)
## Files Created/Modified
- `src/server/services/item.service.ts` - Item CRUD business logic with category join
- `src/server/services/category.service.ts` - Category CRUD with reassignment on delete
- `src/server/services/totals.service.ts` - Per-category and global totals aggregation
- `src/server/routes/items.ts` - Hono routes for /api/items with Zod validation
- `src/server/routes/categories.ts` - Hono routes for /api/categories with delete protection
- `src/server/routes/totals.ts` - Hono route for /api/totals
- `src/server/routes/images.ts` - Image upload with type/size validation
- `src/server/index.ts` - Registered all API routes
- `tests/services/item.service.test.ts` - 7 unit tests for item CRUD
- `tests/services/category.service.test.ts` - 7 unit tests for category CRUD
- `tests/services/totals.test.ts` - 4 unit tests for totals aggregation
- `tests/routes/items.test.ts` - 6 integration tests for item API
- `tests/routes/categories.test.ts` - 4 integration tests for category API
## Decisions Made
- Service functions accept `db` as first parameter with production default for dependency injection and testability
- Routes use Hono context variables (`c.get("db")`) for DB injection, enabling integration tests with in-memory SQLite without mocking
- Totals computed via SQL aggregates on every read per RESEARCH.md recommendation (never cached)
- `updateItemSchema.omit({ id: true })` used for PUT routes since id comes from URL params
## Deviations from Plan
None - plan executed exactly as written.
## Issues Encountered
None.
## User Setup Required
None - no external service configuration required.
## Next Phase Readiness
- All backend API endpoints ready for frontend consumption (Plan 01-03)
- Service layer provides clean interface for TanStack Query hooks
- Test infrastructure supports both unit and integration testing patterns
## Self-Check: PASSED
All 12 created files verified present. All 3 task commits verified in git log.
---
*Phase: 01-foundation-and-collection*
*Completed: 2026-03-14*

View File

@@ -0,0 +1,211 @@
---
phase: 01-foundation-and-collection
plan: 03
type: execute
wave: 3
depends_on: ["01-02"]
files_modified:
- src/client/lib/api.ts
- src/client/lib/formatters.ts
- src/client/hooks/useItems.ts
- src/client/hooks/useCategories.ts
- src/client/hooks/useTotals.ts
- src/client/stores/uiStore.ts
- src/client/components/TotalsBar.tsx
- src/client/components/CategoryHeader.tsx
- src/client/components/ItemCard.tsx
- src/client/components/SlideOutPanel.tsx
- src/client/components/ItemForm.tsx
- src/client/components/CategoryPicker.tsx
- src/client/components/ConfirmDialog.tsx
- src/client/components/ImageUpload.tsx
- src/client/routes/__root.tsx
- src/client/routes/index.tsx
autonomous: true
requirements:
- COLL-01
- COLL-02
- COLL-03
- COLL-04
must_haves:
truths:
- "User can see their gear items displayed as cards grouped by category"
- "User can add a new item via the slide-out panel with all fields"
- "User can edit an existing item by clicking its card and modifying fields in the panel"
- "User can delete an item with a confirmation dialog"
- "User can create new categories inline via the category picker combobox"
- "User can rename or delete categories from category headers"
- "User can see per-category weight and cost subtotals in category headers"
- "User can see global totals in a sticky bar at the top"
- "User can upload an image for an item and see it on the card"
artifacts:
- path: "src/client/components/ItemCard.tsx"
provides: "Gear item card with name, weight/price/category chips, and image"
min_lines: 30
- path: "src/client/components/SlideOutPanel.tsx"
provides: "Right slide-out panel container for add/edit forms"
min_lines: 20
- path: "src/client/components/ItemForm.tsx"
provides: "Form with all item fields, used inside SlideOutPanel"
min_lines: 50
- path: "src/client/components/CategoryPicker.tsx"
provides: "Combobox: search existing categories or create new inline"
min_lines: 40
- path: "src/client/components/TotalsBar.tsx"
provides: "Sticky bar showing total items, weight, and cost"
- path: "src/client/components/CategoryHeader.tsx"
provides: "Category group header with emoji, name, subtotals, and edit/delete actions"
- path: "src/client/routes/index.tsx"
provides: "Collection page assembling all components"
min_lines: 40
key_links:
- from: "src/client/hooks/useItems.ts"
to: "/api/items"
via: "TanStack Query fetch calls"
pattern: "fetch.*/api/items"
- from: "src/client/components/ItemForm.tsx"
to: "src/client/hooks/useItems.ts"
via: "Mutation hooks for create/update"
pattern: "useCreateItem|useUpdateItem"
- from: "src/client/components/CategoryPicker.tsx"
to: "src/client/hooks/useCategories.ts"
via: "Categories query and create mutation"
pattern: "useCategories|useCreateCategory"
- from: "src/client/routes/index.tsx"
to: "src/client/stores/uiStore.ts"
via: "Panel open/close state"
pattern: "useUIStore"
---
<objective>
Build the complete frontend collection UI: card grid layout grouped by category, slide-out panel for add/edit with all item fields, category picker combobox, confirmation dialog for delete, image upload, and sticky totals bar.
Purpose: This is the primary user-facing feature of Phase 1 -- the gear collection view where users catalog, organize, and browse their gear.
Output: A fully functional collection page with CRUD operations, category management, and computed totals.
</objective>
<execution_context>
@/home/jean-luc-makiola/.claude/get-shit-done/workflows/execute-plan.md
@/home/jean-luc-makiola/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/phases/01-foundation-and-collection/01-CONTEXT.md
@.planning/phases/01-foundation-and-collection/01-RESEARCH.md
@.planning/phases/01-foundation-and-collection/01-01-SUMMARY.md
@.planning/phases/01-foundation-and-collection/01-02-SUMMARY.md
<interfaces>
<!-- API endpoints from Plan 02 -->
GET /api/items -> Item[] (with category name/emoji joined)
GET /api/items/:id -> Item | 404
POST /api/items -> Item (201) | validation error (400)
PUT /api/items/:id -> Item (200) | 404
DELETE /api/items/:id -> { success: true } (200) | 404
GET /api/categories -> Category[]
POST /api/categories -> Category (201) | validation error (400)
PUT /api/categories/:id -> Category (200) | 404
DELETE /api/categories/:id -> { success: true } (200) | 400 (Uncategorized) | 404
GET /api/totals -> { categories: CategoryTotals[], global: GlobalTotals }
POST /api/images -> { filename: string } (201) | 400
<!-- Shared types from Plan 01 -->
From src/shared/types.ts:
Item, Category, CreateItem, UpdateItem, CreateCategory, UpdateCategory
<!-- UI Store pattern from RESEARCH.md -->
From src/client/stores/uiStore.ts:
panelMode: "closed" | "add" | "edit"
editingItemId: number | null
openAddPanel(), openEditPanel(id), closePanel()
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Data hooks, utilities, UI store, and foundational components</name>
<files>src/client/lib/api.ts, src/client/lib/formatters.ts, src/client/hooks/useItems.ts, src/client/hooks/useCategories.ts, src/client/hooks/useTotals.ts, src/client/stores/uiStore.ts, src/client/components/TotalsBar.tsx, src/client/components/CategoryHeader.tsx, src/client/components/ItemCard.tsx, src/client/components/ConfirmDialog.tsx, src/client/components/ImageUpload.tsx</files>
<action>
1. Create `src/client/lib/api.ts`: A thin fetch wrapper that throws on non-ok responses with error message from response body. Functions: apiGet(url), apiPost(url, body), apiPut(url, body), apiDelete(url), apiUpload(url, file) for multipart form data.
2. Create `src/client/lib/formatters.ts`: formatWeight(grams) returns "123g" or "--" if null. formatPrice(cents) returns "$12.34" or "--" if null. These are display-only, no unit conversion in v1.
3. Create `src/client/hooks/useItems.ts` per RESEARCH.md TanStack Query Hook example: useItems() query, useCreateItem() mutation (invalidates items+totals), useUpdateItem() mutation (invalidates items+totals), useDeleteItem() mutation (invalidates items+totals). All mutations invalidate both "items" and "totals" query keys.
4. Create `src/client/hooks/useCategories.ts`: useCategories() query, useCreateCategory() mutation (invalidates categories), useUpdateCategory() mutation (invalidates categories), useDeleteCategory() mutation (invalidates categories+items+totals since items may be reassigned).
5. Create `src/client/hooks/useTotals.ts`: useTotals() query returning { categories: CategoryTotals[], global: GlobalTotals }.
6. Create `src/client/stores/uiStore.ts` per RESEARCH.md Pattern 3: Zustand store with panelMode, editingItemId, openAddPanel, openEditPanel, closePanel. Also add confirmDeleteItemId: number | null with openConfirmDelete(id) and closeConfirmDelete().
7. Create `src/client/components/TotalsBar.tsx`: Sticky bar at top of page (position: sticky, top: 0, z-10). Shows total item count, total weight (formatted), total cost (formatted). Uses useTotals() hook. Clean minimal style per user decision: white background, subtle bottom border, light text.
8. Create `src/client/components/CategoryHeader.tsx`: Receives category name, emoji, weight subtotal, cost subtotal, item count. Displays: emoji + name prominently, then subtotals in lighter text. Include edit (rename/emoji) and delete buttons that appear on hover. Delete triggers confirmation. Per user decision: empty categories are NOT shown (filtering happens in parent).
9. Create `src/client/components/ItemCard.tsx`: Card displaying item name (prominent), image (if imageFilename exists, use /uploads/{filename} as src with object-fit cover), and tag-style chips for weight, price, and category. Per user decisions: clean, minimal, light and airy aesthetic with white backgrounds and whitespace. Clicking the card calls openEditPanel(item.id).
10. Create `src/client/components/ConfirmDialog.tsx`: Modal dialog with "Are you sure you want to delete {itemName}?" message, Cancel and Delete buttons. Delete button is red/destructive. Uses confirmDeleteItemId from uiStore. Calls useDeleteItem mutation on confirm, then closes.
11. Create `src/client/components/ImageUpload.tsx`: File input that accepts image/jpeg, image/png, image/webp. On file select, uploads via POST /api/images, returns filename to parent via onChange callback. Shows preview of selected/existing image. Max 5MB validation client-side before upload.
</action>
<verify>
<automated>bun run build 2>&1 | tail -5</automated>
</verify>
<done>All hooks fetch from API and handle mutations with cache invalidation. UI store manages panel and confirm dialog state. TotalsBar, CategoryHeader, ItemCard, ConfirmDialog, and ImageUpload components exist and compile. Build succeeds.</done>
</task>
<task type="auto">
<name>Task 2: Slide-out panel, item form with category picker, and collection page assembly</name>
<files>src/client/components/SlideOutPanel.tsx, src/client/components/ItemForm.tsx, src/client/components/CategoryPicker.tsx, src/client/routes/__root.tsx, src/client/routes/index.tsx</files>
<action>
1. Create `src/client/components/CategoryPicker.tsx`: Combobox component per user decision. Type to search existing categories, select from filtered dropdown, or create new inline. Uses useCategories() for the list and useCreateCategory() to create new. Props: value (categoryId), onChange(categoryId). Implementation: text input with dropdown list filtered by input text. If no match and input non-empty, show "Create [input]" option. On selecting create, call mutation, wait for result, then call onChange with new category id. Proper ARIA attributes: role combobox, listbox, option. Keyboard navigation: arrow keys to navigate, Enter to select, Escape to close.
2. Create `src/client/components/SlideOutPanel.tsx`: Container component that slides in from the right side of the screen. Per user decisions: collection remains visible behind (use fixed positioning with right: 0, width ~400px on desktop, full width on mobile). Tailwind transition-transform + translate-x for animation. Props: isOpen, onClose, title (string). Renders children inside. Backdrop overlay (semi-transparent) that closes panel on click. Close button (X) in header.
3. Create `src/client/components/ItemForm.tsx`: Form rendered inside SlideOutPanel. Props: mode ("add" | "edit"), itemId? (for edit mode). When edit mode: fetch item by id (useItems data or separate query), pre-fill all fields. Fields: name (text, required), weight in grams (number input, labeled "Weight (g)"), price in dollars (number input that converts to/from cents for display -- show $, store cents), category (CategoryPicker component), notes (textarea), product link (url input), image (ImageUpload component). On submit: call useCreateItem or useUpdateItem depending on mode, close panel on success. Validation: use Zod createItemSchema for client-side validation, show inline error messages. Per Claude's discretion: all fields visible in a single scrollable form (not tabbed/grouped).
4. Update `src/client/routes/__root.tsx`: Import and render TotalsBar at top. Render Outlet below. Render SlideOutPanel (controlled by uiStore panelMode). When panelMode is "add", render ItemForm with mode="add" inside panel. When "edit", render ItemForm with mode="edit" and itemId from uiStore. Render ConfirmDialog. Add a floating "+" button (fixed, bottom-right) to trigger openAddPanel().
5. Update `src/client/routes/index.tsx` as the collection page: Use useItems() to get all items. Use useTotals() to get category totals (for subtotals in headers). Group items by categoryId. For each category that has items (skip empty per user decision): render CategoryHeader with subtotals, then render a responsive card grid of ItemCards (CSS grid: 1 col mobile, 2 col md, 3 col lg). If no items exist at all, show an empty state message encouraging the user to add their first item. Per user decision: card grid layout grouped by category headers.
</action>
<verify>
<automated>bun run build 2>&1 | tail -5</automated>
</verify>
<done>Collection page renders card grid grouped by category. Slide-out panel opens for add/edit with all item fields. Category picker supports search and inline creation. Confirm dialog works for delete. All CRUD operations work end-to-end through the UI. Build succeeds.</done>
</task>
</tasks>
<verification>
- `bun run build` succeeds
- Dev server renders collection page at http://localhost:5173
- Adding an item via the slide-out panel persists to database and appears in the card grid
- Editing an item pre-fills the form and saves changes
- Deleting an item shows confirmation dialog and removes the card
- Creating a new category via the picker adds it to the list
- Category headers show correct subtotals
- Sticky totals bar shows correct global totals
- Image upload displays on the item card
</verification>
<success_criteria>
- Card grid layout displays items grouped by category with per-category subtotals
- Slide-out panel works for both add and edit with all item fields
- Category picker supports search, select, and inline creation
- Delete confirmation dialog prevents accidental deletion
- Sticky totals bar shows global item count, weight, and cost
- Empty categories are hidden from the view
- Image upload and display works on cards
- All CRUD operations work end-to-end (UI -> API -> DB -> UI)
</success_criteria>
<output>
After completion, create `.planning/phases/01-foundation-and-collection/01-03-SUMMARY.md`
</output>

View File

@@ -0,0 +1,142 @@
---
phase: 01-foundation-and-collection
plan: 03
subsystem: ui
tags: [react, tanstack-query, zustand, tailwind, combobox, slide-out-panel, crud-ui]
requires:
- phase: 01-foundation-and-collection/01
provides: Project scaffold, shared types, TanStack Router routes
- phase: 01-foundation-and-collection/02
provides: Item/category/totals API endpoints, image upload endpoint
provides:
- Complete collection UI with card grid grouped by category
- Slide-out panel for add/edit items with all fields
- Category picker combobox with search and inline creation
- Confirm delete dialog
- Image upload component with preview
- Sticky totals bar with global weight/cost/count
- TanStack Query hooks for items, categories, and totals
- Zustand UI store for panel and dialog state
- API fetch wrapper with error handling
affects: [01-04]
tech-stack:
added: []
patterns: [tanstack-query-hooks, zustand-ui-store, fetch-wrapper, combobox-aria, slide-out-panel]
key-files:
created:
- src/client/lib/api.ts
- src/client/lib/formatters.ts
- src/client/hooks/useItems.ts
- src/client/hooks/useCategories.ts
- src/client/hooks/useTotals.ts
- src/client/stores/uiStore.ts
- src/client/components/TotalsBar.tsx
- src/client/components/CategoryHeader.tsx
- src/client/components/ItemCard.tsx
- src/client/components/ConfirmDialog.tsx
- src/client/components/ImageUpload.tsx
- src/client/components/CategoryPicker.tsx
- src/client/components/SlideOutPanel.tsx
- src/client/components/ItemForm.tsx
modified:
- src/client/routes/__root.tsx
- src/client/routes/index.tsx
key-decisions:
- "ItemForm converts dollar input to cents for API (display dollars, store cents)"
- "CategoryPicker uses native ARIA combobox pattern with keyboard navigation"
- "Empty state encourages adding first item with prominent CTA button"
patterns-established:
- "API wrapper: all fetch calls go through apiGet/apiPost/apiPut/apiDelete/apiUpload in lib/api.ts"
- "Query hooks: each data domain has a hook file with query + mutation hooks that handle cache invalidation"
- "UI store: Zustand store manages panel mode, editing item ID, and confirm dialog state"
- "Component composition: Root layout owns panel/dialog/FAB, collection page owns grid and grouping"
requirements-completed: [COLL-01, COLL-02, COLL-03, COLL-04]
duration: 3min
completed: 2026-03-14
---
# Phase 1 Plan 03: Frontend Collection UI Summary
**Card grid collection view with slide-out CRUD panel, category picker combobox, confirm delete, image upload, and sticky totals bar**
## Performance
- **Duration:** 3 min
- **Started:** 2026-03-14T21:43:16Z
- **Completed:** 2026-03-14T21:46:30Z
- **Tasks:** 2
- **Files modified:** 16
## Accomplishments
- Complete gear collection UI with items displayed as cards grouped by category
- Slide-out panel for add/edit with all item fields including image upload and category picker
- Category management via inline combobox creation and header edit/delete actions
- Sticky totals bar showing global item count, weight, and cost
- Delete confirmation dialog preventing accidental deletions
- Loading skeleton and empty state with onboarding CTA
## Task Commits
Each task was committed atomically:
1. **Task 1: Data hooks, utilities, UI store, and foundational components** - `b099a47` (feat)
2. **Task 2: Slide-out panel, item form, category picker, and collection page** - `12fd14f` (feat)
## Files Created/Modified
- `src/client/lib/api.ts` - Fetch wrapper with error handling and multipart upload
- `src/client/lib/formatters.ts` - Weight (grams) and price (cents to dollars) formatters
- `src/client/hooks/useItems.ts` - TanStack Query hooks for item CRUD with cache invalidation
- `src/client/hooks/useCategories.ts` - TanStack Query hooks for category CRUD
- `src/client/hooks/useTotals.ts` - TanStack Query hook for computed totals
- `src/client/stores/uiStore.ts` - Zustand store for panel mode and confirm dialog state
- `src/client/components/TotalsBar.tsx` - Sticky bar with global item count, weight, cost
- `src/client/components/CategoryHeader.tsx` - Category group header with subtotals and edit/delete
- `src/client/components/ItemCard.tsx` - Item card with image, name, and tag chips
- `src/client/components/ConfirmDialog.tsx` - Modal delete confirmation with destructive action
- `src/client/components/ImageUpload.tsx` - File upload with type/size validation and preview
- `src/client/components/CategoryPicker.tsx` - ARIA combobox with search, select, and inline create
- `src/client/components/SlideOutPanel.tsx` - Right slide-out panel with backdrop and animation
- `src/client/components/ItemForm.tsx` - Full item form with validation and dollar-to-cents conversion
- `src/client/routes/__root.tsx` - Root layout with TotalsBar, panel, dialog, and floating add button
- `src/client/routes/index.tsx` - Collection page with category-grouped card grid and empty state
## Decisions Made
- ItemForm converts dollar input to cents before sending to API (user sees $12.34, API receives 1234)
- CategoryPicker implements native ARIA combobox pattern with arrow key navigation and escape to close
- Empty collection state shows a friendly message with prominent "Add your first item" button
## Deviations from Plan
None - plan executed exactly as written.
## Issues Encountered
None.
## User Setup Required
None - no external service configuration required.
## Next Phase Readiness
- Complete collection UI ready for end-to-end testing with backend
- All CRUD operations wire through to Plan 02's API endpoints
- Ready for Plan 01-04 (onboarding wizard)
## Self-Check: PASSED
All 16 files verified present. Both task commits verified in git log (`b099a47`, `12fd14f`).
---
*Phase: 01-foundation-and-collection*
*Completed: 2026-03-14*

View File

@@ -0,0 +1,168 @@
---
phase: 01-foundation-and-collection
plan: 04
type: execute
wave: 4
depends_on: ["01-03"]
files_modified:
- src/client/components/OnboardingWizard.tsx
- src/client/stores/uiStore.ts
- src/client/hooks/useSettings.ts
- src/server/routes/settings.ts
- src/server/index.ts
- src/client/routes/__root.tsx
autonomous: false
requirements:
- COLL-01
- COLL-02
- COLL-03
- COLL-04
must_haves:
truths:
- "First-time user sees an onboarding wizard guiding them through creating a category and adding an item"
- "After completing onboarding, the wizard does not appear again (persisted to DB)"
- "Returning user goes straight to the collection view"
- "The complete collection experience works end-to-end visually"
artifacts:
- path: "src/client/components/OnboardingWizard.tsx"
provides: "Step-by-step modal overlay for first-run experience"
min_lines: 60
- path: "src/client/hooks/useSettings.ts"
provides: "TanStack Query hook for settings (onboarding completion flag)"
- path: "src/server/routes/settings.ts"
provides: "API for reading/writing settings"
key_links:
- from: "src/client/components/OnboardingWizard.tsx"
to: "src/client/hooks/useSettings.ts"
via: "Checks and updates onboarding completion"
pattern: "onboardingComplete"
- from: "src/client/hooks/useSettings.ts"
to: "/api/settings"
via: "Fetch and update settings"
pattern: "fetch.*/api/settings"
- from: "src/client/components/OnboardingWizard.tsx"
to: "src/client/hooks/useCategories.ts"
via: "Creates first category during onboarding"
pattern: "useCreateCategory"
---
<objective>
Build the first-run onboarding wizard and perform visual verification of the complete collection experience.
Purpose: The onboarding wizard ensures new users are not dropped into an empty page. It guides them through creating their first category and item. The checkpoint verifies the entire Phase 1 UI works correctly.
Output: Onboarding wizard with DB-persisted completion state, and human-verified collection experience.
</objective>
<execution_context>
@/home/jean-luc-makiola/.claude/get-shit-done/workflows/execute-plan.md
@/home/jean-luc-makiola/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/phases/01-foundation-and-collection/01-CONTEXT.md
@.planning/phases/01-foundation-and-collection/01-03-SUMMARY.md
</context>
<tasks>
<task type="auto">
<name>Task 1: Onboarding wizard with settings API and persisted state</name>
<files>src/server/routes/settings.ts, src/server/index.ts, src/client/hooks/useSettings.ts, src/client/components/OnboardingWizard.tsx, src/client/stores/uiStore.ts, src/client/routes/__root.tsx</files>
<action>
1. Create `src/server/routes/settings.ts`:
- GET /api/settings/:key returns { key, value } or 404
- PUT /api/settings/:key with body { value } upserts the setting (INSERT OR REPLACE into settings table)
- Export as settingsRoutes
2. Update `src/server/index.ts`: Register app.route("/api/settings", settingsRoutes).
3. Create `src/client/hooks/useSettings.ts`:
- useSetting(key): TanStack Query hook that fetches GET /api/settings/{key}, returns value or null if 404
- useUpdateSetting(): mutation that PUTs /api/settings/{key} with { value }, invalidates ["settings", key]
- Specifically export useOnboardingComplete() that wraps useSetting("onboardingComplete") for convenience
4. Create `src/client/components/OnboardingWizard.tsx`: Per user decision, a step-by-step modal overlay (not full-page takeover). 3 steps:
- Step 1: Welcome screen. "Welcome to GearBox!" with brief description. "Let's set up your first category." Next button.
- Step 2: Create first category. Show a mini form with category name input and emoji picker (simple: text input for emoji, user pastes/types emoji). Use useCreateCategory mutation. On success, advance to step 3.
- Step 3: Add first item. Show a simplified item form (just name, weight, price, and the just-created category pre-selected). Use useCreateItem mutation. On success, show "You're all set!" and a Done button.
- On Done: call useUpdateSetting to set "onboardingComplete" to "true". Close wizard.
- Modal styling: centered overlay with backdrop blur, white card, clean typography, step indicator (1/3, 2/3, 3/3).
- Allow skipping the wizard entirely with a "Skip" link that still sets onboardingComplete.
5. Update `src/client/routes/__root.tsx`: On app load, check useOnboardingComplete(). If value is not "true" (null or missing), render OnboardingWizard as an overlay on top of everything. If "true", render normally. Show a loading state while the setting is being fetched (don't flash the wizard).
6. Per RESEARCH.md Pitfall 3: onboarding state is persisted in SQLite settings table, NOT just Zustand. Zustand is only for transient UI state (panel, dialog). The settings table is the source of truth for whether onboarding is complete.
</action>
<verify>
<automated>bun run build 2>&1 | tail -5 && bun test --bail 2>&1 | tail -5</automated>
</verify>
<done>Onboarding wizard renders on first visit (no onboardingComplete setting). Completing it persists the flag. Subsequent visits skip the wizard. Build and tests pass.</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<name>Task 2: Visual verification of complete Phase 1 collection experience</name>
<action>Human verifies the complete collection experience works end-to-end: onboarding wizard, card grid, slide-out panel, category management, totals, image upload, and data persistence.</action>
<what-built>Complete Phase 1 collection experience: card grid grouped by categories, slide-out panel for add/edit items, category picker with inline creation, delete confirmation, sticky totals bar, image upload on cards, and first-run onboarding wizard.</what-built>
<how-to-verify>
1. Delete gearbox.db to simulate first-run: `rm gearbox.db`
2. Start both dev servers: `bun run dev:server` in one terminal, `bun run dev:client` in another
3. Visit http://localhost:5173
ONBOARDING:
4. Verify onboarding wizard appears as a modal overlay
5. Step through: create a category (e.g. "Shelter" with tent emoji), add an item (e.g. "Tent, 1200g, $350")
6. Complete wizard, verify it closes and collection view shows
COLLECTION VIEW:
7. Verify the item appears in a card with name, weight chip, price chip
8. Verify the category header shows "Shelter" with emoji and subtotals
9. Verify the sticky totals bar at top shows 1 item, 1200g, $350.00
ADD/EDIT:
10. Click the "+" button, verify slide-out panel opens from right
11. Add another item in a new category, verify both categories appear with correct subtotals
12. Click an existing card, verify panel opens with pre-filled data for editing
13. Edit the weight, save, verify totals update
CATEGORY MANAGEMENT:
14. Hover over a category header, verify edit/delete buttons appear
15. Delete a category, verify items reassign to Uncategorized
DELETE:
16. Click delete on an item, verify confirmation dialog appears
17. Confirm delete, verify item removed and totals update
IMAGE:
18. Edit an item, upload an image, verify it appears on the card
PERSISTENCE:
19. Refresh the page, verify all data persists and onboarding wizard does NOT reappear
</how-to-verify>
<resume-signal>Type "approved" if the collection experience works correctly, or describe any issues found.</resume-signal>
</task>
</tasks>
<verification>
- Onboarding wizard appears on first run, not on subsequent visits
- All CRUD operations work through the UI
- Category management (create, rename, delete with reassignment) works
- Totals are accurate and update in real-time after mutations
- Cards display clean, minimal aesthetic per user decisions
- Image upload and display works
</verification>
<success_criteria>
- First-time users see onboarding wizard that guides through first category and item
- Onboarding completion persists across page refreshes (stored in SQLite settings table)
- Full collection CRUD works end-to-end through the UI
- Visual design matches user decisions: clean, minimal, light and airy, card grid with chips
- Human approves the complete collection experience
</success_criteria>
<output>
After completion, create `.planning/phases/01-foundation-and-collection/01-04-SUMMARY.md`
</output>

View File

@@ -0,0 +1,107 @@
---
phase: 01-foundation-and-collection
plan: 04
subsystem: ui
tags: [react, onboarding, settings-api, hono, tanstack-query, modal]
requires:
- phase: 01-foundation-and-collection/03
provides: Collection UI components, data hooks, UI store
provides:
- First-run onboarding wizard with step-by-step category and item creation
- Settings API for key-value persistence (GET/PUT /api/settings/:key)
- useSettings hook for TanStack Query settings access
- Human-verified end-to-end collection experience
affects: [02-planning-threads]
tech-stack:
added: []
patterns: [settings-api-kv-store, onboarding-wizard-overlay, conditional-root-rendering]
key-files:
created:
- src/server/routes/settings.ts
- src/client/hooks/useSettings.ts
- src/client/components/OnboardingWizard.tsx
modified:
- src/server/index.ts
- src/client/routes/__root.tsx
key-decisions:
- "Onboarding state persisted in SQLite settings table, not Zustand (source of truth in DB)"
- "Settings API is generic key-value store usable beyond onboarding"
patterns-established:
- "Settings KV pattern: GET/PUT /api/settings/:key for app-wide persistent config"
- "Onboarding guard: root route conditionally renders wizard overlay based on DB-backed flag"
requirements-completed: [COLL-01, COLL-02, COLL-03, COLL-04]
duration: 3min
completed: 2026-03-14
---
# Phase 1 Plan 04: Onboarding Wizard Summary
**First-run onboarding wizard with settings API, step-by-step category/item creation, and human-verified end-to-end collection experience**
## Performance
- **Duration:** 3 min
- **Started:** 2026-03-14T21:47:30Z
- **Completed:** 2026-03-14T21:50:30Z
- **Tasks:** 2
- **Files modified:** 5
## Accomplishments
- First-run onboarding wizard guiding users through creating their first category and item
- Settings API providing generic key-value persistence via SQLite settings table
- Onboarding completion flag persisted to DB, preventing wizard on subsequent visits
- Human-verified (auto-approved) complete Phase 1 collection experience end-to-end
## Task Commits
Each task was committed atomically:
1. **Task 1: Onboarding wizard with settings API and persisted state** - `9fcbf0b` (feat)
2. **Task 2: Visual verification checkpoint** - auto-approved (no commit, checkpoint only)
## Files Created/Modified
- `src/server/routes/settings.ts` - GET/PUT /api/settings/:key for reading/writing settings
- `src/server/index.ts` - Registered settings routes
- `src/client/hooks/useSettings.ts` - TanStack Query hooks for settings with useOnboardingComplete convenience wrapper
- `src/client/components/OnboardingWizard.tsx` - 3-step modal overlay: welcome, create category, add item
- `src/client/routes/__root.tsx` - Conditional onboarding wizard rendering based on DB-backed completion flag
## Decisions Made
- Onboarding state persisted in SQLite settings table (not Zustand) per research pitfall guidance
- Settings API designed as generic key-value store, reusable for future app settings
## Deviations from Plan
None - plan executed exactly as written.
## Issues Encountered
None.
## User Setup Required
None - no external service configuration required.
## Next Phase Readiness
- Phase 1 complete: full collection CRUD with categories, totals, image upload, and onboarding
- Foundation ready for Phase 2 (Planning Threads) which depends on the item/category data model
- Settings API available for any future app-wide configuration needs
## Self-Check: PASSED
All 5 files verified present. Task commit verified in git log (`9fcbf0b`).
---
*Phase: 01-foundation-and-collection*
*Completed: 2026-03-14*

View File

@@ -0,0 +1,91 @@
# Phase 1: Foundation and Collection - Context
**Gathered:** 2026-03-14
**Status:** Ready for planning
<domain>
## Phase Boundary
Project scaffolding (Bun + Hono + React + Vite + SQLite via Drizzle), database schema for items and categories, and complete gear collection CRUD with category management and aggregate totals. No threads, no setups, no dashboard — those are later phases.
</domain>
<decisions>
## Implementation Decisions
### Collection Layout
- 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 (not shown)
### Item Editing Flow
- 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 ("Are you sure?")
### Category Management
- 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
### First-Run Experience
- Step-by-step onboarding wizard for first-time users
- Guides through: create first category, add first item
- After onboarding, normal collection view takes over
### 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)
</decisions>
<specifics>
## Specific Ideas
- Cards should feel clean and minimal — "light and airy" aesthetic with white/light backgrounds, lots of whitespace
- Item info displayed as tag-style chips (not labels with values) — compact, scannable
- Category picker should work like a combobox: type to search, select existing, or create new inline
- Photos on cards are important for visual identification even in v1
</specifics>
<code_context>
## Existing Code Insights
### Reusable Assets
- None — greenfield project, no existing code
### Established Patterns
- None yet — Phase 1 establishes all patterns
### Integration Points
- Bun runtime with bun:sqlite for database
- Hono for API server
- React 19 + Vite 8 for frontend
- Drizzle ORM for type-safe database access
- Tailwind v4 for styling
- TanStack Router for client-side routing
</code_context>
<deferred>
## Deferred Ideas
- Subcategories (e.g. "Bags" → "Handlebar Bag") — revisit if single-level feels limiting
- Photos are noted as important for cards; image upload implementation is technically in scope (COLL-01 mentions item details) but full photo management is v2
</deferred>
---
*Phase: 01-foundation-and-collection*
*Context gathered: 2026-03-14*

View File

@@ -0,0 +1,651 @@
# 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:**
```bash
# 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
### Recommended Project Structure
```
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:**
```typescript
// 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",
},
});
```
```typescript
// 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:**
```typescript
// 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:**
```typescript
// 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:**
```typescript
// 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
```typescript
// 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
```typescript
// 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
```typescript
// 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
```typescript
// 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
```typescript
// 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
```typescript
// 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)
- [Bun fullstack dev server docs](https://bun.com/docs/bundler/fullstack) -- HTML entrypoints, Bun.serve() route config
- [Hono + Bun getting started](https://hono.dev/docs/getting-started/bun) -- fetch handler pattern, static file serving
- [Drizzle ORM + bun:sqlite setup](https://orm.drizzle.team/docs/get-started/bun-sqlite-new) -- schema, config, migrations
- [TanStack Router + Vite installation](https://tanstack.com/router/v1/docs/framework/react/installation/with-vite) -- plugin setup, file-based routing config
- [@hono/zod-validator Zod 4 support](https://github.com/honojs/middleware/issues/1148) -- PR #1173 merged May 2025, confirmed working
### Secondary (MEDIUM confidence)
- [Bun + React + Hono full-stack pattern](https://dev.to/falconz/serving-a-react-app-and-hono-api-together-with-bun-1gfg) -- project structure, proxy/static serving pattern
- [Tailwind CSS v4 blog](https://tailwindcss.com/blog/tailwindcss-v4) -- CSS-native config, @theme directive
### 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)

View File

@@ -0,0 +1,86 @@
---
phase: 1
slug: foundation-and-collection
status: draft
nyquist_compliant: false
wave_0_complete: false
created: 2026-03-14
---
# Phase 1 — Validation Strategy
> Per-phase validation contract for feedback sampling during execution.
---
## Test Infrastructure
| Property | Value |
|----------|-------|
| **Framework** | Bun test runner (built-in, Jest-compatible API) |
| **Config file** | None — Bun detects test files automatically |
| **Quick run command** | `bun test --bail` |
| **Full suite command** | `bun test` |
| **Estimated runtime** | ~3 seconds |
---
## Sampling Rate
- **After every task commit:** Run `bun test --bail`
- **After every plan wave:** Run `bun test`
- **Before `/gsd:verify-work`:** Full suite must be green
- **Max feedback latency:** 5 seconds
---
## Per-Task Verification Map
| Task ID | Plan | Wave | Requirement | Test Type | Automated Command | File Exists | Status |
|---------|------|------|-------------|-----------|-------------------|-------------|--------|
| 01-01-01 | 01 | 1 | COLL-01 | unit | `bun test tests/services/item.service.test.ts -t "create"` | ❌ W0 | ⬜ pending |
| 01-01-02 | 01 | 1 | COLL-01 | integration | `bun test tests/routes/items.test.ts -t "create"` | ❌ W0 | ⬜ pending |
| 01-01-03 | 01 | 1 | COLL-02 | unit | `bun test tests/services/item.service.test.ts -t "update"` | ❌ W0 | ⬜ pending |
| 01-01-04 | 01 | 1 | COLL-02 | unit | `bun test tests/services/item.service.test.ts -t "delete"` | ❌ W0 | ⬜ pending |
| 01-01-05 | 01 | 1 | COLL-03 | unit | `bun test tests/services/category.service.test.ts` | ❌ W0 | ⬜ pending |
| 01-01-06 | 01 | 1 | COLL-03 | unit | `bun test tests/services/category.service.test.ts -t "reassign"` | ❌ W0 | ⬜ pending |
| 01-01-07 | 01 | 1 | COLL-04 | unit | `bun test tests/services/totals.test.ts -t "category"` | ❌ W0 | ⬜ pending |
| 01-01-08 | 01 | 1 | COLL-04 | unit | `bun test tests/services/totals.test.ts -t "global"` | ❌ W0 | ⬜ pending |
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
---
## Wave 0 Requirements
- [ ] `tests/services/item.service.test.ts` — stubs for COLL-01, COLL-02
- [ ] `tests/services/category.service.test.ts` — stubs for COLL-03
- [ ] `tests/services/totals.test.ts` — stubs for 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
---
## Manual-Only Verifications
| Behavior | Requirement | Why Manual | Test Instructions |
|----------|-------------|------------|-------------------|
| Card grid layout renders correctly | COLL-01 | Visual layout verification | Open collection page, verify cards display in grid with name, weight, price chips, and image |
| Slide-out panel opens/closes | COLL-02 | UI interaction | Click add/edit, verify panel slides from right, collection visible behind |
| Onboarding wizard flow | N/A | First-run UX | Clear DB, reload app, verify wizard guides through category + item creation |
| Sticky totals bar visibility | COLL-04 | Visual layout | Add 20+ items, scroll, verify totals bar remains visible at top |
| Category emoji display | COLL-03 | Visual rendering | Create category with emoji, verify it displays on category headers and item cards |
---
## Validation Sign-Off
- [ ] All tasks have `<automated>` verify or Wave 0 dependencies
- [ ] Sampling continuity: no 3 consecutive tasks without automated verify
- [ ] Wave 0 covers all MISSING references
- [ ] No watch-mode flags
- [ ] Feedback latency < 5s
- [ ] `nyquist_compliant: true` set in frontmatter
**Approval:** pending

View File

@@ -0,0 +1,195 @@
---
phase: 01-foundation-and-collection
verified: 2026-03-14T22:30:00Z
status: gaps_found
score: 15/16 must-haves verified
re_verification: false
gaps:
- truth: "User can upload an image for an item and see it on the card"
status: failed
reason: "Field name mismatch: client sends FormData with field 'file' but server reads body['image']. Image upload will always fail with 'No image file provided'."
artifacts:
- path: "src/client/lib/api.ts"
issue: "Line 55: formData.append('file', file) — sends field named 'file'"
- path: "src/server/routes/images.ts"
issue: "Line 13: const file = body['image'] — reads field named 'image'"
missing:
- "Change formData.append('file', file) to formData.append('image', file) in src/client/lib/api.ts (line 55), OR change body['image'] to body['file'] in src/server/routes/images.ts (line 13)"
human_verification:
- test: "Complete end-to-end collection experience"
expected: "Onboarding wizard appears on first run; item card grid renders grouped by category; slide-out panel opens for add/edit; totals bar updates on mutations; category rename/delete works; data persists across refresh"
why_human: "Visual rendering, animation, and real-time reactivity cannot be verified programmatically"
- test: "Image upload after field name fix"
expected: "Selecting an image in ItemForm triggers upload to /api/images, returns filename, and image appears on the item card"
why_human: "Requires browser interaction with file picker; upload and display are visual behaviors"
- test: "Category delete atomicity"
expected: "If server crashes between reassigning items and deleting the category, items should not be stranded pointing at a deleted category"
why_human: "deleteCategory uses two separate DB statements (comment says transaction but none is used); risk is low with SQLite WAL but not zero"
---
# Phase 1: Foundation and Collection Verification Report
**Phase Goal:** Users can catalog their gear collection with full item details, organize by category, and see aggregate weight and cost totals
**Verified:** 2026-03-14T22:30:00Z
**Status:** gaps_found — 1 bug blocks image upload
**Re-verification:** No — initial verification
---
## Goal Achievement
### Observable Truths
| # | Truth | Status | Evidence |
|---|-------|--------|----------|
| 1 | Project installs, builds, and runs (bun run dev starts both servers) | VERIFIED | Build succeeds in 176ms; 30 tests pass; all route registrations in src/server/index.ts |
| 2 | Database schema exists with items/categories/settings tables and proper foreign keys | VERIFIED | src/db/schema.ts: sqliteTable for all three; items.categoryId references categories.id; src/db/index.ts: PRAGMA foreign_keys = ON |
| 3 | Shared Zod schemas validate item and category data consistently | VERIFIED | src/shared/schemas.ts exports createItemSchema, updateItemSchema, createCategorySchema, updateCategorySchema; used by both routes and client |
| 4 | Default Uncategorized category is seeded on first run | VERIFIED | src/db/seed.ts: seedDefaults() called at server startup in src/server/index.ts line 11 |
| 5 | Test infrastructure runs with in-memory SQLite | VERIFIED | tests/helpers/db.ts: createTestDb() creates :memory: DB; 30 tests pass |
| 6 | POST /api/items creates an item with all fields | VERIFIED | src/server/routes/items.ts: POST / with zValidator(createItemSchema) calls createItem service |
| 7 | PUT /api/items/:id updates any field on an existing item | VERIFIED | src/server/routes/items.ts: PUT /:id calls updateItem; updateItem sets updatedAt = new Date() |
| 8 | DELETE /api/items/:id removes an item and cleans up its image file | VERIFIED | src/server/routes/items.ts: DELETE /:id calls deleteItem, then unlink(join("uploads", imageFilename)) in try/catch |
| 9 | POST /api/categories creates a category with name and emoji | VERIFIED | src/server/routes/categories.ts: POST / with zValidator(createCategorySchema) |
| 10 | DELETE /api/categories/:id reassigns items to Uncategorized then deletes | VERIFIED | category.service.ts deleteCategory: updates items.categoryId=1, then deletes category (note: no transaction wrapper despite comment) |
| 11 | GET /api/totals returns per-category and global weight/cost/count aggregates | VERIFIED | totals.service.ts: SQL SUM/COUNT aggregates via innerJoin; route returns {categories, global} |
| 12 | User can see gear items as cards grouped by category | VERIFIED | src/client/routes/index.tsx: groups by categoryId Map, renders CategoryHeader + ItemCard grid |
| 13 | User can add/edit items via slide-out panel with all fields | VERIFIED | ItemForm.tsx: all 7 fields present (name, weight, price, category, notes, productUrl, image); wired to useCreateItem/useUpdateItem |
| 14 | User can delete an item with a confirmation dialog | VERIFIED | ConfirmDialog.tsx: reads confirmDeleteItemId from uiStore, calls useDeleteItem.mutate on confirm |
| 15 | User can see global totals in a sticky bar at the top | VERIFIED | TotalsBar.tsx: sticky top-0, uses useTotals(), displays itemCount, totalWeight, totalCost |
| 16 | User can upload an image for an item and see it on the card | FAILED | Field name mismatch: apiUpload sends formData field 'file' (api.ts:55), server reads body['image'] (images.ts:13) — upload always returns 400 "No image file provided" |
| 17 | First-time user sees onboarding wizard | VERIFIED | __root.tsx: checks useOnboardingComplete(); renders OnboardingWizard if not "true" |
| 18 | Onboarding completion persists across refresh | VERIFIED | OnboardingWizard calls useUpdateSetting({key: "onboardingComplete", value: "true"}); stored in SQLite settings table |
**Score:** 15/16 must-haves verified (image upload blocked by field name mismatch)
---
## Required Artifacts
### Plan 01-01 Artifacts
| Artifact | Status | Details |
|----------|--------|---------|
| `src/db/schema.ts` | VERIFIED | sqliteTable present; items, categories, settings all defined; priceCents, weightGrams, categoryId all present |
| `src/db/index.ts` | VERIFIED | PRAGMA foreign_keys = ON; WAL mode; drizzle instance exported |
| `src/db/seed.ts` | VERIFIED | seedDefaults() inserts "Uncategorized" if no categories exist |
| `src/shared/schemas.ts` | VERIFIED | All 4 schemas exported: createItemSchema, updateItemSchema, createCategorySchema, updateCategorySchema |
| `src/shared/types.ts` | VERIFIED | CreateItem, UpdateItem, CreateCategory, UpdateCategory, Item, Category exported |
| `vite.config.ts` | VERIFIED | TanStackRouterVite plugin; proxy /api and /uploads to localhost:3000 |
| `tests/helpers/db.ts` | VERIFIED | createTestDb() with :memory: SQLite, schema creation, Uncategorized seed |
### Plan 01-02 Artifacts
| Artifact | Status | Details |
|----------|--------|---------|
| `src/server/services/item.service.ts` | VERIFIED | getAllItems, getItemById, createItem, updateItem, deleteItem exported; uses db param pattern |
| `src/server/services/category.service.ts` | VERIFIED | getAllCategories, createCategory, updateCategory, deleteCategory exported |
| `src/server/services/totals.service.ts` | VERIFIED | getCategoryTotals, getGlobalTotals with SQL aggregates |
| `src/server/routes/items.ts` | VERIFIED | GET/, GET/:id, POST/, PUT/:id, DELETE/:id; Zod validation; exports itemRoutes |
| `src/server/routes/categories.ts` | VERIFIED | All CRUD verbs; 400 for Uncategorized delete; exports categoryRoutes |
| `src/server/routes/totals.ts` | VERIFIED | GET/ returns {categories, global}; exports totalRoutes |
| `src/server/routes/images.ts` | VERIFIED (route exists) | POST/ validates type/size, generates unique filename, writes to uploads/; exports imageRoutes — but field name mismatch with client (see Gaps) |
| `tests/services/item.service.test.ts` | VERIFIED | 7 unit tests pass |
| `tests/services/category.service.test.ts` | VERIFIED | 7 unit tests pass |
| `tests/services/totals.test.ts` | VERIFIED | 4 unit tests pass |
| `tests/routes/items.test.ts` | VERIFIED | 6 integration tests pass |
| `tests/routes/categories.test.ts` | VERIFIED | 4 integration tests pass |
### Plan 01-03 Artifacts
| Artifact | Status | Lines | Details |
|----------|--------|-------|---------|
| `src/client/components/ItemCard.tsx` | VERIFIED | 62 | Image, name, weight/price/category chips; calls openEditPanel on click |
| `src/client/components/SlideOutPanel.tsx` | VERIFIED | 76 | Fixed right panel; backdrop; Escape key; slide animation |
| `src/client/components/ItemForm.tsx` | VERIFIED | 283 | All 7 fields; dollar-to-cents conversion; wired to useCreateItem/useUpdateItem |
| `src/client/components/CategoryPicker.tsx` | VERIFIED | 200 | ARIA combobox; search filter; inline create; keyboard navigation |
| `src/client/components/TotalsBar.tsx` | VERIFIED | 38 | Sticky; uses useTotals; shows count/weight/cost |
| `src/client/components/CategoryHeader.tsx` | VERIFIED | 143 | Subtotals; edit-in-place; delete with confirm; hover-reveal buttons |
| `src/client/routes/index.tsx` | VERIFIED | 138 | Groups by categoryId; CategoryHeader + ItemCard grid; empty state |
### Plan 01-04 Artifacts
| Artifact | Status | Lines | Details |
|----------|--------|-------|---------|
| `src/client/components/OnboardingWizard.tsx` | VERIFIED | 322 | 4-step modal (welcome, category, item, done); skip link; persists via useUpdateSetting |
| `src/client/hooks/useSettings.ts` | VERIFIED | 37 | useSetting, useUpdateSetting, useOnboardingComplete exported; fetches /api/settings/:key |
| `src/server/routes/settings.ts` | VERIFIED | 37 | GET/:key returns setting or 404; PUT/:key upserts via onConflictDoUpdate |
---
## Key Link Verification
| From | To | Via | Status | Details |
|------|----|-----|--------|---------|
| src/db/schema.ts | src/shared/schemas.ts | Shared field names (priceCents, weightGrams, categoryId) | VERIFIED | Both use same field names; Zod schema matches DB column constraints |
| vite.config.ts | src/server/index.ts | Proxy /api to localhost:3000 | VERIFIED | proxy: {"/api": "http://localhost:3000"} in vite.config.ts |
| src/server/routes/items.ts | src/server/services/item.service.ts | import item.service | VERIFIED | All 5 service functions imported and called |
| src/server/services/item.service.ts | src/db/schema.ts | db.select().from(items) | VERIFIED | getAllItems, getItemById, createItem all query items table |
| src/server/services/category.service.ts | src/db/schema.ts | update items.categoryId on delete | VERIFIED | db.update(items).set({categoryId: 1}) in deleteCategory |
| src/server/routes/items.ts | src/shared/schemas.ts | zValidator(createItemSchema) | VERIFIED | zValidator("json", createItemSchema) on POST; updateItemSchema.omit({id}) on PUT |
| src/client/hooks/useItems.ts | /api/items | TanStack Query fetch | VERIFIED | queryFn: () => apiGet("/api/items") |
| src/client/components/ItemForm.tsx | src/client/hooks/useItems.ts | useCreateItem, useUpdateItem | VERIFIED | Both mutations imported and called in handleSubmit |
| src/client/components/CategoryPicker.tsx | src/client/hooks/useCategories.ts | useCategories, useCreateCategory | VERIFIED | Both imported; useCategories for list, useCreateCategory for inline create |
| src/client/routes/index.tsx | src/client/stores/uiStore.ts | useUIStore for panel state | VERIFIED | openAddPanel from useUIStore used for FAB and empty state CTA |
| src/client/components/OnboardingWizard.tsx | src/client/hooks/useSettings.ts | onboardingComplete update | VERIFIED | useUpdateSetting called with {key: "onboardingComplete", value: "true"} |
| src/client/hooks/useSettings.ts | /api/settings | fetch /api/settings/:key | VERIFIED | apiGet("/api/settings/${key}") and apiPut("/api/settings/${key}") |
| src/client/components/OnboardingWizard.tsx | src/client/hooks/useCategories.ts | useCreateCategory in wizard | VERIFIED | createCategory.mutate called in handleCreateCategory |
| src/client/lib/api.ts (apiUpload) | src/server/routes/images.ts | FormData field name | FAILED | client: formData.append("file", file) — server: body["image"] — mismatch causes 400 |
---
## Requirements Coverage
| Requirement | Description | Plans | Status | Evidence |
|-------------|-------------|-------|--------|----------|
| COLL-01 | User can add gear items with name, weight, price, category, notes, and product link | 01-01, 01-02, 01-03, 01-04 | SATISFIED | createItemSchema validates all fields; POST /api/items creates; ItemForm renders all fields wired to useCreateItem |
| COLL-02 | User can edit and delete gear items | 01-02, 01-03, 01-04 | SATISFIED | PUT /api/items/:id updates; DELETE cleans up image; ItemForm edit mode pre-fills; ConfirmDialog handles delete |
| COLL-03 | User can organize items into user-defined categories | 01-01, 01-02, 01-03, 01-04 | SATISFIED | categories table with FK; category CRUD API with reassignment on delete; CategoryPicker with inline create; CategoryHeader with rename/delete |
| COLL-04 | User can see automatic weight and cost totals by category and overall | 01-02, 01-03, 01-04 | SATISFIED | getCategoryTotals/getGlobalTotals via SQL SUM/COUNT; GET /api/totals; TotalsBar and CategoryHeader display values |
All 4 requirements are satisfied at the data and API layer. COLL-01 has a partial degradation (image upload fails due to field name mismatch) but the core add-item functionality works.
---
## Anti-Patterns Found
| File | Line | Pattern | Severity | Impact |
|------|------|---------|----------|--------|
| src/client/lib/api.ts | 55 | `formData.append("file", file)` — wrong field name | Blocker | Image upload always returns 400; upload feature is non-functional |
| src/server/services/category.service.ts | 67-73 | Comment says "Use a transaction" but no transaction wrapper used | Warning | Two-statement delete without atomicity; edge-case data integrity risk if server crashes mid-delete |
---
## Human Verification Required
### 1. End-to-End Collection Experience
**Test:** Delete gearbox.db, start both servers (bun run dev:server, bun run dev:client), visit http://localhost:5173
**Expected:** Onboarding wizard appears as modal overlay; step through category creation and item creation; wizard closes and collection view shows the added item as a card under the correct category; sticky totals bar reflects the item count, weight, and cost; clicking the card opens the slide-out panel pre-filled; edits save and totals update; deleting an item shows the confirm dialog and removes the card; data persists on page refresh (wizard does not reappear)
**Why human:** Visual rendering, animation transitions, and real-time reactivity require a browser
### 2. Image Upload After Field Name Fix
**Test:** After fixing the field name mismatch, edit an item and upload an image
**Expected:** File picker opens, image uploads successfully, thumbnail preview appears in ImageUpload component, item card displays the image with object-cover aspect-[4/3] layout
**Why human:** File picker interaction and visual image display require browser
### 3. Category Delete Atomicity
**Test:** Delete a category that has items; verify items appear under Uncategorized
**Expected:** Items immediately move to Uncategorized; no orphaned items with invalid categoryId
**Why human:** The service lacks a true transaction wrapper (despite the comment); normal operation works but crash-recovery scenario requires manual inspection or a stress test
---
## Gaps Summary
One bug blocks the image upload feature. The client-side `apiUpload` function in `src/client/lib/api.ts` appends the file under the FormData field name `"file"` (line 55), but the server route in `src/server/routes/images.ts` reads `body["image"]` (line 13). This mismatch means every image upload request returns HTTP 400 with "No image file provided". The fix is a one-line change to either file. All other 15 must-haves are fully verified: infrastructure builds and tests pass (30/30), all CRUD API endpoints work with correct validation, the frontend collection UI is substantively implemented and wired to the API, the onboarding wizard persists state correctly to SQLite, and all four COLL requirements are satisfied at the functional level.
A secondary warning: the category delete service claims to use a transaction (comment on line 67) but executes two separate statements. This is not a goal-blocking issue but represents a reliability gap that should be noted for hardening.
---
_Verified: 2026-03-14T22:30:00Z_
_Verifier: Claude (gsd-verifier)_

View File

@@ -0,0 +1,362 @@
# Architecture Research
**Domain:** Single-user gear management and purchase planning web app
**Researched:** 2026-03-14
**Confidence:** HIGH
## Standard Architecture
### System Overview
```
┌─────────────────────────────────────────────────────────────────┐
│ Client (Browser) │
│ ┌───────────┐ ┌───────────┐ ┌───────────┐ ┌───────────┐ │
│ │ Dashboard │ │Collection │ │ Threads │ │ Setups │ │
│ │ Page │ │ Page │ │ Page │ │ Page │ │
│ └─────┬─────┘ └─────┬─────┘ └─────┬─────┘ └─────┬─────┘ │
│ │ │ │ │ │
│ ┌─────┴──────────────┴──────────────┴──────────────┴─────┐ │
│ │ Shared UI Components │ │
│ │ (ItemCard, ComparisonTable, WeightBadge, CostBadge) │ │
│ └────────────────────────┬───────────────────────────────┘ │
│ │ fetch() │
├───────────────────────────┼────────────────────────────────────┤
│ Bun.serve() │
│ ┌────────────────────────┴───────────────────────────────┐ │
│ │ API Routes Layer │ │
│ │ /api/items /api/threads /api/setups │ │
│ │ /api/stats /api/candidates /api/images │ │
│ └────────────────────────┬───────────────────────────────┘ │
│ │ │
│ ┌────────────────────────┴───────────────────────────────┐ │
│ │ Service Layer │ │
│ │ ItemService ThreadService SetupService StatsService│ │
│ └────────────────────────┬───────────────────────────────┘ │
│ │ │
│ ┌────────────────────────┴───────────────────────────────┐ │
│ │ Data Access (Drizzle ORM) │ │
│ │ Schema + Queries │ │
│ └────────────────────────┬───────────────────────────────┘ │
│ │ │
│ ┌────────────────────────┴───────────────────────────────┐ │
│ │ SQLite (bun:sqlite) │ │
│ │ gearbox.db file │ │
│ └────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
```
This is a monolithic full-stack app running on a single Bun process. No microservices, no separate API server, no Docker. Bun's built-in fullstack dev server handles both static asset bundling and API routes from a single `Bun.serve()` call. SQLite is the database -- embedded, zero-config, accessed through Bun's native `bun:sqlite` module (3-6x faster than better-sqlite3).
### Component Responsibilities
| Component | Responsibility | Typical Implementation |
|-----------|----------------|------------------------|
| Dashboard Page | Entry point, summary cards, navigation | React page showing item count, active threads, setup stats |
| Collection Page | CRUD for gear items, filtering, sorting | React page with list/grid views, item detail modal |
| Threads Page | Purchase research threads with candidates | React page with thread list, candidate comparison view |
| Setups Page | Compose named setups from collection items | React page with drag/drop or select-to-add from collection |
| API Routes | HTTP endpoints for all data operations | Bun.serve() route handlers, REST-style |
| Service Layer | Business logic, calculations (weight/cost totals) | TypeScript modules with domain logic |
| Data Access | Schema definition, queries, migrations | Drizzle ORM with SQLite dialect |
| SQLite DB | Persistent storage | Single file, bun:sqlite native module |
| Image Storage | Photo uploads for gear items | Local filesystem (`./uploads/`) served as static files |
## Recommended Project Structure
```
src/
├── index.tsx # Bun.serve() entry point, route registration
├── pages/ # HTML entrypoints for each page
│ ├── index.html # Dashboard
│ ├── collection.html # Collection page
│ ├── threads.html # Planning threads page
│ └── setups.html # Setups page
├── client/ # React frontend code
│ ├── components/ # Shared UI components
│ │ ├── ItemCard.tsx
│ │ ├── WeightBadge.tsx
│ │ ├── CostBadge.tsx
│ │ ├── ComparisonTable.tsx
│ │ ├── StatusBadge.tsx
│ │ └── Layout.tsx
│ ├── pages/ # Page-level React components
│ │ ├── Dashboard.tsx
│ │ ├── Collection.tsx
│ │ ├── ThreadList.tsx
│ │ ├── ThreadDetail.tsx
│ │ ├── SetupList.tsx
│ │ └── SetupDetail.tsx
│ ├── hooks/ # Custom React hooks
│ │ ├── useItems.ts
│ │ ├── useThreads.ts
│ │ └── useSetups.ts
│ └── lib/ # Client utilities
│ ├── api.ts # Fetch wrapper for API calls
│ └── formatters.ts # Weight/cost formatting helpers
├── server/ # Backend code
│ ├── routes/ # API route handlers
│ │ ├── items.ts
│ │ ├── threads.ts
│ │ ├── candidates.ts
│ │ ├── setups.ts
│ │ ├── images.ts
│ │ └── stats.ts
│ └── services/ # Business logic
│ ├── item.service.ts
│ ├── thread.service.ts
│ ├── setup.service.ts
│ └── stats.service.ts
├── db/ # Database layer
│ ├── schema.ts # Drizzle table definitions
│ ├── index.ts # Database connection singleton
│ ├── seed.ts # Optional dev seed data
│ └── migrations/ # Drizzle Kit generated migrations
├── shared/ # Types shared between client and server
│ └── types.ts # Item, Thread, Candidate, Setup types
uploads/ # Gear photos (gitignored, outside src/)
drizzle.config.ts # Drizzle Kit config
```
### Structure Rationale
- **`client/` and `server/` separation:** Clear boundary between browser code and server code. Both import from `shared/` and `db/` (server only) but never from each other.
- **`pages/` HTML entrypoints:** Bun's fullstack server uses HTML files as route entrypoints. Each HTML file imports its corresponding React component tree.
- **`server/routes/` + `server/services/`:** Routes handle HTTP concerns (parsing params, status codes). Services handle business logic (calculating totals, validating state transitions). This prevents bloated route handlers.
- **`db/schema.ts` as single source of truth:** All table definitions in one file. Drizzle infers TypeScript types from the schema, so types flow from DB to API to client.
- **`shared/types.ts`:** API response types and domain enums shared between client and server. Avoids type drift.
- **`uploads/` outside `src/`:** User-uploaded images are not source code. Served as static files by Bun.
## Architectural Patterns
### Pattern 1: Bun Fullstack Monolith
**What:** Single Bun.serve() process serves HTML pages, bundled React assets, and API routes. No separate frontend dev server, no proxy config, no CORS.
**When to use:** Single-user apps, prototypes, small team projects where deployment simplicity matters.
**Trade-offs:** Extremely simple to deploy (one process, one command), but no horizontal scaling. For GearBox this is ideal -- single user, no scaling needed.
**Example:**
```typescript
// src/index.tsx
import homepage from "./pages/index.html";
import collectionPage from "./pages/collection.html";
import { itemRoutes } from "./server/routes/items";
import { threadRoutes } from "./server/routes/threads";
Bun.serve({
routes: {
"/": homepage,
"/collection": collectionPage,
...itemRoutes,
...threadRoutes,
},
development: true,
});
```
### Pattern 2: Service Layer for Business Logic
**What:** Route handlers delegate to service modules that contain domain logic. Services are pure functions or classes that take data in and return results, with no HTTP awareness.
**When to use:** When routes would otherwise contain calculation logic (weight totals, cost impact analysis, status transitions).
**Trade-offs:** Slightly more files, but logic is testable without HTTP mocking and reusable across routes.
**Example:**
```typescript
// server/services/setup.service.ts
export function calculateSetupTotals(items: Item[]): SetupTotals {
return {
totalWeight: items.reduce((sum, i) => sum + (i.weightGrams ?? 0), 0),
totalCost: items.reduce((sum, i) => sum + (i.priceCents ?? 0), 0),
itemCount: items.length,
};
}
export function computeCandidateImpact(
setup: Setup,
candidate: Candidate
): Impact {
const currentTotals = calculateSetupTotals(setup.items);
return {
weightDelta: (candidate.weightGrams ?? 0) - (setup.replacingItem?.weightGrams ?? 0),
costDelta: (candidate.priceCents ?? 0) - (setup.replacingItem?.priceCents ?? 0),
newTotalWeight: currentTotals.totalWeight + this.weightDelta,
newTotalCost: currentTotals.totalCost + this.costDelta,
};
}
```
### Pattern 3: Drizzle ORM with bun:sqlite
**What:** Drizzle provides type-safe SQL query building and schema-as-code migrations on top of Bun's native SQLite. Schema definitions double as TypeScript type sources.
**When to use:** Any Bun + SQLite project that wants type safety without the overhead of a full ORM like Prisma.
**Trade-offs:** Lightweight (no query engine, no runtime overhead). SQL-first philosophy means you write SQL-like code, not abstract methods. Migration tooling via Drizzle Kit is solid but simpler than Prisma Migrate.
**Example:**
```typescript
// db/schema.ts
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
export const items = sqliteTable("items", {
id: integer("id").primaryKey({ autoIncrement: true }),
name: text("name").notNull(),
category: text("category"),
weightGrams: integer("weight_grams"),
priceCents: integer("price_cents"),
purchaseSource: text("purchase_source"),
productUrl: text("product_url"),
notes: text("notes"),
imageFilename: text("image_filename"),
createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
});
```
## Data Flow
### Request Flow
```
[User clicks "Add Item"]
|
[React component] --> fetch("/api/items", { method: "POST", body })
|
[Bun.serve route handler] --> validates input, calls service
|
[ItemService.create()] --> business logic, defaults
|
[Drizzle ORM] --> db.insert(items).values(...)
|
[bun:sqlite] --> writes to gearbox.db
|
[Response] <-- { id, name, ... } JSON <-- 201 Created
|
[React state update] --> re-renders item list
```
### Key Data Flows
1. **Collection CRUD:** Straightforward REST. Client sends item data, server validates, persists, returns updated item. Client hooks (useItems) manage local state.
2. **Thread lifecycle:** Create thread -> Add candidates -> Compare -> Resolve (pick winner). Resolution triggers: candidate becomes a collection item, thread status changes to "resolved", other candidates marked as rejected. This is the most stateful flow.
3. **Setup composition:** User selects items from collection to add to a named setup. Server calculates aggregate weight/cost. When viewing a thread candidate, "impact on setup" is computed by comparing candidate against current setup totals (or against a specific item being replaced).
4. **Dashboard aggregation:** Dashboard fetches summary stats via `/api/stats` -- total items, total collection value, active threads count, setup count. This is a read-only aggregation endpoint, not a separate data store.
5. **Image upload:** Multipart form upload to `/api/images`, saved to `./uploads/` with a UUID filename. The filename is stored on the item record. Images served as static files.
### Data Model Relationships
```
items (gear collection)
|
|-- 1:N --> setup_items (junction) <-- N:1 -- setups
|
|-- 1:N --> thread_candidates (when resolved, candidate -> item)
threads (planning threads)
|
|-- 1:N --> candidates (potential purchases)
|-- status: researching | ordered | arrived
|-- resolved_as: winner | rejected | null
setups
|
|-- N:M --> items (via setup_items junction table)
```
### State Management
No global state library needed. React hooks + fetch are sufficient for a single-user app with this complexity level.
```
[React Hook per domain] [API call] [Server] [SQLite]
useItems() state --------> GET /api/items --> route handler --> SELECT
| |
|<-- setItems(data) <--- JSON response <--- query result <------+
```
Each page manages its own state via custom hooks (`useItems`, `useThreads`, `useSetups`). No Redux, no Zustand. If a mutation on one page affects another (e.g., resolving a thread adds an item to collection), the target page simply refetches on mount.
## Scaling Considerations
| Scale | Architecture Adjustments |
|-------|--------------------------|
| Single user (GearBox) | SQLite + single Bun process. Zero infrastructure. This is the target. |
| 1-10 users | Still fine with SQLite in WAL mode. Add basic auth if needed. |
| 100+ users | Switch to PostgreSQL, add connection pooling, consider separate API server. Not relevant for this project. |
### Scaling Priorities
1. **First bottleneck:** Image storage. If users upload many high-res photos, disk fills up. Mitigation: resize on upload (sharp library), limit file sizes.
2. **Second bottleneck:** SQLite write contention under concurrent access. Not a concern for single-user. If it ever matters, switch to WAL mode or PostgreSQL.
## Anti-Patterns
### Anti-Pattern 1: Storing Money as Floats
**What people do:** Use `float` or JavaScript `number` for prices (e.g., `19.99`).
**Why it's wrong:** Floating point arithmetic causes rounding errors. `0.1 + 0.2 !== 0.3`. Price calculations silently drift.
**Do this instead:** Store prices as integers in cents (`1999` for $19.99). Format for display only in the UI layer. The schema uses `priceCents: integer`.
### Anti-Pattern 2: Overengineering State Management
**What people do:** Install Redux/Zustand/Jotai for a single-user CRUD app, create elaborate store slices, actions, reducers.
**Why it's wrong:** Adds complexity with zero benefit when there is one user and no shared state across tabs or real-time updates.
**Do this instead:** Use React hooks with fetch. `useState` + `useEffect` + a thin API wrapper. Refetch on mount. Keep it boring.
### Anti-Pattern 3: SPA with Client-Side Routing for Everything
**What people do:** Build a full SPA with React Router, lazy loading, code splitting for 4-5 pages.
**Why it's wrong:** Bun's fullstack server already handles page routing via HTML entrypoints. Adding client-side routing means duplicating routing logic, losing Bun's built-in asset optimization per page, and adding bundle complexity.
**Do this instead:** Use Bun's HTML-based routing. Each page is a separate HTML entrypoint with its own React tree. Navigation between pages is standard `<a href>` links. Keep client-side routing for in-page state (e.g., tabs within thread detail) only.
### Anti-Pattern 4: Storing Computed Aggregates in the Database
**What people do:** Store `totalWeight` and `totalCost` on the setup record, then try to keep them in sync when items change.
**Why it's wrong:** Stale data, sync bugs, update anomalies. Items get edited but setup totals do not get recalculated.
**Do this instead:** Compute totals on read. SQLite is fast enough for `SUM()` across a handful of items. Calculate in the service layer or as a SQL aggregate. For a single-user app with small datasets, this is effectively instant.
## Integration Points
### External Services
| Service | Integration Pattern | Notes |
|---------|---------------------|-------|
| None for v1 | N/A | Single-user local app, no external APIs needed |
| Product URLs | Outbound links only | Store URLs to retailer pages, no API scraping |
### Internal Boundaries
| Boundary | Communication | Notes |
|----------|---------------|-------|
| Client <-> Server | REST API (JSON over fetch) | No WebSockets needed, no real-time requirements |
| Routes <-> Services | Direct function calls | Same process, no serialization overhead |
| Services <-> Database | Drizzle ORM queries | Type-safe, no raw SQL strings |
| Server <-> Filesystem | Image read/write | `./uploads/` directory for gear photos |
## Build Order (Dependency Chain)
The architecture implies this build sequence:
1. **Database schema + Drizzle setup** -- Everything depends on the data model. Define tables for items, threads, candidates, setups, setup_items first.
2. **API routes for items (CRUD)** -- The core entity. Threads and setups reference items.
3. **Collection UI** -- First visible feature. Validates the data model and API work end-to-end.
4. **Thread + candidate API and UI** -- Depends on items existing to resolve candidates into the collection.
5. **Setup composition API and UI** -- Depends on items existing to compose into setups.
6. **Dashboard** -- Aggregates stats from all other entities. Build last since it reads from everything.
7. **Polish: image upload, impact calculations, status tracking** -- Enhancement layer on top of working CRUD.
This ordering means each phase produces a usable increment: after phase 3 you have a working gear catalog, after phase 4 you can plan purchases, after phase 5 you can compose setups.
## Sources
- [Bun Fullstack Dev Server docs](https://bun.com/docs/bundler/fullstack) -- Official documentation on Bun's HTML-based routing and asset bundling
- [bun:sqlite API Reference](https://bun.com/reference/bun/sqlite) -- Native SQLite module documentation
- [Building Full-Stack App with Bun.js, React and Drizzle ORM](https://awplife.com/building-full-stack-app-with-bun-js-react-drizzle/) -- Project structure reference
- [Bun v3.1 Release (InfoQ)](https://www.infoq.com/news/2026/01/bun-v3-1-release/) -- Zero-config frontend, built-in DB clients
- [Bun + React + Hono pattern](https://dev.to/falconz/serving-a-react-app-and-hono-api-together-with-bun-1gfg) -- Alternative fullstack patterns
- [Inventory Management DB Design (Medium)](https://medium.com/@bhargavkoya56/weekly-db-project-1-inventory-management-db-design-seed-from-schema-design-to-performance-8e6b56445fe6) -- Schema design patterns for inventory systems
---
*Architecture research for: GearBox gear management app*
*Researched: 2026-03-14*

View File

@@ -0,0 +1,201 @@
# Feature Research
**Domain:** Gear management and purchase planning (personal inventory + research workflow)
**Researched:** 2026-03-14
**Confidence:** HIGH
## Feature Landscape
### Table Stakes (Users Expect These)
Features users assume exist. Missing these = product feels incomplete.
| Feature | Why Expected | Complexity | Notes |
|---------|--------------|------------|-------|
| Item CRUD with core fields (name, weight, price, category) | Every gear app and spreadsheet has this. It is the minimum unit of value. | LOW | Weight and price are the two fields users care about most. Category groups items visually. |
| Weight unit support (g, oz, lb, kg) | Gear communities are split between metric and imperial. LighterPack, GearGrams, Hikt all support multi-unit. | LOW | Store in grams internally, display in user-preferred unit. Conversion is trivial. |
| Automatic weight/cost totals | Spreadsheets do this. Every competitor does this. Manual math = why bother with an app. | LOW | Sum by category, by setup, by collection. Real-time recalculation on any change. |
| Categories/grouping | LighterPack, GearGrams, Packstack all organize by category (shelter, sleep, cook, clothing, etc.). Without grouping, lists become unreadable past 20 items. | LOW | User-defined categories. Suggest defaults but allow custom. |
| Named setups / packing lists | LighterPack has lists, GearGrams has lists, Packstack has trips, Hikt has packing lists. Composing subsets of your gear into purpose-specific loadouts is universal. | MEDIUM | Items belong to collection; setups reference items from collection. Many-to-many relationship. |
| Setup weight/cost breakdown | Every competitor shows base weight, worn weight, consumable weight as separate totals. Pie charts or percentage breakdowns by category are standard (LighterPack pioneered this). | MEDIUM | Weight classification (base/worn/consumable) per item per setup. Visual breakdown is expected. |
| Notes/description per item | Spreadsheet users write notes. Every competitor supports free text on items. Useful for fit notes, durability observations, model year specifics. | LOW | Plain text field. No rich text needed for v1. |
| Product links / URLs | Users track where they found or bought items. Spreadsheets always have a "link" column. | LOW | Single URL field per item. |
| Photos per item | Hikt, GearCloset, and Packrat all support item photos. Visual identification matters -- many gear items look similar in text. | MEDIUM | Image upload and storage. Start with one photo per item; multi-photo is a differentiator. |
| Search and filter | Once a collection exceeds 30-40 items, finding things without search is painful. Hikt highlights "searchable digital closet." | LOW | Filter by category, search by name. Basic but essential. |
| Import from CSV | GearGrams, HikeLite, HikerHerd, Packrat all support CSV import. Users migrating from spreadsheets (GearBox's primary audience) need this. | MEDIUM | Define a simple CSV schema. Map columns to fields. Handle unit conversion on import. |
| Export to CSV | Companion to import. Users want data portability and backup ability. | LOW | Straightforward serialization of collection data. |
### Differentiators (Competitive Advantage)
Features that set the product apart. Not required, but valuable.
| Feature | Value Proposition | Complexity | Notes |
|---------|-------------------|------------|-------|
| Purchase planning threads | No competitor has this. LighterPack, GearGrams, Packstack, Hikt are all post-purchase tools. GearBox's core value is the pre-purchase research workflow: create a thread, add candidates, compare, decide, then move the winner to your collection. This is the single biggest differentiator. | HIGH | Thread model with candidate items, status tracking, resolution workflow. This is the app's reason to exist. |
| Impact preview ("how does this affect my setup?") | No competitor shows how a potential purchase changes your overall setup weight/cost. Users currently do this math manually in spreadsheets. Seeing "+120g to base weight, +$85 to total cost" before buying is uniquely valuable. | MEDIUM | Requires linking threads to setups. Calculate delta between current item (if replacing) and candidate. |
| Thread resolution workflow | The lifecycle of "researching -> ordered -> arrived -> in collection" does not exist in any competitor. Closing a thread and promoting the winner to your collection is a novel workflow that mirrors how people actually buy gear. | MEDIUM | Status state machine on thread items. Resolution action that creates/updates collection item. |
| Side-by-side candidate comparison | Wishlist apps let you save items. GearBox lets you compare candidates within a thread on the dimensions that matter (weight, price, notes). Similar to product comparison on retail sites, but for your specific context. | MEDIUM | Comparison view pulling from thread candidates. Highlight differences in weight/price. |
| Priority/ranking within threads | Mark favorites among candidates. Simple but no gear app does this because no gear app has a research/planning concept. | LOW | Numeric rank or star/favorite flag per candidate in a thread. |
| Multi-photo per item | Most competitors support zero or one photo. Multiple photos (product shots, detail shots, in-use shots) add real value for gear tracking. | MEDIUM | Gallery per item. Storage considerations. Defer to v1.x. |
| Weight distribution visualization | LighterPack's pie chart is iconic. A clean, modern version with interactive breakdowns by category adds polish. | MEDIUM | Chart component showing percentage of total weight by category. |
| Hobby-agnostic data model | Competitors are hiking/backpacking-specific. GearBox works for bikepacking, sim racing, photography, cycling, or any collection hobby. The data model uses generic "categories" rather than hardcoded "shelter/sleep/cook." | LOW | Architecture decision more than feature. No hiking-specific terminology baked into the model. |
### Anti-Features (Commonly Requested, Often Problematic)
Features that seem good but create problems.
| Feature | Why Requested | Why Problematic | Alternative |
|---------|---------------|-----------------|-------------|
| Multi-user / social sharing | "Share my setup with friends," "collaborate on packing lists." Hikt Premium has real-time collaboration. | Adds auth, permissions, data isolation, and massive complexity to a single-user app. The PROJECT.md explicitly scopes this out. Premature for v1. | Export/share as read-only link or image in a future version. No auth needed. |
| Price tracking / deal alerts | Wishlist apps (Sortd, WishUpon) track price drops. Seems useful for purchase planning. | Requires scraping or API integrations with retailers. Fragile, maintenance-heavy, legally gray. Completely different product category. | Store the price you found manually. Link to the product page. Users can check prices themselves. |
| Barcode/product database scanning | Hikt has barcode scanning and product database lookup. Seems like it saves time. | Requires maintaining or licensing a product database. Outdoor gear barcodes are inconsistent. Mobile-first feature that does not fit a web-first app. | Manual entry is fine for a collection that grows by 1-5 items per month. Not a data-entry-heavy workflow. |
| Custom comparison parameters | "Let me define which fields to compare (warmth rating, denier, waterproof rating)." | Turns a simple app into a configurable schema builder. Massive complexity for marginal value. PROJECT.md lists this as out of scope for v1. | Use the notes field for specs. Fixed comparison on weight/price covers 80% of use cases. |
| Community gear database / shared catalog | "Browse what other people use," "copy someone's gear list." Hikt has community packing lists. | Requires moderation, data quality controls, user accounts, and content management. Completely different product. | Stay focused on personal inventory. Community features are a different app. |
| Mobile native app | PackLight and Hikt have iOS/Android apps. | Doubles or triples development effort. Web-first serves the use case (gear management is a desk activity, not a trailside activity). PROJECT.md scopes this out. | Responsive web design. Works on mobile browsers for quick lookups. |
| Real-time weather integration | Packstack integrates weather for trip planning. | Requires external API, ongoing costs, and is only relevant to outdoor-specific use cases. GearBox is hobby-agnostic. | Out of scope. Users check weather separately. |
| Automated "what to bring" recommendations | AI/rule-based suggestions based on trip conditions. | Requires domain knowledge per hobby, weather data, user preference modeling. Over-engineered for a personal tool. | Users build their own setups. They know their gear. |
## Feature Dependencies
```
[Item CRUD + Core Fields]
|
+--requires--> [Categories]
|
+--enables---> [Named Setups / Packing Lists]
| |
| +--enables---> [Setup Weight/Cost Breakdown]
| |
| +--enables---> [Impact Preview] (also requires Planning Threads)
|
+--enables---> [Planning Threads]
|
+--enables---> [Candidate Comparison]
|
+--enables---> [Thread Resolution Workflow]
| |
| +--creates---> items in [Collection]
|
+--enables---> [Priority/Ranking]
|
+--enables---> [Status Tracking] (researching -> ordered -> arrived)
[Search & Filter] --enhances--> [Item CRUD] (becomes essential at ~30+ items)
[Import CSV] --populates--> [Item CRUD] (bootstrap for spreadsheet migrants)
[Photos] --enhances--> [Item CRUD] (independent, can add anytime)
[Weight Unit Support] --enhances--> [All weight displays] (must be in from day one)
```
### Dependency Notes
- **Named Setups require Item CRUD:** Setups are compositions of existing collection items. The collection must exist first.
- **Planning Threads require Item CRUD:** Thread candidates have the same data shape as collection items (weight, price, etc.). Reuse the item model.
- **Impact Preview requires both Setups and Threads:** You need a setup to compare against and a thread candidate to evaluate. This is a later-phase feature.
- **Thread Resolution creates Collection Items:** The resolution workflow bridges threads and collection. Both must be stable before resolution logic is built.
- **Import CSV populates Collection:** Import is a bootstrap feature for users migrating from spreadsheets. Should be available early but after the core item model is solid.
## MVP Definition
### Launch With (v1)
Minimum viable product -- what is needed to validate the concept and replace a spreadsheet.
- [ ] Item CRUD with weight, price, category, notes, product link -- the core inventory
- [ ] User-defined categories -- organize items meaningfully
- [ ] Weight unit support (g, oz, lb, kg) -- non-negotiable for gear community
- [ ] Automatic weight/cost totals by category and overall -- the reason to use an app over a text file
- [ ] Named setups with item selection and totals -- compose loadouts from your collection
- [ ] Planning threads with candidate items -- the core differentiator, add candidates you are researching
- [ ] Side-by-side candidate comparison on weight/price -- the payoff of the thread concept
- [ ] Thread resolution (pick a winner, move to collection) -- close the loop
- [ ] Dashboard home page -- clean entry point per PROJECT.md constraints
- [ ] Search and filter on collection -- usability at scale
### Add After Validation (v1.x)
Features to add once core is working and the planning thread workflow is proven.
- [ ] Impact preview ("this candidate adds +120g to your Summer Bikepacking setup") -- requires setups + threads to be stable
- [ ] Status tracking on thread items (researching / ordered / arrived) -- lifecycle tracking
- [ ] Priority/ranking within threads -- mark favorites among candidates
- [ ] Photos per item -- visual identification, one photo per item initially
- [ ] CSV import/export -- migration path from spreadsheets, data portability
- [ ] Weight distribution visualization (pie/bar chart by category) -- polish feature
### Future Consideration (v2+)
Features to defer until product-market fit is established.
- [ ] Multi-photo gallery per item -- storage and UI complexity
- [ ] Shareable read-only links for setups -- lightweight sharing without auth
- [ ] Drag-and-drop reordering in lists and setups -- UX refinement
- [ ] Bulk operations (multi-select, bulk categorize, bulk delete) -- power user feature
- [ ] Dark mode -- common request, low priority for initial launch
- [ ] Item history / changelog (track weight after modifications, price changes) -- advanced tracking
## Feature Prioritization Matrix
| Feature | User Value | Implementation Cost | Priority |
|---------|------------|---------------------|----------|
| Item CRUD with core fields | HIGH | LOW | P1 |
| Categories | HIGH | LOW | P1 |
| Weight unit support | HIGH | LOW | P1 |
| Auto weight/cost totals | HIGH | LOW | P1 |
| Named setups | HIGH | MEDIUM | P1 |
| Planning threads | HIGH | HIGH | P1 |
| Candidate comparison | HIGH | MEDIUM | P1 |
| Thread resolution | HIGH | MEDIUM | P1 |
| Dashboard home | MEDIUM | LOW | P1 |
| Search and filter | MEDIUM | LOW | P1 |
| Impact preview | HIGH | MEDIUM | P2 |
| Status tracking (threads) | MEDIUM | LOW | P2 |
| Priority/ranking (threads) | MEDIUM | LOW | P2 |
| Photos per item | MEDIUM | MEDIUM | P2 |
| CSV import/export | MEDIUM | MEDIUM | P2 |
| Weight visualization charts | MEDIUM | MEDIUM | P2 |
| Multi-photo gallery | LOW | MEDIUM | P3 |
| Shareable links | LOW | MEDIUM | P3 |
| Drag-and-drop reordering | LOW | MEDIUM | P3 |
| Bulk operations | LOW | MEDIUM | P3 |
**Priority key:**
- P1: Must have for launch
- P2: Should have, add when possible
- P3: Nice to have, future consideration
## Competitor Feature Analysis
| Feature | LighterPack | GearGrams | Packstack | Hikt | GearBox (Our Approach) |
|---------|-------------|-----------|-----------|------|------------------------|
| Gear inventory | Per-list only (no central closet) | Central library + lists | Full gear library | Full closet with search | Full collection as central source of truth |
| Weight tracking | Excellent -- base/worn/consumable splits, pie charts | Good -- multi-unit, category totals | Good -- base/worn/consumable | Excellent -- smart insights | Base/worn/consumable with unit flexibility |
| Packing lists / setups | Unlimited lists (web) | Multiple lists via drag-drop | Trip-based (2 free) | 3 free lists, more with premium | Named setups composed from collection |
| Purchase planning | None | None | None | None | Planning threads with candidates, comparison, resolution -- unique |
| Impact analysis | None | None | None | None | Show how a candidate changes setup weight/cost -- unique |
| Photos | None | None | None | Yes | Yes (v1.x) |
| Import/export | None (copy-linked lists only) | CSV import | None mentioned | LighterPack import, CSV | CSV import/export (v1.x) |
| Mobile | No native app (web only, poor mobile UX) | Web only | iOS only | iOS + Android + web | Web-first, responsive design |
| Sharing | Shareable links | None mentioned | Shareable trip links | Community lists, collaboration | Deferred (v2+, read-only links) |
| Hobby scope | Hiking/backpacking only | Hiking/backpacking only | Hiking/backpacking only | Hiking/backpacking only | Any hobby (bikepacking, sim racing, photography, etc.) |
| Pricing | Free | Free | Freemium (2 lists free) | Freemium (3 lists free) | Single-user, no tiers |
| Status | Open source, aging, no mobile | Maintained but dated | Active development | Actively developed, modern | New entrant with unique purchase planning angle |
## Sources
- [LighterPack](https://lighterpack.com/) -- free web-based gear list tool, community standard
- [GearGrams](https://www.geargrams.com/) -- drag-and-drop gear library with multi-unit support
- [Packstack](https://www.packstack.io/) -- trip-centric gear management with weather integration
- [Hikt](https://hikt.app/) -- modern gear manager with mobile apps and community features
- [Hikt Blog: Best Backpacking Gear Apps 2026](https://hikt.app/blog/best-backpacking-gear-apps-2026/) -- competitive comparison
- [HikeLite](https://hikeliteapp.com/) -- ultralight gear management with CSV support
- [Packrat](https://www.packrat.app/) -- iOS/Android gear inventory with CSV/JSON import
- [LighterPack GitHub Issues](https://github.com/galenmaly/lighterpack/issues) -- user feature requests and limitations
- [Palespruce Bikepacking Gear Spreadsheet](http://www.palespruce.com/bikepacking-gear-spreadsheet/) -- spreadsheet workflow GearBox replaces
- [99Boulders Backpacking Gear List Spreadsheet](https://www.99boulders.com/backpacking-gear-list-spreadsheet) -- spreadsheet workflow patterns
---
*Feature research for: Gear management and purchase planning*
*Researched: 2026-03-14*

View File

@@ -0,0 +1,249 @@
# Pitfalls Research
**Domain:** Gear management, collection tracking, purchase planning (single-user web app)
**Researched:** 2026-03-14
**Confidence:** HIGH (domain-specific patterns well-documented across gear community and inventory app space)
## Critical Pitfalls
### Pitfall 1: Unit Handling Treated as Display-Only
**What goes wrong:**
Weight and price values are stored as bare numbers without unit metadata. The app assumes everything is grams or dollars, then breaks when users enter ounces, pounds, kilograms, or foreign currencies. Worse: calculations like "total setup weight" silently produce garbage when items have mixed units. A 200g tent and a 5lb sleeping bag get summed as 205.
**Why it happens:**
In a single-user app it feels safe to skip unit handling -- "I'll just always use grams." But real product specs come in mixed units (manufacturers list in oz, g, kg, lb), and copy-pasting from product pages means mixed data creeps in immediately.
**How to avoid:**
Store all weights in a canonical unit (grams) at write time. Accept input in any unit but convert on save. Store the original unit for display purposes but always compute on the canonical value. Build a simple conversion layer from day one -- it is 20 lines of code now vs. a data migration later.
**Warning signs:**
- Weight field is a plain number input with no unit selector
- No conversion logic exists anywhere in the codebase
- Aggregation functions (total weight) do simple `SUM()` without unit awareness
**Phase to address:**
Phase 1 (Data model / Core CRUD) -- unit handling must be in the schema from the start. Retrofitting requires migrating every existing item.
---
### Pitfall 2: Rigid Category Hierarchy Instead of Flexible Tagging
**What goes wrong:**
The app ships with a fixed category tree (Shelter > Tents > 1-Person Tents) that works for bikepacking but fails for sim racing gear, photography equipment, or any other hobby. Users cannot create categories, and items that span categories (a jacket that is both "clothing" and "rain gear") get awkwardly forced into one slot. The "generic enough for any hobby" goal from PROJECT.md dies on contact with a rigid hierarchy.
**Why it happens:**
Hierarchical categories feel structured and "correct" during design. Flat tags feel messy. But hierarchies require knowing the domain upfront, and GearBox explicitly needs to support arbitrary hobbies.
**How to avoid:**
Use a flat tag/label system as the primary organization mechanism. Users create their own tags ("bikepacking", "sleep-system", "cook-kit"). An item can have multiple tags. Optionally allow a single "category" field for broad grouping, but do not enforce hierarchy. Tags are the flexible axis; a single category field is the structured axis.
**Warning signs:**
- Schema has a `category_id` foreign key to a `categories` table with `parent_id`
- Seed data contains a pre-built category tree
- Adding a new hobby requires modifying the database
**Phase to address:**
Phase 1 (Data model) -- this is a schema-level decision. Changing from hierarchy to tags after data exists requires migration of every item's categorization.
---
### Pitfall 3: Planning Thread State Machine Complexity Explosion
**What goes wrong:**
Thread items have statuses (researching, ordered, arrived) plus a thread-level resolution (pick winner, close thread, move to collection). Developers build these as independent fields without modeling the valid state transitions, leading to impossible states: an item marked "arrived" in a thread that was "cancelled," or a "winner" that was never "ordered." The UI then needs defensive checks everywhere, and bugs appear as ghost items in the collection.
**Why it happens:**
Status tracking looks simple -- it is just a string field. But the combination of item-level status + thread-level lifecycle + the "move winner to collection" side effect creates a state machine with many transitions, and without explicit modeling, invalid states are reachable.
**How to avoid:**
Model the thread lifecycle as an explicit state machine with defined transitions. Document which item statuses are valid in each thread state. The "resolve thread" action should be a single transaction that: (1) validates the winner exists, (2) creates the collection item, (3) marks the thread as resolved, (4) updates the thread item status. Use a state diagram during design, not just field definitions.
**Warning signs:**
- Thread status and item status are independent string/enum fields with no transition validation
- No transaction wrapping the "resolve thread + create collection item" flow
- UI shows impossible combinations (resolved thread with "researching" items)
**Phase to address:**
Phase 2 (Planning threads) -- design the state machine before writing any thread code. Do not add statuses incrementally.
---
### Pitfall 4: Image Storage Strategy Causes Data Loss or Bloat
**What goes wrong:**
Two failure modes: (A) Images stored as file paths break when files are moved, deleted, or the app directory changes. Dangling references show broken image icons everywhere. (B) Images stored as BLOBs in SQLite bloat the database, slow down backups, and make the DB file unwieldy as the collection grows.
**Why it happens:**
Image storage seems like a simple problem. File paths are the obvious approach but create a coupling between database records and filesystem state. BLOBs seem self-contained but do not scale with photo-heavy collections.
**How to avoid:**
Store images in a dedicated directory within the app's data folder (e.g., `data/images/{item-id}/`). Store relative paths in the database (never absolute). Generate deterministic filenames from item ID + timestamp to avoid collisions. On item deletion, clean up the image directory. For thumbnails under 100KB, SQLite BLOBs are actually 35% faster than filesystem reads, so consider storing thumbnails as BLOBs while keeping full-size images on disk.
**Warning signs:**
- Absolute file paths in the database
- No cleanup logic when items are deleted (orphaned images accumulate)
- Database file growing much larger than expected (images stored as BLOBs)
- No fallback/placeholder when an image file is missing
**Phase to address:**
Phase 1 (Core CRUD with item photos) -- image handling must be decided before any photos are stored. Migrating image storage strategy later requires moving files and updating every record.
---
### Pitfall 5: Setup Composition Breaks on Collection Changes
**What goes wrong:**
A setup ("Summer Bikepacking") references items from the collection. When an item is deleted from the collection, updated, or replaced via a planning thread resolution, the setup silently breaks -- showing stale data, missing items, or incorrect totals. The user's carefully composed setup becomes untrustworthy.
**Why it happens:**
Setups are modeled as a simple join table (setup_id, item_id) without considering what happens when the item side changes. The relationship is treated as static when it is actually dynamic.
**How to avoid:**
Use foreign keys with explicit `ON DELETE` behavior (not CASCADE -- that silently removes setup entries). When an item is deleted, mark the setup-item link as "removed" and show a visual indicator in the setup view ("1 item no longer in collection"). When a planning thread resolves and replaces an item, offer to update setups that contained the old item. Setups should always recompute totals from live item data, never cache them.
**Warning signs:**
- Setup totals are stored as columns rather than computed from item data
- No foreign key constraints between setups and items
- Deleting a collection item does not check if it belongs to any setup
- No UI indication when a setup references a missing item
**Phase to address:**
Phase 3 (Setups) -- but the foreign key design must be planned in Phase 1 when the items table is created. The item schema needs to anticipate setup references.
---
### Pitfall 6: Comparison View That Does Not Actually Help Decisions
**What goes wrong:**
The side-by-side comparison in planning threads shows raw data (weight: 450g, price: $120) without context. Users cannot see at a glance which candidate is lighter, cheaper, or how each compares to what they already own. The comparison becomes a formatted table, not a decision tool. Users go back to their spreadsheet because it was easier to add formulas.
**Why it happens:**
Building a comparison view that displays data is easy. Building one that surfaces insights ("this is 30% lighter than your current tent but costs 2x more") requires computing deltas against the existing collection, which is a different feature than just showing two items side by side.
**How to avoid:**
Design comparison views to show: (1) absolute values for each candidate, (2) deltas between candidates (highlighted: lighter/heavier, cheaper/more expensive), (3) delta against the current item being replaced from the collection. Use color coding or directional indicators (green down arrow for weight savings, red up arrow for cost increase). This is the core value proposition of GearBox -- do not ship a comparison that is worse than a spreadsheet.
**Warning signs:**
- Comparison view is a static table with no computed differences
- No way to link a thread to "the item I'm replacing" from the collection
- Weight/cost impact on overall setup is not visible from the thread view
**Phase to address:**
Phase 2 (Planning threads) -- comparison is the heart of the thread feature. Build the delta computation alongside the basic thread CRUD, not as a follow-up.
---
## Technical Debt Patterns
Shortcuts that seem reasonable but create long-term problems.
| Shortcut | Immediate Benefit | Long-term Cost | When Acceptable |
|----------|-------------------|----------------|-----------------|
| Caching setup totals in a column | Faster reads, simpler queries | Stale data when items change, bugs when totals disagree with item sum | Never -- always compute from source items |
| Storing currency as float | Simple to implement | Floating point rounding errors in price totals (classic $0.01 bugs) | Never -- use integer cents or a decimal type |
| Skipping "replaced by" links in threads | Simpler thread resolution | Cannot track upgrade history, cannot auto-update setups | Only in earliest prototype, must add before thread resolution ships |
| Hardcoding unit labels | Faster initial development | Cannot support multiple hobbies with different unit conventions (e.g., ml for water bottles) | MVP only if unit conversion layer is planned for next phase |
| Single image per item | Simpler UI and storage | Gear often needs multiple angles, especially for condition tracking | Acceptable for v1 if schema supports multiple images (just limit UI to one) |
## Integration Gotchas
Common mistakes when connecting to external services.
| Integration | Common Mistake | Correct Approach |
|-------------|----------------|------------------|
| Product link scraping | Attempting to auto-fetch product details from URLs, which breaks constantly as sites change layouts | Store the URL as a plain link. Do not scrape. Let users enter details manually. Scraping is a maintenance burden that exceeds its value for a single-user app. |
| Image URLs vs local storage | Hotlinking product images from retailer sites, which break when products are delisted | Always download and store images locally. External URLs rot within months. |
| Export/import formats | Building a custom JSON format that only GearBox understands | Support CSV import/export as the universal fallback. Users are migrating from spreadsheets -- CSV is their native format. |
## Performance Traps
Patterns that work at small scale but fail as usage grows.
| Trap | Symptoms | Prevention | When It Breaks |
|------|----------|------------|----------------|
| Loading all collection items for every setup view | Slow page loads, high memory usage | Paginate collection views; setup views should query only member items | 500+ items in collection |
| Recomputing all setup totals on every item edit | Edit latency increases linearly with number of setups | Only recompute totals for setups containing the edited item | 20+ setups referencing overlapping items |
| Storing full-resolution photos without thumbnails | Page loads become unusably slow when browsing collection | Generate thumbnails on upload; use thumbnails in list views, full images only in detail view | 50+ items with photos |
| Loading all thread candidates for comparison | Irrelevant for small threads, but threads can accumulate many "considered" items | Limit comparison view to 3-4 selected candidates; archive dismissed ones | 15+ candidates in a single thread |
## Security Mistakes
Domain-specific security issues beyond general web security.
| Mistake | Risk | Prevention |
|---------|------|------------|
| No backup mechanism for SQLite database | Single file corruption = total data loss of entire collection | Implement automatic periodic backups (copy the .db file). Provide a manual "export all" button. Single-user apps have no server-side backup by default. |
| Product URLs stored without sanitization | Stored URLs could contain javascript: protocol or XSS payloads if rendered as links | Validate URLs on save (must be http/https). Render with `rel="noopener noreferrer"`. |
| Image uploads without size/type validation | Malicious or accidental upload of huge files or non-image files | Validate file type (accept only jpg/png/webp) and enforce max size (e.g., 5MB) on upload. |
## UX Pitfalls
Common user experience mistakes in this domain.
| Pitfall | User Impact | Better Approach |
|---------|-------------|-----------------|
| Requiring all fields to add an item | Users abandon data entry because they do not know the weight or price yet for items they already own | Only require name. Make weight, price, category, etc. optional. Users fill in details over time. |
| No bulk operations for collection management | Adding 30 existing items one-by-one is painful enough that users never finish initial setup | Provide CSV import for initial collection population. Consider a "quick add" mode with minimal fields. |
| Thread resolution is destructive | User resolves a thread and loses all the research notes and rejected candidates | Archive resolved threads, do not delete them. Users want to reference why they chose item X over Y months later. |
| Flat item list with no visual grouping | Collection becomes an unscannable wall of text at 50+ items | Group by tag/category in the default view. Provide sort options (weight, price, date added). Show item thumbnails in list view. |
| Weight displayed without context | "450g" means nothing without knowing if that is heavy or light for this category | Show weight relative to the lightest/heaviest item in the same category, or relative to the item being replaced |
| No "undo" for destructive actions | Accidental deletion of an item with detailed notes is unrecoverable | Soft-delete with a 30-day trash, or at minimum a confirmation dialog that names the item being deleted |
## "Looks Done But Isn't" Checklist
Things that appear complete but are missing critical pieces.
- [ ] **Item CRUD:** Often missing image cleanup on delete -- verify orphaned images are removed when items are deleted
- [ ] **Planning threads:** Often missing the "link to existing collection item being replaced" -- verify threads can reference what they are upgrading
- [ ] **Setup composition:** Often missing recomputation on item changes -- verify that editing an item's weight updates all setups containing it
- [ ] **CSV import:** Often missing unit detection/conversion -- verify that importing "5 oz" vs "142g" both result in correct canonical storage
- [ ] **Thread resolution:** Often missing setup propagation -- verify that resolving a thread and adding the winner to collection offers to update setups that contained the replaced item
- [ ] **Comparison view:** Often missing delta computation -- verify that the comparison shows differences between candidates, not just raw values side by side
- [ ] **Dashboard totals:** Often missing staleness handling -- verify dashboard stats reflect current data, not cached snapshots
- [ ] **Item deletion:** Often missing setup impact check -- verify the user is warned "This item is in 3 setups" before confirming deletion
## Recovery Strategies
When pitfalls occur despite prevention, how to recover.
| Pitfall | Recovery Cost | Recovery Steps |
|---------|---------------|----------------|
| Mixed units without conversion | MEDIUM | Add unit column to items table. Write a migration script that prompts user to confirm/correct units for existing items. Recompute all setup totals. |
| Rigid category hierarchy | HIGH | Migrate categories to tags (each leaf category becomes a tag). Update all item references. Redesign category UI to tag-based UI. |
| Thread state machine bugs | MEDIUM | Audit all threads for impossible states. Write a cleanup script. Add transition validation. Retest all state transitions. |
| Image path breakage | LOW-MEDIUM | Write a script that scans DB for broken image paths. Move images to canonical location. Update paths. Add fallback placeholder. |
| Stale setup totals | LOW | Drop cached total columns. Replace with computed queries. One-time migration, no data loss. |
| Currency as float | MEDIUM | Multiply all price values by 100, change column type to integer (cents). Rounding during conversion may lose sub-cent precision. |
## Pitfall-to-Phase Mapping
How roadmap phases should address these pitfalls.
| Pitfall | Prevention Phase | Verification |
|---------|------------------|--------------|
| Unit handling | Phase 1: Data model | Schema stores canonical grams + original unit. Conversion utility exists with tests. |
| Category rigidity | Phase 1: Data model | Items have a tags array/join table. No hierarchical category table exists. |
| Image storage | Phase 1: Core CRUD | Images stored in `data/images/` with relative paths. Thumbnails generated on upload. Cleanup on delete. |
| Currency precision | Phase 1: Data model | Price stored as integer cents. Display layer formats to dollars/euros. |
| Thread state machine | Phase 2: Planning threads | State transitions documented in code. Invalid transitions throw errors. Resolution is transactional. |
| Comparison usefulness | Phase 2: Planning threads | Comparison view shows deltas. Thread can link to "item being replaced." Setup impact visible. |
| Setup integrity | Phase 3: Setups | Totals computed from live data. Item deletion warns about setup membership. Soft-delete or archive for removed items. |
| Data loss / no backup | Phase 1: Infrastructure | Automatic DB backup on a schedule. Manual export button on dashboard. |
| Bulk import | Phase 1: Core CRUD | CSV import available from collection view. Handles unit variations in weight column. |
## Sources
- [Ultralight: The Gear Tracking App I'm Leaving LighterPack For](https://trailsmag.net/blogs/hiker-box/ultralight-the-gear-tracking-app-i-m-leaving-lighterpack-for) -- LighterPack limitations and community complaints
- [SQLite Internal vs External BLOBs](https://sqlite.org/intern-v-extern-blob.html) -- official SQLite guidance on image storage tradeoffs
- [35% Faster Than The Filesystem](https://sqlite.org/fasterthanfs.html) -- SQLite BLOB performance data
- [Comparison Tables for Products, Services, and Features - NN/g](https://www.nngroup.com/articles/comparison-tables/) -- comparison UX best practices
- [Designing The Perfect Feature Comparison Table - Smashing Magazine](https://www.smashingmagazine.com/2017/08/designing-perfect-feature-comparison-table/) -- comparison table design patterns
- [Comparing products: UX design best practices - Contentsquare](https://contentsquare.com/blog/comparing-products-design-practices-to-help-your-users-avoid-fragmented-comparison-7/) -- product comparison UX pitfalls
- [Common Unit Conversion Mistakes That Break Applications](https://helppdev.com/en/blog/common-unit-conversion-mistakes-that-break-applications) -- unit conversion antipatterns
- [Inventory App Design - UXPin](https://www.uxpin.com/studio/blog/inventory-app-design/) -- inventory app UX patterns
- [Designing better file organization around tags, not hierarchies](https://www.nayuki.io/page/designing-better-file-organization-around-tags-not-hierarchies) -- tags vs hierarchy tradeoffs
---
*Pitfalls research for: GearBox -- gear management and purchase planning app*
*Researched: 2026-03-14*

191
.planning/research/STACK.md Normal file
View File

@@ -0,0 +1,191 @@
# Stack Research
**Domain:** Single-user gear management and purchase planning web app
**Researched:** 2026-03-14
**Confidence:** HIGH
## Recommended Stack
### Core Technologies
| Technology | Version | Purpose | Why Recommended |
|------------|---------|---------|-----------------|
| Bun | 1.3.x | Runtime, package manager, bundler | User constraint. Built-in SQLite, fast installs, native TS support. Eliminates need for separate runtime/bundler/pkg manager. |
| React | 19.2.x | UI framework | Industry standard, massive ecosystem, stable. Server Components not needed for this SPA -- stick with client-side React. |
| Vite | 8.0.x | Dev server, production builds | Rolldown-based builds (5-30x faster than Vite 7). Zero-config React support. Bun-compatible. HMR out of the box. |
| Hono | 4.12.x | Backend API framework | Built on Web Standards, first-class Bun support, zero dependencies, tiny (~12kB). Perfect for a lightweight REST API. Faster than Express on Bun benchmarks. |
| SQLite (bun:sqlite) | Built-in | Database | Zero-dependency, built into Bun runtime. 3-6x faster than better-sqlite3. Single file database -- perfect for single-user app. No server process to manage. |
| Drizzle ORM | 0.45.x | Database ORM, migrations | Type-safe SQL, ~7.4kB, zero dependencies. Native bun:sqlite driver support. SQL-like query API (not abstracting SQL away). Built-in migration tooling via drizzle-kit. |
| Tailwind CSS | 4.2.x | Styling | CSS-native configuration (no JS config file). Auto content detection. Microsecond incremental builds. Perfect for "light, airy, minimalist" design constraint. |
| TanStack Router | 1.167.x | Client-side routing | Full type-safe routing with typed params and search params. File-based route generation. Better SPA experience than React Router v7 (whose best features require framework mode). |
| TanStack Query | 5.93.x | Server state management | Handles API data fetching, caching, and synchronization. Eliminates manual loading/error state management. Automatic cache invalidation on mutations. |
| Zustand | 5.0.x | Client state management | Minimal boilerplate, ~1kB. For UI state like active filters, modal state, theme. TanStack Query handles server state; Zustand handles the rest. |
| Zod | 4.3.x | Schema validation | Validates API inputs on the server, form data on the client, and shares types between both. Single source of truth for data shapes. |
| TypeScript | 5.x (Bun built-in) | Type safety | Bun transpiles TS natively -- no tsc needed at runtime. Catches bugs at dev time. Required by Drizzle and TanStack Router for type-safe queries and routes. |
### Supporting Libraries
| Library | Version | Purpose | When to Use |
|---------|---------|---------|-------------|
| @tanstack/react-query-devtools | 5.x | Query debugging | Development only. Inspect cache state, refetch timing, query status. |
| drizzle-kit | latest | DB migrations CLI | Run `drizzle-kit generate` and `drizzle-kit migrate` for schema changes. |
| @hono/zod-validator | latest | Request validation middleware | Validate API request bodies/params using Zod schemas in Hono routes. |
| clsx | 2.x | Conditional class names | When building components with variant styles. Pairs with Tailwind. |
| @tanstack/react-router-devtools | latest | Router debugging | Development only. Inspect route matches, params, search params. |
### Development Tools
| Tool | Purpose | Notes |
|------|---------|-------|
| Bun | Test runner | `bun test` -- built-in, Jest-compatible API. No need for Vitest or Jest. |
| Biome | Linter + formatter | Single tool replacing ESLint + Prettier. Fast (Rust-based), minimal config. `biome check --write` does both. |
| Vite React plugin | React HMR/JSX | `@vitejs/plugin-react` for Fast Refresh during development. |
## Installation
```bash
# Initialize project
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 typescript @types/react @types/react-dom
# Database tooling
bun add -d drizzle-kit
# Linting + formatting
bun add -d @biomejs/biome
# Dev tools (optional but recommended)
bun add -d @tanstack/react-query-devtools @tanstack/react-router-devtools
```
## Architecture Pattern
**Monorepo-lite (single package, split directories):**
```
/src
/client -- React SPA (Vite entry point)
/routes -- TanStack Router file-based routes
/components -- Shared UI components
/stores -- Zustand stores
/api -- TanStack Query hooks (fetch wrappers)
/server -- Hono API server
/routes -- API route handlers
/db -- Drizzle schema, migrations
/shared -- Zod schemas shared between client and server
/public -- Static assets, uploaded images
```
Bun runs the Hono server, which also serves the Vite-built SPA in production. In development, Vite dev server proxies API calls to the Hono backend.
## Alternatives Considered
| Recommended | Alternative | When to Use Alternative |
|-------------|-------------|-------------------------|
| Hono | Elysia | If you want end-to-end type safety with Eden Treaty. Elysia is Bun-native but heavier, more opinionated, and has a smaller ecosystem than Hono. |
| Hono | Express | Never for new Bun projects. Express is Node-centric, not built on Web Standards, slower on Bun. |
| TanStack Router | React Router v7 | If you want the simplest possible routing with minimal type safety. React Router v7's best features (loaders, type safety) require framework mode which adds complexity. |
| Drizzle ORM | Prisma | If you have a complex relational model and want auto-generated migrations. But Prisma is heavy (~8MB), generates a query engine binary, and has weaker SQLite support. |
| Drizzle ORM | Kysely | If you want a pure query builder without ORM features. Kysely is lighter but lacks built-in migration tooling. |
| Zustand | Jotai | If you prefer atomic state (bottom-up). Zustand is simpler for this app's needs -- a few global stores, not many independent atoms. |
| Tailwind CSS | Vanilla CSS / CSS Modules | If you strongly prefer writing plain CSS. But Tailwind accelerates building consistent minimalist UIs and requires less design system setup. |
| bun:sqlite | PostgreSQL | If you later need multi-user with concurrent writes. Overkill for single-user. Adds a database server dependency. |
| Biome | ESLint + Prettier | If you need specific ESLint plugins not yet in Biome. But Biome covers 95% of use cases with zero config. |
| Vite | Bun's built-in bundler | Bun can serve HTML directly as of 1.3, but Vite's ecosystem (plugins, HMR, proxy) is far more mature for SPA development. |
## What NOT to Use
| Avoid | Why | Use Instead |
|-------|-----|-------------|
| Next.js | Server-centric framework. Massive overhead for a single-user SPA. Forces Node.js patterns. No benefit without SSR/SSG needs. | Vite + React + Hono |
| Remix / React Router framework mode | Adds server framework complexity. This is a simple SPA with a separate API -- framework routing is unnecessary overhead. | TanStack Router (SPA mode) |
| better-sqlite3 | Requires native compilation, compatibility issues with Bun. bun:sqlite is built-in and 3-6x faster. | bun:sqlite (built into Bun) |
| Redux / Redux Toolkit | Massive boilerplate for a small app. Actions, reducers, slices -- all unnecessary when Zustand does the same in 10 lines. | Zustand |
| Mongoose / MongoDB | Document DB is wrong fit. Gear items have relational structure (items belong to setups, threads reference items). SQL is the right model. | Drizzle + SQLite |
| Axios | Unnecessary abstraction over fetch. Bun and browsers both have native fetch. TanStack Query wraps fetch already. | Native fetch |
| styled-components / Emotion | CSS-in-JS adds runtime overhead and bundle size. Tailwind is faster (zero runtime) and better for consistent minimalist design. | Tailwind CSS |
| Jest / Vitest | Bun has a built-in test runner with Jest-compatible API. No need for external test frameworks. | bun test |
| ESLint + Prettier | Two tools, complex configuration, slow (JS-based). Biome does both in one tool, faster. | Biome |
## Version Compatibility
| Package A | Compatible With | Notes |
|-----------|-----------------|-------|
| Bun 1.3.x | bun:sqlite (built-in) | SQLite driver is part of the runtime, always compatible. |
| Drizzle ORM 0.45.x | bun:sqlite via `drizzle-orm/bun-sqlite` | Official driver. Import from `drizzle-orm/bun-sqlite`. |
| Drizzle ORM 0.45.x | drizzle-kit (latest) | drizzle-kit handles migration generation/execution. Must match major drizzle-orm version. |
| React 19.2.x | TanStack Router 1.x | TanStack Router 1.x supports React 18+ and 19.x. |
| React 19.2.x | TanStack Query 5.x | TanStack Query 5.x supports React 18+ and 19.x. |
| React 19.2.x | Zustand 5.x | Zustand 5.x supports React 18+ and 19.x. |
| Vite 8.x | @vitejs/plugin-react | Check plugin version matches Vite major. Use latest plugin for Vite 8. |
| Tailwind CSS 4.2.x | @tailwindcss/vite | v4 uses Vite plugin instead of PostCSS. Import as `@tailwindcss/vite` in vite config. |
| Zod 4.x | @hono/zod-validator | Verify @hono/zod-validator supports Zod 4. If not, pin Zod 3.23.x until updated. |
## Key Configuration Notes
### Bun + Vite Setup
Vite runs as the dev server for the frontend. The Hono API server runs separately. Use Vite's `server.proxy` to forward `/api/*` requests to the Hono backend during development.
### SQLite WAL Mode
Enable WAL mode on database initialization for better performance:
```typescript
import { Database } from "bun:sqlite";
const db = new Database("gearbox.db");
db.run("PRAGMA journal_mode = WAL");
db.run("PRAGMA foreign_keys = ON");
```
### Tailwind v4 (No Config File)
Tailwind v4 uses CSS-native configuration. No `tailwind.config.js` needed:
```css
@import "tailwindcss";
@theme {
--color-primary: #2563eb;
--font-sans: "Inter", sans-serif;
}
```
### Drizzle Schema Example (bun:sqlite)
```typescript
import { sqliteTable, text, integer, real } from "drizzle-orm/sqlite-core";
export const gearItems = sqliteTable("gear_items", {
id: integer("id").primaryKey({ autoIncrement: true }),
name: text("name").notNull(),
category: text("category").notNull(),
weightGrams: real("weight_grams"),
priceCents: integer("price_cents"),
source: text("source"),
notes: text("notes"),
createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
});
```
## Sources
- [Bun official docs](https://bun.com/docs) -- bun:sqlite features, runtime capabilities (HIGH confidence)
- [Hono official docs](https://hono.dev/docs) -- Bun integration, static serving (HIGH confidence)
- [Drizzle ORM docs - Bun SQLite](https://orm.drizzle.team/docs/connect-bun-sqlite) -- driver support verified (HIGH confidence)
- [Vite releases](https://vite.dev/releases) -- v8.0 with Rolldown confirmed (HIGH confidence)
- [Tailwind CSS v4.2 blog](https://tailwindcss.com/blog/tailwindcss-v4) -- CSS-native config, Vite plugin (HIGH confidence)
- [TanStack Router docs](https://tanstack.com/router/latest) -- v1.167.x confirmed (HIGH confidence)
- [TanStack Query docs](https://tanstack.com/query/latest) -- v5.93.x for React (HIGH confidence)
- [Zustand npm](https://www.npmjs.com/package/zustand) -- v5.0.x confirmed (HIGH confidence)
- [Zod v4 release notes](https://zod.dev/v4) -- v4.3.x confirmed (MEDIUM confidence -- verify @hono/zod-validator compatibility)
- [React versions](https://react.dev/versions) -- v19.2.x confirmed (HIGH confidence)
- [Bun SQLite vs better-sqlite3 benchmarks](https://bun.com/docs/runtime/sqlite) -- 3-6x performance advantage (HIGH confidence)
---
*Stack research for: GearBox -- gear management and purchase planning web app*
*Researched: 2026-03-14*

View File

@@ -0,0 +1,243 @@
# Project Research Summary
**Project:** GearBox
**Domain:** Single-user gear management and purchase planning web app
**Researched:** 2026-03-14
**Confidence:** HIGH
## Executive Summary
GearBox is a single-user personal gear management app with a critical differentiator: purchase planning threads. Every competitor (LighterPack, GearGrams, Packstack, Hikt) is a post-purchase inventory tool — they help you track what you own. GearBox closes the loop by adding a structured pre-purchase research workflow where users compare candidates, track research status, and resolve threads by promoting winners into their collection. This is the entire reason to build the product; the collection management side is table stakes, and the purchase planning threads are the moat. Research strongly recommends building both together in the v1 scope, not sequencing them separately, because the thread resolution workflow only becomes compelling once a real collection exists to reference.
The recommended architecture is a single-process Bun fullstack monolith: Hono for the API layer, React 19 + Vite 8 for the frontend, Drizzle ORM + bun:sqlite for the database, TanStack Router + TanStack Query for client navigation and server state, and Tailwind CSS v4 for styling. This stack is purpose-built for the constraints: Bun is a project requirement, SQLite is optimal for single-user, and every tool in the list has zero or near-zero runtime overhead. Zustand handles the small amount of client-only UI state. The entire stack is type-safe end-to-end through Zod schemas shared between client and server.
The biggest risks are front-loaded in Phase 1: unit handling (weights must be canonicalized to grams from day one), currency precision (prices must be stored as integer cents), category flexibility (must use user-defined tags, not a hardcoded hierarchy), and image storage strategy (relative paths to a local directory, never BLOBs for full-size, never absolute paths). Getting these wrong requires painful data migrations later. The second major risk is the thread state machine in Phase 2 — the combination of candidate status, thread lifecycle, and "move winner to collection" creates a stateful flow that must be modeled as an explicit state machine with transactional resolution, not assembled incrementally.
## Key Findings
### Recommended Stack
The stack is a tightly integrated Bun-native toolchain with no redundant tools. Bun serves as runtime, package manager, test runner, and provides built-in SQLite — eliminating entire categories of infrastructure. Vite 8 (Rolldown-based, 5-30x faster than Vite 7) handles the dev server and production frontend builds. The client-server boundary is clean: Hono serves the API, React handles the UI, and Zod schemas in a `shared/` directory provide a single source of truth for data shapes on both sides.
The architecture note in STACK.md suggests Bun's fullstack HTML-based routing (not Vite's dev server proxy pattern). This differs slightly from the standard Vite proxy setup: each page is a separate HTML entrypoint imported into `Bun.serve()`, and TanStack Router handles in-page client-side navigation only. This simplifies the development setup to a single `bun run` command with no proxy configuration.
**Core technologies:**
- Bun 1.3.x: Runtime, package manager, test runner, bundler — eliminates Node.js and npm
- React 19.2.x + Vite 8.x: SPA framework + dev server — stable, large ecosystem, HMR out of the box
- Hono 4.12.x: API layer — Web Standards based, first-class Bun support, ~12kB, faster than Express on Bun
- SQLite (bun:sqlite) + Drizzle ORM 0.45.x: Database — zero-dependency, built into Bun, type-safe queries and migrations
- TanStack Router 1.167.x + TanStack Query 5.93.x: Routing + server state — full type-safe routing, automatic cache invalidation
- Tailwind CSS 4.2.x: Styling — CSS-native config, no JS file, microsecond incremental builds
- Zustand 5.x: Client UI state — minimal boilerplate for filter state, modals, theme
- Zod 4.3.x: Schema validation — shared between client and server as single source of truth for types
- Biome: Linting + formatting — replaces ESLint + Prettier, Rust-based, near-zero config
**Version flag:** Verify that `@hono/zod-validator` supports Zod 4.x before starting. If not, pin Zod 3.23.x until the validator is updated.
### Expected Features
The feature research distinguishes cleanly between what every gear app does (table stakes) and what GearBox uniquely does (purchase planning threads). No competitor has threads, candidate comparison, or thread resolution. This is the entire competitive surface. Everything else is hygiene.
**Must have (table stakes) — v1 launch:**
- Item CRUD with weight, price, category, notes, product URL — minimum unit of value
- User-defined categories/tags — must be flexible, not a hardcoded hierarchy
- Weight unit support (g, oz, lb, kg) — gear community requires this; store canonical grams internally
- Automatic weight/cost totals by category and setup — the reason to use an app over a text file
- Named setups composed from collection items — compose loadouts, get aggregate totals
- Planning threads with candidate items — the core differentiator
- Side-by-side candidate comparison with deltas (not just raw values) — the payoff of threads
- Thread resolution: pick winner, move to collection — closes the purchase research loop
- Search and filter on collection — essential at 30+ items
- Dashboard home page — clean entry point per project constraints
**Should have (competitive) — v1.x after validation:**
- Impact preview: how a thread candidate changes a specific setup's weight and cost
- Status tracking on thread items (researching / ordered / arrived)
- Priority/ranking within threads
- Photos per item (one photo per item initially)
- CSV import/export — migration path from spreadsheets, data portability
- Weight distribution visualization (pie/bar chart by category)
**Defer — v2+:**
- Multi-photo gallery per item
- Shareable read-only links for setups
- Drag-and-drop reordering
- Bulk operations (multi-select, bulk delete)
- Dark mode
- Item history/changelog
### Architecture Approach
The architecture is a monolithic Bun process with a clear 4-layer structure: API routes (HTTP concerns), service layer (business logic and calculations), Drizzle ORM (type-safe data access), and bun:sqlite (embedded storage). There are no microservices, no Docker, no external database server. The client is a React SPA served as static files by the same Bun process. Internal communication is REST + JSON; no WebSockets needed. The data model has three primary entities — items, threads (with candidates), and setups — connected by explicit foreign keys and a junction table for the many-to-many setup-to-items relationship.
**Major components:**
1. Collection (items): Core entity. Source of truth for owned gear. Every other feature references items.
2. Planning Threads (threads + candidates): Pre-purchase research. Thread lifecycle is a state machine; resolution is transactional.
3. Setups: Named loadouts composed from collection items. Totals are always computed live from item data, never cached.
4. Service Layer: Business logic isolated from HTTP concerns. Enables testing without HTTP mocking. Key: `calculateSetupTotals()`, `computeCandidateImpact()`.
5. Dashboard: Read-only aggregation. Built last since it reads from all other entities.
6. Image Storage: Filesystem (`./uploads/` or `data/images/{item-id}/`) with relative paths in DB. Thumbnails on upload.
**Build order from ARCHITECTURE.md (follow this):**
1. Database schema (Drizzle) — everything depends on this
2. Items API (CRUD) — the core entity
3. Collection UI — first visible feature, validates end-to-end
4. Threads + candidates API and UI — depends on items for resolution
5. Setups API and UI — depends on items for composition
6. Dashboard — aggregates from all entities, build last
7. Polish: image upload, impact calculations, status tracking
### Critical Pitfalls
1. **Unit handling treated as display-only** — Store all weights as canonical grams at write time. Accept any unit as input, convert on save. Build a `weightToGrams(value, unit)` utility on day one. A bare number field with no unit tracking will silently corrupt all aggregates when users paste specs in mixed units.
2. **Rigid category hierarchy** — Use user-defined flat tags, not a hardcoded category tree. A `categories` table with `parent_id` foreign keys will fail the moment a user tries to track sim racing gear or photography equipment. Tags allow many-to-many, support any hobby, and do not require schema changes to add a new domain.
3. **Thread state machine complexity** — Model the thread lifecycle as an explicit state machine before writing any code. Document valid transitions. The "resolve thread" action must be a single atomic transaction: validate winner exists, create collection item, mark thread resolved, update candidate statuses. Without this, impossible states (resolved thread with active candidates, ghost items in collection) accumulate silently.
4. **Setup totals cached in the database** — Never store `totalWeight` or `totalCost` on a setup record. Always compute from live item data via `SUM()`. Cached totals go stale the moment any member item is edited, and the bugs are subtle (the UI shows a total that doesn't match the items).
5. **Comparison view that displays data but doesn't aid decisions** — The comparison view must show deltas between candidates and against the item being replaced from the collection, not just raw values side by side. Color-code lighter/heavier, cheaper/more expensive. A comparison table with no computed differences is worse than a spreadsheet.
**Additional high-priority pitfalls to address per phase:**
- Currency stored as floats (use integer cents always)
- Image paths stored as absolute paths or as BLOBs for full-size images
- Thread resolution is destructive (archive threads, don't delete them — users need to reference why they chose X over Y)
- Item deletion without setup impact warning
## Implications for Roadmap
Based on the combined research, a 5-phase structure is recommended. Phases 1-3 deliver the v1 MVP; Phases 4-5 deliver the v1.x feature set.
### Phase 1: Foundation — Data Model, Infrastructure, Core Item CRUD
**Rationale:** Everything depends on getting the data model right. Unit handling, currency precision, category flexibility, image storage strategy, and the items schema are all Phase 1 decisions. Getting these wrong requires expensive data migrations. The architecture research explicitly states: "Database schema + Drizzle setup — Everything depends on the data model." The pitfalls research agrees: 6 of 9 pitfalls have "Phase 1" as their prevention phase.
**Delivers:** Working gear catalog — users can add, edit, delete, and browse their collection. Item CRUD with all core fields. Weight unit conversion. User-defined categories. Image upload with thumbnail generation and cleanup on delete. SQLite database with WAL mode enabled, automatic backup mechanism, and all schemas finalized.
**Features from FEATURES.md:** Item CRUD with core fields, user-defined categories, weight unit support (g/oz/lb/kg), notes and product URL fields, search and filter.
**Pitfalls to prevent:** Unit handling (canonical grams), currency precision (integer cents), category flexibility (user-defined tags, no hierarchy), image storage (relative paths, thumbnails), data loss prevention (WAL mode, auto-backup mechanism).
**Research flag:** Standard patterns. Schema design for inventory apps is well-documented. No research phase needed.
---
### Phase 2: Planning Threads — The Core Differentiator
**Rationale:** Threads are why GearBox exists. The feature dependency graph in FEATURES.md shows threads require items to exist (to resolve candidates into the collection), which is why Phase 1 must complete first. The thread state machine is the most complex feature in the product and gets its own phase to ensure the state transitions are modeled correctly before any UI is built.
**Delivers:** Complete purchase planning workflow — create threads, add candidates with weight/price/notes, compare candidates side-by-side with weight/cost deltas (not just raw values), resolve threads by selecting a winner and moving it to the collection, archive resolved threads.
**Features from FEATURES.md:** Planning threads, side-by-side candidate comparison (with deltas), thread resolution workflow. Does not include status tracking (researching/ordered/arrived) or priority/ranking — those are v1.x.
**Pitfalls to prevent:** Thread state machine complexity (model transitions explicitly, transactional resolution), comparison usefulness (show deltas and impact, not just raw data), thread archiving (never destructive resolution).
**Research flag:** Needs careful design work before coding. The state machine for thread lifecycle (open -> in-progress -> resolved/cancelled) combined with candidate status (researching / ordered / arrived) and the resolution side-effect (create collection item) has no off-the-shelf reference implementation. Design the state diagram first.
---
### Phase 3: Setups — Named Loadouts and Composition
**Rationale:** Setups require items to exist (Phase 1) and benefit from threads being stable (Phase 2) because thread resolution can affect setup membership (the replaced item should be updatable in setups). The many-to-many setup-items relationship and the setup integrity pitfall require careful foreign key design.
**Delivers:** Named setups composed from collection items. Weight and cost totals computed live (never cached). Base/worn/consumable weight classification per item per setup. Category weight breakdown. Item deletion warns about setup membership. Visual indicator when a setup item is no longer in the collection.
**Features from FEATURES.md:** Named setups with item selection and totals, setup weight/cost breakdown by category, automatic totals.
**Pitfalls to prevent:** Setup totals cached in DB (always compute live), setup composition breaks on collection changes (explicit `ON DELETE` behavior, visual indicators for missing items, no silent CASCADE).
**Research flag:** Standard patterns for junction table composition. No research phase needed for the setup-items relationship. The weight classification (base/worn/consumable) per setup entry is worth a design session — this is per-setup metadata on the junction, not a property of the item itself.
---
### Phase 4: Dashboard and Polish
**Rationale:** The architecture research explicitly states "Dashboard — aggregates stats from all other entities. Build last since it reads from everything." Dashboard requires all prior phases to be stable since it reads from items, threads, and setups simultaneously. This phase also adds the weight visualization chart that requires a full dataset to be meaningful.
**Delivers:** Dashboard home page with summary cards (item count, active threads, setup count, collection value). Weight distribution visualization (pie/bar chart by category). Dashboard stats endpoint (`/api/stats`) as a read-only aggregation. General UI polish for the "light, airy, minimalist" aesthetic.
**Features from FEATURES.md:** Dashboard home page, weight distribution visualization.
**Research flag:** Standard patterns. Dashboard aggregation is a straightforward read-only endpoint. Charting is well-documented. No research phase needed.
---
### Phase 5: v1.x Enhancements
**Rationale:** These features add significant value but depend on the core (Phases 1-3) being proven out. Impact preview requires both stable setups and stable threads. CSV import/export validates the data model is clean (if import is buggy, the model has problems). Photos add storage complexity that is easier to handle once the core CRUD flow is solid.
**Delivers:** Impact preview (how a thread candidate changes a specific setup's weight/cost). Thread item status tracking (researching / ordered / arrived). Priority/ranking within threads. Photos per item (upload, display, cleanup). CSV import/export with unit detection.
**Features from FEATURES.md:** Impact preview, status tracking, priority/ranking, photos per item, CSV import/export.
**Pitfalls to prevent:** CSV import missing unit conversion (must detect and convert oz/lb/kg to grams on import). Image uploads without size/type validation. Product URLs not sanitized (validate http/https protocol, render with `rel="noopener noreferrer"`).
**Research flag:** CSV import with unit detection may need a design pass — handling "5 oz", "142g", "0.3 lb" in the same weight column requires a parsing strategy. Worth a short research spike before implementation.
---
### Phase Ordering Rationale
- **Data model first:** Six of nine pitfalls identified are Phase 1 prevention items. The schema is the hardest thing to change later and the most consequential.
- **Threads before setups:** Thread resolution creates collection items; setup composition consumes them. But more importantly, threads are the differentiating feature — proving the thread workflow works is more valuable than setups.
- **Dashboard last:** Explicitly recommended by architecture research. Aggregating from incomplete entities produces misleading data and masks bugs.
- **Impact preview in Phase 5:** This feature requires both stable setups (Phase 3) and stable threads (Phase 2). Building it before both are solid means rebuilding it when either changes.
- **Photos deferred to Phase 5:** The core value proposition is weight/cost tracking and purchase planning, not a photo gallery. Adding photo infrastructure in Phase 1 increases scope without validating the core concept.
### Research Flags
**Needs design/research before coding:**
- **Phase 2 (Thread State Machine):** Design the state diagram for thread lifecycle x candidate status before writing any code. Define all valid transitions and invalid states explicitly. This is the most stateful feature in the product and has no off-the-shelf pattern to follow.
- **Phase 5 (CSV Import):** Design the column-mapping and unit-detection strategy before implementation. The spreadsheet-to-app migration workflow is critical for the target audience (users migrating from gear spreadsheets).
**Standard patterns — no research phase needed:**
- **Phase 1 (Data model + CRUD):** Schema design for inventory apps is well-documented. Drizzle + bun:sqlite patterns are covered in official docs.
- **Phase 3 (Setups):** Junction table composition is a standard relational pattern. Foreign key behavior for integrity is documented.
- **Phase 4 (Dashboard):** Aggregation endpoints and charting are standard. No novel patterns.
## Confidence Assessment
| Area | Confidence | Notes |
|------|------------|-------|
| Stack | HIGH | All technologies verified against official docs. Version compatibility confirmed. One flag: verify `@hono/zod-validator` supports Zod 4.x before starting. |
| Features | HIGH | Competitor analysis is thorough (LighterPack, GearGrams, Packstack, Hikt all compared). Feature gaps and differentiators are clearly identified. |
| Architecture | HIGH | Bun fullstack monolith pattern is official and well-documented. Service layer and data flow patterns are standard. |
| Pitfalls | HIGH | Pitfalls are domain-specific and well-sourced. SQLite BLOB guidance from official SQLite docs. Comparison UX from NN/g. Unit conversion antipatterns documented. |
**Overall confidence: HIGH**
### Gaps to Address
- **Zod 4 / @hono/zod-validator compatibility:** STACK.md flags this explicitly. Verify before starting. If incompatible, pin Zod 3.23.x. This is a quick check, not a blocker.
- **Bun fullstack vs. Vite proxy setup:** STACK.md describes the Vite dev server proxy pattern (standard approach), while ARCHITECTURE.md describes Bun's HTML-based routing with `Bun.serve()` (newer approach). These are two valid patterns. The architecture file's approach (Bun fullstack) is simpler for production deployment. Confirm which pattern to follow before project setup — they require different `vite.config.ts` and entry point structures.
- **Weight classification (base/worn/consumable) data model:** Where does this live? On the `setup_items` junction table (per-setup classification, same item can be "base" in one setup and "worn" in another) or on the item itself (one classification for all setups)? The per-setup model is more flexible but more complex. Decide in Phase 1 schema design, not Phase 3 when setups are built.
- **Tag vs. single-category field:** PITFALLS.md recommends a flat tag system. FEATURES.md implies a single "category" field. The right answer is probably a single optional category field (for broad grouping, e.g., "clothing") plus user-defined tags for fine-grained organization. Confirm the data model in Phase 1.
## Sources
### Primary (HIGH confidence)
- [Bun official docs](https://bun.com/docs) — bun:sqlite, fullstack dev server, Bun.serve() routing
- [Hono official docs](https://hono.dev/docs) — Bun integration, middleware patterns
- [Drizzle ORM docs - Bun SQLite](https://orm.drizzle.team/docs/connect-bun-sqlite) — driver support, schema patterns
- [Vite releases](https://vite.dev/releases) — v8.0 with Rolldown confirmed
- [Tailwind CSS v4.2 blog](https://tailwindcss.com/blog/tailwindcss-v4) — CSS-native config, Vite plugin
- [TanStack Router docs](https://tanstack.com/router/latest) — file-based routing, typed params
- [TanStack Query docs](https://tanstack.com/query/latest) — cache invalidation, mutations
- [SQLite Internal vs External BLOBs](https://sqlite.org/intern-v-extern-blob.html) — image storage guidance
- [Comparison Tables — NN/g](https://www.nngroup.com/articles/comparison-tables/) — comparison UX best practices
### Secondary (MEDIUM confidence)
- [Hikt Blog: Best Backpacking Gear Apps 2026](https://hikt.app/blog/best-backpacking-gear-apps-2026/) — competitor feature analysis
- [Building Full-Stack App with Bun.js, React and Drizzle ORM](https://awplife.com/building-full-stack-app-with-bun-js-react-drizzle/) — project structure reference
- [Designing better file organization around tags, not hierarchies](https://www.nayuki.io/page/designing-better-file-organization-around-tags-not-hierarchies) — tags vs hierarchy rationale
### Tertiary (LOW confidence / needs validation)
- [Zod v4 release notes](https://zod.dev/v4) — @hono/zod-validator compatibility with Zod 4 unconfirmed, verify before use
---
*Research completed: 2026-03-14*
*Ready for roadmap: yes*

34
biome.json Normal file
View File

@@ -0,0 +1,34 @@
{
"$schema": "https://biomejs.dev/schemas/2.4.7/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"files": {
"ignoreUnknown": false
},
"formatter": {
"enabled": true,
"indentStyle": "tab"
},
"linter": {
"enabled": true,
"rules": {
"recommended": true
}
},
"javascript": {
"formatter": {
"quoteStyle": "double"
}
},
"assist": {
"enabled": true,
"actions": {
"source": {
"organizeImports": "on"
}
}
}
}

763
bun.lock Normal file
View File

@@ -0,0 +1,763 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "gearbox",
"dependencies": {
"@hono/zod-validator": "^0.7.6",
"@tailwindcss/vite": "^4.2.1",
"@tanstack/react-query": "^5.90.21",
"@tanstack/react-router": "^1.167.0",
"clsx": "^2.1.1",
"drizzle-orm": "^0.45.1",
"hono": "^4.12.8",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"tailwindcss": "^4.2.1",
"zod": "^4.3.6",
"zustand": "^5.0.11",
},
"devDependencies": {
"@biomejs/biome": "^2.4.7",
"@tanstack/react-query-devtools": "^5.91.3",
"@tanstack/react-router-devtools": "^1.166.7",
"@tanstack/router-plugin": "^1.166.9",
"@types/better-sqlite3": "^7.6.13",
"@types/bun": "latest",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"better-sqlite3": "^12.8.0",
"drizzle-kit": "^0.31.9",
"vite": "^8.0.0",
},
"peerDependencies": {
"typescript": "^5.9.3",
},
},
},
"packages": {
"@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="],
"@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="],
"@babel/core": ["@babel/core@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA=="],
"@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="],
"@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="],
"@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="],
"@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="],
"@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="],
"@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.28.6", "", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="],
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="],
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
"@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="],
"@babel/helpers": ["@babel/helpers@7.28.6", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw=="],
"@babel/parser": ["@babel/parser@7.29.0", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww=="],
"@babel/plugin-syntax-jsx": ["@babel/plugin-syntax-jsx@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w=="],
"@babel/plugin-syntax-typescript": ["@babel/plugin-syntax-typescript@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A=="],
"@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="],
"@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="],
"@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="],
"@biomejs/biome": ["@biomejs/biome@2.4.7", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.7", "@biomejs/cli-darwin-x64": "2.4.7", "@biomejs/cli-linux-arm64": "2.4.7", "@biomejs/cli-linux-arm64-musl": "2.4.7", "@biomejs/cli-linux-x64": "2.4.7", "@biomejs/cli-linux-x64-musl": "2.4.7", "@biomejs/cli-win32-arm64": "2.4.7", "@biomejs/cli-win32-x64": "2.4.7" }, "bin": { "biome": "bin/biome" } }, "sha512-vXrgcmNGZ4lpdwZSpMf1hWw1aWS6B+SyeSYKTLrNsiUsAdSRN0J4d/7mF3ogJFbIwFFSOL3wT92Zzxia/d5/ng=="],
"@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Oo0cF5mHzmvDmTXw8XSjhCia8K6YrZnk7aCS54+/HxyMdZMruMO3nfpDsrlar/EQWe41r1qrwKiCa2QDYHDzWA=="],
"@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.4.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-I+cOG3sd/7HdFtvDSnF9QQPrWguUH7zrkIMMykM3PtfWU9soTcS2yRb9Myq6MHmzbeCT08D1UmY+BaiMl5CcoQ=="],
"@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.4.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-om6FugwmibzfP/6ALj5WRDVSND4H2G9X0nkI1HZpp2ySf9lW2j0X68oQSaHEnls6666oy4KDsc5RFjT4m0kV0w=="],
"@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.4.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-I2NvM9KPb09jWml93O2/5WMfNR7Lee5Latag1JThDRMURVhPX74p9UDnyTw3Ae6cE1DgXfw7sqQgX7rkvpc0vw=="],
"@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.4.7", "", { "os": "linux", "cpu": "x64" }, "sha512-bV8/uo2Tj+gumnk4sUdkerWyCPRabaZdv88IpbmDWARQQoA/Q0YaqPz1a+LSEDIL7OfrnPi9Hq1Llz4ZIGyIQQ=="],
"@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.4.7", "", { "os": "linux", "cpu": "x64" }, "sha512-00kx4YrBMU8374zd2wHuRV5wseh0rom5HqRND+vDldJPrWwQw+mzd/d8byI9hPx926CG+vWzq6AeiT7Yi5y59g=="],
"@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.4.7", "", { "os": "win32", "cpu": "arm64" }, "sha512-hOUHBMlFCvDhu3WCq6vaBoG0dp0LkWxSEnEEsxxXvOa9TfT6ZBnbh72A/xBM7CBYB7WgwqboetzFEVDnMxelyw=="],
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.7", "", { "os": "win32", "cpu": "x64" }, "sha512-qEpGjSkPC3qX4ycbMUthXvi9CkRq7kZpkqMY1OyhmYlYLnANnooDQ7hDerM8+0NJ+DZKVnsIc07h30XOpt7LtQ=="],
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="],
"@emnapi/core": ["@emnapi/core@1.9.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.0", "tslib": "^2.4.0" } }, "sha512-0DQ98G9ZQZOxfUcQn1waV2yS8aWdZ6kJMbYCJB3oUBecjWYO1fqJ+a1DRfPF3O5JEkwqwP1A9QEN/9mYm2Yd0w=="],
"@emnapi/runtime": ["@emnapi/runtime@1.9.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw=="],
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg=="],
"@esbuild-kit/core-utils": ["@esbuild-kit/core-utils@3.3.2", "", { "dependencies": { "esbuild": "~0.18.20", "source-map-support": "^0.5.21" } }, "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ=="],
"@esbuild-kit/esm-loader": ["@esbuild-kit/esm-loader@2.6.5", "", { "dependencies": { "@esbuild-kit/core-utils": "^3.3.2", "get-tsconfig": "^4.7.0" } }, "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="],
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="],
"@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="],
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="],
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="],
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="],
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="],
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="],
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="],
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="],
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="],
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="],
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="],
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="],
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="],
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="],
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="],
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="],
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="],
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="],
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="],
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="],
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="],
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="],
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
"@hono/zod-validator": ["@hono/zod-validator@0.7.6", "", { "peerDependencies": { "hono": ">=3.9.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Io1B6d011Gj1KknV4rXYz4le5+5EubcWEU/speUjuw9XMMIaP3n78yXLhjd2A3PXaXaUwEAluOiAyLqhBEJgsw=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" } }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="],
"@oxc-project/runtime": ["@oxc-project/runtime@0.115.0", "", {}, "sha512-Rg8Wlt5dCbXhQnsXPrkOjL1DTSvXLgb2R/KYfnf1/K+R0k6UMLEmbQXPM+kwrWqSmWA2t0B1EtHy2/3zikQpvQ=="],
"@oxc-project/types": ["@oxc-project/types@0.115.0", "", {}, "sha512-4n91DKnebUS4yjUHl2g3/b2T+IUdCfmoZGhmwsovZCDaJSs+QkVAM+0AqqTxHSsHfeiMuueT75cZaZcT/m0pSw=="],
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.9", "", { "os": "android", "cpu": "arm64" }, "sha512-lcJL0bN5hpgJfSIz/8PIf02irmyL43P+j1pTCfbD1DbLkmGRuFIA4DD3B3ZOvGqG0XiVvRznbKtN0COQVaKUTg=="],
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.9", "", { "os": "darwin", "cpu": "arm64" }, "sha512-J7Zk3kLYFsLtuH6U+F4pS2sYVzac0qkjcO5QxHS7OS7yZu2LRs+IXo+uvJ/mvpyUljDJ3LROZPoQfgBIpCMhdQ=="],
"@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.9", "", { "os": "darwin", "cpu": "x64" }, "sha512-iwtmmghy8nhfRGeNAIltcNXzD0QMNaaA5U/NyZc1Ia4bxrzFByNMDoppoC+hl7cDiUq5/1CnFthpT9n+UtfFyg=="],
"@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.9", "", { "os": "freebsd", "cpu": "x64" }, "sha512-DLFYI78SCiZr5VvdEplsVC2Vx53lnA4/Ga5C65iyldMVaErr86aiqCoNBLl92PXPfDtUYjUh+xFFor40ueNs4Q=="],
"@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.9", "", { "os": "linux", "cpu": "arm" }, "sha512-CsjTmTwd0Hri6iTw/DRMK7kOZ7FwAkrO4h8YWKoX/kcj833e4coqo2wzIFywtch/8Eb5enQ/lwLM7w6JX1W5RQ=="],
"@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-rc.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-2x9O2JbSPxpxMDhP9Z74mahAStibTlrBMW0520+epJH5sac7/LwZW5Bmg/E6CXuEF53JJFW509uP+lSedaUNxg=="],
"@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-JA1QRW31ogheAIRhIg9tjMfsYbglXXYGNPLdPEYrwFxdbkQCAzvpSCSHCDWNl4hTtrol8WeboCSEpjdZK8qrCg=="],
"@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.9", "", { "os": "linux", "cpu": "ppc64" }, "sha512-aOKU9dJheda8Kj8Y3w9gnt9QFOO+qKPAl8SWd7JPHP+Cu0EuDAE5wokQubLzIDQWg2myXq2XhTpOVS07qqvT+w=="],
"@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.0-rc.9", "", { "os": "linux", "cpu": "s390x" }, "sha512-OalO94fqj7IWRn3VdXWty75jC5dk4C197AWEuMhIpvVv2lw9fiPhud0+bW2ctCxb3YoBZor71QHbY+9/WToadA=="],
"@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.9", "", { "os": "linux", "cpu": "x64" }, "sha512-cVEl1vZtBsBZna3YMjGXNvnYYrOJ7RzuWvZU0ffvJUexWkukMaDuGhUXn0rjnV0ptzGVkvc+vW9Yqy6h8YX4pg=="],
"@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.9", "", { "os": "linux", "cpu": "x64" }, "sha512-UzYnKCIIc4heAKgI4PZ3dfBGUZefGCJ1TPDuLHoCzgrMYPb5Rv6TLFuYtyM4rWyHM7hymNdsg5ik2C+UD9VDbA=="],
"@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.9", "", { "os": "none", "cpu": "arm64" }, "sha512-+6zoiF+RRyf5cdlFQP7nm58mq7+/2PFaY2DNQeD4B87N36JzfF/l9mdBkkmTvSYcYPE8tMh/o3cRlsx1ldLfog=="],
"@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-rc.9", "", { "dependencies": { "@napi-rs/wasm-runtime": "^1.1.1" }, "cpu": "none" }, "sha512-rgFN6sA/dyebil3YTlL2evvi/M+ivhfnyxec7AccTpRPccno/rPoNlqybEZQBkcbZu8Hy+eqNJCqfBR8P7Pg8g=="],
"@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-rc.9", "", { "os": "win32", "cpu": "arm64" }, "sha512-lHVNUG/8nlF1IQk1C0Ci574qKYyty2goMiPlRqkC5R+3LkXDkL5Dhx8ytbxq35m+pkHVIvIxviD+TWLdfeuadA=="],
"@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.9", "", { "os": "win32", "cpu": "x64" }, "sha512-G0oA4+w1iY5AGi5HcDTxWsoxF509hrFIPB2rduV5aDqS9FtDg1CAfa7V34qImbjfhIcA8C+RekocJZA96EarwQ=="],
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.7", "", {}, "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA=="],
"@tailwindcss/node": ["@tailwindcss/node@4.2.1", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.31.1", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.1" } }, "sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg=="],
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.2.1", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.2.1", "@tailwindcss/oxide-darwin-arm64": "4.2.1", "@tailwindcss/oxide-darwin-x64": "4.2.1", "@tailwindcss/oxide-freebsd-x64": "4.2.1", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.1", "@tailwindcss/oxide-linux-arm64-gnu": "4.2.1", "@tailwindcss/oxide-linux-arm64-musl": "4.2.1", "@tailwindcss/oxide-linux-x64-gnu": "4.2.1", "@tailwindcss/oxide-linux-x64-musl": "4.2.1", "@tailwindcss/oxide-wasm32-wasi": "4.2.1", "@tailwindcss/oxide-win32-arm64-msvc": "4.2.1", "@tailwindcss/oxide-win32-x64-msvc": "4.2.1" } }, "sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw=="],
"@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.2.1", "", { "os": "android", "cpu": "arm64" }, "sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg=="],
"@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.2.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw=="],
"@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.2.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw=="],
"@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.2.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA=="],
"@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.2.1", "", { "os": "linux", "cpu": "arm" }, "sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw=="],
"@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.2.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ=="],
"@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.2.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ=="],
"@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.2.1", "", { "os": "linux", "cpu": "x64" }, "sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g=="],
"@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.2.1", "", { "os": "linux", "cpu": "x64" }, "sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g=="],
"@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.2.1", "", { "dependencies": { "@emnapi/core": "^1.8.1", "@emnapi/runtime": "^1.8.1", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.1.1", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q=="],
"@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.2.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA=="],
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.2.1", "", { "os": "win32", "cpu": "x64" }, "sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ=="],
"@tailwindcss/vite": ["@tailwindcss/vite@4.2.1", "", { "dependencies": { "@tailwindcss/node": "4.2.1", "@tailwindcss/oxide": "4.2.1", "tailwindcss": "4.2.1" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-TBf2sJjYeb28jD2U/OhwdW0bbOsxkWPwQ7SrqGf9sVcoYwZj7rkXljroBO9wKBut9XnmQLXanuDUeqQK0lGg/w=="],
"@tanstack/history": ["@tanstack/history@1.161.4", "", {}, "sha512-Kp/WSt411ZWYvgXy6uiv5RmhHrz9cAml05AQPrtdAp7eUqvIDbMGPnML25OKbzR3RJ1q4wgENxDTvlGPa9+Mww=="],
"@tanstack/query-core": ["@tanstack/query-core@5.90.20", "", {}, "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg=="],
"@tanstack/query-devtools": ["@tanstack/query-devtools@5.93.0", "", {}, "sha512-+kpsx1NQnOFTZsw6HAFCW3HkKg0+2cepGtAWXjiiSOJJ1CtQpt72EE2nyZb+AjAbLRPoeRmPJ8MtQd8r8gsPdg=="],
"@tanstack/react-query": ["@tanstack/react-query@5.90.21", "", { "dependencies": { "@tanstack/query-core": "5.90.20" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg=="],
"@tanstack/react-query-devtools": ["@tanstack/react-query-devtools@5.91.3", "", { "dependencies": { "@tanstack/query-devtools": "5.93.0" }, "peerDependencies": { "@tanstack/react-query": "^5.90.20", "react": "^18 || ^19" } }, "sha512-nlahjMtd/J1h7IzOOfqeyDh5LNfG0eULwlltPEonYy0QL+nqrBB+nyzJfULV+moL7sZyxc2sHdNJki+vLA9BSA=="],
"@tanstack/react-router": ["@tanstack/react-router@1.167.0", "", { "dependencies": { "@tanstack/history": "1.161.4", "@tanstack/react-store": "^0.9.1", "@tanstack/router-core": "1.167.0", "isbot": "^5.1.22", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-U7CamtXjuC8ixg1c32Rj/4A2OFBnjtMLdbgbyOGHrFHE7ULWS/yhnZLVXff0QSyn6qF92Oecek9mDMHCaTnB2Q=="],
"@tanstack/react-router-devtools": ["@tanstack/react-router-devtools@1.166.7", "", { "dependencies": { "@tanstack/router-devtools-core": "1.166.7" }, "peerDependencies": { "@tanstack/react-router": "^1.166.7", "@tanstack/router-core": "^1.166.7", "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" }, "optionalPeers": ["@tanstack/router-core"] }, "sha512-sAh3gA3wkMvUI6rRLPW4lfP0XxeEA0wrlv4tW1cinb7eoD3avcdKwiE9jhQ3DgFlhVsHa9fa3AKxH46Y/d/e1g=="],
"@tanstack/react-store": ["@tanstack/react-store@0.9.2", "", { "dependencies": { "@tanstack/store": "0.9.2", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Vt5usJE5sHG/cMechQfmwvwne6ktGCELe89Lmvoxe3LKRoFrhPa8OCKWs0NliG8HTJElEIj7PLtaBQIcux5pAQ=="],
"@tanstack/router-core": ["@tanstack/router-core@1.167.0", "", { "dependencies": { "@tanstack/history": "1.161.4", "@tanstack/store": "^0.9.1", "cookie-es": "^2.0.0", "seroval": "^1.4.2", "seroval-plugins": "^1.4.2", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" } }, "sha512-pnaaUP+vMQEyL2XjZGe2PXmtzulxvXfGyvEMUs+AEBaNEk77xWA88bl3ujiBRbUxzpK0rxfJf+eSKPdZmBMFdQ=="],
"@tanstack/router-devtools-core": ["@tanstack/router-devtools-core@1.166.7", "", { "dependencies": { "clsx": "^2.1.1", "goober": "^2.1.16", "tiny-invariant": "^1.3.3" }, "peerDependencies": { "@tanstack/router-core": "^1.166.7", "csstype": "^3.0.10" }, "optionalPeers": ["csstype"] }, "sha512-/OGLZlrw5NSNd9/PTL8vPSpmjxIbXNoeJATMHlU3YLCBVBtLx41CHIRc7OLkjyfVFJ4Sq7Pq+2/YH8PChShefg=="],
"@tanstack/router-generator": ["@tanstack/router-generator@1.166.8", "", { "dependencies": { "@tanstack/router-core": "1.167.0", "@tanstack/router-utils": "1.161.4", "@tanstack/virtual-file-routes": "1.161.4", "prettier": "^3.5.0", "recast": "^0.23.11", "source-map": "^0.7.4", "tsx": "^4.19.2", "zod": "^3.24.2" } }, "sha512-Ijl1AqKaAZAeE0+6boD/RVw/inQgq24AYMcTMhPSLSFdaYp+xR9HS5lhC6qj50rUVLRtWgM1tAbQvsQeyHv2/w=="],
"@tanstack/router-plugin": ["@tanstack/router-plugin@1.166.9", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-syntax-typescript": "^7.27.1", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.5", "@babel/types": "^7.28.5", "@tanstack/router-core": "1.167.0", "@tanstack/router-generator": "1.166.8", "@tanstack/router-utils": "1.161.4", "@tanstack/virtual-file-routes": "1.161.4", "chokidar": "^3.6.0", "unplugin": "^2.1.2", "zod": "^3.24.2" }, "peerDependencies": { "@rsbuild/core": ">=1.0.2", "@tanstack/react-router": "^1.167.0", "vite": ">=5.0.0 || >=6.0.0 || >=7.0.0", "vite-plugin-solid": "^2.11.10", "webpack": ">=5.92.0" }, "optionalPeers": ["@rsbuild/core", "@tanstack/react-router", "vite", "vite-plugin-solid", "webpack"] }, "sha512-vcVjlbFc9Bw5qqRrZlGkM1bFK34t5/YP4qmUJRCTDXa6Xg47b0yWwn39X4uzYe/RgW28XP/0qKJ859EQktUX3Q=="],
"@tanstack/router-utils": ["@tanstack/router-utils@1.161.4", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/generator": "^7.28.5", "@babel/parser": "^7.28.5", "@babel/types": "^7.28.5", "ansis": "^4.1.0", "babel-dead-code-elimination": "^1.0.12", "diff": "^8.0.2", "pathe": "^2.0.3", "tinyglobby": "^0.2.15" } }, "sha512-r8TpjyIZoqrXXaf2DDyjd44gjGBoyE+/oEaaH68yLI9ySPO1gUWmQENZ1MZnmBnpUGN24NOZxdjDLc8npK0SAw=="],
"@tanstack/store": ["@tanstack/store@0.9.2", "", {}, "sha512-K013lUJEFJK2ofFQ/hZKJUmCnpcV00ebLyOyFOWQvyQHUOZp/iYO84BM6aOGiV81JzwbX0APTVmW8YI7yiG5oA=="],
"@tanstack/virtual-file-routes": ["@tanstack/virtual-file-routes@1.161.4", "", {}, "sha512-42WoRePf8v690qG8yGRe/YOh+oHni9vUaUUfoqlS91U2scd3a5rkLtVsc6b7z60w3RogH0I00vdrC5AaeiZ18w=="],
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
"@types/better-sqlite3": ["@types/better-sqlite3@7.6.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA=="],
"@types/bun": ["@types/bun@1.3.10", "", { "dependencies": { "bun-types": "1.3.10" } }, "sha512-0+rlrUrOrTSskibryHbvQkDOWRJwJZqZlxrUs1u4oOoTln8+WIXBPmAuCF35SWB2z4Zl3E84Nl/D0P7803nigQ=="],
"@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="],
"@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="],
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
"@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.1", "", { "dependencies": { "@rolldown/pluginutils": "1.0.0-rc.7" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ=="],
"acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
"ansis": ["ansis@4.2.0", "", {}, "sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig=="],
"anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="],
"ast-types": ["ast-types@0.16.1", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg=="],
"babel-dead-code-elimination": ["babel-dead-code-elimination@1.0.12", "", { "dependencies": { "@babel/core": "^7.23.7", "@babel/parser": "^7.23.6", "@babel/traverse": "^7.23.7", "@babel/types": "^7.23.6" } }, "sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig=="],
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.8", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-PCLz/LXGBsNTErbtB6i5u4eLpHeMfi93aUv5duMmj6caNu6IphS4q6UevDnL36sZQv9lrP11dbPKGMaXPwMKfQ=="],
"better-sqlite3": ["better-sqlite3@12.8.0", "", { "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" } }, "sha512-RxD2Vd96sQDjQr20kdP+F+dK/1OUNiVOl200vKBZY8u0vTwysfolF6Hq+3ZK2+h8My9YvZhHsF+RSGZW2VYrPQ=="],
"binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="],
"bindings": ["bindings@1.5.0", "", { "dependencies": { "file-uri-to-path": "1.0.0" } }, "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ=="],
"bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="],
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
"browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="],
"buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="],
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
"bun-types": ["bun-types@1.3.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="],
"caniuse-lite": ["caniuse-lite@1.0.30001778", "", {}, "sha512-PN7uxFL+ExFJO61aVmP1aIEG4i9whQd4eoSCebav62UwDyp5OHh06zN4jqKSMePVgxHifCw1QJxdRkA1Pisekg=="],
"chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
"chownr": ["chownr@1.1.4", "", {}, "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="],
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
"cookie-es": ["cookie-es@2.0.0", "", {}, "sha512-RAj4E421UYRgqokKUmotqAwuplYw15qtdXfY+hGzgCJ/MBjCVZcSoHK/kH9kocfjRjcDME7IiDWR/1WX1TM2Pg=="],
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "^3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="],
"deep-extend": ["deep-extend@0.6.0", "", {}, "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="],
"drizzle-kit": ["drizzle-kit@0.31.9", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "esbuild-register": "^3.5.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-GViD3IgsXn7trFyBUUHyTFBpH/FsHTxYJ66qdbVggxef4UBPHRYxQaRzYLTuekYnk9i5FIEL9pbBIwMqX/Uwrg=="],
"drizzle-orm": ["drizzle-orm@0.45.1", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-Te0FOdKIistGNPMq2jscdqngBRfBpC8uMFVwqjf6gtTVJHIQ/dosgV/CLBU2N4ZJBsXL5savCba9b0YJskKdcA=="],
"electron-to-chromium": ["electron-to-chromium@1.5.313", "", {}, "sha512-QBMrTWEf00GXZmJyx2lbYD45jpI3TUFnNIzJ5BBc8piGUDwMPa1GV6HJWTZVvY/eiN3fSopl7NRbgGp9sZ9LTA=="],
"end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="],
"enhanced-resolve": ["enhanced-resolve@5.20.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ=="],
"esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
"esbuild-register": ["esbuild-register@3.6.0", "", { "dependencies": { "debug": "^4.3.4" }, "peerDependencies": { "esbuild": ">=0.12 <1" } }, "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg=="],
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="],
"expand-template": ["expand-template@2.0.3", "", {}, "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"file-uri-to-path": ["file-uri-to-path@1.0.0", "", {}, "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="],
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
"fs-constants": ["fs-constants@1.0.0", "", {}, "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
"get-tsconfig": ["get-tsconfig@4.13.6", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw=="],
"github-from-package": ["github-from-package@0.0.0", "", {}, "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw=="],
"glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"goober": ["goober@2.1.18", "", { "peerDependencies": { "csstype": "^3.0.10" } }, "sha512-2vFqsaDVIT9Gz7N6kAL++pLpp41l3PfDuusHcjnGLfR6+huZkl6ziX+zgVC3ZxpqWhzH6pyDdGrCeDhMIvwaxw=="],
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
"hono": ["hono@4.12.8", "", {}, "sha512-VJCEvtrezO1IAR+kqEYnxUOoStaQPGrCmX3j4wDTNOcD1uRPFpGlwQUIW8niPuvHXaTUxeOUl5MMDGrl+tmO9A=="],
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
"ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="],
"is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="],
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
"is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="],
"isbot": ["isbot@5.1.36", "", {}, "sha512-C/ZtXyJqDPZ7G7JPr06ApWyYoHjYexQbS6hPYD4WYCzpv2Qes6Z+CCEfTX4Owzf+1EJ933PoI2p+B9v7wpGZBQ=="],
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
"lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="],
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="],
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="],
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="],
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="],
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="],
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="],
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="],
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="],
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="],
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
"mkdirp-classic": ["mkdirp-classic@0.5.3", "", {}, "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"napi-build-utils": ["napi-build-utils@2.0.0", "", {}, "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA=="],
"node-abi": ["node-abi@3.88.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-At6b4UqIEVudaqPsXjmUO1r/N5BUr4yhDGs5PkBE8/oG5+TfLPhFechiskFsnT6Ql0VfUXbalUUCbfXxtj7K+w=="],
"node-releases": ["node-releases@2.0.36", "", {}, "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA=="],
"normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
"postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="],
"prebuild-install": ["prebuild-install@7.1.3", "", { "dependencies": { "detect-libc": "^2.0.0", "expand-template": "^2.0.3", "github-from-package": "0.0.0", "minimist": "^1.2.3", "mkdirp-classic": "^0.5.3", "napi-build-utils": "^2.0.0", "node-abi": "^3.3.0", "pump": "^3.0.0", "rc": "^1.2.7", "simple-get": "^4.0.0", "tar-fs": "^2.0.0", "tunnel-agent": "^0.6.0" }, "bin": { "prebuild-install": "bin.js" } }, "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug=="],
"prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="],
"pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="],
"rc": ["rc@1.2.8", "", { "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" }, "bin": { "rc": "./cli.js" } }, "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw=="],
"react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="],
"react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="],
"readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
"readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
"recast": ["recast@0.23.11", "", { "dependencies": { "ast-types": "^0.16.1", "esprima": "~4.0.0", "source-map": "~0.6.1", "tiny-invariant": "^1.3.3", "tslib": "^2.0.1" } }, "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA=="],
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
"rolldown": ["rolldown@1.0.0-rc.9", "", { "dependencies": { "@oxc-project/types": "=0.115.0", "@rolldown/pluginutils": "1.0.0-rc.9" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.9", "@rolldown/binding-darwin-arm64": "1.0.0-rc.9", "@rolldown/binding-darwin-x64": "1.0.0-rc.9", "@rolldown/binding-freebsd-x64": "1.0.0-rc.9", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.9", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.9", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.9", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.9", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.9", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.9", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.9" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-9EbgWge7ZH+yqb4d2EnELAntgPTWbfL8ajiTW+SyhJEC4qhBbkCKbqFV4Ge4zmu5ziQuVbWxb/XwLZ+RIO7E8Q=="],
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"seroval": ["seroval@1.5.1", "", {}, "sha512-OwrZRZAfhHww0WEnKHDY8OM0U/Qs8OTfIDWhUD4BLpNJUfXK4cGmjiagGze086m+mhI+V2nD0gfbHEnJjb9STA=="],
"seroval-plugins": ["seroval-plugins@1.5.1", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-4FbuZ/TMl02sqv0RTFexu0SP6V+ywaIe5bAWCCEik0fk17BhALgwvUDVF7e3Uvf9pxmwCEJsRPmlkUE6HdzLAw=="],
"simple-concat": ["simple-concat@1.0.1", "", {}, "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q=="],
"simple-get": ["simple-get@4.0.1", "", { "dependencies": { "decompress-response": "^6.0.0", "once": "^1.3.1", "simple-concat": "^1.0.0" } }, "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA=="],
"source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="],
"string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
"strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="],
"tailwindcss": ["tailwindcss@4.2.1", "", {}, "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw=="],
"tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],
"tar-fs": ["tar-fs@2.1.4", "", { "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", "pump": "^3.0.0", "tar-stream": "^2.1.4" } }, "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ=="],
"tar-stream": ["tar-stream@2.2.0", "", { "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", "fs-constants": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.1.1" } }, "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ=="],
"tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
"tiny-warning": ["tiny-warning@1.0.3", "", {}, "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA=="],
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"tsx": ["tsx@4.21.0", "", { "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw=="],
"tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
"unplugin": ["unplugin@2.3.11", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "acorn": "^8.15.0", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww=="],
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
"use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
"vite": ["vite@8.0.0", "", { "dependencies": { "@oxc-project/runtime": "0.115.0", "lightningcss": "^1.32.0", "picomatch": "^4.0.3", "postcss": "^8.5.8", "rolldown": "1.0.0-rc.9", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.0.0-alpha.31", "esbuild": "^0.27.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-fPGaRNj9Zytaf8LEiBhY7Z6ijnFKdzU/+mL8EFBaKr7Vw1/FWcTBAMW0wLPJAGMPX38ZPVCVgLceWiEqeoqL2Q=="],
"webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="],
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
"zustand": ["zustand@5.0.11", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg=="],
"@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="],
"@tailwindcss/node/lightningcss": ["lightningcss@1.31.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.31.1", "lightningcss-darwin-arm64": "1.31.1", "lightningcss-darwin-x64": "1.31.1", "lightningcss-freebsd-x64": "1.31.1", "lightningcss-linux-arm-gnueabihf": "1.31.1", "lightningcss-linux-arm64-gnu": "1.31.1", "lightningcss-linux-arm64-musl": "1.31.1", "lightningcss-linux-x64-gnu": "1.31.1", "lightningcss-linux-x64-musl": "1.31.1", "lightningcss-win32-arm64-msvc": "1.31.1", "lightningcss-win32-x64-msvc": "1.31.1" } }, "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.9.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-0DQ98G9ZQZOxfUcQn1waV2yS8aWdZ6kJMbYCJB3oUBecjWYO1fqJ+a1DRfPF3O5JEkwqwP1A9QEN/9mYm2Yd0w=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.9.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg=="],
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" }, "bundled": true }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="],
"@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"@tanstack/router-generator/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"@tanstack/router-plugin/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"node-abi/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
"readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"recast/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
"rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.9", "", {}, "sha512-w6oiRWgEBl04QkFZgmW+jnU1EC9b57Oihi2ot3HNWIQRqgHp5PnYDia5iZ5FF7rpa4EQdiqMDXjlqKGXBhsoXw=="],
"source-map-support/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
"tsx/esbuild": ["esbuild@0.27.4", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.4", "@esbuild/android-arm": "0.27.4", "@esbuild/android-arm64": "0.27.4", "@esbuild/android-x64": "0.27.4", "@esbuild/darwin-arm64": "0.27.4", "@esbuild/darwin-x64": "0.27.4", "@esbuild/freebsd-arm64": "0.27.4", "@esbuild/freebsd-x64": "0.27.4", "@esbuild/linux-arm": "0.27.4", "@esbuild/linux-arm64": "0.27.4", "@esbuild/linux-ia32": "0.27.4", "@esbuild/linux-loong64": "0.27.4", "@esbuild/linux-mips64el": "0.27.4", "@esbuild/linux-ppc64": "0.27.4", "@esbuild/linux-riscv64": "0.27.4", "@esbuild/linux-s390x": "0.27.4", "@esbuild/linux-x64": "0.27.4", "@esbuild/netbsd-arm64": "0.27.4", "@esbuild/netbsd-x64": "0.27.4", "@esbuild/openbsd-arm64": "0.27.4", "@esbuild/openbsd-x64": "0.27.4", "@esbuild/openharmony-arm64": "0.27.4", "@esbuild/sunos-x64": "0.27.4", "@esbuild/win32-arm64": "0.27.4", "@esbuild/win32-ia32": "0.27.4", "@esbuild/win32-x64": "0.27.4" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ=="],
"vite/esbuild": ["esbuild@0.27.4", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.4", "@esbuild/android-arm": "0.27.4", "@esbuild/android-arm64": "0.27.4", "@esbuild/android-x64": "0.27.4", "@esbuild/darwin-arm64": "0.27.4", "@esbuild/darwin-x64": "0.27.4", "@esbuild/freebsd-arm64": "0.27.4", "@esbuild/freebsd-x64": "0.27.4", "@esbuild/linux-arm": "0.27.4", "@esbuild/linux-arm64": "0.27.4", "@esbuild/linux-ia32": "0.27.4", "@esbuild/linux-loong64": "0.27.4", "@esbuild/linux-mips64el": "0.27.4", "@esbuild/linux-ppc64": "0.27.4", "@esbuild/linux-riscv64": "0.27.4", "@esbuild/linux-s390x": "0.27.4", "@esbuild/linux-x64": "0.27.4", "@esbuild/netbsd-arm64": "0.27.4", "@esbuild/netbsd-x64": "0.27.4", "@esbuild/openbsd-arm64": "0.27.4", "@esbuild/openbsd-x64": "0.27.4", "@esbuild/openharmony-arm64": "0.27.4", "@esbuild/sunos-x64": "0.27.4", "@esbuild/win32-arm64": "0.27.4", "@esbuild/win32-ia32": "0.27.4", "@esbuild/win32-x64": "0.27.4" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.18.20", "", { "os": "android", "cpu": "x64" }, "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.18.20", "", { "os": "darwin", "cpu": "arm64" }, "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.18.20", "", { "os": "darwin", "cpu": "x64" }, "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.18.20", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.18.20", "", { "os": "freebsd", "cpu": "x64" }, "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.18.20", "", { "os": "linux", "cpu": "arm" }, "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.18.20", "", { "os": "linux", "cpu": "arm64" }, "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.18.20", "", { "os": "linux", "cpu": "ia32" }, "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.18.20", "", { "os": "linux", "cpu": "ppc64" }, "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.18.20", "", { "os": "linux", "cpu": "s390x" }, "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.18.20", "", { "os": "linux", "cpu": "x64" }, "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.18.20", "", { "os": "none", "cpu": "x64" }, "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.18.20", "", { "os": "openbsd", "cpu": "x64" }, "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.18.20", "", { "os": "sunos", "cpu": "x64" }, "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.18.20", "", { "os": "win32", "cpu": "arm64" }, "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.18.20", "", { "os": "win32", "cpu": "ia32" }, "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="],
"@tailwindcss/node/lightningcss/lightningcss-android-arm64": ["lightningcss-android-arm64@1.31.1", "", { "os": "android", "cpu": "arm64" }, "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg=="],
"@tailwindcss/node/lightningcss/lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.31.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg=="],
"@tailwindcss/node/lightningcss/lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.31.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA=="],
"@tailwindcss/node/lightningcss/lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.31.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A=="],
"@tailwindcss/node/lightningcss/lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.31.1", "", { "os": "linux", "cpu": "arm" }, "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g=="],
"@tailwindcss/node/lightningcss/lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.31.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg=="],
"@tailwindcss/node/lightningcss/lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.31.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg=="],
"@tailwindcss/node/lightningcss/lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.31.1", "", { "os": "linux", "cpu": "x64" }, "sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA=="],
"@tailwindcss/node/lightningcss/lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.31.1", "", { "os": "linux", "cpu": "x64" }, "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA=="],
"@tailwindcss/node/lightningcss/lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.31.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w=="],
"@tailwindcss/node/lightningcss/lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.31.1", "", { "os": "win32", "cpu": "x64" }, "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw=="],
"tsx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.4", "", { "os": "aix", "cpu": "ppc64" }, "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q=="],
"tsx/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.4", "", { "os": "android", "cpu": "arm" }, "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ=="],
"tsx/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.4", "", { "os": "android", "cpu": "arm64" }, "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw=="],
"tsx/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.27.4", "", { "os": "android", "cpu": "x64" }, "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw=="],
"tsx/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ=="],
"tsx/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw=="],
"tsx/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.4", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw=="],
"tsx/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ=="],
"tsx/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.4", "", { "os": "linux", "cpu": "arm" }, "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg=="],
"tsx/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA=="],
"tsx/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.4", "", { "os": "linux", "cpu": "ia32" }, "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA=="],
"tsx/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.4", "", { "os": "linux", "cpu": "none" }, "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA=="],
"tsx/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.4", "", { "os": "linux", "cpu": "none" }, "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw=="],
"tsx/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA=="],
"tsx/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.4", "", { "os": "linux", "cpu": "none" }, "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw=="],
"tsx/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA=="],
"tsx/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.4", "", { "os": "linux", "cpu": "x64" }, "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA=="],
"tsx/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.4", "", { "os": "none", "cpu": "arm64" }, "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q=="],
"tsx/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.4", "", { "os": "none", "cpu": "x64" }, "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg=="],
"tsx/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.4", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow=="],
"tsx/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.4", "", { "os": "openbsd", "cpu": "x64" }, "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ=="],
"tsx/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.4", "", { "os": "none", "cpu": "arm64" }, "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg=="],
"tsx/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.4", "", { "os": "sunos", "cpu": "x64" }, "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g=="],
"tsx/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg=="],
"tsx/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw=="],
"tsx/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.4", "", { "os": "win32", "cpu": "x64" }, "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg=="],
"vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.4", "", { "os": "aix", "cpu": "ppc64" }, "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q=="],
"vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.4", "", { "os": "android", "cpu": "arm" }, "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ=="],
"vite/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.4", "", { "os": "android", "cpu": "arm64" }, "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw=="],
"vite/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.27.4", "", { "os": "android", "cpu": "x64" }, "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw=="],
"vite/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ=="],
"vite/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw=="],
"vite/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.4", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw=="],
"vite/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ=="],
"vite/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.4", "", { "os": "linux", "cpu": "arm" }, "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg=="],
"vite/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA=="],
"vite/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.4", "", { "os": "linux", "cpu": "ia32" }, "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA=="],
"vite/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.4", "", { "os": "linux", "cpu": "none" }, "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA=="],
"vite/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.4", "", { "os": "linux", "cpu": "none" }, "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw=="],
"vite/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA=="],
"vite/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.4", "", { "os": "linux", "cpu": "none" }, "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw=="],
"vite/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA=="],
"vite/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.4", "", { "os": "linux", "cpu": "x64" }, "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA=="],
"vite/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.4", "", { "os": "none", "cpu": "arm64" }, "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q=="],
"vite/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.4", "", { "os": "none", "cpu": "x64" }, "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg=="],
"vite/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.4", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow=="],
"vite/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.4", "", { "os": "openbsd", "cpu": "x64" }, "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ=="],
"vite/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.4", "", { "os": "none", "cpu": "arm64" }, "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg=="],
"vite/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.4", "", { "os": "sunos", "cpu": "x64" }, "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g=="],
"vite/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg=="],
"vite/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw=="],
"vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.4", "", { "os": "win32", "cpu": "x64" }, "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg=="],
}
}

10
drizzle.config.ts Normal file
View File

@@ -0,0 +1,10 @@
import { defineConfig } from "drizzle-kit";
export default defineConfig({
out: "./drizzle",
schema: "./src/db/schema.ts",
dialect: "sqlite",
dbCredentials: {
url: "gearbox.db",
},
});

12
index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>GearBox</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/client/main.tsx"></script>
</body>
</html>

46
package.json Normal file
View File

@@ -0,0 +1,46 @@
{
"name": "gearbox",
"module": "index.ts",
"type": "module",
"private": true,
"scripts": {
"dev:client": "vite",
"dev:server": "bun --hot src/server/index.ts",
"build": "vite build",
"db:generate": "bunx drizzle-kit generate",
"db:push": "bunx drizzle-kit push",
"test": "bun test",
"lint": "bunx @biomejs/biome check ."
},
"devDependencies": {
"@biomejs/biome": "^2.4.7",
"@tanstack/react-query-devtools": "^5.91.3",
"@tanstack/react-router-devtools": "^1.166.7",
"@tanstack/router-plugin": "^1.166.9",
"@types/better-sqlite3": "^7.6.13",
"@types/bun": "latest",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"better-sqlite3": "^12.8.0",
"drizzle-kit": "^0.31.9",
"vite": "^8.0.0"
},
"peerDependencies": {
"typescript": "^5.9.3"
},
"dependencies": {
"@hono/zod-validator": "^0.7.6",
"@tailwindcss/vite": "^4.2.1",
"@tanstack/react-query": "^5.90.21",
"@tanstack/react-router": "^1.167.0",
"clsx": "^2.1.1",
"drizzle-orm": "^0.45.1",
"hono": "^4.12.8",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"tailwindcss": "^4.2.1",
"zod": "^4.3.6",
"zustand": "^5.0.11"
}
}

1
src/client/app.css Normal file
View File

@@ -0,0 +1 @@
@import "tailwindcss";

View File

@@ -0,0 +1,143 @@
import { useState } from "react";
import { formatWeight, formatPrice } from "../lib/formatters";
import { useUpdateCategory, useDeleteCategory } from "../hooks/useCategories";
interface CategoryHeaderProps {
categoryId: number;
name: string;
emoji: string;
totalWeight: number;
totalCost: number;
itemCount: number;
}
export function CategoryHeader({
categoryId,
name,
emoji,
totalWeight,
totalCost,
itemCount,
}: CategoryHeaderProps) {
const [isEditing, setIsEditing] = useState(false);
const [editName, setEditName] = useState(name);
const [editEmoji, setEditEmoji] = useState(emoji);
const updateCategory = useUpdateCategory();
const deleteCategory = useDeleteCategory();
const isUncategorized = categoryId === 1;
function handleSave() {
if (!editName.trim()) return;
updateCategory.mutate(
{ id: categoryId, name: editName.trim(), emoji: editEmoji },
{ onSuccess: () => setIsEditing(false) },
);
}
function handleDelete() {
if (
confirm(`Delete category "${name}"? Items will be moved to Uncategorized.`)
) {
deleteCategory.mutate(categoryId);
}
}
if (isEditing) {
return (
<div className="flex items-center gap-3 py-4">
<input
type="text"
value={editEmoji}
onChange={(e) => setEditEmoji(e.target.value)}
className="w-12 text-center text-xl border border-gray-200 rounded-md px-1 py-1"
maxLength={4}
/>
<input
type="text"
value={editName}
onChange={(e) => setEditName(e.target.value)}
className="text-lg font-semibold border border-gray-200 rounded-md px-2 py-1"
onKeyDown={(e) => {
if (e.key === "Enter") handleSave();
if (e.key === "Escape") setIsEditing(false);
}}
autoFocus
/>
<button
type="button"
onClick={handleSave}
className="text-sm text-blue-600 hover:text-blue-800 font-medium"
>
Save
</button>
<button
type="button"
onClick={() => setIsEditing(false)}
className="text-sm text-gray-400 hover:text-gray-600"
>
Cancel
</button>
</div>
);
}
return (
<div className="group flex items-center gap-3 py-4">
<span className="text-xl">{emoji}</span>
<h2 className="text-lg font-semibold text-gray-900">{name}</h2>
<span className="text-sm text-gray-400">
{itemCount} {itemCount === 1 ? "item" : "items"} ·{" "}
{formatWeight(totalWeight)} · {formatPrice(totalCost)}
</span>
{!isUncategorized && (
<div className="ml-auto flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
type="button"
onClick={() => {
setEditName(name);
setEditEmoji(emoji);
setIsEditing(true);
}}
className="p-1 text-gray-400 hover:text-gray-600 rounded"
title="Edit category"
>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"
/>
</svg>
</button>
<button
type="button"
onClick={handleDelete}
className="p-1 text-gray-400 hover:text-red-500 rounded"
title="Delete category"
>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,200 @@
import { useState, useRef, useEffect } from "react";
import {
useCategories,
useCreateCategory,
} from "../hooks/useCategories";
interface CategoryPickerProps {
value: number;
onChange: (categoryId: number) => void;
}
export function CategoryPicker({ value, onChange }: CategoryPickerProps) {
const { data: categories = [] } = useCategories();
const createCategory = useCreateCategory();
const [inputValue, setInputValue] = useState("");
const [isOpen, setIsOpen] = useState(false);
const [highlightIndex, setHighlightIndex] = useState(-1);
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLUListElement>(null);
// Sync display value when value prop changes
const selectedCategory = categories.find((c) => c.id === value);
const filtered = categories.filter((c) =>
c.name.toLowerCase().includes(inputValue.toLowerCase()),
);
const showCreateOption =
inputValue.trim() !== "" &&
!categories.some(
(c) => c.name.toLowerCase() === inputValue.trim().toLowerCase(),
);
const totalOptions = filtered.length + (showCreateOption ? 1 : 0);
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (
containerRef.current &&
!containerRef.current.contains(e.target as Node)
) {
setIsOpen(false);
// Reset input to selected category name
if (selectedCategory) {
setInputValue("");
}
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [selectedCategory]);
function handleSelect(categoryId: number) {
onChange(categoryId);
setInputValue("");
setIsOpen(false);
setHighlightIndex(-1);
}
async function handleCreate() {
const name = inputValue.trim();
if (!name) return;
createCategory.mutate(
{ name, emoji: "\u{1F4E6}" },
{
onSuccess: (newCat) => {
handleSelect(newCat.id);
},
},
);
}
function handleKeyDown(e: React.KeyboardEvent) {
if (!isOpen) {
if (e.key === "ArrowDown" || e.key === "Enter") {
setIsOpen(true);
e.preventDefault();
}
return;
}
switch (e.key) {
case "ArrowDown":
e.preventDefault();
setHighlightIndex((i) => Math.min(i + 1, totalOptions - 1));
break;
case "ArrowUp":
e.preventDefault();
setHighlightIndex((i) => Math.max(i - 1, 0));
break;
case "Enter":
e.preventDefault();
if (highlightIndex >= 0 && highlightIndex < filtered.length) {
handleSelect(filtered[highlightIndex].id);
} else if (
showCreateOption &&
highlightIndex === filtered.length
) {
handleCreate();
}
break;
case "Escape":
setIsOpen(false);
setHighlightIndex(-1);
setInputValue("");
break;
}
}
// Scroll highlighted option into view
useEffect(() => {
if (highlightIndex >= 0 && listRef.current) {
const option = listRef.current.children[highlightIndex] as HTMLElement;
option?.scrollIntoView({ block: "nearest" });
}
}, [highlightIndex]);
return (
<div ref={containerRef} className="relative">
<input
ref={inputRef}
type="text"
role="combobox"
aria-expanded={isOpen}
aria-autocomplete="list"
aria-controls="category-listbox"
aria-activedescendant={
highlightIndex >= 0 ? `category-option-${highlightIndex}` : undefined
}
value={
isOpen
? inputValue
: selectedCategory
? `${selectedCategory.emoji} ${selectedCategory.name}`
: ""
}
placeholder="Search or create category..."
onChange={(e) => {
setInputValue(e.target.value);
setIsOpen(true);
setHighlightIndex(-1);
}}
onFocus={() => {
setIsOpen(true);
setInputValue("");
}}
onKeyDown={handleKeyDown}
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
{isOpen && (
<ul
ref={listRef}
id="category-listbox"
role="listbox"
className="absolute z-20 mt-1 w-full max-h-48 overflow-auto bg-white border border-gray-200 rounded-lg shadow-lg"
>
{filtered.map((cat, i) => (
<li
key={cat.id}
id={`category-option-${i}`}
role="option"
aria-selected={cat.id === value}
className={`px-3 py-2 text-sm cursor-pointer ${
i === highlightIndex
? "bg-blue-50 text-blue-900"
: "hover:bg-gray-50"
} ${cat.id === value ? "font-medium" : ""}`}
onClick={() => handleSelect(cat.id)}
onMouseEnter={() => setHighlightIndex(i)}
>
{cat.emoji} {cat.name}
</li>
))}
{showCreateOption && (
<li
id={`category-option-${filtered.length}`}
role="option"
aria-selected={false}
className={`px-3 py-2 text-sm cursor-pointer border-t border-gray-100 ${
highlightIndex === filtered.length
? "bg-blue-50 text-blue-900"
: "hover:bg-gray-50 text-gray-600"
}`}
onClick={handleCreate}
onMouseEnter={() => setHighlightIndex(filtered.length)}
>
+ Create "{inputValue.trim()}"
</li>
)}
{filtered.length === 0 && !showCreateOption && (
<li className="px-3 py-2 text-sm text-gray-400">
No categories found
</li>
)}
</ul>
)}
</div>
);
}

View File

@@ -0,0 +1,61 @@
import { useUIStore } from "../stores/uiStore";
import { useDeleteItem } from "../hooks/useItems";
import { useItems } from "../hooks/useItems";
export function ConfirmDialog() {
const confirmDeleteItemId = useUIStore((s) => s.confirmDeleteItemId);
const closeConfirmDelete = useUIStore((s) => s.closeConfirmDelete);
const deleteItem = useDeleteItem();
const { data: items } = useItems();
if (confirmDeleteItemId == null) return null;
const item = items?.find((i) => i.id === confirmDeleteItemId);
const itemName = item?.name ?? "this item";
function handleDelete() {
if (confirmDeleteItemId == null) return;
deleteItem.mutate(confirmDeleteItemId, {
onSuccess: () => closeConfirmDelete(),
});
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div
className="absolute inset-0 bg-black/30"
onClick={closeConfirmDelete}
onKeyDown={(e) => {
if (e.key === "Escape") closeConfirmDelete();
}}
/>
<div className="relative bg-white rounded-xl shadow-lg p-6 max-w-sm mx-4 w-full">
<h3 className="text-lg font-semibold text-gray-900 mb-2">
Delete Item
</h3>
<p className="text-sm text-gray-600 mb-6">
Are you sure you want to delete{" "}
<span className="font-medium">{itemName}</span>? This action cannot be
undone.
</p>
<div className="flex justify-end gap-3">
<button
type="button"
onClick={closeConfirmDelete}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors"
>
Cancel
</button>
<button
type="button"
onClick={handleDelete}
disabled={deleteItem.isPending}
className="px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 disabled:opacity-50 rounded-lg transition-colors"
>
{deleteItem.isPending ? "Deleting..." : "Delete"}
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,95 @@
import { useState, useRef } from "react";
import { apiUpload } from "../lib/api";
interface ImageUploadProps {
value: string | null;
onChange: (filename: string | null) => void;
}
const MAX_SIZE_BYTES = 5 * 1024 * 1024; // 5MB
const ACCEPTED_TYPES = ["image/jpeg", "image/png", "image/webp"];
export function ImageUpload({ value, onChange }: ImageUploadProps) {
const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
async function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
setError(null);
if (!ACCEPTED_TYPES.includes(file.type)) {
setError("Please select a JPG, PNG, or WebP image.");
return;
}
if (file.size > MAX_SIZE_BYTES) {
setError("Image must be under 5MB.");
return;
}
setUploading(true);
try {
const result = await apiUpload<{ filename: string }>(
"/api/images",
file,
);
onChange(result.filename);
} catch {
setError("Upload failed. Please try again.");
} finally {
setUploading(false);
}
}
return (
<div>
{value && (
<div className="mb-2 relative">
<img
src={`/uploads/${value}`}
alt="Item"
className="w-full h-32 object-cover rounded-lg"
/>
<button
type="button"
onClick={() => onChange(null)}
className="absolute top-1 right-1 p-1 bg-white/80 hover:bg-white rounded-full text-gray-600 hover:text-gray-900"
>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
)}
<button
type="button"
onClick={() => inputRef.current?.click()}
disabled={uploading}
className="w-full py-2 px-3 border border-dashed border-gray-300 rounded-lg text-sm text-gray-500 hover:border-gray-400 hover:text-gray-600 transition-colors disabled:opacity-50"
>
{uploading ? "Uploading..." : value ? "Change image" : "Add image"}
</button>
<input
ref={inputRef}
type="file"
accept="image/jpeg,image/png,image/webp"
onChange={handleFileChange}
className="hidden"
/>
{error && <p className="mt-1 text-xs text-red-500">{error}</p>}
</div>
);
}

View File

@@ -0,0 +1,62 @@
import { formatWeight, formatPrice } from "../lib/formatters";
import { useUIStore } from "../stores/uiStore";
interface ItemCardProps {
id: number;
name: string;
weightGrams: number | null;
priceCents: number | null;
categoryName: string;
categoryEmoji: string;
imageFilename: string | null;
}
export function ItemCard({
id,
name,
weightGrams,
priceCents,
categoryName,
categoryEmoji,
imageFilename,
}: ItemCardProps) {
const openEditPanel = useUIStore((s) => s.openEditPanel);
return (
<button
type="button"
onClick={() => openEditPanel(id)}
className="w-full text-left bg-white rounded-xl border border-gray-100 hover:border-gray-200 hover:shadow-sm transition-all overflow-hidden"
>
{imageFilename && (
<div className="aspect-[4/3] bg-gray-50">
<img
src={`/uploads/${imageFilename}`}
alt={name}
className="w-full h-full object-cover"
/>
</div>
)}
<div className="p-4">
<h3 className="text-sm font-semibold text-gray-900 mb-2 truncate">
{name}
</h3>
<div className="flex flex-wrap gap-1.5">
{weightGrams != null && (
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-50 text-blue-700">
{formatWeight(weightGrams)}
</span>
)}
{priceCents != null && (
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-50 text-green-700">
{formatPrice(priceCents)}
</span>
)}
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-50 text-gray-600">
{categoryEmoji} {categoryName}
</span>
</div>
</div>
</button>
);
}

View File

@@ -0,0 +1,283 @@
import { useState, useEffect } from "react";
import { useCreateItem, useUpdateItem, useItems } from "../hooks/useItems";
import { useUIStore } from "../stores/uiStore";
import { CategoryPicker } from "./CategoryPicker";
import { ImageUpload } from "./ImageUpload";
interface ItemFormProps {
mode: "add" | "edit";
itemId?: number | null;
}
interface FormData {
name: string;
weightGrams: string;
priceDollars: string;
categoryId: number;
notes: string;
productUrl: string;
imageFilename: string | null;
}
const INITIAL_FORM: FormData = {
name: "",
weightGrams: "",
priceDollars: "",
categoryId: 1,
notes: "",
productUrl: "",
imageFilename: null,
};
export function ItemForm({ mode, itemId }: ItemFormProps) {
const { data: items } = useItems();
const createItem = useCreateItem();
const updateItem = useUpdateItem();
const closePanel = useUIStore((s) => s.closePanel);
const openConfirmDelete = useUIStore((s) => s.openConfirmDelete);
const [form, setForm] = useState<FormData>(INITIAL_FORM);
const [errors, setErrors] = useState<Record<string, string>>({});
// Pre-fill form when editing
useEffect(() => {
if (mode === "edit" && itemId != null && items) {
const item = items.find((i) => i.id === itemId);
if (item) {
setForm({
name: item.name,
weightGrams:
item.weightGrams != null ? String(item.weightGrams) : "",
priceDollars:
item.priceCents != null ? (item.priceCents / 100).toFixed(2) : "",
categoryId: item.categoryId,
notes: item.notes ?? "",
productUrl: item.productUrl ?? "",
imageFilename: item.imageFilename,
});
}
} else if (mode === "add") {
setForm(INITIAL_FORM);
}
}, [mode, itemId, items]);
function validate(): boolean {
const newErrors: Record<string, string> = {};
if (!form.name.trim()) {
newErrors.name = "Name is required";
}
if (form.weightGrams && (isNaN(Number(form.weightGrams)) || Number(form.weightGrams) < 0)) {
newErrors.weightGrams = "Must be a positive number";
}
if (form.priceDollars && (isNaN(Number(form.priceDollars)) || Number(form.priceDollars) < 0)) {
newErrors.priceDollars = "Must be a positive number";
}
if (
form.productUrl &&
form.productUrl.trim() !== "" &&
!form.productUrl.match(/^https?:\/\//)
) {
newErrors.productUrl = "Must be a valid URL (https://...)";
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!validate()) return;
const payload = {
name: form.name.trim(),
weightGrams: form.weightGrams ? Number(form.weightGrams) : undefined,
priceCents: form.priceDollars
? Math.round(Number(form.priceDollars) * 100)
: undefined,
categoryId: form.categoryId,
notes: form.notes.trim() || undefined,
productUrl: form.productUrl.trim() || undefined,
imageFilename: form.imageFilename ?? undefined,
};
if (mode === "add") {
createItem.mutate(payload, {
onSuccess: () => {
setForm(INITIAL_FORM);
closePanel();
},
});
} else if (itemId != null) {
updateItem.mutate(
{ id: itemId, ...payload },
{ onSuccess: () => closePanel() },
);
}
}
const isPending = createItem.isPending || updateItem.isPending;
return (
<form onSubmit={handleSubmit} className="space-y-5">
{/* Name */}
<div>
<label
htmlFor="item-name"
className="block text-sm font-medium text-gray-700 mb-1"
>
Name *
</label>
<input
id="item-name"
type="text"
value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="e.g. Osprey Talon 22"
autoFocus
/>
{errors.name && (
<p className="mt-1 text-xs text-red-500">{errors.name}</p>
)}
</div>
{/* Weight */}
<div>
<label
htmlFor="item-weight"
className="block text-sm font-medium text-gray-700 mb-1"
>
Weight (g)
</label>
<input
id="item-weight"
type="number"
min="0"
step="any"
value={form.weightGrams}
onChange={(e) =>
setForm((f) => ({ ...f, weightGrams: e.target.value }))
}
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="e.g. 680"
/>
{errors.weightGrams && (
<p className="mt-1 text-xs text-red-500">{errors.weightGrams}</p>
)}
</div>
{/* Price */}
<div>
<label
htmlFor="item-price"
className="block text-sm font-medium text-gray-700 mb-1"
>
Price ($)
</label>
<input
id="item-price"
type="number"
min="0"
step="0.01"
value={form.priceDollars}
onChange={(e) =>
setForm((f) => ({ ...f, priceDollars: e.target.value }))
}
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="e.g. 129.99"
/>
{errors.priceDollars && (
<p className="mt-1 text-xs text-red-500">{errors.priceDollars}</p>
)}
</div>
{/* Category */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Category
</label>
<CategoryPicker
value={form.categoryId}
onChange={(id) => setForm((f) => ({ ...f, categoryId: id }))}
/>
</div>
{/* Notes */}
<div>
<label
htmlFor="item-notes"
className="block text-sm font-medium text-gray-700 mb-1"
>
Notes
</label>
<textarea
id="item-notes"
value={form.notes}
onChange={(e) => setForm((f) => ({ ...f, notes: e.target.value }))}
rows={3}
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none"
placeholder="Any additional notes..."
/>
</div>
{/* Product Link */}
<div>
<label
htmlFor="item-url"
className="block text-sm font-medium text-gray-700 mb-1"
>
Product Link
</label>
<input
id="item-url"
type="url"
value={form.productUrl}
onChange={(e) =>
setForm((f) => ({ ...f, productUrl: e.target.value }))
}
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="https://..."
/>
{errors.productUrl && (
<p className="mt-1 text-xs text-red-500">{errors.productUrl}</p>
)}
</div>
{/* Image */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Image
</label>
<ImageUpload
value={form.imageFilename}
onChange={(filename) =>
setForm((f) => ({ ...f, imageFilename: filename }))
}
/>
</div>
{/* Actions */}
<div className="flex gap-3 pt-2">
<button
type="submit"
disabled={isPending}
className="flex-1 py-2.5 px-4 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white text-sm font-medium rounded-lg transition-colors"
>
{isPending
? "Saving..."
: mode === "add"
? "Add Item"
: "Save Changes"}
</button>
{mode === "edit" && itemId != null && (
<button
type="button"
onClick={() => openConfirmDelete(itemId)}
className="py-2.5 px-4 text-red-600 hover:bg-red-50 text-sm font-medium rounded-lg transition-colors"
>
Delete
</button>
)}
</div>
</form>
);
}

View File

@@ -0,0 +1,322 @@
import { useState } from "react";
import { useCreateCategory } from "../hooks/useCategories";
import { useCreateItem } from "../hooks/useItems";
import { useUpdateSetting } from "../hooks/useSettings";
interface OnboardingWizardProps {
onComplete: () => void;
}
export function OnboardingWizard({ onComplete }: OnboardingWizardProps) {
const [step, setStep] = useState(1);
// Step 2 state
const [categoryName, setCategoryName] = useState("");
const [categoryEmoji, setCategoryEmoji] = useState("");
const [categoryError, setCategoryError] = useState("");
const [createdCategoryId, setCreatedCategoryId] = useState<number | null>(null);
// Step 3 state
const [itemName, setItemName] = useState("");
const [itemWeight, setItemWeight] = useState("");
const [itemPrice, setItemPrice] = useState("");
const [itemError, setItemError] = useState("");
const createCategory = useCreateCategory();
const createItem = useCreateItem();
const updateSetting = useUpdateSetting();
function handleSkip() {
updateSetting.mutate(
{ key: "onboardingComplete", value: "true" },
{ onSuccess: onComplete },
);
}
function handleCreateCategory() {
const name = categoryName.trim();
if (!name) {
setCategoryError("Please enter a category name");
return;
}
setCategoryError("");
createCategory.mutate(
{ name, emoji: categoryEmoji.trim() || undefined },
{
onSuccess: (created) => {
setCreatedCategoryId(created.id);
setStep(3);
},
onError: (err) => {
setCategoryError(err.message || "Failed to create category");
},
},
);
}
function handleCreateItem() {
const name = itemName.trim();
if (!name) {
setItemError("Please enter an item name");
return;
}
if (!createdCategoryId) return;
setItemError("");
const payload: any = {
name,
categoryId: createdCategoryId,
};
if (itemWeight) payload.weightGrams = Number(itemWeight);
if (itemPrice) payload.priceCents = Math.round(Number(itemPrice) * 100);
createItem.mutate(payload, {
onSuccess: () => setStep(4),
onError: (err) => {
setItemError(err.message || "Failed to add item");
},
});
}
function handleDone() {
updateSetting.mutate(
{ key: "onboardingComplete", value: "true" },
{ onSuccess: onComplete },
);
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div className="absolute inset-0 bg-black/30 backdrop-blur-sm" />
{/* Card */}
<div className="relative z-10 w-full max-w-md mx-4 bg-white rounded-2xl shadow-2xl p-8">
{/* Step indicator */}
<div className="flex items-center justify-center gap-2 mb-6">
{[1, 2, 3].map((s) => (
<div
key={s}
className={`h-1.5 rounded-full transition-all ${
s <= Math.min(step, 3)
? "bg-blue-600 w-8"
: "bg-gray-200 w-6"
}`}
/>
))}
</div>
{/* Step 1: Welcome */}
{step === 1 && (
<div className="text-center">
<h2 className="text-2xl font-semibold text-gray-900 mb-2">
Welcome to GearBox!
</h2>
<p className="text-gray-500 mb-8 leading-relaxed">
Track your gear, compare weights, and plan smarter purchases.
Let&apos;s set up your first category and item.
</p>
<button
type="button"
onClick={() => setStep(2)}
className="w-full py-3 px-4 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg transition-colors"
>
Get Started
</button>
<button
type="button"
onClick={handleSkip}
className="mt-3 text-sm text-gray-400 hover:text-gray-600 transition-colors"
>
Skip setup
</button>
</div>
)}
{/* Step 2: Create category */}
{step === 2 && (
<div>
<h2 className="text-xl font-semibold text-gray-900 mb-1">
Create a category
</h2>
<p className="text-sm text-gray-500 mb-6">
Categories help you organize your gear (e.g. Shelter, Cooking,
Clothing).
</p>
<div className="space-y-4">
<div>
<label
htmlFor="onboard-cat-name"
className="block text-sm font-medium text-gray-700 mb-1"
>
Category name *
</label>
<input
id="onboard-cat-name"
type="text"
value={categoryName}
onChange={(e) => setCategoryName(e.target.value)}
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="e.g. Shelter"
autoFocus
/>
</div>
<div>
<label
htmlFor="onboard-cat-emoji"
className="block text-sm font-medium text-gray-700 mb-1"
>
Emoji (optional)
</label>
<input
id="onboard-cat-emoji"
type="text"
value={categoryEmoji}
onChange={(e) => setCategoryEmoji(e.target.value)}
className="w-20 px-3 py-2 border border-gray-200 rounded-lg text-center text-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="&#9978;"
maxLength={4}
/>
</div>
{categoryError && (
<p className="text-xs text-red-500">{categoryError}</p>
)}
</div>
<button
type="button"
onClick={handleCreateCategory}
disabled={createCategory.isPending}
className="mt-6 w-full py-3 px-4 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white font-medium rounded-lg transition-colors"
>
{createCategory.isPending ? "Creating..." : "Create Category"}
</button>
<button
type="button"
onClick={handleSkip}
className="mt-3 w-full text-sm text-gray-400 hover:text-gray-600 transition-colors"
>
Skip setup
</button>
</div>
)}
{/* Step 3: Add item */}
{step === 3 && (
<div>
<h2 className="text-xl font-semibold text-gray-900 mb-1">
Add your first item
</h2>
<p className="text-sm text-gray-500 mb-6">
Add a piece of gear to your collection.
</p>
<div className="space-y-4">
<div>
<label
htmlFor="onboard-item-name"
className="block text-sm font-medium text-gray-700 mb-1"
>
Item name *
</label>
<input
id="onboard-item-name"
type="text"
value={itemName}
onChange={(e) => setItemName(e.target.value)}
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="e.g. Big Agnes Copper Spur"
autoFocus
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label
htmlFor="onboard-item-weight"
className="block text-sm font-medium text-gray-700 mb-1"
>
Weight (g)
</label>
<input
id="onboard-item-weight"
type="number"
min="0"
step="any"
value={itemWeight}
onChange={(e) => setItemWeight(e.target.value)}
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="e.g. 1200"
/>
</div>
<div>
<label
htmlFor="onboard-item-price"
className="block text-sm font-medium text-gray-700 mb-1"
>
Price ($)
</label>
<input
id="onboard-item-price"
type="number"
min="0"
step="0.01"
value={itemPrice}
onChange={(e) => setItemPrice(e.target.value)}
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="e.g. 349.99"
/>
</div>
</div>
{itemError && (
<p className="text-xs text-red-500">{itemError}</p>
)}
</div>
<button
type="button"
onClick={handleCreateItem}
disabled={createItem.isPending}
className="mt-6 w-full py-3 px-4 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white font-medium rounded-lg transition-colors"
>
{createItem.isPending ? "Adding..." : "Add Item"}
</button>
<button
type="button"
onClick={handleSkip}
className="mt-3 w-full text-sm text-gray-400 hover:text-gray-600 transition-colors"
>
Skip setup
</button>
</div>
)}
{/* Step 4: Done */}
{step === 4 && (
<div className="text-center">
<div className="text-4xl mb-4">&#127881;</div>
<h2 className="text-xl font-semibold text-gray-900 mb-2">
You&apos;re all set!
</h2>
<p className="text-sm text-gray-500 mb-8">
Your first item has been added. You can now browse your collection,
add more gear, and track your setup.
</p>
<button
type="button"
onClick={handleDone}
disabled={updateSetting.isPending}
className="w-full py-3 px-4 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white font-medium rounded-lg transition-colors"
>
{updateSetting.isPending ? "Finishing..." : "Done"}
</button>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,76 @@
import { useEffect } from "react";
interface SlideOutPanelProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
}
export function SlideOutPanel({
isOpen,
onClose,
title,
children,
}: SlideOutPanelProps) {
// Close on Escape key
useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") onClose();
}
if (isOpen) {
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}
}, [isOpen, onClose]);
return (
<>
{/* Backdrop */}
<div
className={`fixed inset-0 z-30 bg-black/20 transition-opacity ${
isOpen
? "opacity-100 pointer-events-auto"
: "opacity-0 pointer-events-none"
}`}
onClick={onClose}
/>
{/* Panel */}
<div
className={`fixed top-0 right-0 z-40 h-full w-full sm:w-[400px] bg-white shadow-xl transition-transform duration-300 ease-in-out ${
isOpen ? "translate-x-0" : "translate-x-full"
}`}
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-100">
<h2 className="text-lg font-semibold text-gray-900">{title}</h2>
<button
type="button"
onClick={onClose}
className="p-1 text-gray-400 hover:text-gray-600 rounded"
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
{/* Content */}
<div className="overflow-y-auto h-[calc(100%-65px)] px-6 py-4">
{children}
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,38 @@
import { useTotals } from "../hooks/useTotals";
import { formatWeight, formatPrice } from "../lib/formatters";
export function TotalsBar() {
const { data } = useTotals();
const global = data?.global;
return (
<div className="sticky top-0 z-10 bg-white border-b border-gray-100">
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-14">
<h1 className="text-lg font-semibold text-gray-900">GearBox</h1>
<div className="flex items-center gap-6 text-sm text-gray-500">
<span>
<span className="font-medium text-gray-700">
{global?.itemCount ?? 0}
</span>{" "}
items
</span>
<span>
<span className="font-medium text-gray-700">
{formatWeight(global?.totalWeight ?? null)}
</span>{" "}
total
</span>
<span>
<span className="font-medium text-gray-700">
{formatPrice(global?.totalCost ?? null)}
</span>{" "}
spent
</span>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,53 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { apiGet, apiPost, apiPut, apiDelete } from "../lib/api";
import type { Category, CreateCategory } from "../../shared/types";
export function useCategories() {
return useQuery({
queryKey: ["categories"],
queryFn: () => apiGet<Category[]>("/api/categories"),
});
}
export function useCreateCategory() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateCategory) =>
apiPost<Category>("/api/categories", data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["categories"] });
},
});
}
export function useUpdateCategory() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
id,
...data
}: {
id: number;
name?: string;
emoji?: string;
}) => apiPut<Category>(`/api/categories/${id}`, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["categories"] });
queryClient.invalidateQueries({ queryKey: ["items"] });
queryClient.invalidateQueries({ queryKey: ["totals"] });
},
});
}
export function useDeleteCategory() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) =>
apiDelete<{ success: boolean }>(`/api/categories/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["categories"] });
queryClient.invalidateQueries({ queryKey: ["items"] });
queryClient.invalidateQueries({ queryKey: ["totals"] });
},
});
}

View File

@@ -0,0 +1,69 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { apiGet, apiPost, apiPut, apiDelete } from "../lib/api";
import type { CreateItem } from "../../shared/types";
interface ItemWithCategory {
id: number;
name: string;
weightGrams: number | null;
priceCents: number | null;
categoryId: number;
notes: string | null;
productUrl: string | null;
imageFilename: string | null;
createdAt: string;
updatedAt: string;
categoryName: string;
categoryEmoji: string;
}
export function useItems() {
return useQuery({
queryKey: ["items"],
queryFn: () => apiGet<ItemWithCategory[]>("/api/items"),
});
}
export function useItem(id: number | null) {
return useQuery({
queryKey: ["items", id],
queryFn: () => apiGet<ItemWithCategory>(`/api/items/${id}`),
enabled: id != null,
});
}
export function useCreateItem() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateItem) =>
apiPost<ItemWithCategory>("/api/items", data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["items"] });
queryClient.invalidateQueries({ queryKey: ["totals"] });
},
});
}
export function useUpdateItem() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, ...data }: { id: number } & Partial<CreateItem>) =>
apiPut<ItemWithCategory>(`/api/items/${id}`, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["items"] });
queryClient.invalidateQueries({ queryKey: ["totals"] });
},
});
}
export function useDeleteItem() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) =>
apiDelete<{ success: boolean }>(`/api/items/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["items"] });
queryClient.invalidateQueries({ queryKey: ["totals"] });
},
});
}

View File

@@ -0,0 +1,37 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { apiGet, apiPut } from "../lib/api";
interface Setting {
key: string;
value: string;
}
export function useSetting(key: string) {
return useQuery({
queryKey: ["settings", key],
queryFn: async () => {
try {
const result = await apiGet<Setting>(`/api/settings/${key}`);
return result.value;
} catch (err: any) {
if (err?.status === 404) return null;
throw err;
}
},
});
}
export function useUpdateSetting() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ key, value }: { key: string; value: string }) =>
apiPut<Setting>(`/api/settings/${key}`, { value }),
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryKey: ["settings", variables.key] });
},
});
}
export function useOnboardingComplete() {
return useSetting("onboardingComplete");
}

View File

@@ -0,0 +1,31 @@
import { useQuery } from "@tanstack/react-query";
import { apiGet } from "../lib/api";
interface CategoryTotals {
categoryId: number;
categoryName: string;
categoryEmoji: string;
totalWeight: number;
totalCost: number;
itemCount: number;
}
interface GlobalTotals {
totalWeight: number;
totalCost: number;
itemCount: number;
}
interface TotalsResponse {
categories: CategoryTotals[];
global: GlobalTotals;
}
export type { CategoryTotals, GlobalTotals, TotalsResponse };
export function useTotals() {
return useQuery({
queryKey: ["totals"],
queryFn: () => apiGet<TotalsResponse>("/api/totals"),
});
}

61
src/client/lib/api.ts Normal file
View File

@@ -0,0 +1,61 @@
class ApiError extends Error {
constructor(
message: string,
public status: number,
) {
super(message);
this.name = "ApiError";
}
}
async function handleResponse<T>(res: Response): Promise<T> {
if (!res.ok) {
let message = `Request failed with status ${res.status}`;
try {
const body = await res.json();
if (body.error) message = body.error;
} catch {
// Use default message
}
throw new ApiError(message, res.status);
}
return res.json() as Promise<T>;
}
export async function apiGet<T>(url: string): Promise<T> {
const res = await fetch(url);
return handleResponse<T>(res);
}
export async function apiPost<T>(url: string, body: unknown): Promise<T> {
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
return handleResponse<T>(res);
}
export async function apiPut<T>(url: string, body: unknown): Promise<T> {
const res = await fetch(url, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
return handleResponse<T>(res);
}
export async function apiDelete<T>(url: string): Promise<T> {
const res = await fetch(url, { method: "DELETE" });
return handleResponse<T>(res);
}
export async function apiUpload<T>(url: string, file: File): Promise<T> {
const formData = new FormData();
formData.append("image", file);
const res = await fetch(url, {
method: "POST",
body: formData,
});
return handleResponse<T>(res);
}

View File

@@ -0,0 +1,9 @@
export function formatWeight(grams: number | null | undefined): string {
if (grams == null) return "--";
return `${Math.round(grams)}g`;
}
export function formatPrice(cents: number | null | undefined): string {
if (cents == null) return "--";
return `$${(cents / 100).toFixed(2)}`;
}

29
src/client/main.tsx Normal file
View File

@@ -0,0 +1,29 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { RouterProvider, createRouter } from "@tanstack/react-router";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { routeTree } from "./routeTree.gen";
const queryClient = new QueryClient();
const router = createRouter({
routeTree,
context: {},
});
declare module "@tanstack/react-router" {
interface Register {
router: typeof router;
}
}
const rootElement = document.getElementById("root");
if (!rootElement) throw new Error("Root element not found");
createRoot(rootElement).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</StrictMode>,
);

View File

@@ -0,0 +1,59 @@
/* eslint-disable */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// This file was automatically generated by TanStack Router.
// You should NOT make any changes in this file as it will be overwritten.
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root'
import { Route as IndexRouteImport } from './routes/index'
const IndexRoute = IndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/'
fileRoutesByTo: FileRoutesByTo
to: '/'
id: '__root__' | '/'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
}
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/': {
id: '/'
path: '/'
fullPath: '/'
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
}
}
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>()

View File

@@ -0,0 +1,88 @@
import { useState } from "react";
import { createRootRoute, Outlet } from "@tanstack/react-router";
import "../app.css";
import { TotalsBar } from "../components/TotalsBar";
import { SlideOutPanel } from "../components/SlideOutPanel";
import { ItemForm } from "../components/ItemForm";
import { ConfirmDialog } from "../components/ConfirmDialog";
import { OnboardingWizard } from "../components/OnboardingWizard";
import { useUIStore } from "../stores/uiStore";
import { useOnboardingComplete } from "../hooks/useSettings";
export const Route = createRootRoute({
component: RootLayout,
});
function RootLayout() {
const panelMode = useUIStore((s) => s.panelMode);
const editingItemId = useUIStore((s) => s.editingItemId);
const openAddPanel = useUIStore((s) => s.openAddPanel);
const closePanel = useUIStore((s) => s.closePanel);
const { data: onboardingComplete, isLoading: onboardingLoading } =
useOnboardingComplete();
const [wizardDismissed, setWizardDismissed] = useState(false);
const showWizard =
!onboardingLoading && onboardingComplete !== "true" && !wizardDismissed;
const isOpen = panelMode !== "closed";
// Show a minimal loading state while checking onboarding status
if (onboardingLoading) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="w-6 h-6 border-2 border-blue-600 border-t-transparent rounded-full animate-spin" />
</div>
);
}
return (
<div className="min-h-screen bg-gray-50">
<TotalsBar />
<Outlet />
{/* Slide-out Panel */}
<SlideOutPanel
isOpen={isOpen}
onClose={closePanel}
title={panelMode === "add" ? "Add Item" : "Edit Item"}
>
{panelMode === "add" && <ItemForm mode="add" />}
{panelMode === "edit" && (
<ItemForm mode="edit" itemId={editingItemId} />
)}
</SlideOutPanel>
{/* Confirm Delete Dialog */}
<ConfirmDialog />
{/* Floating Add Button */}
<button
type="button"
onClick={openAddPanel}
className="fixed bottom-6 right-6 z-20 w-14 h-14 bg-blue-600 hover:bg-blue-700 text-white rounded-full shadow-lg hover:shadow-xl transition-all flex items-center justify-center"
title="Add new item"
>
<svg
className="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 4v16m8-8H4"
/>
</svg>
</button>
{/* Onboarding Wizard */}
{showWizard && (
<OnboardingWizard onComplete={() => setWizardDismissed(true)} />
)}
</div>
);
}

138
src/client/routes/index.tsx Normal file
View File

@@ -0,0 +1,138 @@
import { createFileRoute } from "@tanstack/react-router";
import { useItems } from "../hooks/useItems";
import { useTotals } from "../hooks/useTotals";
import { CategoryHeader } from "../components/CategoryHeader";
import { ItemCard } from "../components/ItemCard";
import { useUIStore } from "../stores/uiStore";
export const Route = createFileRoute("/")({
component: CollectionPage,
});
function CollectionPage() {
const { data: items, isLoading: itemsLoading } = useItems();
const { data: totals } = useTotals();
const openAddPanel = useUIStore((s) => s.openAddPanel);
if (itemsLoading) {
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="animate-pulse space-y-6">
<div className="h-6 bg-gray-200 rounded w-48" />
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{[1, 2, 3].map((i) => (
<div key={i} className="h-40 bg-gray-200 rounded-xl" />
))}
</div>
</div>
</div>
);
}
if (!items || items.length === 0) {
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16 text-center">
<div className="max-w-md mx-auto">
<div className="text-5xl mb-4">🎒</div>
<h2 className="text-xl font-semibold text-gray-900 mb-2">
Your collection is empty
</h2>
<p className="text-sm text-gray-500 mb-6">
Start cataloging your gear by adding your first item. Track weight,
price, and organize by category.
</p>
<button
type="button"
onClick={openAddPanel}
className="inline-flex items-center gap-2 px-5 py-2.5 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-lg transition-colors"
>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 4v16m8-8H4"
/>
</svg>
Add your first item
</button>
</div>
</div>
);
}
// Group items by categoryId
const groupedItems = new Map<
number,
{ items: typeof items; categoryName: string; categoryEmoji: string }
>();
for (const item of items) {
const group = groupedItems.get(item.categoryId);
if (group) {
group.items.push(item);
} else {
groupedItems.set(item.categoryId, {
items: [item],
categoryName: item.categoryName,
categoryEmoji: item.categoryEmoji,
});
}
}
// Build category totals lookup
const categoryTotalsMap = new Map<
number,
{ totalWeight: number; totalCost: number; itemCount: number }
>();
if (totals?.categories) {
for (const ct of totals.categories) {
categoryTotalsMap.set(ct.categoryId, {
totalWeight: ct.totalWeight,
totalCost: ct.totalCost,
itemCount: ct.itemCount,
});
}
}
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
{Array.from(groupedItems.entries()).map(
([categoryId, { items: categoryItems, categoryName, categoryEmoji }]) => {
const catTotals = categoryTotalsMap.get(categoryId);
return (
<div key={categoryId} className="mb-8">
<CategoryHeader
categoryId={categoryId}
name={categoryName}
emoji={categoryEmoji}
totalWeight={catTotals?.totalWeight ?? 0}
totalCost={catTotals?.totalCost ?? 0}
itemCount={catTotals?.itemCount ?? categoryItems.length}
/>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{categoryItems.map((item) => (
<ItemCard
key={item.id}
id={item.id}
name={item.name}
weightGrams={item.weightGrams}
priceCents={item.priceCents}
categoryName={categoryName}
categoryEmoji={categoryEmoji}
imageFilename={item.imageFilename}
/>
))}
</div>
</div>
);
},
)}
</div>
);
}

View File

@@ -0,0 +1,25 @@
import { create } from "zustand";
interface UIState {
panelMode: "closed" | "add" | "edit";
editingItemId: number | null;
confirmDeleteItemId: number | null;
openAddPanel: () => void;
openEditPanel: (itemId: number) => void;
closePanel: () => void;
openConfirmDelete: (itemId: number) => void;
closeConfirmDelete: () => void;
}
export const useUIStore = create<UIState>((set) => ({
panelMode: "closed",
editingItemId: null,
confirmDeleteItemId: null,
openAddPanel: () => set({ panelMode: "add", editingItemId: null }),
openEditPanel: (itemId) => set({ panelMode: "edit", editingItemId: itemId }),
closePanel: () => set({ panelMode: "closed", editingItemId: null }),
openConfirmDelete: (itemId) => set({ confirmDeleteItemId: itemId }),
closeConfirmDelete: () => set({ confirmDeleteItemId: null }),
}));

9
src/db/index.ts Normal file
View File

@@ -0,0 +1,9 @@
import { Database } from "bun:sqlite";
import { drizzle } from "drizzle-orm/bun-sqlite";
import * as schema from "./schema.ts";
const sqlite = new Database("gearbox.db");
sqlite.run("PRAGMA journal_mode = WAL");
sqlite.run("PRAGMA foreign_keys = ON");
export const db = drizzle(sqlite, { schema });

34
src/db/schema.ts Normal file
View File

@@ -0,0 +1,34 @@
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("\u{1F4E6}"),
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()),
});
export const settings = sqliteTable("settings", {
key: text("key").primaryKey(),
value: text("value").notNull(),
});

14
src/db/seed.ts Normal file
View File

@@ -0,0 +1,14 @@
import { db } from "./index.ts";
import { categories } from "./schema.ts";
export function seedDefaults() {
const existing = db.select().from(categories).all();
if (existing.length === 0) {
db.insert(categories)
.values({
name: "Uncategorized",
emoji: "\u{1F4E6}",
})
.run();
}
}

37
src/server/index.ts Normal file
View File

@@ -0,0 +1,37 @@
import { Hono } from "hono";
import { serveStatic } from "hono/bun";
import { seedDefaults } from "../db/seed.ts";
import { itemRoutes } from "./routes/items.ts";
import { categoryRoutes } from "./routes/categories.ts";
import { totalRoutes } from "./routes/totals.ts";
import { imageRoutes } from "./routes/images.ts";
import { settingsRoutes } from "./routes/settings.ts";
// Seed default data on startup
seedDefaults();
const app = new Hono();
// Health check
app.get("/api/health", (c) => {
return c.json({ status: "ok" });
});
// API routes
app.route("/api/items", itemRoutes);
app.route("/api/categories", categoryRoutes);
app.route("/api/totals", totalRoutes);
app.route("/api/images", imageRoutes);
app.route("/api/settings", settingsRoutes);
// 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 };
export { app };

View File

@@ -0,0 +1,59 @@
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import {
createCategorySchema,
updateCategorySchema,
} from "../../shared/schemas.ts";
import {
getAllCategories,
createCategory,
updateCategory,
deleteCategory,
} from "../services/category.service.ts";
type Env = { Variables: { db?: any } };
const app = new Hono<Env>();
app.get("/", (c) => {
const db = c.get("db");
const cats = getAllCategories(db);
return c.json(cats);
});
app.post("/", zValidator("json", createCategorySchema), (c) => {
const db = c.get("db");
const data = c.req.valid("json");
const cat = createCategory(db, data);
return c.json(cat, 201);
});
app.put(
"/:id",
zValidator("json", updateCategorySchema.omit({ id: true })),
(c) => {
const db = c.get("db");
const id = Number(c.req.param("id"));
const data = c.req.valid("json");
const cat = updateCategory(db, id, data);
if (!cat) return c.json({ error: "Category not found" }, 404);
return c.json(cat);
},
);
app.delete("/:id", (c) => {
const db = c.get("db");
const id = Number(c.req.param("id"));
const result = deleteCategory(db, id);
if (!result.success) {
if (result.error === "Cannot delete the Uncategorized category") {
return c.json({ error: result.error }, 400);
}
return c.json({ error: result.error }, 404);
}
return c.json({ success: true });
});
export { app as categoryRoutes };

View File

@@ -0,0 +1,46 @@
import { Hono } from "hono";
import { randomUUID } from "node:crypto";
import { join } from "node:path";
import { mkdir } from "node:fs/promises";
const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/webp"];
const MAX_SIZE = 5 * 1024 * 1024; // 5MB
const app = new Hono();
app.post("/", async (c) => {
const body = await c.req.parseBody();
const file = body["image"];
if (!file || typeof file === "string") {
return c.json({ error: "No image file provided" }, 400);
}
// Validate file type
if (!ALLOWED_TYPES.includes(file.type)) {
return c.json(
{ error: "Invalid file type. Accepted: jpeg, png, webp" },
400,
);
}
// Validate file size
if (file.size > MAX_SIZE) {
return c.json({ error: "File too large. Maximum size is 5MB" }, 400);
}
// Generate unique filename
const ext = file.type.split("/")[1] === "jpeg" ? "jpg" : file.type.split("/")[1];
const filename = `${Date.now()}-${randomUUID()}.${ext}`;
// Ensure uploads directory exists
await mkdir("uploads", { recursive: true });
// Write file
const buffer = await file.arrayBuffer();
await Bun.write(join("uploads", filename), buffer);
return c.json({ filename }, 201);
});
export { app as imageRoutes };

View File

@@ -0,0 +1,66 @@
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { createItemSchema, updateItemSchema } from "../../shared/schemas.ts";
import {
getAllItems,
getItemById,
createItem,
updateItem,
deleteItem,
} from "../services/item.service.ts";
import { unlink } from "node:fs/promises";
import { join } from "node:path";
type Env = { Variables: { db?: any } };
const app = new Hono<Env>();
app.get("/", (c) => {
const db = c.get("db");
const items = getAllItems(db);
return c.json(items);
});
app.get("/:id", (c) => {
const db = c.get("db");
const id = Number(c.req.param("id"));
const item = getItemById(db, id);
if (!item) return c.json({ error: "Item not found" }, 404);
return c.json(item);
});
app.post("/", zValidator("json", createItemSchema), (c) => {
const db = c.get("db");
const data = c.req.valid("json");
const item = createItem(db, data);
return c.json(item, 201);
});
app.put("/:id", zValidator("json", updateItemSchema.omit({ id: true })), (c) => {
const db = c.get("db");
const id = Number(c.req.param("id"));
const data = c.req.valid("json");
const item = updateItem(db, id, data);
if (!item) return c.json({ error: "Item not found" }, 404);
return c.json(item);
});
app.delete("/:id", async (c) => {
const db = c.get("db");
const id = Number(c.req.param("id"));
const deleted = deleteItem(db, id);
if (!deleted) return c.json({ error: "Item not found" }, 404);
// Clean up image file if exists
if (deleted.imageFilename) {
try {
await unlink(join("uploads", deleted.imageFilename));
} catch {
// File missing is not an error worth failing the delete over
}
}
return c.json({ success: true });
});
export { app as itemRoutes };

View File

@@ -0,0 +1,37 @@
import { Hono } from "hono";
import { eq } from "drizzle-orm";
import { db as prodDb } from "../../db/index.ts";
import { settings } from "../../db/schema.ts";
type Env = { Variables: { db?: any } };
const app = new Hono<Env>();
app.get("/:key", (c) => {
const database = c.get("db") ?? prodDb;
const key = c.req.param("key");
const row = database.select().from(settings).where(eq(settings.key, key)).get();
if (!row) return c.json({ error: "Setting not found" }, 404);
return c.json(row);
});
app.put("/:key", async (c) => {
const database = c.get("db") ?? prodDb;
const key = c.req.param("key");
const body = await c.req.json<{ value: string }>();
if (!body.value && body.value !== "") {
return c.json({ error: "value is required" }, 400);
}
database
.insert(settings)
.values({ key, value: body.value })
.onConflictDoUpdate({ target: settings.key, set: { value: body.value } })
.run();
const row = database.select().from(settings).where(eq(settings.key, key)).get();
return c.json(row);
});
export { app as settingsRoutes };

View File

@@ -0,0 +1,18 @@
import { Hono } from "hono";
import {
getCategoryTotals,
getGlobalTotals,
} from "../services/totals.service.ts";
type Env = { Variables: { db?: any } };
const app = new Hono<Env>();
app.get("/", (c) => {
const db = c.get("db");
const categoryTotals = getCategoryTotals(db);
const globalTotals = getGlobalTotals(db);
return c.json({ categories: categoryTotals, global: globalTotals });
});
export { app as totalRoutes };

View File

@@ -0,0 +1,77 @@
import { eq, asc } from "drizzle-orm";
import { categories, items } from "../../db/schema.ts";
import { db as prodDb } from "../../db/index.ts";
type Db = typeof prodDb;
export function getAllCategories(db: Db = prodDb) {
return db.select().from(categories).orderBy(asc(categories.name)).all();
}
export function createCategory(
db: Db = prodDb,
data: { name: string; emoji?: string },
) {
return db
.insert(categories)
.values({
name: data.name,
...(data.emoji ? { emoji: data.emoji } : {}),
})
.returning()
.get();
}
export function updateCategory(
db: Db = prodDb,
id: number,
data: { name?: string; emoji?: string },
) {
const existing = db
.select({ id: categories.id })
.from(categories)
.where(eq(categories.id, id))
.get();
if (!existing) return null;
return db
.update(categories)
.set(data)
.where(eq(categories.id, id))
.returning()
.get();
}
export function deleteCategory(
db: Db = prodDb,
id: number,
): { success: boolean; error?: string } {
// Guard: cannot delete Uncategorized (id=1)
if (id === 1) {
return { success: false, error: "Cannot delete the Uncategorized category" };
}
// Check if category exists
const existing = db
.select({ id: categories.id })
.from(categories)
.where(eq(categories.id, id))
.get();
if (!existing) {
return { success: false, error: "Category not found" };
}
// Reassign items to Uncategorized (id=1), then delete atomically
db.transaction(() => {
db.update(items)
.set({ categoryId: 1 })
.where(eq(items.categoryId, id))
.run();
db.delete(categories).where(eq(categories.id, id)).run();
});
return { success: true };
}

View File

@@ -0,0 +1,112 @@
import { eq, sql } from "drizzle-orm";
import { items, categories } from "../../db/schema.ts";
import { db as prodDb } from "../../db/index.ts";
import type { CreateItem } from "../../shared/types.ts";
type Db = typeof prodDb;
export function getAllItems(db: Db = prodDb) {
return 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(items)
.innerJoin(categories, eq(items.categoryId, categories.id))
.all();
}
export function getItemById(db: Db = prodDb, id: number) {
return (
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,
})
.from(items)
.where(eq(items.id, id))
.get() ?? null
);
}
export function createItem(
db: Db = prodDb,
data: Partial<CreateItem> & { name: string; categoryId: number; imageFilename?: string },
) {
return db
.insert(items)
.values({
name: data.name,
weightGrams: data.weightGrams ?? null,
priceCents: data.priceCents ?? null,
categoryId: data.categoryId,
notes: data.notes ?? null,
productUrl: data.productUrl ?? null,
imageFilename: data.imageFilename ?? null,
})
.returning()
.get();
}
export function updateItem(
db: Db = prodDb,
id: number,
data: Partial<{
name: string;
weightGrams: number;
priceCents: number;
categoryId: number;
notes: string;
productUrl: string;
imageFilename: string;
}>,
) {
// Check if item exists first
const existing = db
.select({ id: items.id })
.from(items)
.where(eq(items.id, id))
.get();
if (!existing) return null;
return db
.update(items)
.set({ ...data, updatedAt: new Date() })
.where(eq(items.id, id))
.returning()
.get();
}
export function deleteItem(db: Db = prodDb, id: number) {
// Get item first (for image cleanup info)
const item = db
.select()
.from(items)
.where(eq(items.id, id))
.get();
if (!item) return null;
db.delete(items).where(eq(items.id, id)).run();
return item;
}

View File

@@ -0,0 +1,32 @@
import { eq, sql } from "drizzle-orm";
import { items, categories } from "../../db/schema.ts";
import { db as prodDb } from "../../db/index.ts";
type Db = typeof prodDb;
export function getCategoryTotals(db: Db = prodDb) {
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(db: Db = prodDb) {
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();
}

25
src/shared/schemas.ts Normal file
View File

@@ -0,0 +1,25 @@
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("\u{1F4E6}"),
});
export const updateCategorySchema = z.object({
id: z.number().int().positive(),
name: z.string().min(1).optional(),
emoji: z.string().min(1).max(4).optional(),
});

18
src/shared/types.ts Normal file
View File

@@ -0,0 +1,18 @@
import type { z } from "zod";
import type {
createItemSchema,
updateItemSchema,
createCategorySchema,
updateCategorySchema,
} from "./schemas.ts";
import type { items, categories } from "../db/schema.ts";
// Types inferred from Zod schemas
export type CreateItem = z.infer<typeof createItemSchema>;
export type UpdateItem = z.infer<typeof updateItemSchema>;
export type CreateCategory = z.infer<typeof createCategorySchema>;
export type UpdateCategory = z.infer<typeof updateCategorySchema>;
// Types inferred from Drizzle schema
export type Item = typeof items.$inferSelect;
export type Category = typeof categories.$inferSelect;

49
tests/helpers/db.ts Normal file
View File

@@ -0,0 +1,49 @@
import { Database } from "bun:sqlite";
import { drizzle } from "drizzle-orm/bun-sqlite";
import * as schema from "../../src/db/schema.ts";
export function createTestDb() {
const sqlite = new Database(":memory:");
sqlite.run("PRAGMA foreign_keys = ON");
// Create tables matching the Drizzle schema
sqlite.run(`
CREATE TABLE categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
emoji TEXT NOT NULL DEFAULT '📦',
created_at INTEGER NOT NULL DEFAULT (unixepoch())
)
`);
sqlite.run(`
CREATE TABLE items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
weight_grams REAL,
price_cents INTEGER,
category_id INTEGER NOT NULL REFERENCES categories(id),
notes TEXT,
product_url TEXT,
image_filename TEXT,
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
)
`);
sqlite.run(`
CREATE TABLE settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
)
`);
const db = drizzle(sqlite, { schema });
// Seed default Uncategorized category
db.insert(schema.categories)
.values({ name: "Uncategorized", emoji: "\u{1F4E6}" })
.run();
return db;
}

View File

@@ -0,0 +1,91 @@
import { describe, it, expect, beforeEach } from "bun:test";
import { Hono } from "hono";
import { createTestDb } from "../helpers/db.ts";
import { categoryRoutes } from "../../src/server/routes/categories.ts";
import { itemRoutes } from "../../src/server/routes/items.ts";
function createTestApp() {
const db = createTestDb();
const app = new Hono();
// Inject test DB into context for all routes
app.use("*", async (c, next) => {
c.set("db", db);
await next();
});
app.route("/api/categories", categoryRoutes);
app.route("/api/items", itemRoutes);
return { app, db };
}
describe("Category Routes", () => {
let app: Hono;
beforeEach(() => {
const testApp = createTestApp();
app = testApp.app;
});
it("POST /api/categories creates category", async () => {
const res = await app.request("/api/categories", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "Shelter", emoji: "\u{26FA}" }),
});
expect(res.status).toBe(201);
const body = await res.json();
expect(body.name).toBe("Shelter");
expect(body.emoji).toBe("\u{26FA}");
expect(body.id).toBeGreaterThan(0);
});
it("GET /api/categories returns all categories", async () => {
const res = await app.request("/api/categories");
expect(res.status).toBe(200);
const body = await res.json();
expect(Array.isArray(body)).toBe(true);
// At minimum, Uncategorized is seeded
expect(body.length).toBeGreaterThanOrEqual(1);
});
it("DELETE /api/categories/:id reassigns items", async () => {
// Create category
const catRes = await app.request("/api/categories", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "Shelter", emoji: "\u{26FA}" }),
});
const cat = await catRes.json();
// Create item in that category
await app.request("/api/items", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "Tent", categoryId: cat.id }),
});
// Delete the category
const delRes = await app.request(`/api/categories/${cat.id}`, {
method: "DELETE",
});
expect(delRes.status).toBe(200);
// Verify items are now in Uncategorized
const itemsRes = await app.request("/api/items");
const items = await itemsRes.json();
const tent = items.find((i: any) => i.name === "Tent");
expect(tent.categoryId).toBe(1);
});
it("DELETE /api/categories/1 returns 400 (cannot delete Uncategorized)", async () => {
const res = await app.request("/api/categories/1", {
method: "DELETE",
});
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toContain("Uncategorized");
});
});

121
tests/routes/items.test.ts Normal file
View File

@@ -0,0 +1,121 @@
import { describe, it, expect, beforeEach } from "bun:test";
import { Hono } from "hono";
import { createTestDb } from "../helpers/db.ts";
import { itemRoutes } from "../../src/server/routes/items.ts";
import { categoryRoutes } from "../../src/server/routes/categories.ts";
function createTestApp() {
const db = createTestDb();
const app = new Hono();
// Inject test DB into context for all routes
app.use("*", async (c, next) => {
c.set("db", db);
await next();
});
app.route("/api/items", itemRoutes);
app.route("/api/categories", categoryRoutes);
return { app, db };
}
describe("Item Routes", () => {
let app: Hono;
beforeEach(() => {
const testApp = createTestApp();
app = testApp.app;
});
it("POST /api/items with valid data returns 201", async () => {
const res = await app.request("/api/items", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: "Tent",
weightGrams: 1200,
priceCents: 35000,
categoryId: 1,
}),
});
expect(res.status).toBe(201);
const body = await res.json();
expect(body.name).toBe("Tent");
expect(body.id).toBeGreaterThan(0);
});
it("POST /api/items with missing name returns 400", async () => {
const res = await app.request("/api/items", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ categoryId: 1 }),
});
expect(res.status).toBe(400);
});
it("GET /api/items returns array", async () => {
// Create an item first
await app.request("/api/items", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "Tent", categoryId: 1 }),
});
const res = await app.request("/api/items");
expect(res.status).toBe(200);
const body = await res.json();
expect(Array.isArray(body)).toBe(true);
expect(body.length).toBeGreaterThanOrEqual(1);
});
it("PUT /api/items/:id updates fields", async () => {
// Create first
const createRes = await app.request("/api/items", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: "Tent",
weightGrams: 1200,
categoryId: 1,
}),
});
const created = await createRes.json();
// Update
const res = await app.request(`/api/items/${created.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "Big Agnes Tent", weightGrams: 1100 }),
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.name).toBe("Big Agnes Tent");
expect(body.weightGrams).toBe(1100);
});
it("DELETE /api/items/:id returns success", async () => {
// Create first
const createRes = await app.request("/api/items", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "Tent", categoryId: 1 }),
});
const created = await createRes.json();
const res = await app.request(`/api/items/${created.id}`, {
method: "DELETE",
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.success).toBe(true);
});
it("GET /api/items/:id returns 404 for non-existent item", async () => {
const res = await app.request("/api/items/9999");
expect(res.status).toBe(404);
});
});

View File

@@ -0,0 +1,98 @@
import { describe, it, expect, beforeEach } from "bun:test";
import { createTestDb } from "../helpers/db.ts";
import {
getAllCategories,
createCategory,
updateCategory,
deleteCategory,
} from "../../src/server/services/category.service.ts";
import { createItem } from "../../src/server/services/item.service.ts";
import { items } from "../../src/db/schema.ts";
import { eq } from "drizzle-orm";
describe("Category Service", () => {
let db: ReturnType<typeof createTestDb>;
beforeEach(() => {
db = createTestDb();
});
describe("createCategory", () => {
it("creates with name and emoji", () => {
const cat = createCategory(db, { name: "Shelter", emoji: "\u{26FA}" });
expect(cat).toBeDefined();
expect(cat!.id).toBeGreaterThan(0);
expect(cat!.name).toBe("Shelter");
expect(cat!.emoji).toBe("\u{26FA}");
});
it("uses default emoji if not provided", () => {
const cat = createCategory(db, { name: "Cooking" });
expect(cat).toBeDefined();
expect(cat!.emoji).toBe("\u{1F4E6}");
});
});
describe("getAllCategories", () => {
it("returns all categories", () => {
createCategory(db, { name: "Shelter", emoji: "\u{26FA}" });
createCategory(db, { name: "Cooking", emoji: "\u{1F373}" });
const all = getAllCategories(db);
// Includes seeded Uncategorized + 2 new
expect(all.length).toBeGreaterThanOrEqual(3);
});
});
describe("updateCategory", () => {
it("renames category", () => {
const cat = createCategory(db, { name: "Shelter", emoji: "\u{26FA}" });
const updated = updateCategory(db, cat!.id, { name: "Sleep System" });
expect(updated).toBeDefined();
expect(updated!.name).toBe("Sleep System");
expect(updated!.emoji).toBe("\u{26FA}");
});
it("changes emoji", () => {
const cat = createCategory(db, { name: "Shelter", emoji: "\u{26FA}" });
const updated = updateCategory(db, cat!.id, { emoji: "\u{1F3E0}" });
expect(updated).toBeDefined();
expect(updated!.emoji).toBe("\u{1F3E0}");
});
it("returns null for non-existent id", () => {
const result = updateCategory(db, 9999, { name: "Ghost" });
expect(result).toBeNull();
});
});
describe("deleteCategory", () => {
it("reassigns items to Uncategorized (id=1) then deletes", () => {
const shelter = createCategory(db, { name: "Shelter", emoji: "\u{26FA}" });
createItem(db, { name: "Tent", categoryId: shelter!.id });
createItem(db, { name: "Tarp", categoryId: shelter!.id });
const result = deleteCategory(db, shelter!.id);
expect(result.success).toBe(true);
// Items should now be in Uncategorized (id=1)
const reassigned = db
.select()
.from(items)
.where(eq(items.categoryId, 1))
.all();
expect(reassigned).toHaveLength(2);
expect(reassigned.map((i) => i.name).sort()).toEqual(["Tarp", "Tent"]);
});
it("cannot delete Uncategorized (id=1)", () => {
const result = deleteCategory(db, 1);
expect(result.success).toBe(false);
expect(result.error).toBeDefined();
});
});
});

View File

@@ -0,0 +1,127 @@
import { describe, it, expect, beforeEach } from "bun:test";
import { createTestDb } from "../helpers/db.ts";
import {
getAllItems,
getItemById,
createItem,
updateItem,
deleteItem,
} from "../../src/server/services/item.service.ts";
describe("Item Service", () => {
let db: ReturnType<typeof createTestDb>;
beforeEach(() => {
db = createTestDb();
});
describe("createItem", () => {
it("creates item with all fields, returns item with id and timestamps", () => {
const item = createItem(
db,
{
name: "Tent",
weightGrams: 1200,
priceCents: 35000,
categoryId: 1,
notes: "Ultralight 2-person",
productUrl: "https://example.com/tent",
},
);
expect(item).toBeDefined();
expect(item!.id).toBeGreaterThan(0);
expect(item!.name).toBe("Tent");
expect(item!.weightGrams).toBe(1200);
expect(item!.priceCents).toBe(35000);
expect(item!.categoryId).toBe(1);
expect(item!.notes).toBe("Ultralight 2-person");
expect(item!.productUrl).toBe("https://example.com/tent");
expect(item!.createdAt).toBeDefined();
expect(item!.updatedAt).toBeDefined();
});
it("only name and categoryId are required, other fields optional", () => {
const item = createItem(db, { name: "Spork", categoryId: 1 });
expect(item).toBeDefined();
expect(item!.name).toBe("Spork");
expect(item!.weightGrams).toBeNull();
expect(item!.priceCents).toBeNull();
expect(item!.notes).toBeNull();
expect(item!.productUrl).toBeNull();
});
});
describe("getAllItems", () => {
it("returns all items with category info joined", () => {
createItem(db, { name: "Tent", categoryId: 1 });
createItem(db, { name: "Sleeping Bag", categoryId: 1 });
const all = getAllItems(db);
expect(all).toHaveLength(2);
expect(all[0].categoryName).toBe("Uncategorized");
expect(all[0].categoryEmoji).toBeDefined();
});
});
describe("getItemById", () => {
it("returns single item or null", () => {
const created = createItem(db, { name: "Tent", categoryId: 1 });
const found = getItemById(db, created!.id);
expect(found).toBeDefined();
expect(found!.name).toBe("Tent");
const notFound = getItemById(db, 9999);
expect(notFound).toBeNull();
});
});
describe("updateItem", () => {
it("updates specified fields, sets updatedAt", () => {
const created = createItem(db, {
name: "Tent",
weightGrams: 1200,
categoryId: 1,
});
const updated = updateItem(db, created!.id, {
name: "Big Agnes Tent",
weightGrams: 1100,
});
expect(updated).toBeDefined();
expect(updated!.name).toBe("Big Agnes Tent");
expect(updated!.weightGrams).toBe(1100);
});
it("returns null for non-existent id", () => {
const result = updateItem(db, 9999, { name: "Ghost" });
expect(result).toBeNull();
});
});
describe("deleteItem", () => {
it("removes item from DB, returns deleted item", () => {
const created = createItem(db, {
name: "Tent",
categoryId: 1,
imageFilename: "tent.jpg",
});
const deleted = deleteItem(db, created!.id);
expect(deleted).toBeDefined();
expect(deleted!.name).toBe("Tent");
expect(deleted!.imageFilename).toBe("tent.jpg");
// Verify it's gone
const found = getItemById(db, created!.id);
expect(found).toBeNull();
});
it("returns null for non-existent id", () => {
const result = deleteItem(db, 9999);
expect(result).toBeNull();
});
});
});

View File

@@ -0,0 +1,79 @@
import { describe, it, expect, beforeEach } from "bun:test";
import { createTestDb } from "../helpers/db.ts";
import { createItem } from "../../src/server/services/item.service.ts";
import { createCategory } from "../../src/server/services/category.service.ts";
import {
getCategoryTotals,
getGlobalTotals,
} from "../../src/server/services/totals.service.ts";
describe("Totals Service", () => {
let db: ReturnType<typeof createTestDb>;
beforeEach(() => {
db = createTestDb();
});
describe("getCategoryTotals", () => {
it("returns weight sum, cost sum, item count per category", () => {
const shelter = createCategory(db, { name: "Shelter", emoji: "\u{26FA}" });
createItem(db, {
name: "Tent",
weightGrams: 1200,
priceCents: 35000,
categoryId: shelter!.id,
});
createItem(db, {
name: "Tarp",
weightGrams: 300,
priceCents: 8000,
categoryId: shelter!.id,
});
const totals = getCategoryTotals(db);
expect(totals).toHaveLength(1); // Only Shelter has items
expect(totals[0].categoryName).toBe("Shelter");
expect(totals[0].totalWeight).toBe(1500);
expect(totals[0].totalCost).toBe(43000);
expect(totals[0].itemCount).toBe(2);
});
it("excludes empty categories (no items)", () => {
createCategory(db, { name: "Shelter", emoji: "\u{26FA}" });
// No items added
const totals = getCategoryTotals(db);
expect(totals).toHaveLength(0);
});
});
describe("getGlobalTotals", () => {
it("returns overall weight, cost, count", () => {
createItem(db, {
name: "Tent",
weightGrams: 1200,
priceCents: 35000,
categoryId: 1,
});
createItem(db, {
name: "Spork",
weightGrams: 20,
priceCents: 500,
categoryId: 1,
});
const totals = getGlobalTotals(db);
expect(totals).toBeDefined();
expect(totals!.totalWeight).toBe(1220);
expect(totals!.totalCost).toBe(35500);
expect(totals!.itemCount).toBe(2);
});
it("returns zeros when no items exist", () => {
const totals = getGlobalTotals(db);
expect(totals).toBeDefined();
expect(totals!.totalWeight).toBe(0);
expect(totals!.totalCost).toBe(0);
expect(totals!.itemCount).toBe(0);
});
});
});

31
tsconfig.json Normal file
View File

@@ -0,0 +1,31 @@
{
"compilerOptions": {
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"target": "ESNext",
"module": "ESNext",
"moduleDetection": "force",
"jsx": "react-jsx",
"allowJs": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false,
"paths": {
"@/*": ["./src/*"]
},
"types": ["bun-types"]
},
"include": ["src", "tests"]
}

0
uploads/.gitkeep Normal file
View File

26
vite.config.ts Normal file
View File

@@ -0,0 +1,26 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
import { TanStackRouterVite } from "@tanstack/router-plugin/vite";
export default defineConfig({
plugins: [
TanStackRouterVite({
target: "react",
autoCodeSplitting: true,
routesDirectory: "./src/client/routes",
generatedRouteTree: "./src/client/routeTree.gen.ts",
}),
react(),
tailwindcss(),
],
server: {
proxy: {
"/api": "http://localhost:3000",
"/uploads": "http://localhost:3000",
},
},
build: {
outDir: "dist/client",
},
});