9 Commits

Author SHA1 Message Date
94ebd84cc7 refactor: move setups list into collection page as third tab
All checks were successful
CI / ci (push) Successful in 13s
Setups now lives alongside My Gear and Planning under /collection?tab=setups
instead of its own /setups route. Dashboard card updated to link to the new
tab. Setup detail pages (/setups/:id) remain unchanged.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 00:07:48 +01:00
5938a686c7 feat: add package icon as favicon and in top bar title
All checks were successful
CI / ci (push) Successful in 12s
Add Lucide package icon as SVG favicon (white stroke) and display it
next to the GearBox title in the TotalsBar.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 22:57:43 +01:00
9bcdcc7168 style: replace blue accent with gray and mute card badge colors
Switch all interactive UI elements (buttons, focus rings, active tabs,
FAB, links, spinners) from blue to gray to match icon colors for a
more cohesive look. Mute card badge text colors to pastels (blue-400,
green-500, purple-500) to keep the focus on card content.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 22:42:38 +01:00
628907bb20 docs: add user-facing README and update compose for production
All checks were successful
CI / ci (push) Successful in 20s
Add README with Docker setup instructions for self-hosting. Update
docker-compose.yml to use the pre-built registry image instead of
local build, and add a healthcheck against /api/health.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 21:53:29 +01:00
891bb248c8 fix: use bun-sqlite migrator instead of drizzle-kit push in Docker
All checks were successful
CI / ci (push) Successful in 21s
drizzle-kit push depends on better-sqlite3 which isn't supported in
Bun, causing migrations to fail and the server to crash-loop in prod.
Replace with drizzle-orm/bun-sqlite/migrator that applies the existing
SQL migration files using the native bun:sqlite driver.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 21:30:39 +01:00
81f89fd14e fix: install docker-cli on dind runner for image build
All checks were successful
CI / ci (push) Successful in 12s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 19:53:41 +01:00
b496462df5 chore: auto-fix Biome formatting and configure lint rules
All checks were successful
CI / ci (push) Successful in 15s
Run biome check --write --unsafe to fix tabs, import ordering, and
non-null assertions across entire codebase. Disable a11y rules not
applicable to this single-user app. Exclude auto-generated routeTree.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 19:51:34 +01:00
4d0452b7b3 fix: handle better-sqlite3 native build in Docker and skip in CI
Some checks failed
CI / ci (push) Failing after 8s
Install python3/make/g++ in Dockerfile deps stage for drizzle-kit's
better-sqlite3 dependency. Use --ignore-scripts in CI workflows since
lint, test, and build don't need the native module.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 19:41:55 +01:00
8ec96b9a6c fix: use correct branch name "Develop" in CI workflow triggers
Some checks failed
CI / ci (push) Failing after 29s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 19:39:54 +01:00
80 changed files with 5023 additions and 4857 deletions

View File

@@ -2,9 +2,9 @@ name: CI
on: on:
push: push:
branches: [develop] branches: [Develop]
pull_request: pull_request:
branches: [develop] branches: [Develop]
jobs: jobs:
ci: ci:
@@ -16,7 +16,7 @@ jobs:
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Install dependencies - name: Install dependencies
run: bun install --frozen-lockfile run: bun install --frozen-lockfile --ignore-scripts
- name: Lint - name: Lint
run: bun run lint run: bun run lint

View File

@@ -23,7 +23,7 @@ jobs:
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Install dependencies - name: Install dependencies
run: bun install --frozen-lockfile run: bun install --frozen-lockfile --ignore-scripts
- name: Lint - name: Lint
run: bun run lint run: bun run lint
@@ -40,7 +40,7 @@ jobs:
steps: steps:
- name: Clone repository - name: Clone repository
run: | run: |
apk add --no-cache git curl jq apk add --no-cache git curl jq docker-cli
git clone https://${{ secrets.GITEA_TOKEN }}@gitea.jeanlucmakiola.de/${{ gitea.repository }}.git repo git clone https://${{ secrets.GITEA_TOKEN }}@gitea.jeanlucmakiola.de/${{ gitea.repository }}.git repo
cd repo cd repo
git checkout ${{ gitea.ref_name }} git checkout ${{ gitea.ref_name }}

View File

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

View File

@@ -1,5 +1,6 @@
FROM oven/bun:1 AS deps FROM oven/bun:1 AS deps
WORKDIR /app WORKDIR /app
RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
COPY package.json bun.lock ./ COPY package.json bun.lock ./
RUN bun install --frozen-lockfile RUN bun install --frozen-lockfile

View File

@@ -1,2 +1,83 @@
# GearBox # GearBox
A single-user web app for managing gear collections (bikepacking, sim racing, etc.), tracking weight and price, and planning purchases through research threads.
## Features
- Organize gear into categories with custom icons
- Track weight and price for every item
- Create setups (packing lists) from your collection with automatic weight/cost totals
- Research threads for comparing candidates before buying
- Image uploads for items and candidates
## Quick Start
### Docker Compose (recommended)
Create a `docker-compose.yml`:
```yaml
services:
gearbox:
image: gitea.jeanlucmakiola.de/makiolaj/gearbox:latest
container_name: gearbox
ports:
- "3000:3000"
environment:
- NODE_ENV=production
- DATABASE_PATH=./data/gearbox.db
volumes:
- gearbox-data:/app/data
- gearbox-uploads:/app/uploads
healthcheck:
test: ["CMD", "bun", "-e", "fetch('http://localhost:3000/api/health').then(r=>r.ok?process.exit(0):process.exit(1)).catch(()=>process.exit(1))"]
interval: 30s
timeout: 5s
start_period: 10s
retries: 3
restart: unless-stopped
volumes:
gearbox-data:
gearbox-uploads:
```
Then run:
```bash
docker compose up -d
```
GearBox will be available at `http://localhost:3000`.
### Docker
```bash
docker run -d \
--name gearbox \
-p 3000:3000 \
-e NODE_ENV=production \
-e DATABASE_PATH=./data/gearbox.db \
-v gearbox-data:/app/data \
-v gearbox-uploads:/app/uploads \
--restart unless-stopped \
gitea.jeanlucmakiola.de/makiolaj/gearbox:latest
```
## Data
All data is stored in two Docker volumes:
- **gearbox-data** -- SQLite database
- **gearbox-uploads** -- uploaded images
Back up these volumes to preserve your data.
## Updating
```bash
docker compose pull
docker compose up -d
```
Database migrations run automatically on startup.

View File

@@ -6,7 +6,8 @@
"useIgnoreFile": true "useIgnoreFile": true
}, },
"files": { "files": {
"ignoreUnknown": false "ignoreUnknown": false,
"includes": ["**", "!src/client/routeTree.gen.ts"]
}, },
"formatter": { "formatter": {
"enabled": true, "enabled": true,
@@ -15,7 +16,22 @@
"linter": { "linter": {
"enabled": true, "enabled": true,
"rules": { "rules": {
"recommended": true "recommended": true,
"a11y": {
"noSvgWithoutTitle": "off",
"noStaticElementInteractions": "off",
"useKeyWithClickEvents": "off",
"useSemanticElements": "off",
"noAutofocus": "off",
"useAriaPropsSupportedByRole": "off",
"noLabelWithoutControl": "off"
},
"suspicious": {
"noExplicitAny": "off"
},
"style": {
"noNonNullAssertion": "off"
}
} }
}, },
"javascript": { "javascript": {

View File

@@ -1,6 +1,6 @@
services: services:
gearbox: gearbox:
build: . image: gitea.jeanlucmakiola.de/makiolaj/gearbox:latest
container_name: gearbox container_name: gearbox
ports: ports:
- "3000:3000" - "3000:3000"
@@ -10,6 +10,12 @@ services:
volumes: volumes:
- gearbox-data:/app/data - gearbox-data:/app/data
- gearbox-uploads:/app/uploads - gearbox-uploads:/app/uploads
healthcheck:
test: ["CMD", "bun", "-e", "fetch('http://localhost:3000/api/health').then(r=>r.ok?process.exit(0):process.exit(1)).catch(()=>process.exit(1))"]
interval: 30s
timeout: 5s
start_period: 10s
retries: 3
restart: unless-stopped restart: unless-stopped
volumes: volumes:

View File

@@ -1,467 +1,441 @@
{ {
"version": "6", "version": "6",
"dialect": "sqlite", "dialect": "sqlite",
"id": "78e5f5c8-f8f0-43f4-93f8-5ef68154ed17", "id": "78e5f5c8-f8f0-43f4-93f8-5ef68154ed17",
"prevId": "00000000-0000-0000-0000-000000000000", "prevId": "00000000-0000-0000-0000-000000000000",
"tables": { "tables": {
"categories": { "categories": {
"name": "categories", "name": "categories",
"columns": { "columns": {
"id": { "id": {
"name": "id", "name": "id",
"type": "integer", "type": "integer",
"primaryKey": true, "primaryKey": true,
"notNull": true, "notNull": true,
"autoincrement": true "autoincrement": true
}, },
"name": { "name": {
"name": "name", "name": "name",
"type": "text", "type": "text",
"primaryKey": false, "primaryKey": false,
"notNull": true, "notNull": true,
"autoincrement": false "autoincrement": false
}, },
"emoji": { "emoji": {
"name": "emoji", "name": "emoji",
"type": "text", "type": "text",
"primaryKey": false, "primaryKey": false,
"notNull": true, "notNull": true,
"autoincrement": false, "autoincrement": false,
"default": "'📦'" "default": "'📦'"
}, },
"created_at": { "created_at": {
"name": "created_at", "name": "created_at",
"type": "integer", "type": "integer",
"primaryKey": false, "primaryKey": false,
"notNull": true, "notNull": true,
"autoincrement": false "autoincrement": false
} }
}, },
"indexes": { "indexes": {
"categories_name_unique": { "categories_name_unique": {
"name": "categories_name_unique", "name": "categories_name_unique",
"columns": [ "columns": ["name"],
"name" "isUnique": true
], }
"isUnique": true },
} "foreignKeys": {},
}, "compositePrimaryKeys": {},
"foreignKeys": {}, "uniqueConstraints": {},
"compositePrimaryKeys": {}, "checkConstraints": {}
"uniqueConstraints": {}, },
"checkConstraints": {} "items": {
}, "name": "items",
"items": { "columns": {
"name": "items", "id": {
"columns": { "name": "id",
"id": { "type": "integer",
"name": "id", "primaryKey": true,
"type": "integer", "notNull": true,
"primaryKey": true, "autoincrement": true
"notNull": true, },
"autoincrement": true "name": {
}, "name": "name",
"name": { "type": "text",
"name": "name", "primaryKey": false,
"type": "text", "notNull": true,
"primaryKey": false, "autoincrement": false
"notNull": true, },
"autoincrement": false "weight_grams": {
}, "name": "weight_grams",
"weight_grams": { "type": "real",
"name": "weight_grams", "primaryKey": false,
"type": "real", "notNull": false,
"primaryKey": false, "autoincrement": false
"notNull": false, },
"autoincrement": false "price_cents": {
}, "name": "price_cents",
"price_cents": { "type": "integer",
"name": "price_cents", "primaryKey": false,
"type": "integer", "notNull": false,
"primaryKey": false, "autoincrement": false
"notNull": false, },
"autoincrement": false "category_id": {
}, "name": "category_id",
"category_id": { "type": "integer",
"name": "category_id", "primaryKey": false,
"type": "integer", "notNull": true,
"primaryKey": false, "autoincrement": false
"notNull": true, },
"autoincrement": false "notes": {
}, "name": "notes",
"notes": { "type": "text",
"name": "notes", "primaryKey": false,
"type": "text", "notNull": false,
"primaryKey": false, "autoincrement": false
"notNull": false, },
"autoincrement": false "product_url": {
}, "name": "product_url",
"product_url": { "type": "text",
"name": "product_url", "primaryKey": false,
"type": "text", "notNull": false,
"primaryKey": false, "autoincrement": false
"notNull": false, },
"autoincrement": false "image_filename": {
}, "name": "image_filename",
"image_filename": { "type": "text",
"name": "image_filename", "primaryKey": false,
"type": "text", "notNull": false,
"primaryKey": false, "autoincrement": false
"notNull": false, },
"autoincrement": false "created_at": {
}, "name": "created_at",
"created_at": { "type": "integer",
"name": "created_at", "primaryKey": false,
"type": "integer", "notNull": true,
"primaryKey": false, "autoincrement": false
"notNull": true, },
"autoincrement": false "updated_at": {
}, "name": "updated_at",
"updated_at": { "type": "integer",
"name": "updated_at", "primaryKey": false,
"type": "integer", "notNull": true,
"primaryKey": false, "autoincrement": false
"notNull": true, }
"autoincrement": false },
} "indexes": {},
}, "foreignKeys": {
"indexes": {}, "items_category_id_categories_id_fk": {
"foreignKeys": { "name": "items_category_id_categories_id_fk",
"items_category_id_categories_id_fk": { "tableFrom": "items",
"name": "items_category_id_categories_id_fk", "tableTo": "categories",
"tableFrom": "items", "columnsFrom": ["category_id"],
"tableTo": "categories", "columnsTo": ["id"],
"columnsFrom": [ "onDelete": "no action",
"category_id" "onUpdate": "no action"
], }
"columnsTo": [ },
"id" "compositePrimaryKeys": {},
], "uniqueConstraints": {},
"onDelete": "no action", "checkConstraints": {}
"onUpdate": "no action" },
} "settings": {
}, "name": "settings",
"compositePrimaryKeys": {}, "columns": {
"uniqueConstraints": {}, "key": {
"checkConstraints": {} "name": "key",
}, "type": "text",
"settings": { "primaryKey": true,
"name": "settings", "notNull": true,
"columns": { "autoincrement": false
"key": { },
"name": "key", "value": {
"type": "text", "name": "value",
"primaryKey": true, "type": "text",
"notNull": true, "primaryKey": false,
"autoincrement": false "notNull": true,
}, "autoincrement": false
"value": { }
"name": "value", },
"type": "text", "indexes": {},
"primaryKey": false, "foreignKeys": {},
"notNull": true, "compositePrimaryKeys": {},
"autoincrement": false "uniqueConstraints": {},
} "checkConstraints": {}
}, },
"indexes": {}, "setup_items": {
"foreignKeys": {}, "name": "setup_items",
"compositePrimaryKeys": {}, "columns": {
"uniqueConstraints": {}, "id": {
"checkConstraints": {} "name": "id",
}, "type": "integer",
"setup_items": { "primaryKey": true,
"name": "setup_items", "notNull": true,
"columns": { "autoincrement": true
"id": { },
"name": "id", "setup_id": {
"type": "integer", "name": "setup_id",
"primaryKey": true, "type": "integer",
"notNull": true, "primaryKey": false,
"autoincrement": true "notNull": true,
}, "autoincrement": false
"setup_id": { },
"name": "setup_id", "item_id": {
"type": "integer", "name": "item_id",
"primaryKey": false, "type": "integer",
"notNull": true, "primaryKey": false,
"autoincrement": false "notNull": true,
}, "autoincrement": false
"item_id": { }
"name": "item_id", },
"type": "integer", "indexes": {},
"primaryKey": false, "foreignKeys": {
"notNull": true, "setup_items_setup_id_setups_id_fk": {
"autoincrement": false "name": "setup_items_setup_id_setups_id_fk",
} "tableFrom": "setup_items",
}, "tableTo": "setups",
"indexes": {}, "columnsFrom": ["setup_id"],
"foreignKeys": { "columnsTo": ["id"],
"setup_items_setup_id_setups_id_fk": { "onDelete": "cascade",
"name": "setup_items_setup_id_setups_id_fk", "onUpdate": "no action"
"tableFrom": "setup_items", },
"tableTo": "setups", "setup_items_item_id_items_id_fk": {
"columnsFrom": [ "name": "setup_items_item_id_items_id_fk",
"setup_id" "tableFrom": "setup_items",
], "tableTo": "items",
"columnsTo": [ "columnsFrom": ["item_id"],
"id" "columnsTo": ["id"],
], "onDelete": "cascade",
"onDelete": "cascade", "onUpdate": "no action"
"onUpdate": "no action" }
}, },
"setup_items_item_id_items_id_fk": { "compositePrimaryKeys": {},
"name": "setup_items_item_id_items_id_fk", "uniqueConstraints": {},
"tableFrom": "setup_items", "checkConstraints": {}
"tableTo": "items", },
"columnsFrom": [ "setups": {
"item_id" "name": "setups",
], "columns": {
"columnsTo": [ "id": {
"id" "name": "id",
], "type": "integer",
"onDelete": "cascade", "primaryKey": true,
"onUpdate": "no action" "notNull": true,
} "autoincrement": true
}, },
"compositePrimaryKeys": {}, "name": {
"uniqueConstraints": {}, "name": "name",
"checkConstraints": {} "type": "text",
}, "primaryKey": false,
"setups": { "notNull": true,
"name": "setups", "autoincrement": false
"columns": { },
"id": { "created_at": {
"name": "id", "name": "created_at",
"type": "integer", "type": "integer",
"primaryKey": true, "primaryKey": false,
"notNull": true, "notNull": true,
"autoincrement": true "autoincrement": false
}, },
"name": { "updated_at": {
"name": "name", "name": "updated_at",
"type": "text", "type": "integer",
"primaryKey": false, "primaryKey": false,
"notNull": true, "notNull": true,
"autoincrement": false "autoincrement": false
}, }
"created_at": { },
"name": "created_at", "indexes": {},
"type": "integer", "foreignKeys": {},
"primaryKey": false, "compositePrimaryKeys": {},
"notNull": true, "uniqueConstraints": {},
"autoincrement": false "checkConstraints": {}
}, },
"updated_at": { "thread_candidates": {
"name": "updated_at", "name": "thread_candidates",
"type": "integer", "columns": {
"primaryKey": false, "id": {
"notNull": true, "name": "id",
"autoincrement": false "type": "integer",
} "primaryKey": true,
}, "notNull": true,
"indexes": {}, "autoincrement": true
"foreignKeys": {}, },
"compositePrimaryKeys": {}, "thread_id": {
"uniqueConstraints": {}, "name": "thread_id",
"checkConstraints": {} "type": "integer",
}, "primaryKey": false,
"thread_candidates": { "notNull": true,
"name": "thread_candidates", "autoincrement": false
"columns": { },
"id": { "name": {
"name": "id", "name": "name",
"type": "integer", "type": "text",
"primaryKey": true, "primaryKey": false,
"notNull": true, "notNull": true,
"autoincrement": true "autoincrement": false
}, },
"thread_id": { "weight_grams": {
"name": "thread_id", "name": "weight_grams",
"type": "integer", "type": "real",
"primaryKey": false, "primaryKey": false,
"notNull": true, "notNull": false,
"autoincrement": false "autoincrement": false
}, },
"name": { "price_cents": {
"name": "name", "name": "price_cents",
"type": "text", "type": "integer",
"primaryKey": false, "primaryKey": false,
"notNull": true, "notNull": false,
"autoincrement": false "autoincrement": false
}, },
"weight_grams": { "category_id": {
"name": "weight_grams", "name": "category_id",
"type": "real", "type": "integer",
"primaryKey": false, "primaryKey": false,
"notNull": false, "notNull": true,
"autoincrement": false "autoincrement": false
}, },
"price_cents": { "notes": {
"name": "price_cents", "name": "notes",
"type": "integer", "type": "text",
"primaryKey": false, "primaryKey": false,
"notNull": false, "notNull": false,
"autoincrement": false "autoincrement": false
}, },
"category_id": { "product_url": {
"name": "category_id", "name": "product_url",
"type": "integer", "type": "text",
"primaryKey": false, "primaryKey": false,
"notNull": true, "notNull": false,
"autoincrement": false "autoincrement": false
}, },
"notes": { "image_filename": {
"name": "notes", "name": "image_filename",
"type": "text", "type": "text",
"primaryKey": false, "primaryKey": false,
"notNull": false, "notNull": false,
"autoincrement": false "autoincrement": false
}, },
"product_url": { "created_at": {
"name": "product_url", "name": "created_at",
"type": "text", "type": "integer",
"primaryKey": false, "primaryKey": false,
"notNull": false, "notNull": true,
"autoincrement": false "autoincrement": false
}, },
"image_filename": { "updated_at": {
"name": "image_filename", "name": "updated_at",
"type": "text", "type": "integer",
"primaryKey": false, "primaryKey": false,
"notNull": false, "notNull": true,
"autoincrement": false "autoincrement": false
}, }
"created_at": { },
"name": "created_at", "indexes": {},
"type": "integer", "foreignKeys": {
"primaryKey": false, "thread_candidates_thread_id_threads_id_fk": {
"notNull": true, "name": "thread_candidates_thread_id_threads_id_fk",
"autoincrement": false "tableFrom": "thread_candidates",
}, "tableTo": "threads",
"updated_at": { "columnsFrom": ["thread_id"],
"name": "updated_at", "columnsTo": ["id"],
"type": "integer", "onDelete": "cascade",
"primaryKey": false, "onUpdate": "no action"
"notNull": true, },
"autoincrement": false "thread_candidates_category_id_categories_id_fk": {
} "name": "thread_candidates_category_id_categories_id_fk",
}, "tableFrom": "thread_candidates",
"indexes": {}, "tableTo": "categories",
"foreignKeys": { "columnsFrom": ["category_id"],
"thread_candidates_thread_id_threads_id_fk": { "columnsTo": ["id"],
"name": "thread_candidates_thread_id_threads_id_fk", "onDelete": "no action",
"tableFrom": "thread_candidates", "onUpdate": "no action"
"tableTo": "threads", }
"columnsFrom": [ },
"thread_id" "compositePrimaryKeys": {},
], "uniqueConstraints": {},
"columnsTo": [ "checkConstraints": {}
"id" },
], "threads": {
"onDelete": "cascade", "name": "threads",
"onUpdate": "no action" "columns": {
}, "id": {
"thread_candidates_category_id_categories_id_fk": { "name": "id",
"name": "thread_candidates_category_id_categories_id_fk", "type": "integer",
"tableFrom": "thread_candidates", "primaryKey": true,
"tableTo": "categories", "notNull": true,
"columnsFrom": [ "autoincrement": true
"category_id" },
], "name": {
"columnsTo": [ "name": "name",
"id" "type": "text",
], "primaryKey": false,
"onDelete": "no action", "notNull": true,
"onUpdate": "no action" "autoincrement": false
} },
}, "status": {
"compositePrimaryKeys": {}, "name": "status",
"uniqueConstraints": {}, "type": "text",
"checkConstraints": {} "primaryKey": false,
}, "notNull": true,
"threads": { "autoincrement": false,
"name": "threads", "default": "'active'"
"columns": { },
"id": { "resolved_candidate_id": {
"name": "id", "name": "resolved_candidate_id",
"type": "integer", "type": "integer",
"primaryKey": true, "primaryKey": false,
"notNull": true, "notNull": false,
"autoincrement": true "autoincrement": false
}, },
"name": { "category_id": {
"name": "name", "name": "category_id",
"type": "text", "type": "integer",
"primaryKey": false, "primaryKey": false,
"notNull": true, "notNull": true,
"autoincrement": false "autoincrement": false
}, },
"status": { "created_at": {
"name": "status", "name": "created_at",
"type": "text", "type": "integer",
"primaryKey": false, "primaryKey": false,
"notNull": true, "notNull": true,
"autoincrement": false, "autoincrement": false
"default": "'active'" },
}, "updated_at": {
"resolved_candidate_id": { "name": "updated_at",
"name": "resolved_candidate_id", "type": "integer",
"type": "integer", "primaryKey": false,
"primaryKey": false, "notNull": true,
"notNull": false, "autoincrement": false
"autoincrement": false }
}, },
"category_id": { "indexes": {},
"name": "category_id", "foreignKeys": {
"type": "integer", "threads_category_id_categories_id_fk": {
"primaryKey": false, "name": "threads_category_id_categories_id_fk",
"notNull": true, "tableFrom": "threads",
"autoincrement": false "tableTo": "categories",
}, "columnsFrom": ["category_id"],
"created_at": { "columnsTo": ["id"],
"name": "created_at", "onDelete": "no action",
"type": "integer", "onUpdate": "no action"
"primaryKey": false, }
"notNull": true, },
"autoincrement": false "compositePrimaryKeys": {},
}, "uniqueConstraints": {},
"updated_at": { "checkConstraints": {}
"name": "updated_at", }
"type": "integer", },
"primaryKey": false, "views": {},
"notNull": true, "enums": {},
"autoincrement": false "_meta": {
} "schemas": {},
}, "tables": {},
"indexes": {}, "columns": {}
"foreignKeys": { },
"threads_category_id_categories_id_fk": { "internal": {
"name": "threads_category_id_categories_id_fk", "indexes": {}
"tableFrom": "threads", }
"tableTo": "categories",
"columnsFrom": [
"category_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
} }

View File

@@ -1,467 +1,441 @@
{ {
"version": "6", "version": "6",
"dialect": "sqlite", "dialect": "sqlite",
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"prevId": "78e5f5c8-f8f0-43f4-93f8-5ef68154ed17", "prevId": "78e5f5c8-f8f0-43f4-93f8-5ef68154ed17",
"tables": { "tables": {
"categories": { "categories": {
"name": "categories", "name": "categories",
"columns": { "columns": {
"id": { "id": {
"name": "id", "name": "id",
"type": "integer", "type": "integer",
"primaryKey": true, "primaryKey": true,
"notNull": true, "notNull": true,
"autoincrement": true "autoincrement": true
}, },
"name": { "name": {
"name": "name", "name": "name",
"type": "text", "type": "text",
"primaryKey": false, "primaryKey": false,
"notNull": true, "notNull": true,
"autoincrement": false "autoincrement": false
}, },
"icon": { "icon": {
"name": "icon", "name": "icon",
"type": "text", "type": "text",
"primaryKey": false, "primaryKey": false,
"notNull": true, "notNull": true,
"autoincrement": false, "autoincrement": false,
"default": "'package'" "default": "'package'"
}, },
"created_at": { "created_at": {
"name": "created_at", "name": "created_at",
"type": "integer", "type": "integer",
"primaryKey": false, "primaryKey": false,
"notNull": true, "notNull": true,
"autoincrement": false "autoincrement": false
} }
}, },
"indexes": { "indexes": {
"categories_name_unique": { "categories_name_unique": {
"name": "categories_name_unique", "name": "categories_name_unique",
"columns": [ "columns": ["name"],
"name" "isUnique": true
], }
"isUnique": true },
} "foreignKeys": {},
}, "compositePrimaryKeys": {},
"foreignKeys": {}, "uniqueConstraints": {},
"compositePrimaryKeys": {}, "checkConstraints": {}
"uniqueConstraints": {}, },
"checkConstraints": {} "items": {
}, "name": "items",
"items": { "columns": {
"name": "items", "id": {
"columns": { "name": "id",
"id": { "type": "integer",
"name": "id", "primaryKey": true,
"type": "integer", "notNull": true,
"primaryKey": true, "autoincrement": true
"notNull": true, },
"autoincrement": true "name": {
}, "name": "name",
"name": { "type": "text",
"name": "name", "primaryKey": false,
"type": "text", "notNull": true,
"primaryKey": false, "autoincrement": false
"notNull": true, },
"autoincrement": false "weight_grams": {
}, "name": "weight_grams",
"weight_grams": { "type": "real",
"name": "weight_grams", "primaryKey": false,
"type": "real", "notNull": false,
"primaryKey": false, "autoincrement": false
"notNull": false, },
"autoincrement": false "price_cents": {
}, "name": "price_cents",
"price_cents": { "type": "integer",
"name": "price_cents", "primaryKey": false,
"type": "integer", "notNull": false,
"primaryKey": false, "autoincrement": false
"notNull": false, },
"autoincrement": false "category_id": {
}, "name": "category_id",
"category_id": { "type": "integer",
"name": "category_id", "primaryKey": false,
"type": "integer", "notNull": true,
"primaryKey": false, "autoincrement": false
"notNull": true, },
"autoincrement": false "notes": {
}, "name": "notes",
"notes": { "type": "text",
"name": "notes", "primaryKey": false,
"type": "text", "notNull": false,
"primaryKey": false, "autoincrement": false
"notNull": false, },
"autoincrement": false "product_url": {
}, "name": "product_url",
"product_url": { "type": "text",
"name": "product_url", "primaryKey": false,
"type": "text", "notNull": false,
"primaryKey": false, "autoincrement": false
"notNull": false, },
"autoincrement": false "image_filename": {
}, "name": "image_filename",
"image_filename": { "type": "text",
"name": "image_filename", "primaryKey": false,
"type": "text", "notNull": false,
"primaryKey": false, "autoincrement": false
"notNull": false, },
"autoincrement": false "created_at": {
}, "name": "created_at",
"created_at": { "type": "integer",
"name": "created_at", "primaryKey": false,
"type": "integer", "notNull": true,
"primaryKey": false, "autoincrement": false
"notNull": true, },
"autoincrement": false "updated_at": {
}, "name": "updated_at",
"updated_at": { "type": "integer",
"name": "updated_at", "primaryKey": false,
"type": "integer", "notNull": true,
"primaryKey": false, "autoincrement": false
"notNull": true, }
"autoincrement": false },
} "indexes": {},
}, "foreignKeys": {
"indexes": {}, "items_category_id_categories_id_fk": {
"foreignKeys": { "name": "items_category_id_categories_id_fk",
"items_category_id_categories_id_fk": { "tableFrom": "items",
"name": "items_category_id_categories_id_fk", "tableTo": "categories",
"tableFrom": "items", "columnsFrom": ["category_id"],
"tableTo": "categories", "columnsTo": ["id"],
"columnsFrom": [ "onDelete": "no action",
"category_id" "onUpdate": "no action"
], }
"columnsTo": [ },
"id" "compositePrimaryKeys": {},
], "uniqueConstraints": {},
"onDelete": "no action", "checkConstraints": {}
"onUpdate": "no action" },
} "settings": {
}, "name": "settings",
"compositePrimaryKeys": {}, "columns": {
"uniqueConstraints": {}, "key": {
"checkConstraints": {} "name": "key",
}, "type": "text",
"settings": { "primaryKey": true,
"name": "settings", "notNull": true,
"columns": { "autoincrement": false
"key": { },
"name": "key", "value": {
"type": "text", "name": "value",
"primaryKey": true, "type": "text",
"notNull": true, "primaryKey": false,
"autoincrement": false "notNull": true,
}, "autoincrement": false
"value": { }
"name": "value", },
"type": "text", "indexes": {},
"primaryKey": false, "foreignKeys": {},
"notNull": true, "compositePrimaryKeys": {},
"autoincrement": false "uniqueConstraints": {},
} "checkConstraints": {}
}, },
"indexes": {}, "setup_items": {
"foreignKeys": {}, "name": "setup_items",
"compositePrimaryKeys": {}, "columns": {
"uniqueConstraints": {}, "id": {
"checkConstraints": {} "name": "id",
}, "type": "integer",
"setup_items": { "primaryKey": true,
"name": "setup_items", "notNull": true,
"columns": { "autoincrement": true
"id": { },
"name": "id", "setup_id": {
"type": "integer", "name": "setup_id",
"primaryKey": true, "type": "integer",
"notNull": true, "primaryKey": false,
"autoincrement": true "notNull": true,
}, "autoincrement": false
"setup_id": { },
"name": "setup_id", "item_id": {
"type": "integer", "name": "item_id",
"primaryKey": false, "type": "integer",
"notNull": true, "primaryKey": false,
"autoincrement": false "notNull": true,
}, "autoincrement": false
"item_id": { }
"name": "item_id", },
"type": "integer", "indexes": {},
"primaryKey": false, "foreignKeys": {
"notNull": true, "setup_items_setup_id_setups_id_fk": {
"autoincrement": false "name": "setup_items_setup_id_setups_id_fk",
} "tableFrom": "setup_items",
}, "tableTo": "setups",
"indexes": {}, "columnsFrom": ["setup_id"],
"foreignKeys": { "columnsTo": ["id"],
"setup_items_setup_id_setups_id_fk": { "onDelete": "cascade",
"name": "setup_items_setup_id_setups_id_fk", "onUpdate": "no action"
"tableFrom": "setup_items", },
"tableTo": "setups", "setup_items_item_id_items_id_fk": {
"columnsFrom": [ "name": "setup_items_item_id_items_id_fk",
"setup_id" "tableFrom": "setup_items",
], "tableTo": "items",
"columnsTo": [ "columnsFrom": ["item_id"],
"id" "columnsTo": ["id"],
], "onDelete": "cascade",
"onDelete": "cascade", "onUpdate": "no action"
"onUpdate": "no action" }
}, },
"setup_items_item_id_items_id_fk": { "compositePrimaryKeys": {},
"name": "setup_items_item_id_items_id_fk", "uniqueConstraints": {},
"tableFrom": "setup_items", "checkConstraints": {}
"tableTo": "items", },
"columnsFrom": [ "setups": {
"item_id" "name": "setups",
], "columns": {
"columnsTo": [ "id": {
"id" "name": "id",
], "type": "integer",
"onDelete": "cascade", "primaryKey": true,
"onUpdate": "no action" "notNull": true,
} "autoincrement": true
}, },
"compositePrimaryKeys": {}, "name": {
"uniqueConstraints": {}, "name": "name",
"checkConstraints": {} "type": "text",
}, "primaryKey": false,
"setups": { "notNull": true,
"name": "setups", "autoincrement": false
"columns": { },
"id": { "created_at": {
"name": "id", "name": "created_at",
"type": "integer", "type": "integer",
"primaryKey": true, "primaryKey": false,
"notNull": true, "notNull": true,
"autoincrement": true "autoincrement": false
}, },
"name": { "updated_at": {
"name": "name", "name": "updated_at",
"type": "text", "type": "integer",
"primaryKey": false, "primaryKey": false,
"notNull": true, "notNull": true,
"autoincrement": false "autoincrement": false
}, }
"created_at": { },
"name": "created_at", "indexes": {},
"type": "integer", "foreignKeys": {},
"primaryKey": false, "compositePrimaryKeys": {},
"notNull": true, "uniqueConstraints": {},
"autoincrement": false "checkConstraints": {}
}, },
"updated_at": { "thread_candidates": {
"name": "updated_at", "name": "thread_candidates",
"type": "integer", "columns": {
"primaryKey": false, "id": {
"notNull": true, "name": "id",
"autoincrement": false "type": "integer",
} "primaryKey": true,
}, "notNull": true,
"indexes": {}, "autoincrement": true
"foreignKeys": {}, },
"compositePrimaryKeys": {}, "thread_id": {
"uniqueConstraints": {}, "name": "thread_id",
"checkConstraints": {} "type": "integer",
}, "primaryKey": false,
"thread_candidates": { "notNull": true,
"name": "thread_candidates", "autoincrement": false
"columns": { },
"id": { "name": {
"name": "id", "name": "name",
"type": "integer", "type": "text",
"primaryKey": true, "primaryKey": false,
"notNull": true, "notNull": true,
"autoincrement": true "autoincrement": false
}, },
"thread_id": { "weight_grams": {
"name": "thread_id", "name": "weight_grams",
"type": "integer", "type": "real",
"primaryKey": false, "primaryKey": false,
"notNull": true, "notNull": false,
"autoincrement": false "autoincrement": false
}, },
"name": { "price_cents": {
"name": "name", "name": "price_cents",
"type": "text", "type": "integer",
"primaryKey": false, "primaryKey": false,
"notNull": true, "notNull": false,
"autoincrement": false "autoincrement": false
}, },
"weight_grams": { "category_id": {
"name": "weight_grams", "name": "category_id",
"type": "real", "type": "integer",
"primaryKey": false, "primaryKey": false,
"notNull": false, "notNull": true,
"autoincrement": false "autoincrement": false
}, },
"price_cents": { "notes": {
"name": "price_cents", "name": "notes",
"type": "integer", "type": "text",
"primaryKey": false, "primaryKey": false,
"notNull": false, "notNull": false,
"autoincrement": false "autoincrement": false
}, },
"category_id": { "product_url": {
"name": "category_id", "name": "product_url",
"type": "integer", "type": "text",
"primaryKey": false, "primaryKey": false,
"notNull": true, "notNull": false,
"autoincrement": false "autoincrement": false
}, },
"notes": { "image_filename": {
"name": "notes", "name": "image_filename",
"type": "text", "type": "text",
"primaryKey": false, "primaryKey": false,
"notNull": false, "notNull": false,
"autoincrement": false "autoincrement": false
}, },
"product_url": { "created_at": {
"name": "product_url", "name": "created_at",
"type": "text", "type": "integer",
"primaryKey": false, "primaryKey": false,
"notNull": false, "notNull": true,
"autoincrement": false "autoincrement": false
}, },
"image_filename": { "updated_at": {
"name": "image_filename", "name": "updated_at",
"type": "text", "type": "integer",
"primaryKey": false, "primaryKey": false,
"notNull": false, "notNull": true,
"autoincrement": false "autoincrement": false
}, }
"created_at": { },
"name": "created_at", "indexes": {},
"type": "integer", "foreignKeys": {
"primaryKey": false, "thread_candidates_thread_id_threads_id_fk": {
"notNull": true, "name": "thread_candidates_thread_id_threads_id_fk",
"autoincrement": false "tableFrom": "thread_candidates",
}, "tableTo": "threads",
"updated_at": { "columnsFrom": ["thread_id"],
"name": "updated_at", "columnsTo": ["id"],
"type": "integer", "onDelete": "cascade",
"primaryKey": false, "onUpdate": "no action"
"notNull": true, },
"autoincrement": false "thread_candidates_category_id_categories_id_fk": {
} "name": "thread_candidates_category_id_categories_id_fk",
}, "tableFrom": "thread_candidates",
"indexes": {}, "tableTo": "categories",
"foreignKeys": { "columnsFrom": ["category_id"],
"thread_candidates_thread_id_threads_id_fk": { "columnsTo": ["id"],
"name": "thread_candidates_thread_id_threads_id_fk", "onDelete": "no action",
"tableFrom": "thread_candidates", "onUpdate": "no action"
"tableTo": "threads", }
"columnsFrom": [ },
"thread_id" "compositePrimaryKeys": {},
], "uniqueConstraints": {},
"columnsTo": [ "checkConstraints": {}
"id" },
], "threads": {
"onDelete": "cascade", "name": "threads",
"onUpdate": "no action" "columns": {
}, "id": {
"thread_candidates_category_id_categories_id_fk": { "name": "id",
"name": "thread_candidates_category_id_categories_id_fk", "type": "integer",
"tableFrom": "thread_candidates", "primaryKey": true,
"tableTo": "categories", "notNull": true,
"columnsFrom": [ "autoincrement": true
"category_id" },
], "name": {
"columnsTo": [ "name": "name",
"id" "type": "text",
], "primaryKey": false,
"onDelete": "no action", "notNull": true,
"onUpdate": "no action" "autoincrement": false
} },
}, "status": {
"compositePrimaryKeys": {}, "name": "status",
"uniqueConstraints": {}, "type": "text",
"checkConstraints": {} "primaryKey": false,
}, "notNull": true,
"threads": { "autoincrement": false,
"name": "threads", "default": "'active'"
"columns": { },
"id": { "resolved_candidate_id": {
"name": "id", "name": "resolved_candidate_id",
"type": "integer", "type": "integer",
"primaryKey": true, "primaryKey": false,
"notNull": true, "notNull": false,
"autoincrement": true "autoincrement": false
}, },
"name": { "category_id": {
"name": "name", "name": "category_id",
"type": "text", "type": "integer",
"primaryKey": false, "primaryKey": false,
"notNull": true, "notNull": true,
"autoincrement": false "autoincrement": false
}, },
"status": { "created_at": {
"name": "status", "name": "created_at",
"type": "text", "type": "integer",
"primaryKey": false, "primaryKey": false,
"notNull": true, "notNull": true,
"autoincrement": false, "autoincrement": false
"default": "'active'" },
}, "updated_at": {
"resolved_candidate_id": { "name": "updated_at",
"name": "resolved_candidate_id", "type": "integer",
"type": "integer", "primaryKey": false,
"primaryKey": false, "notNull": true,
"notNull": false, "autoincrement": false
"autoincrement": false }
}, },
"category_id": { "indexes": {},
"name": "category_id", "foreignKeys": {
"type": "integer", "threads_category_id_categories_id_fk": {
"primaryKey": false, "name": "threads_category_id_categories_id_fk",
"notNull": true, "tableFrom": "threads",
"autoincrement": false "tableTo": "categories",
}, "columnsFrom": ["category_id"],
"created_at": { "columnsTo": ["id"],
"name": "created_at", "onDelete": "no action",
"type": "integer", "onUpdate": "no action"
"primaryKey": false, }
"notNull": true, },
"autoincrement": false "compositePrimaryKeys": {},
}, "uniqueConstraints": {},
"updated_at": { "checkConstraints": {}
"name": "updated_at", }
"type": "integer", },
"primaryKey": false, "views": {},
"notNull": true, "enums": {},
"autoincrement": false "_meta": {
} "schemas": {},
}, "tables": {},
"indexes": {}, "columns": {}
"foreignKeys": { },
"threads_category_id_categories_id_fk": { "internal": {
"name": "threads_category_id_categories_id_fk", "indexes": {}
"tableFrom": "threads", }
"tableTo": "categories",
"columnsFrom": [
"category_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
} }

View File

@@ -1,20 +1,20 @@
{ {
"version": "7", "version": "7",
"dialect": "sqlite", "dialect": "sqlite",
"entries": [ "entries": [
{ {
"idx": 0, "idx": 0,
"version": "6", "version": "6",
"when": 1773589489626, "when": 1773589489626,
"tag": "0000_bitter_luckman", "tag": "0000_bitter_luckman",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 1, "idx": 1,
"version": "6", "version": "6",
"when": 1773593102000, "when": 1773593102000,
"tag": "0001_rename_emoji_to_icon", "tag": "0001_rename_emoji_to_icon",
"breakpoints": true "breakpoints": true
} }
] ]
} }

View File

@@ -1,4 +1,4 @@
#!/bin/sh #!/bin/sh
set -e set -e
bun run db:push bun run src/db/migrate.ts
exec bun run src/server/index.ts exec bun run src/server/index.ts

View File

@@ -3,6 +3,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>GearBox</title> <title>GearBox</title>
</head> </head>
<body> <body>

View File

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

1
public/favicon.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m7.5 4.27 9 5.15"/><path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/></svg>

After

Width:  |  Height:  |  Size: 392 B

View File

@@ -46,7 +46,7 @@ export function CandidateCard({
openExternalLink(productUrl); openExternalLink(productUrl);
} }
}} }}
className="absolute top-2 right-2 z-10 w-6 h-6 flex items-center justify-center rounded-full bg-gray-100/80 text-gray-400 hover:bg-blue-100 hover:text-blue-500 opacity-0 group-hover:opacity-100 transition-all cursor-pointer" className="absolute top-2 right-2 z-10 w-6 h-6 flex items-center justify-center rounded-full bg-gray-100/80 text-gray-400 hover:bg-gray-200 hover:text-gray-600 opacity-0 group-hover:opacity-100 transition-all cursor-pointer"
title="Open product link" title="Open product link"
> >
<svg <svg
@@ -73,7 +73,11 @@ export function CandidateCard({
/> />
) : ( ) : (
<div className="w-full h-full flex flex-col items-center justify-center"> <div className="w-full h-full flex flex-col items-center justify-center">
<LucideIcon name={categoryIcon} size={36} className="text-gray-400" /> <LucideIcon
name={categoryIcon}
size={36}
className="text-gray-400"
/>
</div> </div>
)} )}
</div> </div>
@@ -83,24 +87,29 @@ export function CandidateCard({
</h3> </h3>
<div className="flex flex-wrap gap-1.5 mb-3"> <div className="flex flex-wrap gap-1.5 mb-3">
{weightGrams != null && ( {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"> <span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-50 text-blue-400">
{formatWeight(weightGrams)} {formatWeight(weightGrams)}
</span> </span>
)} )}
{priceCents != null && ( {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"> <span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-50 text-green-500">
{formatPrice(priceCents)} {formatPrice(priceCents)}
</span> </span>
)} )}
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-50 text-gray-600"> <span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-50 text-gray-600">
<LucideIcon name={categoryIcon} size={14} className="inline-block mr-1 text-gray-500" /> {categoryName} <LucideIcon
name={categoryIcon}
size={14}
className="inline-block mr-1 text-gray-500"
/>{" "}
{categoryName}
</span> </span>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<button <button
type="button" type="button"
onClick={() => openCandidateEditPanel(id)} onClick={() => openCandidateEditPanel(id)}
className="text-xs text-gray-500 hover:text-blue-600 transition-colors" className="text-xs text-gray-500 hover:text-gray-700 transition-colors"
> >
Edit Edit
</button> </button>

View File

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

View File

@@ -1,6 +1,6 @@
import { useState } from "react"; import { useState } from "react";
import { formatWeight, formatPrice } from "../lib/formatters"; import { useDeleteCategory, useUpdateCategory } from "../hooks/useCategories";
import { useUpdateCategory, useDeleteCategory } from "../hooks/useCategories"; import { formatPrice, formatWeight } from "../lib/formatters";
import { LucideIcon } from "../lib/iconData"; import { LucideIcon } from "../lib/iconData";
import { IconPicker } from "./IconPicker"; import { IconPicker } from "./IconPicker";
@@ -39,7 +39,9 @@ export function CategoryHeader({
function handleDelete() { function handleDelete() {
if ( if (
confirm(`Delete category "${name}"? Items will be moved to Uncategorized.`) confirm(
`Delete category "${name}"? Items will be moved to Uncategorized.`,
)
) { ) {
deleteCategory.mutate(categoryId); deleteCategory.mutate(categoryId);
} }
@@ -58,12 +60,11 @@ export function CategoryHeader({
if (e.key === "Enter") handleSave(); if (e.key === "Enter") handleSave();
if (e.key === "Escape") setIsEditing(false); if (e.key === "Escape") setIsEditing(false);
}} }}
autoFocus
/> />
<button <button
type="button" type="button"
onClick={handleSave} onClick={handleSave}
className="text-sm text-blue-600 hover:text-blue-800 font-medium" className="text-sm text-gray-600 hover:text-gray-800 font-medium"
> >
Save Save
</button> </button>

View File

@@ -1,8 +1,5 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { import { useCategories, useCreateCategory } from "../hooks/useCategories";
useCategories,
useCreateCategory,
} from "../hooks/useCategories";
import { LucideIcon } from "../lib/iconData"; import { LucideIcon } from "../lib/iconData";
import { IconPicker } from "./IconPicker"; import { IconPicker } from "./IconPicker";
@@ -109,10 +106,7 @@ export function CategoryPicker({ value, onChange }: CategoryPickerProps) {
handleConfirmCreate(); handleConfirmCreate();
} else if (highlightIndex >= 0 && highlightIndex < filtered.length) { } else if (highlightIndex >= 0 && highlightIndex < filtered.length) {
handleSelect(filtered[highlightIndex].id); handleSelect(filtered[highlightIndex].id);
} else if ( } else if (showCreateOption && highlightIndex === filtered.length) {
showCreateOption &&
highlightIndex === filtered.length
) {
handleStartCreate(); handleStartCreate();
} }
break; break;
@@ -162,11 +156,7 @@ export function CategoryPicker({ value, onChange }: CategoryPickerProps) {
: undefined : undefined
} }
value={ value={
isOpen isOpen ? inputValue : selectedCategory ? selectedCategory.name : ""
? inputValue
: selectedCategory
? selectedCategory.name
: ""
} }
placeholder="Search or create category..." placeholder="Search or create category..."
onChange={(e) => { onChange={(e) => {
@@ -179,7 +169,7 @@ export function CategoryPicker({ value, onChange }: CategoryPickerProps) {
setInputValue(""); setInputValue("");
}} }}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
className={`w-full py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent ${ className={`w-full py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent ${
!isOpen && selectedCategory ? "pl-8 pr-3" : "px-3" !isOpen && selectedCategory ? "pl-8 pr-3" : "px-3"
}`} }`}
/> />
@@ -188,18 +178,16 @@ export function CategoryPicker({ value, onChange }: CategoryPickerProps) {
<ul <ul
ref={listRef} ref={listRef}
id="category-listbox" 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" 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) => ( {filtered.map((cat, i) => (
<li <li
key={cat.id} key={cat.id}
id={`category-option-${i}`} id={`category-option-${i}`}
role="option"
aria-selected={cat.id === value} aria-selected={cat.id === value}
className={`px-3 py-2 text-sm cursor-pointer flex items-center gap-1.5 ${ className={`px-3 py-2 text-sm cursor-pointer flex items-center gap-1.5 ${
i === highlightIndex i === highlightIndex
? "bg-blue-50 text-blue-900" ? "bg-gray-100 text-gray-900"
: "hover:bg-gray-50" : "hover:bg-gray-50"
} ${cat.id === value ? "font-medium" : ""}`} } ${cat.id === value ? "font-medium" : ""}`}
onClick={() => handleSelect(cat.id)} onClick={() => handleSelect(cat.id)}
@@ -216,11 +204,10 @@ export function CategoryPicker({ value, onChange }: CategoryPickerProps) {
{showCreateOption && !isCreating && ( {showCreateOption && !isCreating && (
<li <li
id={`category-option-${filtered.length}`} id={`category-option-${filtered.length}`}
role="option"
aria-selected={false} aria-selected={false}
className={`px-3 py-2 text-sm cursor-pointer border-t border-gray-100 ${ className={`px-3 py-2 text-sm cursor-pointer border-t border-gray-100 ${
highlightIndex === filtered.length highlightIndex === filtered.length
? "bg-blue-50 text-blue-900" ? "bg-gray-100 text-gray-900"
: "hover:bg-gray-50 text-gray-600" : "hover:bg-gray-50 text-gray-600"
}`} }`}
onClick={handleStartCreate} onClick={handleStartCreate}
@@ -244,7 +231,7 @@ export function CategoryPicker({ value, onChange }: CategoryPickerProps) {
type="button" type="button"
onClick={handleConfirmCreate} onClick={handleConfirmCreate}
disabled={createCategory.isPending} disabled={createCategory.isPending}
className="text-xs font-medium text-blue-600 hover:text-blue-800 disabled:opacity-50" className="text-xs font-medium text-gray-600 hover:text-gray-800 disabled:opacity-50"
> >
{createCategory.isPending ? "..." : "Create"} {createCategory.isPending ? "..." : "Create"}
</button> </button>

View File

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

View File

@@ -93,7 +93,7 @@ export function CreateThreadModal() {
value={name} value={name}
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
placeholder="e.g. Lightweight sleeping bag" placeholder="e.g. Lightweight sleeping bag"
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" className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent"
/> />
</div> </div>
@@ -108,7 +108,7 @@ export function CreateThreadModal() {
id="thread-category" id="thread-category"
value={categoryId ?? ""} value={categoryId ?? ""}
onChange={(e) => setCategoryId(Number(e.target.value))} onChange={(e) => setCategoryId(Number(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 bg-white" className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent bg-white"
> >
{categories?.map((cat) => ( {categories?.map((cat) => (
<option key={cat.id} value={cat.id}> <option key={cat.id} value={cat.id}>
@@ -131,7 +131,7 @@ export function CreateThreadModal() {
<button <button
type="submit" type="submit"
disabled={createThread.isPending} disabled={createThread.isPending}
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 disabled:opacity-50 rounded-lg transition-colors" className="px-4 py-2 text-sm font-medium text-white bg-gray-700 hover:bg-gray-800 disabled:opacity-50 rounded-lg transition-colors"
> >
{createThread.isPending ? "Creating..." : "Create Thread"} {createThread.isPending ? "Creating..." : "Create Thread"}
</button> </button>

View File

@@ -43,7 +43,7 @@ export function DashboardCard({
))} ))}
</div> </div>
{allZero && emptyText && ( {allZero && emptyText && (
<p className="mt-4 text-sm text-blue-600 font-medium">{emptyText}</p> <p className="mt-4 text-sm text-gray-500 font-medium">{emptyText}</p>
)} )}
</Link> </Link>
); );

View File

@@ -37,10 +37,8 @@ export function ExternalLinkDialog() {
<h3 className="text-lg font-semibold text-gray-900 mb-2"> <h3 className="text-lg font-semibold text-gray-900 mb-2">
You are about to leave GearBox You are about to leave GearBox
</h3> </h3>
<p className="text-sm text-gray-600 mb-1"> <p className="text-sm text-gray-600 mb-1">You will be redirected to:</p>
You will be redirected to: <p className="text-sm text-gray-600 break-all mb-6">
</p>
<p className="text-sm text-blue-600 break-all mb-6">
{externalLinkUrl} {externalLinkUrl}
</p> </p>
<div className="flex justify-end gap-3"> <div className="flex justify-end gap-3">
@@ -54,7 +52,7 @@ export function ExternalLinkDialog() {
<button <button
type="button" type="button"
onClick={handleContinue} onClick={handleContinue}
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors" className="px-4 py-2 text-sm font-medium text-white bg-gray-700 hover:bg-gray-800 rounded-lg transition-colors"
> >
Continue Continue
</button> </button>

View File

@@ -8,11 +8,7 @@ interface IconPickerProps {
size?: "sm" | "md"; size?: "sm" | "md";
} }
export function IconPicker({ export function IconPicker({ value, onChange, size = "md" }: IconPickerProps) {
value,
onChange,
size = "md",
}: IconPickerProps) {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [activeGroup, setActiveGroup] = useState(0); const [activeGroup, setActiveGroup] = useState(0);
@@ -99,8 +95,7 @@ export function IconPicker({
const results = iconGroups.flatMap((group) => const results = iconGroups.flatMap((group) =>
group.icons.filter( group.icons.filter(
(icon) => (icon) =>
icon.name.includes(q) || icon.name.includes(q) || icon.keywords.some((kw) => kw.includes(q)),
icon.keywords.some((kw) => kw.includes(q)),
), ),
); );
// Deduplicate by name (some icons appear in multiple groups) // Deduplicate by name (some icons appear in multiple groups)
@@ -118,8 +113,7 @@ export function IconPicker({
setSearch(""); setSearch("");
} }
const buttonSize = const buttonSize = size === "sm" ? "w-10 h-10" : "w-12 h-12";
size === "sm" ? "w-10 h-10" : "w-12 h-12";
const iconSize = size === "sm" ? 20 : 24; const iconSize = size === "sm" ? 20 : 24;
return ( return (
@@ -156,7 +150,7 @@ export function IconPicker({
setActiveGroup(0); setActiveGroup(0);
}} }}
placeholder="Search icons..." placeholder="Search icons..."
className="w-full px-2 py-1.5 text-sm border border-gray-200 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" className="w-full px-2 py-1.5 text-sm border border-gray-200 rounded-md focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent"
/> />
</div> </div>
@@ -170,7 +164,7 @@ export function IconPicker({
onClick={() => setActiveGroup(i)} onClick={() => setActiveGroup(i)}
className={`flex-1 flex items-center justify-center py-1 rounded transition-colors ${ className={`flex-1 flex items-center justify-center py-1 rounded transition-colors ${
i === activeGroup i === activeGroup
? "bg-blue-50 text-blue-700" ? "bg-gray-200 text-gray-700"
: "hover:bg-gray-50 text-gray-500" : "hover:bg-gray-50 text-gray-500"
}`} }`}
title={group.name} title={group.name}
@@ -179,9 +173,7 @@ export function IconPicker({
name={group.icon} name={group.icon}
size={16} size={16}
className={ className={
i === activeGroup i === activeGroup ? "text-gray-700" : "text-gray-400"
? "text-blue-700"
: "text-gray-400"
} }
/> />
</button> </button>

View File

@@ -1,4 +1,4 @@
import { useState, useRef } from "react"; import { useRef, useState } from "react";
import { apiUpload } from "../lib/api"; import { apiUpload } from "../lib/api";
interface ImageUploadProps { interface ImageUploadProps {
@@ -32,10 +32,7 @@ export function ImageUpload({ value, onChange }: ImageUploadProps) {
setUploading(true); setUploading(true);
try { try {
const result = await apiUpload<{ filename: string }>( const result = await apiUpload<{ filename: string }>("/api/images", file);
"/api/images",
file,
);
onChange(result.filename); onChange(result.filename);
} catch { } catch {
setError("Upload failed. Please try again."); setError("Upload failed. Please try again.");

View File

@@ -48,7 +48,7 @@ export function ItemCard({
openExternalLink(productUrl); openExternalLink(productUrl);
} }
}} }}
className={`absolute top-2 ${onRemove ? "right-10" : "right-2"} z-10 w-6 h-6 flex items-center justify-center rounded-full bg-gray-100/80 text-gray-400 hover:bg-blue-100 hover:text-blue-500 opacity-0 group-hover:opacity-100 transition-all cursor-pointer`} className={`absolute top-2 ${onRemove ? "right-10" : "right-2"} z-10 w-6 h-6 flex items-center justify-center rounded-full bg-gray-100/80 text-gray-400 hover:bg-gray-200 hover:text-gray-600 opacity-0 group-hover:opacity-100 transition-all cursor-pointer`}
title="Open product link" title="Open product link"
> >
<svg <svg
@@ -107,7 +107,11 @@ export function ItemCard({
/> />
) : ( ) : (
<div className="w-full h-full flex flex-col items-center justify-center"> <div className="w-full h-full flex flex-col items-center justify-center">
<LucideIcon name={categoryIcon} size={36} className="text-gray-400" /> <LucideIcon
name={categoryIcon}
size={36}
className="text-gray-400"
/>
</div> </div>
)} )}
</div> </div>
@@ -117,17 +121,22 @@ export function ItemCard({
</h3> </h3>
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
{weightGrams != null && ( {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"> <span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-50 text-blue-400">
{formatWeight(weightGrams)} {formatWeight(weightGrams)}
</span> </span>
)} )}
{priceCents != null && ( {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"> <span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-50 text-green-500">
{formatPrice(priceCents)} {formatPrice(priceCents)}
</span> </span>
)} )}
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-50 text-gray-600"> <span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-50 text-gray-600">
<LucideIcon name={categoryIcon} size={14} className="inline-block mr-1 text-gray-500" /> {categoryName} <LucideIcon
name={categoryIcon}
size={14}
className="inline-block mr-1 text-gray-500"
/>{" "}
{categoryName}
</span> </span>
</div> </div>
</div> </div>

View File

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

View File

@@ -1,142 +1,154 @@
import { useState, useEffect } from "react"; import { useEffect, useState } from "react";
import { SlideOutPanel } from "./SlideOutPanel";
import { useItems } from "../hooks/useItems"; import { useItems } from "../hooks/useItems";
import { useSyncSetupItems } from "../hooks/useSetups"; import { useSyncSetupItems } from "../hooks/useSetups";
import { formatWeight, formatPrice } from "../lib/formatters"; import { formatPrice, formatWeight } from "../lib/formatters";
import { LucideIcon } from "../lib/iconData"; import { LucideIcon } from "../lib/iconData";
import { SlideOutPanel } from "./SlideOutPanel";
interface ItemPickerProps { interface ItemPickerProps {
setupId: number; setupId: number;
currentItemIds: number[]; currentItemIds: number[];
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
} }
export function ItemPicker({ export function ItemPicker({
setupId, setupId,
currentItemIds, currentItemIds,
isOpen, isOpen,
onClose, onClose,
}: ItemPickerProps) { }: ItemPickerProps) {
const { data: items } = useItems(); const { data: items } = useItems();
const syncItems = useSyncSetupItems(setupId); const syncItems = useSyncSetupItems(setupId);
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set()); const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
// Reset selected IDs when panel opens // Reset selected IDs when panel opens
useEffect(() => { useEffect(() => {
if (isOpen) { if (isOpen) {
setSelectedIds(new Set(currentItemIds)); setSelectedIds(new Set(currentItemIds));
} }
}, [isOpen, currentItemIds]); }, [isOpen, currentItemIds]);
function handleToggle(itemId: number) { function handleToggle(itemId: number) {
setSelectedIds((prev) => { setSelectedIds((prev) => {
const next = new Set(prev); const next = new Set(prev);
if (next.has(itemId)) { if (next.has(itemId)) {
next.delete(itemId); next.delete(itemId);
} else { } else {
next.add(itemId); next.add(itemId);
} }
return next; return next;
}); });
} }
function handleDone() { function handleDone() {
syncItems.mutate(Array.from(selectedIds), { syncItems.mutate(Array.from(selectedIds), {
onSuccess: () => onClose(), onSuccess: () => onClose(),
}); });
} }
// Group items by category // Group items by category
const grouped = new Map< const grouped = new Map<
number, number,
{ {
categoryName: string; categoryName: string;
categoryIcon: string; categoryIcon: string;
items: NonNullable<typeof items>; items: NonNullable<typeof items>;
} }
>(); >();
if (items) { if (items) {
for (const item of items) { for (const item of items) {
const group = grouped.get(item.categoryId); const group = grouped.get(item.categoryId);
if (group) { if (group) {
group.items.push(item); group.items.push(item);
} else { } else {
grouped.set(item.categoryId, { grouped.set(item.categoryId, {
categoryName: item.categoryName, categoryName: item.categoryName,
categoryIcon: item.categoryIcon, categoryIcon: item.categoryIcon,
items: [item], items: [item],
}); });
} }
} }
} }
return ( return (
<SlideOutPanel isOpen={isOpen} onClose={onClose} title="Select Items"> <SlideOutPanel isOpen={isOpen} onClose={onClose} title="Select Items">
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
<div className="flex-1 overflow-y-auto -mx-6 px-6"> <div className="flex-1 overflow-y-auto -mx-6 px-6">
{!items || items.length === 0 ? ( {!items || items.length === 0 ? (
<div className="py-8 text-center"> <div className="py-8 text-center">
<p className="text-sm text-gray-500"> <p className="text-sm text-gray-500">
No items in your collection yet. No items in your collection yet.
</p> </p>
</div> </div>
) : ( ) : (
Array.from(grouped.entries()).map( Array.from(grouped.entries()).map(
([categoryId, { categoryName, categoryIcon, items: catItems }]) => ( ([
<div key={categoryId} className="mb-4"> categoryId,
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2"> { categoryName, categoryIcon, items: catItems },
<LucideIcon name={categoryIcon} size={16} className="inline-block mr-1 text-gray-500" /> {categoryName} ]) => (
</h3> <div key={categoryId} className="mb-4">
<div className="space-y-1"> <h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2">
{catItems.map((item) => ( <LucideIcon
<label name={categoryIcon}
key={item.id} size={16}
className="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-gray-50 cursor-pointer transition-colors" className="inline-block mr-1 text-gray-500"
> />{" "}
<input {categoryName}
type="checkbox" </h3>
checked={selectedIds.has(item.id)} <div className="space-y-1">
onChange={() => handleToggle(item.id)} {catItems.map((item) => (
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500" <label
/> key={item.id}
<span className="flex-1 text-sm text-gray-900 truncate"> className="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-gray-50 cursor-pointer transition-colors"
{item.name} >
</span> <input
<span className="text-xs text-gray-400 shrink-0"> type="checkbox"
{item.weightGrams != null && formatWeight(item.weightGrams)} checked={selectedIds.has(item.id)}
{item.weightGrams != null && item.priceCents != null && " · "} onChange={() => handleToggle(item.id)}
{item.priceCents != null && formatPrice(item.priceCents)} className="rounded border-gray-300 text-gray-600 focus:ring-gray-400"
</span> />
</label> <span className="flex-1 text-sm text-gray-900 truncate">
))} {item.name}
</div> </span>
</div> <span className="text-xs text-gray-400 shrink-0">
), {item.weightGrams != null &&
) formatWeight(item.weightGrams)}
)} {item.weightGrams != null &&
</div> item.priceCents != null &&
" · "}
{item.priceCents != null &&
formatPrice(item.priceCents)}
</span>
</label>
))}
</div>
</div>
),
)
)}
</div>
{/* Action buttons */} {/* Action buttons */}
<div className="flex gap-3 pt-4 border-t border-gray-100 -mx-6 px-6 pb-2"> <div className="flex gap-3 pt-4 border-t border-gray-100 -mx-6 px-6 pb-2">
<button <button
type="button" type="button"
onClick={onClose} onClick={onClose}
className="flex-1 px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors" className="flex-1 px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors"
> >
Cancel Cancel
</button> </button>
<button <button
type="button" type="button"
onClick={handleDone} onClick={handleDone}
disabled={syncItems.isPending} disabled={syncItems.isPending}
className="flex-1 px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 disabled:opacity-50 rounded-lg transition-colors" className="flex-1 px-4 py-2 text-sm font-medium text-white bg-gray-700 hover:bg-gray-800 disabled:opacity-50 rounded-lg transition-colors"
> >
{syncItems.isPending ? "Saving..." : "Done"} {syncItems.isPending ? "Saving..." : "Done"}
</button> </button>
</div> </div>
</div> </div>
</SlideOutPanel> </SlideOutPanel>
); );
} }

View File

@@ -102,7 +102,7 @@ export function OnboardingWizard({ onComplete }: OnboardingWizardProps) {
<div <div
key={s} key={s}
className={`h-1.5 rounded-full transition-all ${ className={`h-1.5 rounded-full transition-all ${
s <= Math.min(step, 3) ? "bg-blue-600 w-8" : "bg-gray-200 w-6" s <= Math.min(step, 3) ? "bg-gray-700 w-8" : "bg-gray-200 w-6"
}`} }`}
/> />
))} ))}
@@ -121,7 +121,7 @@ export function OnboardingWizard({ onComplete }: OnboardingWizardProps) {
<button <button
type="button" type="button"
onClick={() => setStep(2)} 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" className="w-full py-3 px-4 bg-gray-700 hover:bg-gray-800 text-white font-medium rounded-lg transition-colors"
> >
Get Started Get Started
</button> </button>
@@ -159,9 +159,8 @@ export function OnboardingWizard({ onComplete }: OnboardingWizardProps) {
type="text" type="text"
value={categoryName} value={categoryName}
onChange={(e) => setCategoryName(e.target.value)} 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" className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent"
placeholder="e.g. Shelter" placeholder="e.g. Shelter"
autoFocus
/> />
</div> </div>
@@ -185,7 +184,7 @@ export function OnboardingWizard({ onComplete }: OnboardingWizardProps) {
type="button" type="button"
onClick={handleCreateCategory} onClick={handleCreateCategory}
disabled={createCategory.isPending} 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" className="mt-6 w-full py-3 px-4 bg-gray-700 hover:bg-gray-800 disabled:opacity-50 text-white font-medium rounded-lg transition-colors"
> >
{createCategory.isPending ? "Creating..." : "Create Category"} {createCategory.isPending ? "Creating..." : "Create Category"}
</button> </button>
@@ -222,9 +221,8 @@ export function OnboardingWizard({ onComplete }: OnboardingWizardProps) {
type="text" type="text"
value={itemName} value={itemName}
onChange={(e) => setItemName(e.target.value)} 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" className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent"
placeholder="e.g. Big Agnes Copper Spur" placeholder="e.g. Big Agnes Copper Spur"
autoFocus
/> />
</div> </div>
@@ -243,7 +241,7 @@ export function OnboardingWizard({ onComplete }: OnboardingWizardProps) {
step="any" step="any"
value={itemWeight} value={itemWeight}
onChange={(e) => setItemWeight(e.target.value)} 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" className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent"
placeholder="e.g. 1200" placeholder="e.g. 1200"
/> />
</div> </div>
@@ -261,7 +259,7 @@ export function OnboardingWizard({ onComplete }: OnboardingWizardProps) {
step="0.01" step="0.01"
value={itemPrice} value={itemPrice}
onChange={(e) => setItemPrice(e.target.value)} 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" className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent"
placeholder="e.g. 349.99" placeholder="e.g. 349.99"
/> />
</div> </div>
@@ -274,7 +272,7 @@ export function OnboardingWizard({ onComplete }: OnboardingWizardProps) {
type="button" type="button"
onClick={handleCreateItem} onClick={handleCreateItem}
disabled={createItem.isPending} 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" className="mt-6 w-full py-3 px-4 bg-gray-700 hover:bg-gray-800 disabled:opacity-50 text-white font-medium rounded-lg transition-colors"
> >
{createItem.isPending ? "Adding..." : "Add Item"} {createItem.isPending ? "Adding..." : "Add Item"}
</button> </button>
@@ -309,7 +307,7 @@ export function OnboardingWizard({ onComplete }: OnboardingWizardProps) {
type="button" type="button"
onClick={handleDone} onClick={handleDone}
disabled={updateSetting.isPending} 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" className="w-full py-3 px-4 bg-gray-700 hover:bg-gray-800 disabled:opacity-50 text-white font-medium rounded-lg transition-colors"
> >
{updateSetting.isPending ? "Finishing..." : "Done"} {updateSetting.isPending ? "Finishing..." : "Done"}
</button> </button>

View File

@@ -1,43 +1,41 @@
import { Link } from "@tanstack/react-router"; import { Link } from "@tanstack/react-router";
import { formatWeight, formatPrice } from "../lib/formatters"; import { formatPrice, formatWeight } from "../lib/formatters";
interface SetupCardProps { interface SetupCardProps {
id: number; id: number;
name: string; name: string;
itemCount: number; itemCount: number;
totalWeight: number; totalWeight: number;
totalCost: number; totalCost: number;
} }
export function SetupCard({ export function SetupCard({
id, id,
name, name,
itemCount, itemCount,
totalWeight, totalWeight,
totalCost, totalCost,
}: SetupCardProps) { }: SetupCardProps) {
return ( return (
<Link <Link
to="/setups/$setupId" to="/setups/$setupId"
params={{ setupId: String(id) }} params={{ setupId: String(id) }}
className="block w-full text-left bg-white rounded-xl border border-gray-100 hover:border-gray-200 hover:shadow-md transition-all p-4" className="block w-full text-left bg-white rounded-xl border border-gray-100 hover:border-gray-200 hover:shadow-md transition-all p-4"
> >
<div className="flex items-start justify-between mb-3"> <div className="flex items-start justify-between mb-3">
<h3 className="text-sm font-semibold text-gray-900 truncate"> <h3 className="text-sm font-semibold text-gray-900 truncate">{name}</h3>
{name} <span className="ml-2 inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-50 text-blue-400 shrink-0">
</h3> {itemCount} {itemCount === 1 ? "item" : "items"}
<span className="ml-2 inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-50 text-blue-700 shrink-0"> </span>
{itemCount} {itemCount === 1 ? "item" : "items"} </div>
</span> <div className="flex flex-wrap gap-1.5">
</div> <span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-50 text-blue-400">
<div className="flex flex-wrap gap-1.5"> {formatWeight(totalWeight)}
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-50 text-blue-700"> </span>
{formatWeight(totalWeight)} <span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-50 text-green-500">
</span> {formatPrice(totalCost)}
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-50 text-green-700"> </span>
{formatPrice(totalCost)} </div>
</span> </Link>
</div> );
</Link>
);
} }

View File

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

View File

@@ -66,17 +66,22 @@ export function ThreadCard({
)} )}
</div> </div>
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-50 text-blue-700"> <span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-50 text-gray-600">
<LucideIcon name={categoryIcon} size={16} className="inline-block mr-1 text-gray-500" /> {categoryName} <LucideIcon
name={categoryIcon}
size={16}
className="inline-block mr-1 text-gray-500"
/>{" "}
{categoryName}
</span> </span>
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-purple-50 text-purple-700"> <span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-purple-50 text-purple-500">
{candidateCount} {candidateCount === 1 ? "candidate" : "candidates"} {candidateCount} {candidateCount === 1 ? "candidate" : "candidates"}
</span> </span>
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-50 text-gray-600"> <span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-50 text-gray-600">
{formatDate(createdAt)} {formatDate(createdAt)}
</span> </span>
{priceRange && ( {priceRange && (
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-50 text-green-700"> <span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-50 text-green-500">
{priceRange} {priceRange}
</span> </span>
)} )}

View File

@@ -1,33 +1,36 @@
interface ThreadTabsProps { type TabKey = "gear" | "planning" | "setups";
active: "gear" | "planning";
onChange: (tab: "gear" | "planning") => void; interface CollectionTabsProps {
active: TabKey;
onChange: (tab: TabKey) => void;
} }
const tabs = [ const tabs = [
{ key: "gear" as const, label: "My Gear" }, { key: "gear" as const, label: "My Gear" },
{ key: "planning" as const, label: "Planning" }, { key: "planning" as const, label: "Planning" },
{ key: "setups" as const, label: "Setups" },
]; ];
export function ThreadTabs({ active, onChange }: ThreadTabsProps) { export function CollectionTabs({ active, onChange }: CollectionTabsProps) {
return ( return (
<div className="flex border-b border-gray-200"> <div className="flex border-b border-gray-200">
{tabs.map((tab) => ( {tabs.map((tab) => (
<button <button
key={tab.key} key={tab.key}
type="button" type="button"
onClick={() => onChange(tab.key)} onClick={() => onChange(tab.key)}
className={`px-4 py-2.5 text-sm font-medium transition-colors relative ${ className={`px-4 py-2.5 text-sm font-medium transition-colors relative ${
active === tab.key active === tab.key
? "text-blue-600" ? "text-gray-700"
: "text-gray-500 hover:text-gray-700" : "text-gray-500 hover:text-gray-700"
}`} }`}
> >
{tab.label} {tab.label}
{active === tab.key && ( {active === tab.key && (
<span className="absolute bottom-0 left-0 right-0 h-0.5 bg-blue-600 rounded-t" /> <span className="absolute bottom-0 left-0 right-0 h-0.5 bg-gray-700 rounded-t" />
)} )}
</button> </button>
))} ))}
</div> </div>
); );
} }

View File

@@ -1,59 +1,76 @@
import { Link } from "@tanstack/react-router"; import { Link } from "@tanstack/react-router";
import { useTotals } from "../hooks/useTotals"; import { useTotals } from "../hooks/useTotals";
import { formatWeight, formatPrice } from "../lib/formatters"; import { formatPrice, formatWeight } from "../lib/formatters";
import { LucideIcon } from "../lib/iconData";
interface TotalsBarProps { interface TotalsBarProps {
title?: string; title?: string;
stats?: Array<{ label: string; value: string }>; stats?: Array<{ label: string; value: string }>;
linkTo?: string; linkTo?: string;
} }
export function TotalsBar({ title = "GearBox", stats, linkTo }: TotalsBarProps) { export function TotalsBar({
const { data } = useTotals(); title = "GearBox",
stats,
linkTo,
}: TotalsBarProps) {
const { data } = useTotals();
// When no stats provided, use global totals (backward compatible) // When no stats provided, use global totals (backward compatible)
const displayStats = stats ?? (data?.global const displayStats =
? [ stats ??
{ label: "items", value: String(data.global.itemCount) }, (data?.global
{ label: "total", value: formatWeight(data.global.totalWeight) }, ? [
{ label: "spent", value: formatPrice(data.global.totalCost) }, { label: "items", value: String(data.global.itemCount) },
] { label: "total", value: formatWeight(data.global.totalWeight) },
: [ { label: "spent", value: formatPrice(data.global.totalCost) },
{ label: "items", value: "0" }, ]
{ label: "total", value: formatWeight(null) }, : [
{ label: "spent", value: formatPrice(null) }, { label: "items", value: "0" },
]); { label: "total", value: formatWeight(null) },
{ label: "spent", value: formatPrice(null) },
]);
const titleElement = linkTo ? ( const titleContent = (
<Link to={linkTo} className="text-lg font-semibold text-gray-900 hover:text-blue-600 transition-colors"> <span className="flex items-center gap-2">
{title} <LucideIcon name="package" size={20} className="text-gray-500" />
</Link> {title}
) : ( </span>
<h1 className="text-lg font-semibold text-gray-900">{title}</h1> );
);
// If stats prop is explicitly an empty array, show title only (dashboard mode) const titleElement = linkTo ? (
const showStats = stats === undefined || stats.length > 0; <Link
to={linkTo}
className="text-lg font-semibold text-gray-900 hover:text-gray-600 transition-colors"
>
{titleContent}
</Link>
) : (
<h1 className="text-lg font-semibold text-gray-900">{titleContent}</h1>
);
return ( // If stats prop is explicitly an empty array, show title only (dashboard mode)
<div className="sticky top-0 z-10 bg-white border-b border-gray-100"> const showStats = stats === undefined || stats.length > 0;
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-14"> return (
{titleElement} <div className="sticky top-0 z-10 bg-white border-b border-gray-100">
{showStats && ( <div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div className="flex items-center gap-6 text-sm text-gray-500"> <div className="flex items-center justify-between h-14">
{displayStats.map((stat) => ( {titleElement}
<span key={stat.label}> {showStats && (
<span className="font-medium text-gray-700"> <div className="flex items-center gap-6 text-sm text-gray-500">
{stat.value} {displayStats.map((stat) => (
</span>{" "} <span key={stat.label}>
{stat.label} <span className="font-medium text-gray-700">
</span> {stat.value}
))} </span>{" "}
</div> {stat.label}
)} </span>
</div> ))}
</div> </div>
</div> )}
); </div>
</div>
</div>
);
} }

View File

@@ -1,61 +1,61 @@
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { apiPost, apiPut, apiDelete } from "../lib/api";
import type { CreateCandidate, UpdateCandidate } from "../../shared/types"; import type { CreateCandidate, UpdateCandidate } from "../../shared/types";
import { apiDelete, apiPost, apiPut } from "../lib/api";
interface CandidateResponse { interface CandidateResponse {
id: number; id: number;
threadId: number; threadId: number;
name: string; name: string;
weightGrams: number | null; weightGrams: number | null;
priceCents: number | null; priceCents: number | null;
categoryId: number; categoryId: number;
notes: string | null; notes: string | null;
productUrl: string | null; productUrl: string | null;
imageFilename: string | null; imageFilename: string | null;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
} }
export function useCreateCandidate(threadId: number) { export function useCreateCandidate(threadId: number) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: (data: CreateCandidate & { imageFilename?: string }) => mutationFn: (data: CreateCandidate & { imageFilename?: string }) =>
apiPost<CandidateResponse>(`/api/threads/${threadId}/candidates`, data), apiPost<CandidateResponse>(`/api/threads/${threadId}/candidates`, data),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["threads", threadId] }); queryClient.invalidateQueries({ queryKey: ["threads", threadId] });
queryClient.invalidateQueries({ queryKey: ["threads"] }); queryClient.invalidateQueries({ queryKey: ["threads"] });
}, },
}); });
} }
export function useUpdateCandidate(threadId: number) { export function useUpdateCandidate(threadId: number) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: ({ mutationFn: ({
candidateId, candidateId,
...data ...data
}: UpdateCandidate & { candidateId: number; imageFilename?: string }) => }: UpdateCandidate & { candidateId: number; imageFilename?: string }) =>
apiPut<CandidateResponse>( apiPut<CandidateResponse>(
`/api/threads/${threadId}/candidates/${candidateId}`, `/api/threads/${threadId}/candidates/${candidateId}`,
data, data,
), ),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["threads", threadId] }); queryClient.invalidateQueries({ queryKey: ["threads", threadId] });
queryClient.invalidateQueries({ queryKey: ["threads"] }); queryClient.invalidateQueries({ queryKey: ["threads"] });
}, },
}); });
} }
export function useDeleteCandidate(threadId: number) { export function useDeleteCandidate(threadId: number) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: (candidateId: number) => mutationFn: (candidateId: number) =>
apiDelete<{ success: boolean }>( apiDelete<{ success: boolean }>(
`/api/threads/${threadId}/candidates/${candidateId}`, `/api/threads/${threadId}/candidates/${candidateId}`,
), ),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["threads", threadId] }); queryClient.invalidateQueries({ queryKey: ["threads", threadId] });
queryClient.invalidateQueries({ queryKey: ["threads"] }); queryClient.invalidateQueries({ queryKey: ["threads"] });
}, },
}); });
} }

View File

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

View File

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

View File

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

View File

@@ -1,107 +1,107 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiGet, apiPost, apiPut, apiDelete } from "../lib/api"; import { apiDelete, apiGet, apiPost, apiPut } from "../lib/api";
interface SetupListItem { interface SetupListItem {
id: number; id: number;
name: string; name: string;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
itemCount: number; itemCount: number;
totalWeight: number; totalWeight: number;
totalCost: number; totalCost: number;
} }
interface SetupItemWithCategory { interface SetupItemWithCategory {
id: number; id: number;
name: string; name: string;
weightGrams: number | null; weightGrams: number | null;
priceCents: number | null; priceCents: number | null;
categoryId: number; categoryId: number;
notes: string | null; notes: string | null;
productUrl: string | null; productUrl: string | null;
imageFilename: string | null; imageFilename: string | null;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
categoryName: string; categoryName: string;
categoryIcon: string; categoryIcon: string;
} }
interface SetupWithItems { interface SetupWithItems {
id: number; id: number;
name: string; name: string;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
items: SetupItemWithCategory[]; items: SetupItemWithCategory[];
} }
export type { SetupListItem, SetupWithItems, SetupItemWithCategory }; export type { SetupItemWithCategory, SetupListItem, SetupWithItems };
export function useSetups() { export function useSetups() {
return useQuery({ return useQuery({
queryKey: ["setups"], queryKey: ["setups"],
queryFn: () => apiGet<SetupListItem[]>("/api/setups"), queryFn: () => apiGet<SetupListItem[]>("/api/setups"),
}); });
} }
export function useSetup(setupId: number | null) { export function useSetup(setupId: number | null) {
return useQuery({ return useQuery({
queryKey: ["setups", setupId], queryKey: ["setups", setupId],
queryFn: () => apiGet<SetupWithItems>(`/api/setups/${setupId}`), queryFn: () => apiGet<SetupWithItems>(`/api/setups/${setupId}`),
enabled: setupId != null, enabled: setupId != null,
}); });
} }
export function useCreateSetup() { export function useCreateSetup() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: (data: { name: string }) => mutationFn: (data: { name: string }) =>
apiPost<SetupListItem>("/api/setups", data), apiPost<SetupListItem>("/api/setups", data),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["setups"] }); queryClient.invalidateQueries({ queryKey: ["setups"] });
}, },
}); });
} }
export function useUpdateSetup(setupId: number) { export function useUpdateSetup(setupId: number) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: (data: { name?: string }) => mutationFn: (data: { name?: string }) =>
apiPut<SetupListItem>(`/api/setups/${setupId}`, data), apiPut<SetupListItem>(`/api/setups/${setupId}`, data),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["setups"] }); queryClient.invalidateQueries({ queryKey: ["setups"] });
}, },
}); });
} }
export function useDeleteSetup() { export function useDeleteSetup() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: (id: number) => mutationFn: (id: number) =>
apiDelete<{ success: boolean }>(`/api/setups/${id}`), apiDelete<{ success: boolean }>(`/api/setups/${id}`),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["setups"] }); queryClient.invalidateQueries({ queryKey: ["setups"] });
}, },
}); });
} }
export function useSyncSetupItems(setupId: number) { export function useSyncSetupItems(setupId: number) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: (itemIds: number[]) => mutationFn: (itemIds: number[]) =>
apiPut<{ success: boolean }>(`/api/setups/${setupId}/items`, { itemIds }), apiPut<{ success: boolean }>(`/api/setups/${setupId}/items`, { itemIds }),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["setups"] }); queryClient.invalidateQueries({ queryKey: ["setups"] });
}, },
}); });
} }
export function useRemoveSetupItem(setupId: number) { export function useRemoveSetupItem(setupId: number) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: (itemId: number) => mutationFn: (itemId: number) =>
apiDelete<{ success: boolean }>(`/api/setups/${setupId}/items/${itemId}`), apiDelete<{ success: boolean }>(`/api/setups/${setupId}/items/${itemId}`),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["setups"] }); queryClient.invalidateQueries({ queryKey: ["setups"] });
}, },
}); });
} }

View File

@@ -1,116 +1,116 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiGet, apiPost, apiPut, apiDelete } from "../lib/api"; import { apiDelete, apiGet, apiPost, apiPut } from "../lib/api";
interface ThreadListItem { interface ThreadListItem {
id: number; id: number;
name: string; name: string;
status: "active" | "resolved"; status: "active" | "resolved";
resolvedCandidateId: number | null; resolvedCandidateId: number | null;
categoryId: number; categoryId: number;
categoryName: string; categoryName: string;
categoryIcon: string; categoryIcon: string;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
candidateCount: number; candidateCount: number;
minPriceCents: number | null; minPriceCents: number | null;
maxPriceCents: number | null; maxPriceCents: number | null;
} }
interface CandidateWithCategory { interface CandidateWithCategory {
id: number; id: number;
threadId: number; threadId: number;
name: string; name: string;
weightGrams: number | null; weightGrams: number | null;
priceCents: number | null; priceCents: number | null;
categoryId: number; categoryId: number;
notes: string | null; notes: string | null;
productUrl: string | null; productUrl: string | null;
imageFilename: string | null; imageFilename: string | null;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
categoryName: string; categoryName: string;
categoryIcon: string; categoryIcon: string;
} }
interface ThreadWithCandidates { interface ThreadWithCandidates {
id: number; id: number;
name: string; name: string;
status: "active" | "resolved"; status: "active" | "resolved";
resolvedCandidateId: number | null; resolvedCandidateId: number | null;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
candidates: CandidateWithCategory[]; candidates: CandidateWithCategory[];
} }
export function useThreads(includeResolved = false) { export function useThreads(includeResolved = false) {
return useQuery({ return useQuery({
queryKey: ["threads", { includeResolved }], queryKey: ["threads", { includeResolved }],
queryFn: () => queryFn: () =>
apiGet<ThreadListItem[]>( apiGet<ThreadListItem[]>(
`/api/threads${includeResolved ? "?includeResolved=true" : ""}`, `/api/threads${includeResolved ? "?includeResolved=true" : ""}`,
), ),
}); });
} }
export function useThread(threadId: number | null) { export function useThread(threadId: number | null) {
return useQuery({ return useQuery({
queryKey: ["threads", threadId], queryKey: ["threads", threadId],
queryFn: () => apiGet<ThreadWithCandidates>(`/api/threads/${threadId}`), queryFn: () => apiGet<ThreadWithCandidates>(`/api/threads/${threadId}`),
enabled: threadId != null, enabled: threadId != null,
}); });
} }
export function useCreateThread() { export function useCreateThread() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: (data: { name: string; categoryId: number }) => mutationFn: (data: { name: string; categoryId: number }) =>
apiPost<ThreadListItem>("/api/threads", data), apiPost<ThreadListItem>("/api/threads", data),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["threads"] }); queryClient.invalidateQueries({ queryKey: ["threads"] });
}, },
}); });
} }
export function useUpdateThread() { export function useUpdateThread() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: ({ id, ...data }: { id: number; name?: string }) => mutationFn: ({ id, ...data }: { id: number; name?: string }) =>
apiPut<ThreadListItem>(`/api/threads/${id}`, data), apiPut<ThreadListItem>(`/api/threads/${id}`, data),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["threads"] }); queryClient.invalidateQueries({ queryKey: ["threads"] });
}, },
}); });
} }
export function useDeleteThread() { export function useDeleteThread() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: (id: number) => mutationFn: (id: number) =>
apiDelete<{ success: boolean }>(`/api/threads/${id}`), apiDelete<{ success: boolean }>(`/api/threads/${id}`),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["threads"] }); queryClient.invalidateQueries({ queryKey: ["threads"] });
}, },
}); });
} }
export function useResolveThread() { export function useResolveThread() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: ({ mutationFn: ({
threadId, threadId,
candidateId, candidateId,
}: { }: {
threadId: number; threadId: number;
candidateId: number; candidateId: number;
}) => }) =>
apiPost<{ success: boolean; item: unknown }>( apiPost<{ success: boolean; item: unknown }>(
`/api/threads/${threadId}/resolve`, `/api/threads/${threadId}/resolve`,
{ candidateId }, { candidateId },
), ),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["threads"] }); queryClient.invalidateQueries({ queryKey: ["threads"] });
queryClient.invalidateQueries({ queryKey: ["items"] }); queryClient.invalidateQueries({ queryKey: ["items"] });
queryClient.invalidateQueries({ queryKey: ["totals"] }); queryClient.invalidateQueries({ queryKey: ["totals"] });
}, },
}); });
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -10,7 +10,6 @@
import { Route as rootRouteImport } from './routes/__root' import { Route as rootRouteImport } from './routes/__root'
import { Route as IndexRouteImport } from './routes/index' import { Route as IndexRouteImport } from './routes/index'
import { Route as SetupsIndexRouteImport } from './routes/setups/index'
import { Route as CollectionIndexRouteImport } from './routes/collection/index' import { Route as CollectionIndexRouteImport } from './routes/collection/index'
import { Route as ThreadsThreadIdRouteImport } from './routes/threads/$threadId' import { Route as ThreadsThreadIdRouteImport } from './routes/threads/$threadId'
import { Route as SetupsSetupIdRouteImport } from './routes/setups/$setupId' import { Route as SetupsSetupIdRouteImport } from './routes/setups/$setupId'
@@ -20,11 +19,6 @@ const IndexRoute = IndexRouteImport.update({
path: '/', path: '/',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } as any)
const SetupsIndexRoute = SetupsIndexRouteImport.update({
id: '/setups/',
path: '/setups/',
getParentRoute: () => rootRouteImport,
} as any)
const CollectionIndexRoute = CollectionIndexRouteImport.update({ const CollectionIndexRoute = CollectionIndexRouteImport.update({
id: '/collection/', id: '/collection/',
path: '/collection/', path: '/collection/',
@@ -46,14 +40,12 @@ export interface FileRoutesByFullPath {
'/setups/$setupId': typeof SetupsSetupIdRoute '/setups/$setupId': typeof SetupsSetupIdRoute
'/threads/$threadId': typeof ThreadsThreadIdRoute '/threads/$threadId': typeof ThreadsThreadIdRoute
'/collection/': typeof CollectionIndexRoute '/collection/': typeof CollectionIndexRoute
'/setups/': typeof SetupsIndexRoute
} }
export interface FileRoutesByTo { export interface FileRoutesByTo {
'/': typeof IndexRoute '/': typeof IndexRoute
'/setups/$setupId': typeof SetupsSetupIdRoute '/setups/$setupId': typeof SetupsSetupIdRoute
'/threads/$threadId': typeof ThreadsThreadIdRoute '/threads/$threadId': typeof ThreadsThreadIdRoute
'/collection': typeof CollectionIndexRoute '/collection': typeof CollectionIndexRoute
'/setups': typeof SetupsIndexRoute
} }
export interface FileRoutesById { export interface FileRoutesById {
__root__: typeof rootRouteImport __root__: typeof rootRouteImport
@@ -61,30 +53,18 @@ export interface FileRoutesById {
'/setups/$setupId': typeof SetupsSetupIdRoute '/setups/$setupId': typeof SetupsSetupIdRoute
'/threads/$threadId': typeof ThreadsThreadIdRoute '/threads/$threadId': typeof ThreadsThreadIdRoute
'/collection/': typeof CollectionIndexRoute '/collection/': typeof CollectionIndexRoute
'/setups/': typeof SetupsIndexRoute
} }
export interface FileRouteTypes { export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: fullPaths: '/' | '/setups/$setupId' | '/threads/$threadId' | '/collection/'
| '/'
| '/setups/$setupId'
| '/threads/$threadId'
| '/collection/'
| '/setups/'
fileRoutesByTo: FileRoutesByTo fileRoutesByTo: FileRoutesByTo
to: to: '/' | '/setups/$setupId' | '/threads/$threadId' | '/collection'
| '/'
| '/setups/$setupId'
| '/threads/$threadId'
| '/collection'
| '/setups'
id: id:
| '__root__' | '__root__'
| '/' | '/'
| '/setups/$setupId' | '/setups/$setupId'
| '/threads/$threadId' | '/threads/$threadId'
| '/collection/' | '/collection/'
| '/setups/'
fileRoutesById: FileRoutesById fileRoutesById: FileRoutesById
} }
export interface RootRouteChildren { export interface RootRouteChildren {
@@ -92,7 +72,6 @@ export interface RootRouteChildren {
SetupsSetupIdRoute: typeof SetupsSetupIdRoute SetupsSetupIdRoute: typeof SetupsSetupIdRoute
ThreadsThreadIdRoute: typeof ThreadsThreadIdRoute ThreadsThreadIdRoute: typeof ThreadsThreadIdRoute
CollectionIndexRoute: typeof CollectionIndexRoute CollectionIndexRoute: typeof CollectionIndexRoute
SetupsIndexRoute: typeof SetupsIndexRoute
} }
declare module '@tanstack/react-router' { declare module '@tanstack/react-router' {
@@ -104,13 +83,6 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof IndexRouteImport preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport parentRoute: typeof rootRouteImport
} }
'/setups/': {
id: '/setups/'
path: '/setups'
fullPath: '/setups/'
preLoaderRoute: typeof SetupsIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/collection/': { '/collection/': {
id: '/collection/' id: '/collection/'
path: '/collection' path: '/collection'
@@ -140,7 +112,6 @@ const rootRouteChildren: RootRouteChildren = {
SetupsSetupIdRoute: SetupsSetupIdRoute, SetupsSetupIdRoute: SetupsSetupIdRoute,
ThreadsThreadIdRoute: ThreadsThreadIdRoute, ThreadsThreadIdRoute: ThreadsThreadIdRoute,
CollectionIndexRoute: CollectionIndexRoute, CollectionIndexRoute: CollectionIndexRoute,
SetupsIndexRoute: SetupsIndexRoute,
} }
export const routeTree = rootRouteImport export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren) ._addFileChildren(rootRouteChildren)

View File

@@ -1,323 +1,329 @@
import { useState } from "react";
import { import {
createRootRoute, createRootRoute,
Outlet, Outlet,
useMatchRoute, useMatchRoute,
useNavigate, useNavigate,
} from "@tanstack/react-router"; } from "@tanstack/react-router";
import { useState } from "react";
import "../app.css"; import "../app.css";
import { TotalsBar } from "../components/TotalsBar";
import { SlideOutPanel } from "../components/SlideOutPanel";
import { ItemForm } from "../components/ItemForm";
import { CandidateForm } from "../components/CandidateForm"; import { CandidateForm } from "../components/CandidateForm";
import { ConfirmDialog } from "../components/ConfirmDialog"; import { ConfirmDialog } from "../components/ConfirmDialog";
import { ExternalLinkDialog } from "../components/ExternalLinkDialog"; import { ExternalLinkDialog } from "../components/ExternalLinkDialog";
import { ItemForm } from "../components/ItemForm";
import { OnboardingWizard } from "../components/OnboardingWizard"; import { OnboardingWizard } from "../components/OnboardingWizard";
import { useUIStore } from "../stores/uiStore"; import { SlideOutPanel } from "../components/SlideOutPanel";
import { useOnboardingComplete } from "../hooks/useSettings"; import { TotalsBar } from "../components/TotalsBar";
import { useThread, useResolveThread } from "../hooks/useThreads";
import { useDeleteCandidate } from "../hooks/useCandidates"; import { useDeleteCandidate } from "../hooks/useCandidates";
import { useOnboardingComplete } from "../hooks/useSettings";
import { useResolveThread, useThread } from "../hooks/useThreads";
import { useUIStore } from "../stores/uiStore";
export const Route = createRootRoute({ export const Route = createRootRoute({
component: RootLayout, component: RootLayout,
}); });
function RootLayout() { function RootLayout() {
const navigate = useNavigate(); const navigate = useNavigate();
// Item panel state // Item panel state
const panelMode = useUIStore((s) => s.panelMode); const panelMode = useUIStore((s) => s.panelMode);
const editingItemId = useUIStore((s) => s.editingItemId); const editingItemId = useUIStore((s) => s.editingItemId);
const openAddPanel = useUIStore((s) => s.openAddPanel); const openAddPanel = useUIStore((s) => s.openAddPanel);
const closePanel = useUIStore((s) => s.closePanel); const closePanel = useUIStore((s) => s.closePanel);
// Candidate panel state // Candidate panel state
const candidatePanelMode = useUIStore((s) => s.candidatePanelMode); const candidatePanelMode = useUIStore((s) => s.candidatePanelMode);
const editingCandidateId = useUIStore((s) => s.editingCandidateId); const editingCandidateId = useUIStore((s) => s.editingCandidateId);
const closeCandidatePanel = useUIStore((s) => s.closeCandidatePanel); const closeCandidatePanel = useUIStore((s) => s.closeCandidatePanel);
// Candidate delete state // Candidate delete state
const confirmDeleteCandidateId = useUIStore( const confirmDeleteCandidateId = useUIStore(
(s) => s.confirmDeleteCandidateId, (s) => s.confirmDeleteCandidateId,
); );
const closeConfirmDeleteCandidate = useUIStore( const closeConfirmDeleteCandidate = useUIStore(
(s) => s.closeConfirmDeleteCandidate, (s) => s.closeConfirmDeleteCandidate,
); );
// Resolution dialog state // Resolution dialog state
const resolveThreadId = useUIStore((s) => s.resolveThreadId); const resolveThreadId = useUIStore((s) => s.resolveThreadId);
const resolveCandidateId = useUIStore((s) => s.resolveCandidateId); const resolveCandidateId = useUIStore((s) => s.resolveCandidateId);
const closeResolveDialog = useUIStore((s) => s.closeResolveDialog); const closeResolveDialog = useUIStore((s) => s.closeResolveDialog);
// Onboarding // Onboarding
const { data: onboardingComplete, isLoading: onboardingLoading } = const { data: onboardingComplete, isLoading: onboardingLoading } =
useOnboardingComplete(); useOnboardingComplete();
const [wizardDismissed, setWizardDismissed] = useState(false); const [wizardDismissed, setWizardDismissed] = useState(false);
const showWizard = const showWizard =
!onboardingLoading && onboardingComplete !== "true" && !wizardDismissed; !onboardingLoading && onboardingComplete !== "true" && !wizardDismissed;
const isItemPanelOpen = panelMode !== "closed"; const isItemPanelOpen = panelMode !== "closed";
const isCandidatePanelOpen = candidatePanelMode !== "closed"; const isCandidatePanelOpen = candidatePanelMode !== "closed";
// Route matching for contextual behavior // Route matching for contextual behavior
const matchRoute = useMatchRoute(); const matchRoute = useMatchRoute();
const threadMatch = matchRoute({ const threadMatch = matchRoute({
to: "/threads/$threadId", to: "/threads/$threadId",
fuzzy: true, fuzzy: true,
}) as { threadId?: string } | false; }) as { threadId?: string } | false;
const currentThreadId = threadMatch ? Number(threadMatch.threadId) : null; const currentThreadId = threadMatch ? Number(threadMatch.threadId) : null;
const isDashboard = !!matchRoute({ to: "/" }); const isDashboard = !!matchRoute({ to: "/" });
const isCollection = !!matchRoute({ to: "/collection", fuzzy: true }); const isCollection = !!matchRoute({ to: "/collection", fuzzy: true });
const isSetupDetail = !!matchRoute({ to: "/setups/$setupId", fuzzy: true }); const isSetupDetail = !!matchRoute({ to: "/setups/$setupId", fuzzy: true });
// Determine TotalsBar props based on current route // Determine TotalsBar props based on current route
const totalsBarProps = isDashboard const _totalsBarProps = isDashboard
? { stats: [] as Array<{ label: string; value: string }> } // Title only, no stats, no link ? { stats: [] as Array<{ label: string; value: string }> } // Title only, no stats, no link
: isSetupDetail : isSetupDetail
? { linkTo: "/" } // Setup detail will render its own local bar; root bar just has link ? { linkTo: "/" } // Setup detail will render its own local bar; root bar just has link
: { linkTo: "/" }; // All other pages: default stats + link to dashboard : { linkTo: "/" }; // All other pages: default stats + link to dashboard
// On dashboard, don't show the default global stats - pass empty stats // On dashboard, don't show the default global stats - pass empty stats
// On collection, let TotalsBar fetch its own global stats (default behavior) // On collection, let TotalsBar fetch its own global stats (default behavior)
const finalTotalsProps = isDashboard const finalTotalsProps = isDashboard
? { stats: [] as Array<{ label: string; value: string }> } ? { stats: [] as Array<{ label: string; value: string }> }
: isCollection : isCollection
? { linkTo: "/" } ? { linkTo: "/" }
: { linkTo: "/" }; : { linkTo: "/" };
// FAB visibility: only show on /collection route when gear tab is active // FAB visibility: only show on /collection route when gear tab is active
const collectionSearch = matchRoute({ to: "/collection" }) as { tab?: string } | false; const collectionSearch = matchRoute({ to: "/collection" }) as
const showFab = isCollection && (!collectionSearch || (collectionSearch as Record<string, string>).tab !== "planning"); | { tab?: string }
| false;
const showFab =
isCollection &&
(!collectionSearch ||
!(collectionSearch as Record<string, string>).tab ||
(collectionSearch as Record<string, string>).tab === "gear");
// Show a minimal loading state while checking onboarding status // Show a minimal loading state while checking onboarding status
if (onboardingLoading) { if (onboardingLoading) {
return ( return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center"> <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 className="w-6 h-6 border-2 border-gray-600 border-t-transparent rounded-full animate-spin" />
</div> </div>
); );
} }
return ( return (
<div className="min-h-screen bg-gray-50"> <div className="min-h-screen bg-gray-50">
<TotalsBar {...finalTotalsProps} /> <TotalsBar {...finalTotalsProps} />
<Outlet /> <Outlet />
{/* Item Slide-out Panel */} {/* Item Slide-out Panel */}
<SlideOutPanel <SlideOutPanel
isOpen={isItemPanelOpen} isOpen={isItemPanelOpen}
onClose={closePanel} onClose={closePanel}
title={panelMode === "add" ? "Add Item" : "Edit Item"} title={panelMode === "add" ? "Add Item" : "Edit Item"}
> >
{panelMode === "add" && <ItemForm mode="add" />} {panelMode === "add" && <ItemForm mode="add" />}
{panelMode === "edit" && ( {panelMode === "edit" && (
<ItemForm mode="edit" itemId={editingItemId} /> <ItemForm mode="edit" itemId={editingItemId} />
)} )}
</SlideOutPanel> </SlideOutPanel>
{/* Candidate Slide-out Panel */} {/* Candidate Slide-out Panel */}
{currentThreadId != null && ( {currentThreadId != null && (
<SlideOutPanel <SlideOutPanel
isOpen={isCandidatePanelOpen} isOpen={isCandidatePanelOpen}
onClose={closeCandidatePanel} onClose={closeCandidatePanel}
title={ title={
candidatePanelMode === "add" ? "Add Candidate" : "Edit Candidate" candidatePanelMode === "add" ? "Add Candidate" : "Edit Candidate"
} }
> >
{candidatePanelMode === "add" && ( {candidatePanelMode === "add" && (
<CandidateForm mode="add" threadId={currentThreadId} /> <CandidateForm mode="add" threadId={currentThreadId} />
)} )}
{candidatePanelMode === "edit" && ( {candidatePanelMode === "edit" && (
<CandidateForm <CandidateForm
mode="edit" mode="edit"
threadId={currentThreadId} threadId={currentThreadId}
candidateId={editingCandidateId} candidateId={editingCandidateId}
/> />
)} )}
</SlideOutPanel> </SlideOutPanel>
)} )}
{/* Item Confirm Delete Dialog */} {/* Item Confirm Delete Dialog */}
<ConfirmDialog /> <ConfirmDialog />
{/* External Link Confirmation Dialog */} {/* External Link Confirmation Dialog */}
<ExternalLinkDialog /> <ExternalLinkDialog />
{/* Candidate Delete Confirm Dialog */} {/* Candidate Delete Confirm Dialog */}
{confirmDeleteCandidateId != null && currentThreadId != null && ( {confirmDeleteCandidateId != null && currentThreadId != null && (
<CandidateDeleteDialog <CandidateDeleteDialog
candidateId={confirmDeleteCandidateId} candidateId={confirmDeleteCandidateId}
threadId={currentThreadId} threadId={currentThreadId}
onClose={closeConfirmDeleteCandidate} onClose={closeConfirmDeleteCandidate}
/> />
)} )}
{/* Resolution Confirm Dialog */} {/* Resolution Confirm Dialog */}
{resolveThreadId != null && resolveCandidateId != null && ( {resolveThreadId != null && resolveCandidateId != null && (
<ResolveDialog <ResolveDialog
threadId={resolveThreadId} threadId={resolveThreadId}
candidateId={resolveCandidateId} candidateId={resolveCandidateId}
onClose={closeResolveDialog} onClose={closeResolveDialog}
onResolved={() => { onResolved={() => {
closeResolveDialog(); closeResolveDialog();
navigate({ to: "/collection", search: { tab: "planning" } }); navigate({ to: "/collection", search: { tab: "planning" } });
}} }}
/> />
)} )}
{/* Floating Add Button - only on collection gear tab */} {/* Floating Add Button - only on collection gear tab */}
{showFab && ( {showFab && (
<button <button
type="button" type="button"
onClick={openAddPanel} 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" className="fixed bottom-6 right-6 z-20 w-14 h-14 bg-gray-700 hover:bg-gray-800 text-white rounded-full shadow-lg hover:shadow-xl transition-all flex items-center justify-center"
title="Add new item" title="Add new item"
> >
<svg <svg
className="w-6 h-6" className="w-6 h-6"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
viewBox="0 0 24 24" viewBox="0 0 24 24"
> >
<path <path
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
strokeWidth={2} strokeWidth={2}
d="M12 4v16m8-8H4" d="M12 4v16m8-8H4"
/> />
</svg> </svg>
</button> </button>
)} )}
{/* Onboarding Wizard */} {/* Onboarding Wizard */}
{showWizard && ( {showWizard && (
<OnboardingWizard onComplete={() => setWizardDismissed(true)} /> <OnboardingWizard onComplete={() => setWizardDismissed(true)} />
)} )}
</div> </div>
); );
} }
function CandidateDeleteDialog({ function CandidateDeleteDialog({
candidateId, candidateId,
threadId, threadId,
onClose, onClose,
}: { }: {
candidateId: number; candidateId: number;
threadId: number; threadId: number;
onClose: () => void; onClose: () => void;
}) { }) {
const deleteCandidate = useDeleteCandidate(threadId); const deleteCandidate = useDeleteCandidate(threadId);
const { data: thread } = useThread(threadId); const { data: thread } = useThread(threadId);
const candidate = thread?.candidates.find((c) => c.id === candidateId); const candidate = thread?.candidates.find((c) => c.id === candidateId);
const candidateName = candidate?.name ?? "this candidate"; const candidateName = candidate?.name ?? "this candidate";
function handleDelete() { function handleDelete() {
deleteCandidate.mutate(candidateId, { deleteCandidate.mutate(candidateId, {
onSuccess: () => onClose(), onSuccess: () => onClose(),
}); });
} }
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center"> <div className="fixed inset-0 z-50 flex items-center justify-center">
<div <div
className="absolute inset-0 bg-black/30" className="absolute inset-0 bg-black/30"
onClick={onClose} onClick={onClose}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Escape") onClose(); if (e.key === "Escape") onClose();
}} }}
/> />
<div className="relative bg-white rounded-xl shadow-lg p-6 max-w-sm mx-4 w-full"> <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"> <h3 className="text-lg font-semibold text-gray-900 mb-2">
Delete Candidate Delete Candidate
</h3> </h3>
<p className="text-sm text-gray-600 mb-6"> <p className="text-sm text-gray-600 mb-6">
Are you sure you want to delete{" "} Are you sure you want to delete{" "}
<span className="font-medium">{candidateName}</span>? This action <span className="font-medium">{candidateName}</span>? This action
cannot be undone. cannot be undone.
</p> </p>
<div className="flex justify-end gap-3"> <div className="flex justify-end gap-3">
<button <button
type="button" type="button"
onClick={onClose} onClick={onClose}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors" className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors"
> >
Cancel Cancel
</button> </button>
<button <button
type="button" type="button"
onClick={handleDelete} onClick={handleDelete}
disabled={deleteCandidate.isPending} disabled={deleteCandidate.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" 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"
> >
{deleteCandidate.isPending ? "Deleting..." : "Delete"} {deleteCandidate.isPending ? "Deleting..." : "Delete"}
</button> </button>
</div> </div>
</div> </div>
</div> </div>
); );
} }
function ResolveDialog({ function ResolveDialog({
threadId, threadId,
candidateId, candidateId,
onClose, onClose,
onResolved, onResolved,
}: { }: {
threadId: number; threadId: number;
candidateId: number; candidateId: number;
onClose: () => void; onClose: () => void;
onResolved: () => void; onResolved: () => void;
}) { }) {
const resolveThread = useResolveThread(); const resolveThread = useResolveThread();
const { data: thread } = useThread(threadId); const { data: thread } = useThread(threadId);
const candidate = thread?.candidates.find((c) => c.id === candidateId); const candidate = thread?.candidates.find((c) => c.id === candidateId);
const candidateName = candidate?.name ?? "this candidate"; const candidateName = candidate?.name ?? "this candidate";
function handleResolve() { function handleResolve() {
resolveThread.mutate( resolveThread.mutate(
{ threadId, candidateId }, { threadId, candidateId },
{ onSuccess: () => onResolved() }, { onSuccess: () => onResolved() },
); );
} }
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center"> <div className="fixed inset-0 z-50 flex items-center justify-center">
<div <div
className="absolute inset-0 bg-black/30" className="absolute inset-0 bg-black/30"
onClick={onClose} onClick={onClose}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Escape") onClose(); if (e.key === "Escape") onClose();
}} }}
/> />
<div className="relative bg-white rounded-xl shadow-lg p-6 max-w-sm mx-4 w-full"> <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"> <h3 className="text-lg font-semibold text-gray-900 mb-2">
Pick Winner Pick Winner
</h3> </h3>
<p className="text-sm text-gray-600 mb-6"> <p className="text-sm text-gray-600 mb-6">
Pick <span className="font-medium">{candidateName}</span> as the Pick <span className="font-medium">{candidateName}</span> as the
winner? This will add it to your collection and archive the thread. winner? This will add it to your collection and archive the thread.
</p> </p>
<div className="flex justify-end gap-3"> <div className="flex justify-end gap-3">
<button <button
type="button" type="button"
onClick={onClose} onClick={onClose}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors" className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors"
> >
Cancel Cancel
</button> </button>
<button <button
type="button" type="button"
onClick={handleResolve} onClick={handleResolve}
disabled={resolveThread.isPending} disabled={resolveThread.isPending}
className="px-4 py-2 text-sm font-medium text-white bg-amber-600 hover:bg-amber-700 disabled:opacity-50 rounded-lg transition-colors" className="px-4 py-2 text-sm font-medium text-white bg-amber-600 hover:bg-amber-700 disabled:opacity-50 rounded-lg transition-colors"
> >
{resolveThread.isPending ? "Resolving..." : "Pick Winner"} {resolveThread.isPending ? "Resolving..." : "Pick Winner"}
</button> </button>
</div> </div>
</div> </div>
</div> </div>
); );
} }

View File

@@ -4,17 +4,19 @@ import { z } from "zod";
import { CategoryHeader } from "../../components/CategoryHeader"; import { CategoryHeader } from "../../components/CategoryHeader";
import { CreateThreadModal } from "../../components/CreateThreadModal"; import { CreateThreadModal } from "../../components/CreateThreadModal";
import { ItemCard } from "../../components/ItemCard"; import { ItemCard } from "../../components/ItemCard";
import { SetupCard } from "../../components/SetupCard";
import { ThreadCard } from "../../components/ThreadCard"; import { ThreadCard } from "../../components/ThreadCard";
import { ThreadTabs } from "../../components/ThreadTabs"; import { CollectionTabs } from "../../components/ThreadTabs";
import { useCategories } from "../../hooks/useCategories"; import { useCategories } from "../../hooks/useCategories";
import { useItems } from "../../hooks/useItems"; import { useItems } from "../../hooks/useItems";
import { useCreateSetup, useSetups } from "../../hooks/useSetups";
import { useThreads } from "../../hooks/useThreads"; import { useThreads } from "../../hooks/useThreads";
import { useTotals } from "../../hooks/useTotals"; import { useTotals } from "../../hooks/useTotals";
import { LucideIcon } from "../../lib/iconData"; import { LucideIcon } from "../../lib/iconData";
import { useUIStore } from "../../stores/uiStore"; import { useUIStore } from "../../stores/uiStore";
const searchSchema = z.object({ const searchSchema = z.object({
tab: z.enum(["gear", "planning"]).catch("gear"), tab: z.enum(["gear", "planning", "setups"]).catch("gear"),
}); });
export const Route = createFileRoute("/collection/")({ export const Route = createFileRoute("/collection/")({
@@ -26,15 +28,21 @@ function CollectionPage() {
const { tab } = Route.useSearch(); const { tab } = Route.useSearch();
const navigate = useNavigate(); const navigate = useNavigate();
function handleTabChange(newTab: "gear" | "planning") { function handleTabChange(newTab: "gear" | "planning" | "setups") {
navigate({ to: "/collection", search: { tab: newTab } }); navigate({ to: "/collection", search: { tab: newTab } });
} }
return ( return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<ThreadTabs active={tab} onChange={handleTabChange} /> <CollectionTabs active={tab} onChange={handleTabChange} />
<div className="mt-6"> <div className="mt-6">
{tab === "gear" ? <CollectionView /> : <PlanningView />} {tab === "gear" ? (
<CollectionView />
) : tab === "planning" ? (
<PlanningView />
) : (
<SetupsView />
)}
</div> </div>
</div> </div>
); );
@@ -79,7 +87,7 @@ function CollectionView() {
<button <button
type="button" type="button"
onClick={openAddPanel} 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" className="inline-flex items-center gap-2 px-5 py-2.5 bg-gray-700 hover:bg-gray-800 text-white text-sm font-medium rounded-lg transition-colors"
> >
<svg <svg
aria-hidden="true" aria-hidden="true"
@@ -217,7 +225,7 @@ function PlanningView() {
<button <button
type="button" type="button"
onClick={openCreateThreadModal} onClick={openCreateThreadModal}
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-lg transition-colors" className="inline-flex items-center gap-2 px-4 py-2 bg-gray-700 hover:bg-gray-800 text-white text-sm font-medium rounded-lg transition-colors"
> >
<svg <svg
aria-hidden="true" aria-hidden="true"
@@ -246,7 +254,7 @@ function PlanningView() {
onClick={() => setActiveTab("active")} onClick={() => setActiveTab("active")}
className={`px-4 py-1.5 text-sm font-medium rounded-full transition-colors ${ className={`px-4 py-1.5 text-sm font-medium rounded-full transition-colors ${
activeTab === "active" activeTab === "active"
? "bg-blue-600 text-white" ? "bg-gray-700 text-white"
: "text-gray-600 hover:bg-gray-200" : "text-gray-600 hover:bg-gray-200"
}`} }`}
> >
@@ -257,7 +265,7 @@ function PlanningView() {
onClick={() => setActiveTab("resolved")} onClick={() => setActiveTab("resolved")}
className={`px-4 py-1.5 text-sm font-medium rounded-full transition-colors ${ className={`px-4 py-1.5 text-sm font-medium rounded-full transition-colors ${
activeTab === "resolved" activeTab === "resolved"
? "bg-blue-600 text-white" ? "bg-gray-700 text-white"
: "text-gray-600 hover:bg-gray-200" : "text-gray-600 hover:bg-gray-200"
}`} }`}
> >
@@ -271,7 +279,7 @@ function PlanningView() {
onChange={(e) => onChange={(e) =>
setCategoryFilter(e.target.value ? Number(e.target.value) : null) setCategoryFilter(e.target.value ? Number(e.target.value) : null)
} }
className="px-3 py-1.5 border border-gray-200 rounded-lg text-sm bg-white focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" className="px-3 py-1.5 border border-gray-200 rounded-lg text-sm bg-white focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent"
> >
<option value="">All categories</option> <option value="">All categories</option>
{categories?.map((cat) => ( {categories?.map((cat) => (
@@ -291,7 +299,7 @@ function PlanningView() {
</h2> </h2>
<div className="space-y-6 text-left mb-10"> <div className="space-y-6 text-left mb-10">
<div className="flex items-start gap-4"> <div className="flex items-start gap-4">
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-blue-100 text-blue-700 font-bold text-sm shrink-0"> <div className="flex items-center justify-center w-8 h-8 rounded-full bg-gray-200 text-gray-700 font-bold text-sm shrink-0">
1 1
</div> </div>
<div> <div>
@@ -302,7 +310,7 @@ function PlanningView() {
</div> </div>
</div> </div>
<div className="flex items-start gap-4"> <div className="flex items-start gap-4">
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-blue-100 text-blue-700 font-bold text-sm shrink-0"> <div className="flex items-center justify-center w-8 h-8 rounded-full bg-gray-200 text-gray-700 font-bold text-sm shrink-0">
2 2
</div> </div>
<div> <div>
@@ -313,7 +321,7 @@ function PlanningView() {
</div> </div>
</div> </div>
<div className="flex items-start gap-4"> <div className="flex items-start gap-4">
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-blue-100 text-blue-700 font-bold text-sm shrink-0"> <div className="flex items-center justify-center w-8 h-8 rounded-full bg-gray-200 text-gray-700 font-bold text-sm shrink-0">
3 3
</div> </div>
<div> <div>
@@ -327,7 +335,7 @@ function PlanningView() {
<button <button
type="button" type="button"
onClick={openCreateThreadModal} onClick={openCreateThreadModal}
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" className="inline-flex items-center gap-2 px-5 py-2.5 bg-gray-700 hover:bg-gray-800 text-white text-sm font-medium rounded-lg transition-colors"
> >
<svg <svg
aria-hidden="true" aria-hidden="true"
@@ -374,3 +382,87 @@ function PlanningView() {
</div> </div>
); );
} }
function SetupsView() {
const [newSetupName, setNewSetupName] = useState("");
const { data: setups, isLoading } = useSetups();
const createSetup = useCreateSetup();
function handleCreateSetup(e: React.FormEvent) {
e.preventDefault();
const name = newSetupName.trim();
if (!name) return;
createSetup.mutate({ name }, { onSuccess: () => setNewSetupName("") });
}
return (
<div>
{/* Create setup form */}
<form onSubmit={handleCreateSetup} className="flex gap-2 mb-6">
<input
type="text"
value={newSetupName}
onChange={(e) => setNewSetupName(e.target.value)}
placeholder="New setup name..."
className="flex-1 px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent"
/>
<button
type="submit"
disabled={!newSetupName.trim() || createSetup.isPending}
className="px-4 py-2 bg-gray-700 hover:bg-gray-800 disabled:opacity-50 text-white text-sm font-medium rounded-lg transition-colors"
>
{createSetup.isPending ? "Creating..." : "Create"}
</button>
</form>
{/* Loading skeleton */}
{isLoading && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{[1, 2].map((i) => (
<div
key={i}
className="h-24 bg-gray-200 rounded-xl animate-pulse"
/>
))}
</div>
)}
{/* Empty state */}
{!isLoading && (!setups || setups.length === 0) && (
<div className="py-16 text-center">
<div className="max-w-md mx-auto">
<div className="mb-4">
<LucideIcon
name="tent"
size={48}
className="text-gray-400 mx-auto"
/>
</div>
<h2 className="text-xl font-semibold text-gray-900 mb-2">
No setups yet
</h2>
<p className="text-sm text-gray-500">
Create one to plan your loadout.
</p>
</div>
</div>
)}
{/* Setup grid */}
{!isLoading && setups && setups.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{setups.map((setup) => (
<SetupCard
key={setup.id}
id={setup.id}
name={setup.name}
itemCount={setup.itemCount}
totalWeight={setup.totalWeight}
totalCost={setup.totalCost}
/>
))}
</div>
)}
</div>
);
}

View File

@@ -45,7 +45,8 @@ function DashboardPage() {
]} ]}
/> />
<DashboardCard <DashboardCard
to="/setups" to="/collection"
search={{ tab: "setups" }}
title="Setups" title="Setups"
icon="tent" icon="tent"
stats={[{ label: "Setups", value: String(setupCount) }]} stats={[{ label: "Setups", value: String(setupCount) }]}

View File

@@ -124,7 +124,7 @@ function SetupDetailPage() {
<button <button
type="button" type="button"
onClick={() => setPickerOpen(true)} onClick={() => setPickerOpen(true)}
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-lg transition-colors" className="inline-flex items-center gap-2 px-4 py-2 bg-gray-700 hover:bg-gray-800 text-white text-sm font-medium rounded-lg transition-colors"
> >
<svg <svg
className="w-4 h-4" className="w-4 h-4"
@@ -170,7 +170,7 @@ function SetupDetailPage() {
<button <button
type="button" type="button"
onClick={() => setPickerOpen(true)} onClick={() => setPickerOpen(true)}
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" className="inline-flex items-center gap-2 px-5 py-2.5 bg-gray-700 hover:bg-gray-800 text-white text-sm font-medium rounded-lg transition-colors"
> >
Add Items Add Items
</button> </button>

View File

@@ -1,93 +0,0 @@
import { createFileRoute } from "@tanstack/react-router";
import { useState } from "react";
import { SetupCard } from "../../components/SetupCard";
import { useCreateSetup, useSetups } from "../../hooks/useSetups";
import { LucideIcon } from "../../lib/iconData";
export const Route = createFileRoute("/setups/")({
component: SetupsListPage,
});
function SetupsListPage() {
const [newSetupName, setNewSetupName] = useState("");
const { data: setups, isLoading } = useSetups();
const createSetup = useCreateSetup();
function handleCreateSetup(e: React.FormEvent) {
e.preventDefault();
const name = newSetupName.trim();
if (!name) return;
createSetup.mutate({ name }, { onSuccess: () => setNewSetupName("") });
}
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
{/* Create setup form */}
<form onSubmit={handleCreateSetup} className="flex gap-2 mb-6">
<input
type="text"
value={newSetupName}
onChange={(e) => setNewSetupName(e.target.value)}
placeholder="New setup name..."
className="flex-1 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"
/>
<button
type="submit"
disabled={!newSetupName.trim() || createSetup.isPending}
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white text-sm font-medium rounded-lg transition-colors"
>
{createSetup.isPending ? "Creating..." : "Create"}
</button>
</form>
{/* Loading skeleton */}
{isLoading && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{[1, 2].map((i) => (
<div
key={i}
className="h-24 bg-gray-200 rounded-xl animate-pulse"
/>
))}
</div>
)}
{/* Empty state */}
{!isLoading && (!setups || setups.length === 0) && (
<div className="py-16 text-center">
<div className="max-w-md mx-auto">
<div className="mb-4">
<LucideIcon
name="tent"
size={48}
className="text-gray-400 mx-auto"
/>
</div>
<h2 className="text-xl font-semibold text-gray-900 mb-2">
No setups yet
</h2>
<p className="text-sm text-gray-500">
Create one to plan your loadout.
</p>
</div>
</div>
)}
{/* Setup grid */}
{!isLoading && setups && setups.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{setups.map((setup) => (
<SetupCard
key={setup.id}
id={setup.id}
name={setup.name}
itemCount={setup.itemCount}
totalWeight={setup.totalWeight}
totalCost={setup.totalCost}
/>
))}
</div>
)}
</div>
);
}

View File

@@ -38,7 +38,7 @@ function ThreadDetailPage() {
<Link <Link
to="/" to="/"
search={{ tab: "planning" }} search={{ tab: "planning" }}
className="text-sm text-blue-600 hover:text-blue-700" className="text-sm text-gray-600 hover:text-gray-700"
> >
Back to planning Back to planning
</Link> </Link>
@@ -67,7 +67,7 @@ function ThreadDetailPage() {
<span <span
className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${ className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
isActive isActive
? "bg-blue-50 text-blue-700" ? "bg-gray-100 text-gray-600"
: "bg-gray-100 text-gray-500" : "bg-gray-100 text-gray-500"
}`} }`}
> >
@@ -92,7 +92,7 @@ function ThreadDetailPage() {
<button <button
type="button" type="button"
onClick={openCandidateAddPanel} onClick={openCandidateAddPanel}
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-lg transition-colors" className="inline-flex items-center gap-2 px-4 py-2 bg-gray-700 hover:bg-gray-800 text-white text-sm font-medium rounded-lg transition-colors"
> >
<svg <svg
className="w-4 h-4" className="w-4 h-4"

13
src/db/migrate.ts Normal file
View File

@@ -0,0 +1,13 @@
import { Database } from "bun:sqlite";
import { drizzle } from "drizzle-orm/bun-sqlite";
import { migrate } from "drizzle-orm/bun-sqlite/migrator";
const sqlite = new Database(process.env.DATABASE_PATH || "gearbox.db");
sqlite.run("PRAGMA journal_mode = WAL");
sqlite.run("PRAGMA foreign_keys = ON");
const db = drizzle(sqlite);
migrate(db, { migrationsFolder: "./drizzle" });
sqlite.close();
console.log("Migrations applied successfully");

View File

@@ -1,93 +1,93 @@
import { sqliteTable, text, integer, real } from "drizzle-orm/sqlite-core"; import { integer, real, sqliteTable, text } from "drizzle-orm/sqlite-core";
export const categories = sqliteTable("categories", { export const categories = sqliteTable("categories", {
id: integer("id").primaryKey({ autoIncrement: true }), id: integer("id").primaryKey({ autoIncrement: true }),
name: text("name").notNull().unique(), name: text("name").notNull().unique(),
icon: text("icon").notNull().default("package"), icon: text("icon").notNull().default("package"),
createdAt: integer("created_at", { mode: "timestamp" }) createdAt: integer("created_at", { mode: "timestamp" })
.notNull() .notNull()
.$defaultFn(() => new Date()), .$defaultFn(() => new Date()),
}); });
export const items = sqliteTable("items", { export const items = sqliteTable("items", {
id: integer("id").primaryKey({ autoIncrement: true }), id: integer("id").primaryKey({ autoIncrement: true }),
name: text("name").notNull(), name: text("name").notNull(),
weightGrams: real("weight_grams"), weightGrams: real("weight_grams"),
priceCents: integer("price_cents"), priceCents: integer("price_cents"),
categoryId: integer("category_id") categoryId: integer("category_id")
.notNull() .notNull()
.references(() => categories.id), .references(() => categories.id),
notes: text("notes"), notes: text("notes"),
productUrl: text("product_url"), productUrl: text("product_url"),
imageFilename: text("image_filename"), imageFilename: text("image_filename"),
createdAt: integer("created_at", { mode: "timestamp" }) createdAt: integer("created_at", { mode: "timestamp" })
.notNull() .notNull()
.$defaultFn(() => new Date()), .$defaultFn(() => new Date()),
updatedAt: integer("updated_at", { mode: "timestamp" }) updatedAt: integer("updated_at", { mode: "timestamp" })
.notNull() .notNull()
.$defaultFn(() => new Date()), .$defaultFn(() => new Date()),
}); });
export const threads = sqliteTable("threads", { export const threads = sqliteTable("threads", {
id: integer("id").primaryKey({ autoIncrement: true }), id: integer("id").primaryKey({ autoIncrement: true }),
name: text("name").notNull(), name: text("name").notNull(),
status: text("status").notNull().default("active"), status: text("status").notNull().default("active"),
resolvedCandidateId: integer("resolved_candidate_id"), resolvedCandidateId: integer("resolved_candidate_id"),
categoryId: integer("category_id") categoryId: integer("category_id")
.notNull() .notNull()
.references(() => categories.id), .references(() => categories.id),
createdAt: integer("created_at", { mode: "timestamp" }) createdAt: integer("created_at", { mode: "timestamp" })
.notNull() .notNull()
.$defaultFn(() => new Date()), .$defaultFn(() => new Date()),
updatedAt: integer("updated_at", { mode: "timestamp" }) updatedAt: integer("updated_at", { mode: "timestamp" })
.notNull() .notNull()
.$defaultFn(() => new Date()), .$defaultFn(() => new Date()),
}); });
export const threadCandidates = sqliteTable("thread_candidates", { export const threadCandidates = sqliteTable("thread_candidates", {
id: integer("id").primaryKey({ autoIncrement: true }), id: integer("id").primaryKey({ autoIncrement: true }),
threadId: integer("thread_id") threadId: integer("thread_id")
.notNull() .notNull()
.references(() => threads.id, { onDelete: "cascade" }), .references(() => threads.id, { onDelete: "cascade" }),
name: text("name").notNull(), name: text("name").notNull(),
weightGrams: real("weight_grams"), weightGrams: real("weight_grams"),
priceCents: integer("price_cents"), priceCents: integer("price_cents"),
categoryId: integer("category_id") categoryId: integer("category_id")
.notNull() .notNull()
.references(() => categories.id), .references(() => categories.id),
notes: text("notes"), notes: text("notes"),
productUrl: text("product_url"), productUrl: text("product_url"),
imageFilename: text("image_filename"), imageFilename: text("image_filename"),
createdAt: integer("created_at", { mode: "timestamp" }) createdAt: integer("created_at", { mode: "timestamp" })
.notNull() .notNull()
.$defaultFn(() => new Date()), .$defaultFn(() => new Date()),
updatedAt: integer("updated_at", { mode: "timestamp" }) updatedAt: integer("updated_at", { mode: "timestamp" })
.notNull() .notNull()
.$defaultFn(() => new Date()), .$defaultFn(() => new Date()),
}); });
export const setups = sqliteTable("setups", { export const setups = sqliteTable("setups", {
id: integer("id").primaryKey({ autoIncrement: true }), id: integer("id").primaryKey({ autoIncrement: true }),
name: text("name").notNull(), name: text("name").notNull(),
createdAt: integer("created_at", { mode: "timestamp" }) createdAt: integer("created_at", { mode: "timestamp" })
.notNull() .notNull()
.$defaultFn(() => new Date()), .$defaultFn(() => new Date()),
updatedAt: integer("updated_at", { mode: "timestamp" }) updatedAt: integer("updated_at", { mode: "timestamp" })
.notNull() .notNull()
.$defaultFn(() => new Date()), .$defaultFn(() => new Date()),
}); });
export const setupItems = sqliteTable("setup_items", { export const setupItems = sqliteTable("setup_items", {
id: integer("id").primaryKey({ autoIncrement: true }), id: integer("id").primaryKey({ autoIncrement: true }),
setupId: integer("setup_id") setupId: integer("setup_id")
.notNull() .notNull()
.references(() => setups.id, { onDelete: "cascade" }), .references(() => setups.id, { onDelete: "cascade" }),
itemId: integer("item_id") itemId: integer("item_id")
.notNull() .notNull()
.references(() => items.id, { onDelete: "cascade" }), .references(() => items.id, { onDelete: "cascade" }),
}); });
export const settings = sqliteTable("settings", { export const settings = sqliteTable("settings", {
key: text("key").primaryKey(), key: text("key").primaryKey(),
value: text("value").notNull(), value: text("value").notNull(),
}); });

View File

@@ -2,13 +2,13 @@ import { db } from "./index.ts";
import { categories } from "./schema.ts"; import { categories } from "./schema.ts";
export function seedDefaults() { export function seedDefaults() {
const existing = db.select().from(categories).all(); const existing = db.select().from(categories).all();
if (existing.length === 0) { if (existing.length === 0) {
db.insert(categories) db.insert(categories)
.values({ .values({
name: "Uncategorized", name: "Uncategorized",
icon: "package", icon: "package",
}) })
.run(); .run();
} }
} }

View File

@@ -1,13 +1,13 @@
import { Hono } from "hono"; import { Hono } from "hono";
import { serveStatic } from "hono/bun"; import { serveStatic } from "hono/bun";
import { seedDefaults } from "../db/seed.ts"; import { seedDefaults } from "../db/seed.ts";
import { itemRoutes } from "./routes/items.ts";
import { categoryRoutes } from "./routes/categories.ts"; import { categoryRoutes } from "./routes/categories.ts";
import { totalRoutes } from "./routes/totals.ts";
import { imageRoutes } from "./routes/images.ts"; import { imageRoutes } from "./routes/images.ts";
import { itemRoutes } from "./routes/items.ts";
import { settingsRoutes } from "./routes/settings.ts"; import { settingsRoutes } from "./routes/settings.ts";
import { threadRoutes } from "./routes/threads.ts";
import { setupRoutes } from "./routes/setups.ts"; import { setupRoutes } from "./routes/setups.ts";
import { threadRoutes } from "./routes/threads.ts";
import { totalRoutes } from "./routes/totals.ts";
// Seed default data on startup // Seed default data on startup
seedDefaults(); seedDefaults();
@@ -16,7 +16,7 @@ const app = new Hono();
// Health check // Health check
app.get("/api/health", (c) => { app.get("/api/health", (c) => {
return c.json({ status: "ok" }); return c.json({ status: "ok" });
}); });
// API routes // API routes
@@ -33,8 +33,8 @@ app.use("/uploads/*", serveStatic({ root: "./" }));
// Serve Vite-built SPA in production // Serve Vite-built SPA in production
if (process.env.NODE_ENV === "production") { if (process.env.NODE_ENV === "production") {
app.use("/*", serveStatic({ root: "./dist/client" })); app.use("/*", serveStatic({ root: "./dist/client" }));
app.get("*", serveStatic({ path: "./dist/client/index.html" })); app.get("*", serveStatic({ path: "./dist/client/index.html" }));
} }
export default { port: 3000, fetch: app.fetch }; export default { port: 3000, fetch: app.fetch };

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,18 +1,18 @@
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator"; import { zValidator } from "@hono/zod-validator";
import { Hono } from "hono";
import { import {
createSetupSchema, createSetupSchema,
updateSetupSchema, syncSetupItemsSchema,
syncSetupItemsSchema, updateSetupSchema,
} from "../../shared/schemas.ts"; } from "../../shared/schemas.ts";
import { import {
getAllSetups, createSetup,
getSetupWithItems, deleteSetup,
createSetup, getAllSetups,
updateSetup, getSetupWithItems,
deleteSetup, removeSetupItem,
syncSetupItems, syncSetupItems,
removeSetupItem, updateSetup,
} from "../services/setup.service.ts"; } from "../services/setup.service.ts";
type Env = { Variables: { db?: any } }; type Env = { Variables: { db?: any } };
@@ -22,63 +22,63 @@ const app = new Hono<Env>();
// Setup CRUD // Setup CRUD
app.get("/", (c) => { app.get("/", (c) => {
const db = c.get("db"); const db = c.get("db");
const setups = getAllSetups(db); const setups = getAllSetups(db);
return c.json(setups); return c.json(setups);
}); });
app.post("/", zValidator("json", createSetupSchema), (c) => { app.post("/", zValidator("json", createSetupSchema), (c) => {
const db = c.get("db"); const db = c.get("db");
const data = c.req.valid("json"); const data = c.req.valid("json");
const setup = createSetup(db, data); const setup = createSetup(db, data);
return c.json(setup, 201); return c.json(setup, 201);
}); });
app.get("/:id", (c) => { app.get("/:id", (c) => {
const db = c.get("db"); const db = c.get("db");
const id = Number(c.req.param("id")); const id = Number(c.req.param("id"));
const setup = getSetupWithItems(db, id); const setup = getSetupWithItems(db, id);
if (!setup) return c.json({ error: "Setup not found" }, 404); if (!setup) return c.json({ error: "Setup not found" }, 404);
return c.json(setup); return c.json(setup);
}); });
app.put("/:id", zValidator("json", updateSetupSchema), (c) => { app.put("/:id", zValidator("json", updateSetupSchema), (c) => {
const db = c.get("db"); const db = c.get("db");
const id = Number(c.req.param("id")); const id = Number(c.req.param("id"));
const data = c.req.valid("json"); const data = c.req.valid("json");
const setup = updateSetup(db, id, data); const setup = updateSetup(db, id, data);
if (!setup) return c.json({ error: "Setup not found" }, 404); if (!setup) return c.json({ error: "Setup not found" }, 404);
return c.json(setup); return c.json(setup);
}); });
app.delete("/:id", (c) => { app.delete("/:id", (c) => {
const db = c.get("db"); const db = c.get("db");
const id = Number(c.req.param("id")); const id = Number(c.req.param("id"));
const deleted = deleteSetup(db, id); const deleted = deleteSetup(db, id);
if (!deleted) return c.json({ error: "Setup not found" }, 404); if (!deleted) return c.json({ error: "Setup not found" }, 404);
return c.json({ success: true }); return c.json({ success: true });
}); });
// Setup Items // Setup Items
app.put("/:id/items", zValidator("json", syncSetupItemsSchema), (c) => { app.put("/:id/items", zValidator("json", syncSetupItemsSchema), (c) => {
const db = c.get("db"); const db = c.get("db");
const id = Number(c.req.param("id")); const id = Number(c.req.param("id"));
const { itemIds } = c.req.valid("json"); const { itemIds } = c.req.valid("json");
const setup = getSetupWithItems(db, id); const setup = getSetupWithItems(db, id);
if (!setup) return c.json({ error: "Setup not found" }, 404); if (!setup) return c.json({ error: "Setup not found" }, 404);
syncSetupItems(db, id, itemIds); syncSetupItems(db, id, itemIds);
return c.json({ success: true }); return c.json({ success: true });
}); });
app.delete("/:id/items/:itemId", (c) => { app.delete("/:id/items/:itemId", (c) => {
const db = c.get("db"); const db = c.get("db");
const setupId = Number(c.req.param("id")); const setupId = Number(c.req.param("id"));
const itemId = Number(c.req.param("itemId")); const itemId = Number(c.req.param("itemId"));
removeSetupItem(db, setupId, itemId); removeSetupItem(db, setupId, itemId);
return c.json({ success: true }); return c.json({ success: true });
}); });
export { app as setupRoutes }; export { app as setupRoutes };

View File

@@ -1,25 +1,25 @@
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import {
createThreadSchema,
updateThreadSchema,
createCandidateSchema,
updateCandidateSchema,
resolveThreadSchema,
} from "../../shared/schemas.ts";
import {
getAllThreads,
getThreadWithCandidates,
createThread,
updateThread,
deleteThread,
createCandidate,
updateCandidate,
deleteCandidate,
resolveThread,
} from "../services/thread.service.ts";
import { unlink } from "node:fs/promises"; import { unlink } from "node:fs/promises";
import { join } from "node:path"; import { join } from "node:path";
import { zValidator } from "@hono/zod-validator";
import { Hono } from "hono";
import {
createCandidateSchema,
createThreadSchema,
resolveThreadSchema,
updateCandidateSchema,
updateThreadSchema,
} from "../../shared/schemas.ts";
import {
createCandidate,
createThread,
deleteCandidate,
deleteThread,
getAllThreads,
getThreadWithCandidates,
resolveThread,
updateCandidate,
updateThread,
} from "../services/thread.service.ts";
type Env = { Variables: { db?: any } }; type Env = { Variables: { db?: any } };
@@ -28,109 +28,113 @@ const app = new Hono<Env>();
// Thread CRUD // Thread CRUD
app.get("/", (c) => { app.get("/", (c) => {
const db = c.get("db"); const db = c.get("db");
const includeResolved = c.req.query("includeResolved") === "true"; const includeResolved = c.req.query("includeResolved") === "true";
const threads = getAllThreads(db, includeResolved); const threads = getAllThreads(db, includeResolved);
return c.json(threads); return c.json(threads);
}); });
app.post("/", zValidator("json", createThreadSchema), (c) => { app.post("/", zValidator("json", createThreadSchema), (c) => {
const db = c.get("db"); const db = c.get("db");
const data = c.req.valid("json"); const data = c.req.valid("json");
const thread = createThread(db, data); const thread = createThread(db, data);
return c.json(thread, 201); return c.json(thread, 201);
}); });
app.get("/:id", (c) => { app.get("/:id", (c) => {
const db = c.get("db"); const db = c.get("db");
const id = Number(c.req.param("id")); const id = Number(c.req.param("id"));
const thread = getThreadWithCandidates(db, id); const thread = getThreadWithCandidates(db, id);
if (!thread) return c.json({ error: "Thread not found" }, 404); if (!thread) return c.json({ error: "Thread not found" }, 404);
return c.json(thread); return c.json(thread);
}); });
app.put("/:id", zValidator("json", updateThreadSchema), (c) => { app.put("/:id", zValidator("json", updateThreadSchema), (c) => {
const db = c.get("db"); const db = c.get("db");
const id = Number(c.req.param("id")); const id = Number(c.req.param("id"));
const data = c.req.valid("json"); const data = c.req.valid("json");
const thread = updateThread(db, id, data); const thread = updateThread(db, id, data);
if (!thread) return c.json({ error: "Thread not found" }, 404); if (!thread) return c.json({ error: "Thread not found" }, 404);
return c.json(thread); return c.json(thread);
}); });
app.delete("/:id", async (c) => { app.delete("/:id", async (c) => {
const db = c.get("db"); const db = c.get("db");
const id = Number(c.req.param("id")); const id = Number(c.req.param("id"));
const deleted = deleteThread(db, id); const deleted = deleteThread(db, id);
if (!deleted) return c.json({ error: "Thread not found" }, 404); if (!deleted) return c.json({ error: "Thread not found" }, 404);
// Clean up candidate image files // Clean up candidate image files
for (const filename of deleted.candidateImages) { for (const filename of deleted.candidateImages) {
try { try {
await unlink(join("uploads", filename)); await unlink(join("uploads", filename));
} catch { } catch {
// File missing is not an error worth failing the delete over // File missing is not an error worth failing the delete over
} }
} }
return c.json({ success: true }); return c.json({ success: true });
}); });
// Candidate CRUD (nested under thread) // Candidate CRUD (nested under thread)
app.post("/:id/candidates", zValidator("json", createCandidateSchema), (c) => { app.post("/:id/candidates", zValidator("json", createCandidateSchema), (c) => {
const db = c.get("db"); const db = c.get("db");
const threadId = Number(c.req.param("id")); const threadId = Number(c.req.param("id"));
// Verify thread exists // Verify thread exists
const thread = getThreadWithCandidates(db, threadId); const thread = getThreadWithCandidates(db, threadId);
if (!thread) return c.json({ error: "Thread not found" }, 404); if (!thread) return c.json({ error: "Thread not found" }, 404);
const data = c.req.valid("json"); const data = c.req.valid("json");
const candidate = createCandidate(db, threadId, data); const candidate = createCandidate(db, threadId, data);
return c.json(candidate, 201); return c.json(candidate, 201);
}); });
app.put("/:threadId/candidates/:candidateId", zValidator("json", updateCandidateSchema), (c) => { app.put(
const db = c.get("db"); "/:threadId/candidates/:candidateId",
const candidateId = Number(c.req.param("candidateId")); zValidator("json", updateCandidateSchema),
const data = c.req.valid("json"); (c) => {
const candidate = updateCandidate(db, candidateId, data); const db = c.get("db");
if (!candidate) return c.json({ error: "Candidate not found" }, 404); const candidateId = Number(c.req.param("candidateId"));
return c.json(candidate); const data = c.req.valid("json");
}); const candidate = updateCandidate(db, candidateId, data);
if (!candidate) return c.json({ error: "Candidate not found" }, 404);
return c.json(candidate);
},
);
app.delete("/:threadId/candidates/:candidateId", async (c) => { app.delete("/:threadId/candidates/:candidateId", async (c) => {
const db = c.get("db"); const db = c.get("db");
const candidateId = Number(c.req.param("candidateId")); const candidateId = Number(c.req.param("candidateId"));
const deleted = deleteCandidate(db, candidateId); const deleted = deleteCandidate(db, candidateId);
if (!deleted) return c.json({ error: "Candidate not found" }, 404); if (!deleted) return c.json({ error: "Candidate not found" }, 404);
// Clean up image file if exists // Clean up image file if exists
if (deleted.imageFilename) { if (deleted.imageFilename) {
try { try {
await unlink(join("uploads", deleted.imageFilename)); await unlink(join("uploads", deleted.imageFilename));
} catch { } catch {
// File missing is not an error // File missing is not an error
} }
} }
return c.json({ success: true }); return c.json({ success: true });
}); });
// Resolution // Resolution
app.post("/:id/resolve", zValidator("json", resolveThreadSchema), (c) => { app.post("/:id/resolve", zValidator("json", resolveThreadSchema), (c) => {
const db = c.get("db"); const db = c.get("db");
const threadId = Number(c.req.param("id")); const threadId = Number(c.req.param("id"));
const { candidateId } = c.req.valid("json"); const { candidateId } = c.req.valid("json");
const result = resolveThread(db, threadId, candidateId); const result = resolveThread(db, threadId, candidateId);
if (!result.success) { if (!result.success) {
return c.json({ error: result.error }, 400); return c.json({ error: result.error }, 400);
} }
return c.json({ success: true, item: result.item }); return c.json({ success: true, item: result.item });
}); });
export { app as threadRoutes }; export { app as threadRoutes };

View File

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

View File

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

View File

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

View File

@@ -1,111 +1,124 @@
import { eq, sql } from "drizzle-orm"; import { eq, sql } from "drizzle-orm";
import { setups, setupItems, items, categories } from "../../db/schema.ts";
import { db as prodDb } from "../../db/index.ts"; import { db as prodDb } from "../../db/index.ts";
import { categories, items, setupItems, setups } from "../../db/schema.ts";
import type { CreateSetup, UpdateSetup } from "../../shared/types.ts"; import type { CreateSetup, UpdateSetup } from "../../shared/types.ts";
type Db = typeof prodDb; type Db = typeof prodDb;
export function createSetup(db: Db = prodDb, data: CreateSetup) { export function createSetup(db: Db = prodDb, data: CreateSetup) {
return db return db.insert(setups).values({ name: data.name }).returning().get();
.insert(setups)
.values({ name: data.name })
.returning()
.get();
} }
export function getAllSetups(db: Db = prodDb) { export function getAllSetups(db: Db = prodDb) {
return db return db
.select({ .select({
id: setups.id, id: setups.id,
name: setups.name, name: setups.name,
createdAt: setups.createdAt, createdAt: setups.createdAt,
updatedAt: setups.updatedAt, updatedAt: setups.updatedAt,
itemCount: sql<number>`COALESCE(( itemCount: sql<number>`COALESCE((
SELECT COUNT(*) FROM setup_items SELECT COUNT(*) FROM setup_items
WHERE setup_items.setup_id = setups.id WHERE setup_items.setup_id = setups.id
), 0)`.as("item_count"), ), 0)`.as("item_count"),
totalWeight: sql<number>`COALESCE(( totalWeight: sql<number>`COALESCE((
SELECT SUM(items.weight_grams) FROM setup_items SELECT SUM(items.weight_grams) FROM setup_items
JOIN items ON items.id = setup_items.item_id JOIN items ON items.id = setup_items.item_id
WHERE setup_items.setup_id = setups.id WHERE setup_items.setup_id = setups.id
), 0)`.as("total_weight"), ), 0)`.as("total_weight"),
totalCost: sql<number>`COALESCE(( totalCost: sql<number>`COALESCE((
SELECT SUM(items.price_cents) FROM setup_items SELECT SUM(items.price_cents) FROM setup_items
JOIN items ON items.id = setup_items.item_id JOIN items ON items.id = setup_items.item_id
WHERE setup_items.setup_id = setups.id WHERE setup_items.setup_id = setups.id
), 0)`.as("total_cost"), ), 0)`.as("total_cost"),
}) })
.from(setups) .from(setups)
.all(); .all();
} }
export function getSetupWithItems(db: Db = prodDb, setupId: number) { export function getSetupWithItems(db: Db = prodDb, setupId: number) {
const setup = db.select().from(setups) const setup = db.select().from(setups).where(eq(setups.id, setupId)).get();
.where(eq(setups.id, setupId)).get(); if (!setup) return null;
if (!setup) return null;
const itemList = db const itemList = db
.select({ .select({
id: items.id, id: items.id,
name: items.name, name: items.name,
weightGrams: items.weightGrams, weightGrams: items.weightGrams,
priceCents: items.priceCents, priceCents: items.priceCents,
categoryId: items.categoryId, categoryId: items.categoryId,
notes: items.notes, notes: items.notes,
productUrl: items.productUrl, productUrl: items.productUrl,
imageFilename: items.imageFilename, imageFilename: items.imageFilename,
createdAt: items.createdAt, createdAt: items.createdAt,
updatedAt: items.updatedAt, updatedAt: items.updatedAt,
categoryName: categories.name, categoryName: categories.name,
categoryIcon: categories.icon, categoryIcon: categories.icon,
}) })
.from(setupItems) .from(setupItems)
.innerJoin(items, eq(setupItems.itemId, items.id)) .innerJoin(items, eq(setupItems.itemId, items.id))
.innerJoin(categories, eq(items.categoryId, categories.id)) .innerJoin(categories, eq(items.categoryId, categories.id))
.where(eq(setupItems.setupId, setupId)) .where(eq(setupItems.setupId, setupId))
.all(); .all();
return { ...setup, items: itemList }; return { ...setup, items: itemList };
} }
export function updateSetup(db: Db = prodDb, setupId: number, data: UpdateSetup) { export function updateSetup(
const existing = db.select({ id: setups.id }).from(setups) db: Db = prodDb,
.where(eq(setups.id, setupId)).get(); setupId: number,
if (!existing) return null; data: UpdateSetup,
) {
const existing = db
.select({ id: setups.id })
.from(setups)
.where(eq(setups.id, setupId))
.get();
if (!existing) return null;
return db return db
.update(setups) .update(setups)
.set({ name: data.name, updatedAt: new Date() }) .set({ name: data.name, updatedAt: new Date() })
.where(eq(setups.id, setupId)) .where(eq(setups.id, setupId))
.returning() .returning()
.get(); .get();
} }
export function deleteSetup(db: Db = prodDb, setupId: number) { export function deleteSetup(db: Db = prodDb, setupId: number) {
const existing = db.select({ id: setups.id }).from(setups) const existing = db
.where(eq(setups.id, setupId)).get(); .select({ id: setups.id })
if (!existing) return false; .from(setups)
.where(eq(setups.id, setupId))
.get();
if (!existing) return false;
db.delete(setups).where(eq(setups.id, setupId)).run(); db.delete(setups).where(eq(setups.id, setupId)).run();
return true; return true;
} }
export function syncSetupItems(db: Db = prodDb, setupId: number, itemIds: number[]) { export function syncSetupItems(
return db.transaction((tx) => { db: Db = prodDb,
// Delete all existing items for this setup setupId: number,
tx.delete(setupItems).where(eq(setupItems.setupId, setupId)).run(); itemIds: number[],
) {
return db.transaction((tx) => {
// Delete all existing items for this setup
tx.delete(setupItems).where(eq(setupItems.setupId, setupId)).run();
// Re-insert new items // Re-insert new items
for (const itemId of itemIds) { for (const itemId of itemIds) {
tx.insert(setupItems).values({ setupId, itemId }).run(); tx.insert(setupItems).values({ setupId, itemId }).run();
} }
}); });
} }
export function removeSetupItem(db: Db = prodDb, setupId: number, itemId: number) { export function removeSetupItem(
db.delete(setupItems) db: Db = prodDb,
.where( setupId: number,
sql`${setupItems.setupId} = ${setupId} AND ${setupItems.itemId} = ${itemId}` itemId: number,
) ) {
.run(); db.delete(setupItems)
.where(
sql`${setupItems.setupId} = ${setupId} AND ${setupItems.itemId} = ${itemId}`,
)
.run();
} }

View File

@@ -1,221 +1,261 @@
import { eq, desc, sql } from "drizzle-orm"; import { desc, eq, sql } from "drizzle-orm";
import { threads, threadCandidates, items, categories } from "../../db/schema.ts";
import { db as prodDb } from "../../db/index.ts"; import { db as prodDb } from "../../db/index.ts";
import type { CreateThread, UpdateThread, CreateCandidate } from "../../shared/types.ts"; import {
categories,
items,
threadCandidates,
threads,
} from "../../db/schema.ts";
import type { CreateCandidate, CreateThread } from "../../shared/types.ts";
type Db = typeof prodDb; type Db = typeof prodDb;
export function createThread(db: Db = prodDb, data: CreateThread) { export function createThread(db: Db = prodDb, data: CreateThread) {
return db return db
.insert(threads) .insert(threads)
.values({ name: data.name, categoryId: data.categoryId }) .values({ name: data.name, categoryId: data.categoryId })
.returning() .returning()
.get(); .get();
} }
export function getAllThreads(db: Db = prodDb, includeResolved = false) { export function getAllThreads(db: Db = prodDb, includeResolved = false) {
const query = db const query = db
.select({ .select({
id: threads.id, id: threads.id,
name: threads.name, name: threads.name,
status: threads.status, status: threads.status,
resolvedCandidateId: threads.resolvedCandidateId, resolvedCandidateId: threads.resolvedCandidateId,
categoryId: threads.categoryId, categoryId: threads.categoryId,
categoryName: categories.name, categoryName: categories.name,
categoryIcon: categories.icon, categoryIcon: categories.icon,
createdAt: threads.createdAt, createdAt: threads.createdAt,
updatedAt: threads.updatedAt, updatedAt: threads.updatedAt,
candidateCount: sql<number>`( candidateCount: sql<number>`(
SELECT COUNT(*) FROM thread_candidates SELECT COUNT(*) FROM thread_candidates
WHERE thread_candidates.thread_id = threads.id WHERE thread_candidates.thread_id = threads.id
)`.as("candidate_count"), )`.as("candidate_count"),
minPriceCents: sql<number | null>`( minPriceCents: sql<number | null>`(
SELECT MIN(price_cents) FROM thread_candidates SELECT MIN(price_cents) FROM thread_candidates
WHERE thread_candidates.thread_id = threads.id WHERE thread_candidates.thread_id = threads.id
)`.as("min_price_cents"), )`.as("min_price_cents"),
maxPriceCents: sql<number | null>`( maxPriceCents: sql<number | null>`(
SELECT MAX(price_cents) FROM thread_candidates SELECT MAX(price_cents) FROM thread_candidates
WHERE thread_candidates.thread_id = threads.id WHERE thread_candidates.thread_id = threads.id
)`.as("max_price_cents"), )`.as("max_price_cents"),
}) })
.from(threads) .from(threads)
.innerJoin(categories, eq(threads.categoryId, categories.id)) .innerJoin(categories, eq(threads.categoryId, categories.id))
.orderBy(desc(threads.createdAt)); .orderBy(desc(threads.createdAt));
if (!includeResolved) { if (!includeResolved) {
return query.where(eq(threads.status, "active")).all(); return query.where(eq(threads.status, "active")).all();
} }
return query.all(); return query.all();
} }
export function getThreadWithCandidates(db: Db = prodDb, threadId: number) { export function getThreadWithCandidates(db: Db = prodDb, threadId: number) {
const thread = db.select().from(threads) const thread = db
.where(eq(threads.id, threadId)).get(); .select()
if (!thread) return null; .from(threads)
.where(eq(threads.id, threadId))
.get();
if (!thread) return null;
const candidateList = db const candidateList = db
.select({ .select({
id: threadCandidates.id, id: threadCandidates.id,
threadId: threadCandidates.threadId, threadId: threadCandidates.threadId,
name: threadCandidates.name, name: threadCandidates.name,
weightGrams: threadCandidates.weightGrams, weightGrams: threadCandidates.weightGrams,
priceCents: threadCandidates.priceCents, priceCents: threadCandidates.priceCents,
categoryId: threadCandidates.categoryId, categoryId: threadCandidates.categoryId,
notes: threadCandidates.notes, notes: threadCandidates.notes,
productUrl: threadCandidates.productUrl, productUrl: threadCandidates.productUrl,
imageFilename: threadCandidates.imageFilename, imageFilename: threadCandidates.imageFilename,
createdAt: threadCandidates.createdAt, createdAt: threadCandidates.createdAt,
updatedAt: threadCandidates.updatedAt, updatedAt: threadCandidates.updatedAt,
categoryName: categories.name, categoryName: categories.name,
categoryIcon: categories.icon, categoryIcon: categories.icon,
}) })
.from(threadCandidates) .from(threadCandidates)
.innerJoin(categories, eq(threadCandidates.categoryId, categories.id)) .innerJoin(categories, eq(threadCandidates.categoryId, categories.id))
.where(eq(threadCandidates.threadId, threadId)) .where(eq(threadCandidates.threadId, threadId))
.all(); .all();
return { ...thread, candidates: candidateList }; return { ...thread, candidates: candidateList };
} }
export function updateThread(db: Db = prodDb, threadId: number, data: Partial<{ name: string; categoryId: number }>) { export function updateThread(
const existing = db.select({ id: threads.id }).from(threads) db: Db = prodDb,
.where(eq(threads.id, threadId)).get(); threadId: number,
if (!existing) return null; data: Partial<{ name: string; categoryId: number }>,
) {
const existing = db
.select({ id: threads.id })
.from(threads)
.where(eq(threads.id, threadId))
.get();
if (!existing) return null;
return db return db
.update(threads) .update(threads)
.set({ ...data, updatedAt: new Date() }) .set({ ...data, updatedAt: new Date() })
.where(eq(threads.id, threadId)) .where(eq(threads.id, threadId))
.returning() .returning()
.get(); .get();
} }
export function deleteThread(db: Db = prodDb, threadId: number) { export function deleteThread(db: Db = prodDb, threadId: number) {
const thread = db.select().from(threads) const thread = db
.where(eq(threads.id, threadId)).get(); .select()
if (!thread) return null; .from(threads)
.where(eq(threads.id, threadId))
.get();
if (!thread) return null;
// Collect candidate image filenames for cleanup // Collect candidate image filenames for cleanup
const candidatesWithImages = db const candidatesWithImages = db
.select({ imageFilename: threadCandidates.imageFilename }) .select({ imageFilename: threadCandidates.imageFilename })
.from(threadCandidates) .from(threadCandidates)
.where(eq(threadCandidates.threadId, threadId)) .where(eq(threadCandidates.threadId, threadId))
.all() .all()
.filter((c) => c.imageFilename != null); .filter((c) => c.imageFilename != null);
db.delete(threads).where(eq(threads.id, threadId)).run(); db.delete(threads).where(eq(threads.id, threadId)).run();
return { ...thread, candidateImages: candidatesWithImages.map((c) => c.imageFilename!) }; return {
...thread,
candidateImages: candidatesWithImages.map((c) => c.imageFilename!),
};
} }
export function createCandidate( export function createCandidate(
db: Db = prodDb, db: Db = prodDb,
threadId: number, threadId: number,
data: Partial<CreateCandidate> & { name: string; categoryId: number; imageFilename?: string }, data: Partial<CreateCandidate> & {
name: string;
categoryId: number;
imageFilename?: string;
},
) { ) {
return db return db
.insert(threadCandidates) .insert(threadCandidates)
.values({ .values({
threadId, threadId,
name: data.name, name: data.name,
weightGrams: data.weightGrams ?? null, weightGrams: data.weightGrams ?? null,
priceCents: data.priceCents ?? null, priceCents: data.priceCents ?? null,
categoryId: data.categoryId, categoryId: data.categoryId,
notes: data.notes ?? null, notes: data.notes ?? null,
productUrl: data.productUrl ?? null, productUrl: data.productUrl ?? null,
imageFilename: data.imageFilename ?? null, imageFilename: data.imageFilename ?? null,
}) })
.returning() .returning()
.get(); .get();
} }
export function updateCandidate( export function updateCandidate(
db: Db = prodDb, db: Db = prodDb,
candidateId: number, candidateId: number,
data: Partial<{ data: Partial<{
name: string; name: string;
weightGrams: number; weightGrams: number;
priceCents: number; priceCents: number;
categoryId: number; categoryId: number;
notes: string; notes: string;
productUrl: string; productUrl: string;
imageFilename: string; imageFilename: string;
}>, }>,
) { ) {
const existing = db.select({ id: threadCandidates.id }).from(threadCandidates) const existing = db
.where(eq(threadCandidates.id, candidateId)).get(); .select({ id: threadCandidates.id })
if (!existing) return null; .from(threadCandidates)
.where(eq(threadCandidates.id, candidateId))
.get();
if (!existing) return null;
return db return db
.update(threadCandidates) .update(threadCandidates)
.set({ ...data, updatedAt: new Date() }) .set({ ...data, updatedAt: new Date() })
.where(eq(threadCandidates.id, candidateId)) .where(eq(threadCandidates.id, candidateId))
.returning() .returning()
.get(); .get();
} }
export function deleteCandidate(db: Db = prodDb, candidateId: number) { export function deleteCandidate(db: Db = prodDb, candidateId: number) {
const candidate = db.select().from(threadCandidates) const candidate = db
.where(eq(threadCandidates.id, candidateId)).get(); .select()
if (!candidate) return null; .from(threadCandidates)
.where(eq(threadCandidates.id, candidateId))
.get();
if (!candidate) return null;
db.delete(threadCandidates).where(eq(threadCandidates.id, candidateId)).run(); db.delete(threadCandidates).where(eq(threadCandidates.id, candidateId)).run();
return candidate; return candidate;
} }
export function resolveThread( export function resolveThread(
db: Db = prodDb, db: Db = prodDb,
threadId: number, threadId: number,
candidateId: number, candidateId: number,
): { success: boolean; item?: any; error?: string } { ): { success: boolean; item?: any; error?: string } {
return db.transaction((tx) => { return db.transaction((tx) => {
// 1. Check thread is active // 1. Check thread is active
const thread = tx.select().from(threads) const thread = tx
.where(eq(threads.id, threadId)).get(); .select()
if (!thread || thread.status !== "active") { .from(threads)
return { success: false, error: "Thread not active" }; .where(eq(threads.id, threadId))
} .get();
if (!thread || thread.status !== "active") {
return { success: false, error: "Thread not active" };
}
// 2. Get the candidate data // 2. Get the candidate data
const candidate = tx.select().from(threadCandidates) const candidate = tx
.where(eq(threadCandidates.id, candidateId)).get(); .select()
if (!candidate) { .from(threadCandidates)
return { success: false, error: "Candidate not found" }; .where(eq(threadCandidates.id, candidateId))
} .get();
if (candidate.threadId !== threadId) { if (!candidate) {
return { success: false, error: "Candidate not in thread" }; return { success: false, error: "Candidate not found" };
} }
if (candidate.threadId !== threadId) {
return { success: false, error: "Candidate not in thread" };
}
// 3. Verify categoryId still exists, fallback to Uncategorized (id=1) // 3. Verify categoryId still exists, fallback to Uncategorized (id=1)
const category = tx.select({ id: categories.id }).from(categories) const category = tx
.where(eq(categories.id, candidate.categoryId)).get(); .select({ id: categories.id })
const safeCategoryId = category ? candidate.categoryId : 1; .from(categories)
.where(eq(categories.id, candidate.categoryId))
.get();
const safeCategoryId = category ? candidate.categoryId : 1;
// 4. Create collection item from candidate data // 4. Create collection item from candidate data
const newItem = tx const newItem = tx
.insert(items) .insert(items)
.values({ .values({
name: candidate.name, name: candidate.name,
weightGrams: candidate.weightGrams, weightGrams: candidate.weightGrams,
priceCents: candidate.priceCents, priceCents: candidate.priceCents,
categoryId: safeCategoryId, categoryId: safeCategoryId,
notes: candidate.notes, notes: candidate.notes,
productUrl: candidate.productUrl, productUrl: candidate.productUrl,
imageFilename: candidate.imageFilename, imageFilename: candidate.imageFilename,
}) })
.returning() .returning()
.get(); .get();
// 5. Archive the thread // 5. Archive the thread
tx.update(threads) tx.update(threads)
.set({ .set({
status: "resolved", status: "resolved",
resolvedCandidateId: candidateId, resolvedCandidateId: candidateId,
updatedAt: new Date(), updatedAt: new Date(),
}) })
.where(eq(threads.id, threadId)) .where(eq(threads.id, threadId))
.run(); .run();
return { success: true, item: newItem }; return { success: true, item: newItem };
}); });
} }

View File

@@ -1,32 +1,32 @@
import { eq, sql } from "drizzle-orm"; import { eq, sql } from "drizzle-orm";
import { items, categories } from "../../db/schema.ts";
import { db as prodDb } from "../../db/index.ts"; import { db as prodDb } from "../../db/index.ts";
import { categories, items } from "../../db/schema.ts";
type Db = typeof prodDb; type Db = typeof prodDb;
export function getCategoryTotals(db: Db = prodDb) { export function getCategoryTotals(db: Db = prodDb) {
return db return db
.select({ .select({
categoryId: items.categoryId, categoryId: items.categoryId,
categoryName: categories.name, categoryName: categories.name,
categoryIcon: categories.icon, categoryIcon: categories.icon,
totalWeight: sql<number>`COALESCE(SUM(${items.weightGrams}), 0)`, totalWeight: sql<number>`COALESCE(SUM(${items.weightGrams}), 0)`,
totalCost: sql<number>`COALESCE(SUM(${items.priceCents}), 0)`, totalCost: sql<number>`COALESCE(SUM(${items.priceCents}), 0)`,
itemCount: sql<number>`COUNT(*)`, itemCount: sql<number>`COUNT(*)`,
}) })
.from(items) .from(items)
.innerJoin(categories, eq(items.categoryId, categories.id)) .innerJoin(categories, eq(items.categoryId, categories.id))
.groupBy(items.categoryId) .groupBy(items.categoryId)
.all(); .all();
} }
export function getGlobalTotals(db: Db = prodDb) { export function getGlobalTotals(db: Db = prodDb) {
return db return db
.select({ .select({
totalWeight: sql<number>`COALESCE(SUM(${items.weightGrams}), 0)`, totalWeight: sql<number>`COALESCE(SUM(${items.weightGrams}), 0)`,
totalCost: sql<number>`COALESCE(SUM(${items.priceCents}), 0)`, totalCost: sql<number>`COALESCE(SUM(${items.priceCents}), 0)`,
itemCount: sql<number>`COUNT(*)`, itemCount: sql<number>`COUNT(*)`,
}) })
.from(items) .from(items)
.get(); .get();
} }

View File

@@ -1,67 +1,67 @@
import { z } from "zod"; import { z } from "zod";
export const createItemSchema = z.object({ export const createItemSchema = z.object({
name: z.string().min(1, "Name is required"), name: z.string().min(1, "Name is required"),
weightGrams: z.number().nonnegative().optional(), weightGrams: z.number().nonnegative().optional(),
priceCents: z.number().int().nonnegative().optional(), priceCents: z.number().int().nonnegative().optional(),
categoryId: z.number().int().positive(), categoryId: z.number().int().positive(),
notes: z.string().optional(), notes: z.string().optional(),
productUrl: z.string().url().optional().or(z.literal("")), productUrl: z.string().url().optional().or(z.literal("")),
imageFilename: z.string().optional(), imageFilename: z.string().optional(),
}); });
export const updateItemSchema = createItemSchema.partial().extend({ export const updateItemSchema = createItemSchema.partial().extend({
id: z.number().int().positive(), id: z.number().int().positive(),
}); });
export const createCategorySchema = z.object({ export const createCategorySchema = z.object({
name: z.string().min(1, "Category name is required"), name: z.string().min(1, "Category name is required"),
icon: z.string().min(1).max(50).default("package"), icon: z.string().min(1).max(50).default("package"),
}); });
export const updateCategorySchema = z.object({ export const updateCategorySchema = z.object({
id: z.number().int().positive(), id: z.number().int().positive(),
name: z.string().min(1).optional(), name: z.string().min(1).optional(),
icon: z.string().min(1).max(50).optional(), icon: z.string().min(1).max(50).optional(),
}); });
// Thread schemas // Thread schemas
export const createThreadSchema = z.object({ export const createThreadSchema = z.object({
name: z.string().min(1, "Thread name is required"), name: z.string().min(1, "Thread name is required"),
categoryId: z.number().int().positive(), categoryId: z.number().int().positive(),
}); });
export const updateThreadSchema = z.object({ export const updateThreadSchema = z.object({
name: z.string().min(1).optional(), name: z.string().min(1).optional(),
categoryId: z.number().int().positive().optional(), categoryId: z.number().int().positive().optional(),
}); });
// Candidate schemas (same fields as items) // Candidate schemas (same fields as items)
export const createCandidateSchema = z.object({ export const createCandidateSchema = z.object({
name: z.string().min(1, "Name is required"), name: z.string().min(1, "Name is required"),
weightGrams: z.number().nonnegative().optional(), weightGrams: z.number().nonnegative().optional(),
priceCents: z.number().int().nonnegative().optional(), priceCents: z.number().int().nonnegative().optional(),
categoryId: z.number().int().positive(), categoryId: z.number().int().positive(),
notes: z.string().optional(), notes: z.string().optional(),
productUrl: z.string().url().optional().or(z.literal("")), productUrl: z.string().url().optional().or(z.literal("")),
imageFilename: z.string().optional(), imageFilename: z.string().optional(),
}); });
export const updateCandidateSchema = createCandidateSchema.partial(); export const updateCandidateSchema = createCandidateSchema.partial();
export const resolveThreadSchema = z.object({ export const resolveThreadSchema = z.object({
candidateId: z.number().int().positive(), candidateId: z.number().int().positive(),
}); });
// Setup schemas // Setup schemas
export const createSetupSchema = z.object({ export const createSetupSchema = z.object({
name: z.string().min(1, "Setup name is required"), name: z.string().min(1, "Setup name is required"),
}); });
export const updateSetupSchema = z.object({ export const updateSetupSchema = z.object({
name: z.string().min(1, "Setup name is required"), name: z.string().min(1, "Setup name is required"),
}); });
export const syncSetupItemsSchema = z.object({ export const syncSetupItemsSchema = z.object({
itemIds: z.array(z.number().int().positive()), itemIds: z.array(z.number().int().positive()),
}); });

View File

@@ -1,19 +1,26 @@
import type { z } from "zod"; import type { z } from "zod";
import type { import type {
createItemSchema, categories,
updateItemSchema, items,
createCategorySchema, setupItems,
updateCategorySchema, setups,
createThreadSchema, threadCandidates,
updateThreadSchema, threads,
createCandidateSchema, } from "../db/schema.ts";
updateCandidateSchema, import type {
resolveThreadSchema, createCandidateSchema,
createSetupSchema, createCategorySchema,
updateSetupSchema, createItemSchema,
syncSetupItemsSchema, createSetupSchema,
createThreadSchema,
resolveThreadSchema,
syncSetupItemsSchema,
updateCandidateSchema,
updateCategorySchema,
updateItemSchema,
updateSetupSchema,
updateThreadSchema,
} from "./schemas.ts"; } from "./schemas.ts";
import type { items, categories, threads, threadCandidates, setups, setupItems } from "../db/schema.ts";
// Types inferred from Zod schemas // Types inferred from Zod schemas
export type CreateItem = z.infer<typeof createItemSchema>; export type CreateItem = z.infer<typeof createItemSchema>;

View File

@@ -3,11 +3,11 @@ import { drizzle } from "drizzle-orm/bun-sqlite";
import * as schema from "../../src/db/schema.ts"; import * as schema from "../../src/db/schema.ts";
export function createTestDb() { export function createTestDb() {
const sqlite = new Database(":memory:"); const sqlite = new Database(":memory:");
sqlite.run("PRAGMA foreign_keys = ON"); sqlite.run("PRAGMA foreign_keys = ON");
// Create tables matching the Drizzle schema // Create tables matching the Drizzle schema
sqlite.run(` sqlite.run(`
CREATE TABLE categories ( CREATE TABLE categories (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE, name TEXT NOT NULL UNIQUE,
@@ -16,7 +16,7 @@ export function createTestDb() {
) )
`); `);
sqlite.run(` sqlite.run(`
CREATE TABLE items ( CREATE TABLE items (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL, name TEXT NOT NULL,
@@ -31,7 +31,7 @@ export function createTestDb() {
) )
`); `);
sqlite.run(` sqlite.run(`
CREATE TABLE threads ( CREATE TABLE threads (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL, name TEXT NOT NULL,
@@ -43,7 +43,7 @@ export function createTestDb() {
) )
`); `);
sqlite.run(` sqlite.run(`
CREATE TABLE thread_candidates ( CREATE TABLE thread_candidates (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
thread_id INTEGER NOT NULL REFERENCES threads(id) ON DELETE CASCADE, thread_id INTEGER NOT NULL REFERENCES threads(id) ON DELETE CASCADE,
@@ -59,7 +59,7 @@ export function createTestDb() {
) )
`); `);
sqlite.run(` sqlite.run(`
CREATE TABLE setups ( CREATE TABLE setups (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL, name TEXT NOT NULL,
@@ -68,7 +68,7 @@ export function createTestDb() {
) )
`); `);
sqlite.run(` sqlite.run(`
CREATE TABLE setup_items ( CREATE TABLE setup_items (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
setup_id INTEGER NOT NULL REFERENCES setups(id) ON DELETE CASCADE, setup_id INTEGER NOT NULL REFERENCES setups(id) ON DELETE CASCADE,
@@ -76,19 +76,19 @@ export function createTestDb() {
) )
`); `);
sqlite.run(` sqlite.run(`
CREATE TABLE settings ( CREATE TABLE settings (
key TEXT PRIMARY KEY, key TEXT PRIMARY KEY,
value TEXT NOT NULL value TEXT NOT NULL
) )
`); `);
const db = drizzle(sqlite, { schema }); const db = drizzle(sqlite, { schema });
// Seed default Uncategorized category // Seed default Uncategorized category
db.insert(schema.categories) db.insert(schema.categories)
.values({ name: "Uncategorized", icon: "package" }) .values({ name: "Uncategorized", icon: "package" })
.run(); .run();
return db; return db;
} }

View File

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

View File

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

View File

@@ -1,229 +1,244 @@
import { describe, it, expect, beforeEach } from "bun:test"; import { beforeEach, describe, expect, it } from "bun:test";
import { Hono } from "hono"; import { Hono } from "hono";
import { createTestDb } from "../helpers/db.ts";
import { setupRoutes } from "../../src/server/routes/setups.ts";
import { itemRoutes } from "../../src/server/routes/items.ts"; import { itemRoutes } from "../../src/server/routes/items.ts";
import { setupRoutes } from "../../src/server/routes/setups.ts";
import { createTestDb } from "../helpers/db.ts";
function createTestApp() { function createTestApp() {
const db = createTestDb(); const db = createTestDb();
const app = new Hono(); const app = new Hono();
app.use("*", async (c, next) => { app.use("*", async (c, next) => {
c.set("db", db); c.set("db", db);
await next(); await next();
}); });
app.route("/api/setups", setupRoutes); app.route("/api/setups", setupRoutes);
app.route("/api/items", itemRoutes); app.route("/api/items", itemRoutes);
return { app, db }; return { app, db };
} }
async function createSetupViaAPI(app: Hono, name: string) { async function createSetupViaAPI(app: Hono, name: string) {
const res = await app.request("/api/setups", { const res = await app.request("/api/setups", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name }), body: JSON.stringify({ name }),
}); });
return res.json(); return res.json();
} }
async function createItemViaAPI(app: Hono, data: any) { async function createItemViaAPI(app: Hono, data: any) {
const res = await app.request("/api/items", { const res = await app.request("/api/items", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(data), body: JSON.stringify(data),
}); });
return res.json(); return res.json();
} }
describe("Setup Routes", () => { describe("Setup Routes", () => {
let app: Hono; let app: Hono;
beforeEach(() => { beforeEach(() => {
const testApp = createTestApp(); const testApp = createTestApp();
app = testApp.app; app = testApp.app;
}); });
describe("POST /api/setups", () => { describe("POST /api/setups", () => {
it("with valid body returns 201 + setup object", async () => { it("with valid body returns 201 + setup object", async () => {
const res = await app.request("/api/setups", { const res = await app.request("/api/setups", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "Day Hike" }), body: JSON.stringify({ name: "Day Hike" }),
}); });
expect(res.status).toBe(201); expect(res.status).toBe(201);
const body = await res.json(); const body = await res.json();
expect(body.name).toBe("Day Hike"); expect(body.name).toBe("Day Hike");
expect(body.id).toBeGreaterThan(0); expect(body.id).toBeGreaterThan(0);
}); });
it("with empty name returns 400", async () => { it("with empty name returns 400", async () => {
const res = await app.request("/api/setups", { const res = await app.request("/api/setups", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "" }), body: JSON.stringify({ name: "" }),
}); });
expect(res.status).toBe(400); expect(res.status).toBe(400);
}); });
}); });
describe("GET /api/setups", () => { describe("GET /api/setups", () => {
it("returns array of setups with totals", async () => { it("returns array of setups with totals", async () => {
const setup = await createSetupViaAPI(app, "Backpacking"); const setup = await createSetupViaAPI(app, "Backpacking");
const item = await createItemViaAPI(app, { const item = await createItemViaAPI(app, {
name: "Tent", name: "Tent",
categoryId: 1, categoryId: 1,
weightGrams: 1200, weightGrams: 1200,
priceCents: 30000, priceCents: 30000,
}); });
// Sync items // Sync items
await app.request(`/api/setups/${setup.id}/items`, { await app.request(`/api/setups/${setup.id}/items`, {
method: "PUT", method: "PUT",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ itemIds: [item.id] }), body: JSON.stringify({ itemIds: [item.id] }),
}); });
const res = await app.request("/api/setups"); const res = await app.request("/api/setups");
expect(res.status).toBe(200); expect(res.status).toBe(200);
const body = await res.json(); const body = await res.json();
expect(Array.isArray(body)).toBe(true); expect(Array.isArray(body)).toBe(true);
expect(body.length).toBeGreaterThanOrEqual(1); expect(body.length).toBeGreaterThanOrEqual(1);
expect(body[0].itemCount).toBeDefined(); expect(body[0].itemCount).toBeDefined();
expect(body[0].totalWeight).toBeDefined(); expect(body[0].totalWeight).toBeDefined();
expect(body[0].totalCost).toBeDefined(); expect(body[0].totalCost).toBeDefined();
}); });
}); });
describe("GET /api/setups/:id", () => { describe("GET /api/setups/:id", () => {
it("returns setup with items", async () => { it("returns setup with items", async () => {
const setup = await createSetupViaAPI(app, "Day Hike"); const setup = await createSetupViaAPI(app, "Day Hike");
const item = await createItemViaAPI(app, { const item = await createItemViaAPI(app, {
name: "Water Bottle", name: "Water Bottle",
categoryId: 1, categoryId: 1,
weightGrams: 200, weightGrams: 200,
priceCents: 2500, priceCents: 2500,
}); });
await app.request(`/api/setups/${setup.id}/items`, { await app.request(`/api/setups/${setup.id}/items`, {
method: "PUT", method: "PUT",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ itemIds: [item.id] }), body: JSON.stringify({ itemIds: [item.id] }),
}); });
const res = await app.request(`/api/setups/${setup.id}`); const res = await app.request(`/api/setups/${setup.id}`);
expect(res.status).toBe(200); expect(res.status).toBe(200);
const body = await res.json(); const body = await res.json();
expect(body.name).toBe("Day Hike"); expect(body.name).toBe("Day Hike");
expect(body.items).toHaveLength(1); expect(body.items).toHaveLength(1);
expect(body.items[0].name).toBe("Water Bottle"); expect(body.items[0].name).toBe("Water Bottle");
}); });
it("returns 404 for non-existent setup", async () => { it("returns 404 for non-existent setup", async () => {
const res = await app.request("/api/setups/9999"); const res = await app.request("/api/setups/9999");
expect(res.status).toBe(404); expect(res.status).toBe(404);
}); });
}); });
describe("PUT /api/setups/:id", () => { describe("PUT /api/setups/:id", () => {
it("updates setup name", async () => { it("updates setup name", async () => {
const setup = await createSetupViaAPI(app, "Original"); const setup = await createSetupViaAPI(app, "Original");
const res = await app.request(`/api/setups/${setup.id}`, { const res = await app.request(`/api/setups/${setup.id}`, {
method: "PUT", method: "PUT",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "Renamed" }), body: JSON.stringify({ name: "Renamed" }),
}); });
expect(res.status).toBe(200); expect(res.status).toBe(200);
const body = await res.json(); const body = await res.json();
expect(body.name).toBe("Renamed"); expect(body.name).toBe("Renamed");
}); });
it("returns 404 for non-existent setup", async () => { it("returns 404 for non-existent setup", async () => {
const res = await app.request("/api/setups/9999", { const res = await app.request("/api/setups/9999", {
method: "PUT", method: "PUT",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "Ghost" }), body: JSON.stringify({ name: "Ghost" }),
}); });
expect(res.status).toBe(404); expect(res.status).toBe(404);
}); });
}); });
describe("DELETE /api/setups/:id", () => { describe("DELETE /api/setups/:id", () => {
it("removes setup", async () => { it("removes setup", async () => {
const setup = await createSetupViaAPI(app, "To Delete"); const setup = await createSetupViaAPI(app, "To Delete");
const res = await app.request(`/api/setups/${setup.id}`, { const res = await app.request(`/api/setups/${setup.id}`, {
method: "DELETE", method: "DELETE",
}); });
expect(res.status).toBe(200); expect(res.status).toBe(200);
const body = await res.json(); const body = await res.json();
expect(body.success).toBe(true); expect(body.success).toBe(true);
// Verify gone // Verify gone
const getRes = await app.request(`/api/setups/${setup.id}`); const getRes = await app.request(`/api/setups/${setup.id}`);
expect(getRes.status).toBe(404); expect(getRes.status).toBe(404);
}); });
it("returns 404 for non-existent setup", async () => { it("returns 404 for non-existent setup", async () => {
const res = await app.request("/api/setups/9999", { method: "DELETE" }); const res = await app.request("/api/setups/9999", { method: "DELETE" });
expect(res.status).toBe(404); expect(res.status).toBe(404);
}); });
}); });
describe("PUT /api/setups/:id/items", () => { describe("PUT /api/setups/:id/items", () => {
it("syncs items to setup", async () => { it("syncs items to setup", async () => {
const setup = await createSetupViaAPI(app, "Kit"); const setup = await createSetupViaAPI(app, "Kit");
const item1 = await createItemViaAPI(app, { name: "Item 1", categoryId: 1 }); const item1 = await createItemViaAPI(app, {
const item2 = await createItemViaAPI(app, { name: "Item 2", categoryId: 1 }); name: "Item 1",
categoryId: 1,
});
const item2 = await createItemViaAPI(app, {
name: "Item 2",
categoryId: 1,
});
const res = await app.request(`/api/setups/${setup.id}/items`, { const res = await app.request(`/api/setups/${setup.id}/items`, {
method: "PUT", method: "PUT",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ itemIds: [item1.id, item2.id] }), body: JSON.stringify({ itemIds: [item1.id, item2.id] }),
}); });
expect(res.status).toBe(200); expect(res.status).toBe(200);
const body = await res.json(); const body = await res.json();
expect(body.success).toBe(true); expect(body.success).toBe(true);
// Verify items // Verify items
const getRes = await app.request(`/api/setups/${setup.id}`); const getRes = await app.request(`/api/setups/${setup.id}`);
const getBody = await getRes.json(); const getBody = await getRes.json();
expect(getBody.items).toHaveLength(2); expect(getBody.items).toHaveLength(2);
}); });
}); });
describe("DELETE /api/setups/:id/items/:itemId", () => { describe("DELETE /api/setups/:id/items/:itemId", () => {
it("removes single item from setup", async () => { it("removes single item from setup", async () => {
const setup = await createSetupViaAPI(app, "Kit"); const setup = await createSetupViaAPI(app, "Kit");
const item1 = await createItemViaAPI(app, { name: "Item 1", categoryId: 1 }); const item1 = await createItemViaAPI(app, {
const item2 = await createItemViaAPI(app, { name: "Item 2", categoryId: 1 }); name: "Item 1",
categoryId: 1,
});
const item2 = await createItemViaAPI(app, {
name: "Item 2",
categoryId: 1,
});
// Sync both items // Sync both items
await app.request(`/api/setups/${setup.id}/items`, { await app.request(`/api/setups/${setup.id}/items`, {
method: "PUT", method: "PUT",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ itemIds: [item1.id, item2.id] }), body: JSON.stringify({ itemIds: [item1.id, item2.id] }),
}); });
// Remove one // Remove one
const res = await app.request(`/api/setups/${setup.id}/items/${item1.id}`, { const res = await app.request(
method: "DELETE", `/api/setups/${setup.id}/items/${item1.id}`,
}); {
method: "DELETE",
},
);
expect(res.status).toBe(200); expect(res.status).toBe(200);
// Verify only one remains // Verify only one remains
const getRes = await app.request(`/api/setups/${setup.id}`); const getRes = await app.request(`/api/setups/${setup.id}`);
const getBody = await getRes.json(); const getBody = await getRes.json();
expect(getBody.items).toHaveLength(1); expect(getBody.items).toHaveLength(1);
expect(getBody.items[0].name).toBe("Item 2"); expect(getBody.items[0].name).toBe("Item 2");
}); });
}); });
}); });

View File

@@ -1,300 +1,300 @@
import { describe, it, expect, beforeEach } from "bun:test"; import { beforeEach, describe, expect, it } from "bun:test";
import { Hono } from "hono"; import { Hono } from "hono";
import { createTestDb } from "../helpers/db.ts";
import { threadRoutes } from "../../src/server/routes/threads.ts"; import { threadRoutes } from "../../src/server/routes/threads.ts";
import { createTestDb } from "../helpers/db.ts";
function createTestApp() { function createTestApp() {
const db = createTestDb(); const db = createTestDb();
const app = new Hono(); const app = new Hono();
// Inject test DB into context for all routes // Inject test DB into context for all routes
app.use("*", async (c, next) => { app.use("*", async (c, next) => {
c.set("db", db); c.set("db", db);
await next(); await next();
}); });
app.route("/api/threads", threadRoutes); app.route("/api/threads", threadRoutes);
return { app, db }; return { app, db };
} }
async function createThreadViaAPI(app: Hono, name: string, categoryId = 1) { async function createThreadViaAPI(app: Hono, name: string, categoryId = 1) {
const res = await app.request("/api/threads", { const res = await app.request("/api/threads", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, categoryId }), body: JSON.stringify({ name, categoryId }),
}); });
return res.json(); return res.json();
} }
async function createCandidateViaAPI(app: Hono, threadId: number, data: any) { async function createCandidateViaAPI(app: Hono, threadId: number, data: any) {
const res = await app.request(`/api/threads/${threadId}/candidates`, { const res = await app.request(`/api/threads/${threadId}/candidates`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(data), body: JSON.stringify(data),
}); });
return res.json(); return res.json();
} }
describe("Thread Routes", () => { describe("Thread Routes", () => {
let app: Hono; let app: Hono;
beforeEach(() => { beforeEach(() => {
const testApp = createTestApp(); const testApp = createTestApp();
app = testApp.app; app = testApp.app;
}); });
describe("POST /api/threads", () => { describe("POST /api/threads", () => {
it("with valid body returns 201 + thread object", async () => { it("with valid body returns 201 + thread object", async () => {
const res = await app.request("/api/threads", { const res = await app.request("/api/threads", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "New Tent", categoryId: 1 }), body: JSON.stringify({ name: "New Tent", categoryId: 1 }),
}); });
expect(res.status).toBe(201); expect(res.status).toBe(201);
const body = await res.json(); const body = await res.json();
expect(body.name).toBe("New Tent"); expect(body.name).toBe("New Tent");
expect(body.id).toBeGreaterThan(0); expect(body.id).toBeGreaterThan(0);
expect(body.status).toBe("active"); expect(body.status).toBe("active");
}); });
it("with empty name returns 400", async () => { it("with empty name returns 400", async () => {
const res = await app.request("/api/threads", { const res = await app.request("/api/threads", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "" }), body: JSON.stringify({ name: "" }),
}); });
expect(res.status).toBe(400); expect(res.status).toBe(400);
}); });
}); });
describe("GET /api/threads", () => { describe("GET /api/threads", () => {
it("returns array of active threads with metadata", async () => { it("returns array of active threads with metadata", async () => {
const thread = await createThreadViaAPI(app, "Backpack Options"); const thread = await createThreadViaAPI(app, "Backpack Options");
await createCandidateViaAPI(app, thread.id, { await createCandidateViaAPI(app, thread.id, {
name: "Pack A", name: "Pack A",
categoryId: 1, categoryId: 1,
priceCents: 20000, priceCents: 20000,
}); });
const res = await app.request("/api/threads"); const res = await app.request("/api/threads");
expect(res.status).toBe(200); expect(res.status).toBe(200);
const body = await res.json(); const body = await res.json();
expect(Array.isArray(body)).toBe(true); expect(Array.isArray(body)).toBe(true);
expect(body.length).toBeGreaterThanOrEqual(1); expect(body.length).toBeGreaterThanOrEqual(1);
expect(body[0].candidateCount).toBeDefined(); expect(body[0].candidateCount).toBeDefined();
}); });
it("?includeResolved=true includes archived threads", async () => { it("?includeResolved=true includes archived threads", async () => {
const t1 = await createThreadViaAPI(app, "Active"); const _t1 = await createThreadViaAPI(app, "Active");
const t2 = await createThreadViaAPI(app, "To Resolve"); const t2 = await createThreadViaAPI(app, "To Resolve");
const candidate = await createCandidateViaAPI(app, t2.id, { const candidate = await createCandidateViaAPI(app, t2.id, {
name: "Winner", name: "Winner",
categoryId: 1, categoryId: 1,
}); });
// Resolve thread // Resolve thread
await app.request(`/api/threads/${t2.id}/resolve`, { await app.request(`/api/threads/${t2.id}/resolve`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ candidateId: candidate.id }), body: JSON.stringify({ candidateId: candidate.id }),
}); });
// Default excludes resolved // Default excludes resolved
const defaultRes = await app.request("/api/threads"); const defaultRes = await app.request("/api/threads");
const defaultBody = await defaultRes.json(); const defaultBody = await defaultRes.json();
expect(defaultBody).toHaveLength(1); expect(defaultBody).toHaveLength(1);
// With includeResolved includes all // With includeResolved includes all
const allRes = await app.request("/api/threads?includeResolved=true"); const allRes = await app.request("/api/threads?includeResolved=true");
const allBody = await allRes.json(); const allBody = await allRes.json();
expect(allBody).toHaveLength(2); expect(allBody).toHaveLength(2);
}); });
}); });
describe("GET /api/threads/:id", () => { describe("GET /api/threads/:id", () => {
it("returns thread with candidates", async () => { it("returns thread with candidates", async () => {
const thread = await createThreadViaAPI(app, "Tent Options"); const thread = await createThreadViaAPI(app, "Tent Options");
await createCandidateViaAPI(app, thread.id, { await createCandidateViaAPI(app, thread.id, {
name: "Tent A", name: "Tent A",
categoryId: 1, categoryId: 1,
priceCents: 30000, priceCents: 30000,
}); });
const res = await app.request(`/api/threads/${thread.id}`); const res = await app.request(`/api/threads/${thread.id}`);
expect(res.status).toBe(200); expect(res.status).toBe(200);
const body = await res.json(); const body = await res.json();
expect(body.name).toBe("Tent Options"); expect(body.name).toBe("Tent Options");
expect(body.candidates).toHaveLength(1); expect(body.candidates).toHaveLength(1);
expect(body.candidates[0].name).toBe("Tent A"); expect(body.candidates[0].name).toBe("Tent A");
}); });
it("returns 404 for non-existent thread", async () => { it("returns 404 for non-existent thread", async () => {
const res = await app.request("/api/threads/9999"); const res = await app.request("/api/threads/9999");
expect(res.status).toBe(404); expect(res.status).toBe(404);
}); });
}); });
describe("PUT /api/threads/:id", () => { describe("PUT /api/threads/:id", () => {
it("updates thread name", async () => { it("updates thread name", async () => {
const thread = await createThreadViaAPI(app, "Original"); const thread = await createThreadViaAPI(app, "Original");
const res = await app.request(`/api/threads/${thread.id}`, { const res = await app.request(`/api/threads/${thread.id}`, {
method: "PUT", method: "PUT",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "Renamed" }), body: JSON.stringify({ name: "Renamed" }),
}); });
expect(res.status).toBe(200); expect(res.status).toBe(200);
const body = await res.json(); const body = await res.json();
expect(body.name).toBe("Renamed"); expect(body.name).toBe("Renamed");
}); });
}); });
describe("DELETE /api/threads/:id", () => { describe("DELETE /api/threads/:id", () => {
it("removes thread", async () => { it("removes thread", async () => {
const thread = await createThreadViaAPI(app, "To Delete"); const thread = await createThreadViaAPI(app, "To Delete");
const res = await app.request(`/api/threads/${thread.id}`, { const res = await app.request(`/api/threads/${thread.id}`, {
method: "DELETE", method: "DELETE",
}); });
expect(res.status).toBe(200); expect(res.status).toBe(200);
const body = await res.json(); const body = await res.json();
expect(body.success).toBe(true); expect(body.success).toBe(true);
// Verify gone // Verify gone
const getRes = await app.request(`/api/threads/${thread.id}`); const getRes = await app.request(`/api/threads/${thread.id}`);
expect(getRes.status).toBe(404); expect(getRes.status).toBe(404);
}); });
}); });
describe("POST /api/threads/:id/candidates", () => { describe("POST /api/threads/:id/candidates", () => {
it("adds candidate, returns 201", async () => { it("adds candidate, returns 201", async () => {
const thread = await createThreadViaAPI(app, "Test"); const thread = await createThreadViaAPI(app, "Test");
const res = await app.request(`/api/threads/${thread.id}/candidates`, { const res = await app.request(`/api/threads/${thread.id}/candidates`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
name: "Candidate A", name: "Candidate A",
categoryId: 1, categoryId: 1,
priceCents: 25000, priceCents: 25000,
weightGrams: 500, weightGrams: 500,
}), }),
}); });
expect(res.status).toBe(201); expect(res.status).toBe(201);
const body = await res.json(); const body = await res.json();
expect(body.name).toBe("Candidate A"); expect(body.name).toBe("Candidate A");
expect(body.threadId).toBe(thread.id); expect(body.threadId).toBe(thread.id);
}); });
}); });
describe("PUT /api/threads/:threadId/candidates/:candidateId", () => { describe("PUT /api/threads/:threadId/candidates/:candidateId", () => {
it("updates candidate", async () => { it("updates candidate", async () => {
const thread = await createThreadViaAPI(app, "Test"); const thread = await createThreadViaAPI(app, "Test");
const candidate = await createCandidateViaAPI(app, thread.id, { const candidate = await createCandidateViaAPI(app, thread.id, {
name: "Original", name: "Original",
categoryId: 1, categoryId: 1,
}); });
const res = await app.request( const res = await app.request(
`/api/threads/${thread.id}/candidates/${candidate.id}`, `/api/threads/${thread.id}/candidates/${candidate.id}`,
{ {
method: "PUT", method: "PUT",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "Updated" }), body: JSON.stringify({ name: "Updated" }),
}, },
); );
expect(res.status).toBe(200); expect(res.status).toBe(200);
const body = await res.json(); const body = await res.json();
expect(body.name).toBe("Updated"); expect(body.name).toBe("Updated");
}); });
}); });
describe("DELETE /api/threads/:threadId/candidates/:candidateId", () => { describe("DELETE /api/threads/:threadId/candidates/:candidateId", () => {
it("removes candidate", async () => { it("removes candidate", async () => {
const thread = await createThreadViaAPI(app, "Test"); const thread = await createThreadViaAPI(app, "Test");
const candidate = await createCandidateViaAPI(app, thread.id, { const candidate = await createCandidateViaAPI(app, thread.id, {
name: "To Remove", name: "To Remove",
categoryId: 1, categoryId: 1,
}); });
const res = await app.request( const res = await app.request(
`/api/threads/${thread.id}/candidates/${candidate.id}`, `/api/threads/${thread.id}/candidates/${candidate.id}`,
{ method: "DELETE" }, { method: "DELETE" },
); );
expect(res.status).toBe(200); expect(res.status).toBe(200);
const body = await res.json(); const body = await res.json();
expect(body.success).toBe(true); expect(body.success).toBe(true);
}); });
}); });
describe("POST /api/threads/:id/resolve", () => { describe("POST /api/threads/:id/resolve", () => {
it("with valid candidateId returns 200 + created item", async () => { it("with valid candidateId returns 200 + created item", async () => {
const thread = await createThreadViaAPI(app, "Tent Decision"); const thread = await createThreadViaAPI(app, "Tent Decision");
const candidate = await createCandidateViaAPI(app, thread.id, { const candidate = await createCandidateViaAPI(app, thread.id, {
name: "Winner", name: "Winner",
categoryId: 1, categoryId: 1,
priceCents: 30000, priceCents: 30000,
}); });
const res = await app.request(`/api/threads/${thread.id}/resolve`, { const res = await app.request(`/api/threads/${thread.id}/resolve`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ candidateId: candidate.id }), body: JSON.stringify({ candidateId: candidate.id }),
}); });
expect(res.status).toBe(200); expect(res.status).toBe(200);
const body = await res.json(); const body = await res.json();
expect(body.item).toBeDefined(); expect(body.item).toBeDefined();
expect(body.item.name).toBe("Winner"); expect(body.item.name).toBe("Winner");
expect(body.item.priceCents).toBe(30000); expect(body.item.priceCents).toBe(30000);
}); });
it("on already-resolved thread returns 400", async () => { it("on already-resolved thread returns 400", async () => {
const thread = await createThreadViaAPI(app, "Already Resolved"); const thread = await createThreadViaAPI(app, "Already Resolved");
const candidate = await createCandidateViaAPI(app, thread.id, { const candidate = await createCandidateViaAPI(app, thread.id, {
name: "Winner", name: "Winner",
categoryId: 1, categoryId: 1,
}); });
// Resolve first time // Resolve first time
await app.request(`/api/threads/${thread.id}/resolve`, { await app.request(`/api/threads/${thread.id}/resolve`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ candidateId: candidate.id }), body: JSON.stringify({ candidateId: candidate.id }),
}); });
// Try again // Try again
const res = await app.request(`/api/threads/${thread.id}/resolve`, { const res = await app.request(`/api/threads/${thread.id}/resolve`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ candidateId: candidate.id }), body: JSON.stringify({ candidateId: candidate.id }),
}); });
expect(res.status).toBe(400); expect(res.status).toBe(400);
}); });
it("with wrong candidateId returns 400", async () => { it("with wrong candidateId returns 400", async () => {
const t1 = await createThreadViaAPI(app, "Thread 1"); const t1 = await createThreadViaAPI(app, "Thread 1");
const t2 = await createThreadViaAPI(app, "Thread 2"); const t2 = await createThreadViaAPI(app, "Thread 2");
const candidate = await createCandidateViaAPI(app, t2.id, { const candidate = await createCandidateViaAPI(app, t2.id, {
name: "Wrong Thread", name: "Wrong Thread",
categoryId: 1, categoryId: 1,
}); });
const res = await app.request(`/api/threads/${t1.id}/resolve`, { const res = await app.request(`/api/threads/${t1.id}/resolve`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ candidateId: candidate.id }), body: JSON.stringify({ candidateId: candidate.id }),
}); });
expect(res.status).toBe(400); expect(res.status).toBe(400);
}); });
}); });
}); });

View File

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

View File

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

View File

@@ -1,192 +1,192 @@
import { describe, it, expect, beforeEach } from "bun:test"; import { beforeEach, describe, expect, it } from "bun:test";
import { createTestDb } from "../helpers/db.ts";
import {
getAllSetups,
getSetupWithItems,
createSetup,
updateSetup,
deleteSetup,
syncSetupItems,
removeSetupItem,
} from "../../src/server/services/setup.service.ts";
import { createItem } from "../../src/server/services/item.service.ts"; import { createItem } from "../../src/server/services/item.service.ts";
import {
createSetup,
deleteSetup,
getAllSetups,
getSetupWithItems,
removeSetupItem,
syncSetupItems,
updateSetup,
} from "../../src/server/services/setup.service.ts";
import { createTestDb } from "../helpers/db.ts";
describe("Setup Service", () => { describe("Setup Service", () => {
let db: ReturnType<typeof createTestDb>; let db: ReturnType<typeof createTestDb>;
beforeEach(() => { beforeEach(() => {
db = createTestDb(); db = createTestDb();
}); });
describe("createSetup", () => { describe("createSetup", () => {
it("creates setup with name, returns setup with id/timestamps", () => { it("creates setup with name, returns setup with id/timestamps", () => {
const setup = createSetup(db, { name: "Day Hike" }); const setup = createSetup(db, { name: "Day Hike" });
expect(setup).toBeDefined(); expect(setup).toBeDefined();
expect(setup.id).toBeGreaterThan(0); expect(setup.id).toBeGreaterThan(0);
expect(setup.name).toBe("Day Hike"); expect(setup.name).toBe("Day Hike");
expect(setup.createdAt).toBeDefined(); expect(setup.createdAt).toBeDefined();
expect(setup.updatedAt).toBeDefined(); expect(setup.updatedAt).toBeDefined();
}); });
}); });
describe("getAllSetups", () => { describe("getAllSetups", () => {
it("returns setups with itemCount, totalWeight, totalCost", () => { it("returns setups with itemCount, totalWeight, totalCost", () => {
const setup = createSetup(db, { name: "Backpacking" }); const setup = createSetup(db, { name: "Backpacking" });
const item1 = createItem(db, { const item1 = createItem(db, {
name: "Tent", name: "Tent",
categoryId: 1, categoryId: 1,
weightGrams: 1200, weightGrams: 1200,
priceCents: 30000, priceCents: 30000,
}); });
const item2 = createItem(db, { const item2 = createItem(db, {
name: "Sleeping Bag", name: "Sleeping Bag",
categoryId: 1, categoryId: 1,
weightGrams: 800, weightGrams: 800,
priceCents: 20000, priceCents: 20000,
}); });
syncSetupItems(db, setup.id, [item1.id, item2.id]); syncSetupItems(db, setup.id, [item1.id, item2.id]);
const setups = getAllSetups(db); const setups = getAllSetups(db);
expect(setups).toHaveLength(1); expect(setups).toHaveLength(1);
expect(setups[0].name).toBe("Backpacking"); expect(setups[0].name).toBe("Backpacking");
expect(setups[0].itemCount).toBe(2); expect(setups[0].itemCount).toBe(2);
expect(setups[0].totalWeight).toBe(2000); expect(setups[0].totalWeight).toBe(2000);
expect(setups[0].totalCost).toBe(50000); expect(setups[0].totalCost).toBe(50000);
}); });
it("returns 0 for weight/cost when setup has no items", () => { it("returns 0 for weight/cost when setup has no items", () => {
createSetup(db, { name: "Empty Setup" }); createSetup(db, { name: "Empty Setup" });
const setups = getAllSetups(db); const setups = getAllSetups(db);
expect(setups).toHaveLength(1); expect(setups).toHaveLength(1);
expect(setups[0].itemCount).toBe(0); expect(setups[0].itemCount).toBe(0);
expect(setups[0].totalWeight).toBe(0); expect(setups[0].totalWeight).toBe(0);
expect(setups[0].totalCost).toBe(0); expect(setups[0].totalCost).toBe(0);
}); });
}); });
describe("getSetupWithItems", () => { describe("getSetupWithItems", () => {
it("returns setup with full item details and category info", () => { it("returns setup with full item details and category info", () => {
const setup = createSetup(db, { name: "Day Hike" }); const setup = createSetup(db, { name: "Day Hike" });
const item = createItem(db, { const item = createItem(db, {
name: "Water Bottle", name: "Water Bottle",
categoryId: 1, categoryId: 1,
weightGrams: 200, weightGrams: 200,
priceCents: 2500, priceCents: 2500,
}); });
syncSetupItems(db, setup.id, [item.id]); syncSetupItems(db, setup.id, [item.id]);
const result = getSetupWithItems(db, setup.id); const result = getSetupWithItems(db, setup.id);
expect(result).toBeDefined(); expect(result).toBeDefined();
expect(result!.name).toBe("Day Hike"); expect(result?.name).toBe("Day Hike");
expect(result!.items).toHaveLength(1); expect(result?.items).toHaveLength(1);
expect(result!.items[0].name).toBe("Water Bottle"); expect(result?.items[0].name).toBe("Water Bottle");
expect(result!.items[0].categoryName).toBe("Uncategorized"); expect(result?.items[0].categoryName).toBe("Uncategorized");
expect(result!.items[0].categoryIcon).toBeDefined(); expect(result?.items[0].categoryIcon).toBeDefined();
}); });
it("returns null for non-existent setup", () => { it("returns null for non-existent setup", () => {
const result = getSetupWithItems(db, 9999); const result = getSetupWithItems(db, 9999);
expect(result).toBeNull(); expect(result).toBeNull();
}); });
}); });
describe("updateSetup", () => { describe("updateSetup", () => {
it("updates setup name, returns updated setup", () => { it("updates setup name, returns updated setup", () => {
const setup = createSetup(db, { name: "Original" }); const setup = createSetup(db, { name: "Original" });
const updated = updateSetup(db, setup.id, { name: "Renamed" }); const updated = updateSetup(db, setup.id, { name: "Renamed" });
expect(updated).toBeDefined(); expect(updated).toBeDefined();
expect(updated!.name).toBe("Renamed"); expect(updated?.name).toBe("Renamed");
}); });
it("returns null for non-existent setup", () => { it("returns null for non-existent setup", () => {
const result = updateSetup(db, 9999, { name: "Ghost" }); const result = updateSetup(db, 9999, { name: "Ghost" });
expect(result).toBeNull(); expect(result).toBeNull();
}); });
}); });
describe("deleteSetup", () => { describe("deleteSetup", () => {
it("removes setup and cascades to setup_items", () => { it("removes setup and cascades to setup_items", () => {
const setup = createSetup(db, { name: "To Delete" }); const setup = createSetup(db, { name: "To Delete" });
const item = createItem(db, { name: "Item", categoryId: 1 }); const item = createItem(db, { name: "Item", categoryId: 1 });
syncSetupItems(db, setup.id, [item.id]); syncSetupItems(db, setup.id, [item.id]);
const deleted = deleteSetup(db, setup.id); const deleted = deleteSetup(db, setup.id);
expect(deleted).toBe(true); expect(deleted).toBe(true);
// Setup gone // Setup gone
const result = getSetupWithItems(db, setup.id); const result = getSetupWithItems(db, setup.id);
expect(result).toBeNull(); expect(result).toBeNull();
}); });
it("returns false for non-existent setup", () => { it("returns false for non-existent setup", () => {
const result = deleteSetup(db, 9999); const result = deleteSetup(db, 9999);
expect(result).toBe(false); expect(result).toBe(false);
}); });
}); });
describe("syncSetupItems", () => { describe("syncSetupItems", () => {
it("sets items for a setup (delete-all + re-insert)", () => { it("sets items for a setup (delete-all + re-insert)", () => {
const setup = createSetup(db, { name: "Kit" }); const setup = createSetup(db, { name: "Kit" });
const item1 = createItem(db, { name: "Item 1", categoryId: 1 }); const item1 = createItem(db, { name: "Item 1", categoryId: 1 });
const item2 = createItem(db, { name: "Item 2", categoryId: 1 }); const item2 = createItem(db, { name: "Item 2", categoryId: 1 });
const item3 = createItem(db, { name: "Item 3", categoryId: 1 }); const item3 = createItem(db, { name: "Item 3", categoryId: 1 });
// Initial sync // Initial sync
syncSetupItems(db, setup.id, [item1.id, item2.id]); syncSetupItems(db, setup.id, [item1.id, item2.id]);
let result = getSetupWithItems(db, setup.id); let result = getSetupWithItems(db, setup.id);
expect(result!.items).toHaveLength(2); expect(result?.items).toHaveLength(2);
// Re-sync with different items // Re-sync with different items
syncSetupItems(db, setup.id, [item2.id, item3.id]); syncSetupItems(db, setup.id, [item2.id, item3.id]);
result = getSetupWithItems(db, setup.id); result = getSetupWithItems(db, setup.id);
expect(result!.items).toHaveLength(2); expect(result?.items).toHaveLength(2);
const names = result!.items.map((i: any) => i.name).sort(); const names = result?.items.map((i: any) => i.name).sort();
expect(names).toEqual(["Item 2", "Item 3"]); expect(names).toEqual(["Item 2", "Item 3"]);
}); });
it("syncing with empty array clears all items", () => { it("syncing with empty array clears all items", () => {
const setup = createSetup(db, { name: "Kit" }); const setup = createSetup(db, { name: "Kit" });
const item = createItem(db, { name: "Item", categoryId: 1 }); const item = createItem(db, { name: "Item", categoryId: 1 });
syncSetupItems(db, setup.id, [item.id]); syncSetupItems(db, setup.id, [item.id]);
syncSetupItems(db, setup.id, []); syncSetupItems(db, setup.id, []);
const result = getSetupWithItems(db, setup.id); const result = getSetupWithItems(db, setup.id);
expect(result!.items).toHaveLength(0); expect(result?.items).toHaveLength(0);
}); });
}); });
describe("removeSetupItem", () => { describe("removeSetupItem", () => {
it("removes single item from setup", () => { it("removes single item from setup", () => {
const setup = createSetup(db, { name: "Kit" }); const setup = createSetup(db, { name: "Kit" });
const item1 = createItem(db, { name: "Item 1", categoryId: 1 }); const item1 = createItem(db, { name: "Item 1", categoryId: 1 });
const item2 = createItem(db, { name: "Item 2", categoryId: 1 }); const item2 = createItem(db, { name: "Item 2", categoryId: 1 });
syncSetupItems(db, setup.id, [item1.id, item2.id]); syncSetupItems(db, setup.id, [item1.id, item2.id]);
removeSetupItem(db, setup.id, item1.id); removeSetupItem(db, setup.id, item1.id);
const result = getSetupWithItems(db, setup.id); const result = getSetupWithItems(db, setup.id);
expect(result!.items).toHaveLength(1); expect(result?.items).toHaveLength(1);
expect(result!.items[0].name).toBe("Item 2"); expect(result?.items[0].name).toBe("Item 2");
}); });
}); });
describe("cascade behavior", () => { describe("cascade behavior", () => {
it("deleting a collection item removes it from all setups", () => { it("deleting a collection item removes it from all setups", () => {
const setup = createSetup(db, { name: "Kit" }); const setup = createSetup(db, { name: "Kit" });
const item1 = createItem(db, { name: "Item 1", categoryId: 1 }); const item1 = createItem(db, { name: "Item 1", categoryId: 1 });
const item2 = createItem(db, { name: "Item 2", categoryId: 1 }); const item2 = createItem(db, { name: "Item 2", categoryId: 1 });
syncSetupItems(db, setup.id, [item1.id, item2.id]); syncSetupItems(db, setup.id, [item1.id, item2.id]);
// Delete item1 from collection (need direct DB access) // Delete item1 from collection (need direct DB access)
const { items: itemsTable } = require("../../src/db/schema.ts"); const { items: itemsTable } = require("../../src/db/schema.ts");
const { eq } = require("drizzle-orm"); const { eq } = require("drizzle-orm");
db.delete(itemsTable).where(eq(itemsTable.id, item1.id)).run(); db.delete(itemsTable).where(eq(itemsTable.id, item1.id)).run();
const result = getSetupWithItems(db, setup.id); const result = getSetupWithItems(db, setup.id);
expect(result!.items).toHaveLength(1); expect(result?.items).toHaveLength(1);
expect(result!.items[0].name).toBe("Item 2"); expect(result?.items[0].name).toBe("Item 2");
}); });
}); });
}); });

View File

@@ -1,280 +1,285 @@
import { describe, it, expect, beforeEach } from "bun:test"; import { beforeEach, describe, expect, it } from "bun:test";
import { createTestDb } from "../helpers/db.ts";
import { import {
createThread, createCandidate,
getAllThreads, createThread,
getThreadWithCandidates, deleteCandidate,
createCandidate, deleteThread,
updateCandidate, getAllThreads,
deleteCandidate, getThreadWithCandidates,
updateThread, resolveThread,
deleteThread, updateCandidate,
resolveThread, updateThread,
} from "../../src/server/services/thread.service.ts"; } from "../../src/server/services/thread.service.ts";
import { createItem } from "../../src/server/services/item.service.ts"; import { createTestDb } from "../helpers/db.ts";
describe("Thread Service", () => { describe("Thread Service", () => {
let db: ReturnType<typeof createTestDb>; let db: ReturnType<typeof createTestDb>;
beforeEach(() => { beforeEach(() => {
db = createTestDb(); db = createTestDb();
}); });
describe("createThread", () => { describe("createThread", () => {
it("creates thread with name, returns thread with id/status/timestamps", () => { it("creates thread with name, returns thread with id/status/timestamps", () => {
const thread = createThread(db, { name: "New Tent", categoryId: 1 }); const thread = createThread(db, { name: "New Tent", categoryId: 1 });
expect(thread).toBeDefined(); expect(thread).toBeDefined();
expect(thread.id).toBeGreaterThan(0); expect(thread.id).toBeGreaterThan(0);
expect(thread.name).toBe("New Tent"); expect(thread.name).toBe("New Tent");
expect(thread.status).toBe("active"); expect(thread.status).toBe("active");
expect(thread.resolvedCandidateId).toBeNull(); expect(thread.resolvedCandidateId).toBeNull();
expect(thread.createdAt).toBeDefined(); expect(thread.createdAt).toBeDefined();
expect(thread.updatedAt).toBeDefined(); expect(thread.updatedAt).toBeDefined();
}); });
}); });
describe("getAllThreads", () => { describe("getAllThreads", () => {
it("returns active threads with candidateCount and price range", () => { it("returns active threads with candidateCount and price range", () => {
const thread = createThread(db, { name: "Backpack Options", categoryId: 1 }); const thread = createThread(db, {
createCandidate(db, thread.id, { name: "Backpack Options",
name: "Pack A", categoryId: 1,
categoryId: 1, });
priceCents: 20000, createCandidate(db, thread.id, {
}); name: "Pack A",
createCandidate(db, thread.id, { categoryId: 1,
name: "Pack B", priceCents: 20000,
categoryId: 1, });
priceCents: 35000, createCandidate(db, thread.id, {
}); name: "Pack B",
categoryId: 1,
priceCents: 35000,
});
const threads = getAllThreads(db); const threads = getAllThreads(db);
expect(threads).toHaveLength(1); expect(threads).toHaveLength(1);
expect(threads[0].name).toBe("Backpack Options"); expect(threads[0].name).toBe("Backpack Options");
expect(threads[0].candidateCount).toBe(2); expect(threads[0].candidateCount).toBe(2);
expect(threads[0].minPriceCents).toBe(20000); expect(threads[0].minPriceCents).toBe(20000);
expect(threads[0].maxPriceCents).toBe(35000); expect(threads[0].maxPriceCents).toBe(35000);
}); });
it("excludes resolved threads by default", () => { it("excludes resolved threads by default", () => {
const t1 = createThread(db, { name: "Active Thread", categoryId: 1 }); const _t1 = createThread(db, { name: "Active Thread", categoryId: 1 });
const t2 = createThread(db, { name: "Resolved Thread", categoryId: 1 }); const t2 = createThread(db, { name: "Resolved Thread", categoryId: 1 });
const candidate = createCandidate(db, t2.id, { const candidate = createCandidate(db, t2.id, {
name: "Winner", name: "Winner",
categoryId: 1, categoryId: 1,
}); });
resolveThread(db, t2.id, candidate.id); resolveThread(db, t2.id, candidate.id);
const active = getAllThreads(db); const active = getAllThreads(db);
expect(active).toHaveLength(1); expect(active).toHaveLength(1);
expect(active[0].name).toBe("Active Thread"); expect(active[0].name).toBe("Active Thread");
}); });
it("includes resolved threads when includeResolved=true", () => { it("includes resolved threads when includeResolved=true", () => {
const t1 = createThread(db, { name: "Active Thread", categoryId: 1 }); const _t1 = createThread(db, { name: "Active Thread", categoryId: 1 });
const t2 = createThread(db, { name: "Resolved Thread", categoryId: 1 }); const t2 = createThread(db, { name: "Resolved Thread", categoryId: 1 });
const candidate = createCandidate(db, t2.id, { const candidate = createCandidate(db, t2.id, {
name: "Winner", name: "Winner",
categoryId: 1, categoryId: 1,
}); });
resolveThread(db, t2.id, candidate.id); resolveThread(db, t2.id, candidate.id);
const all = getAllThreads(db, true); const all = getAllThreads(db, true);
expect(all).toHaveLength(2); expect(all).toHaveLength(2);
}); });
}); });
describe("getThreadWithCandidates", () => { describe("getThreadWithCandidates", () => {
it("returns thread with nested candidates array including category info", () => { it("returns thread with nested candidates array including category info", () => {
const thread = createThread(db, { name: "Tent Options", categoryId: 1 }); const thread = createThread(db, { name: "Tent Options", categoryId: 1 });
createCandidate(db, thread.id, { createCandidate(db, thread.id, {
name: "Tent A", name: "Tent A",
categoryId: 1, categoryId: 1,
weightGrams: 1200, weightGrams: 1200,
priceCents: 30000, priceCents: 30000,
}); });
const result = getThreadWithCandidates(db, thread.id); const result = getThreadWithCandidates(db, thread.id);
expect(result).toBeDefined(); expect(result).toBeDefined();
expect(result!.name).toBe("Tent Options"); expect(result?.name).toBe("Tent Options");
expect(result!.candidates).toHaveLength(1); expect(result?.candidates).toHaveLength(1);
expect(result!.candidates[0].name).toBe("Tent A"); expect(result?.candidates[0].name).toBe("Tent A");
expect(result!.candidates[0].categoryName).toBe("Uncategorized"); expect(result?.candidates[0].categoryName).toBe("Uncategorized");
expect(result!.candidates[0].categoryIcon).toBeDefined(); expect(result?.candidates[0].categoryIcon).toBeDefined();
}); });
it("returns null for non-existent thread", () => { it("returns null for non-existent thread", () => {
const result = getThreadWithCandidates(db, 9999); const result = getThreadWithCandidates(db, 9999);
expect(result).toBeNull(); expect(result).toBeNull();
}); });
}); });
describe("createCandidate", () => { describe("createCandidate", () => {
it("adds candidate to thread with all item-compatible fields", () => { it("adds candidate to thread with all item-compatible fields", () => {
const thread = createThread(db, { name: "Tent Options", categoryId: 1 }); const thread = createThread(db, { name: "Tent Options", categoryId: 1 });
const candidate = createCandidate(db, thread.id, { const candidate = createCandidate(db, thread.id, {
name: "Tent A", name: "Tent A",
weightGrams: 1200, weightGrams: 1200,
priceCents: 30000, priceCents: 30000,
categoryId: 1, categoryId: 1,
notes: "Ultralight 2-person", notes: "Ultralight 2-person",
productUrl: "https://example.com/tent", productUrl: "https://example.com/tent",
}); });
expect(candidate).toBeDefined(); expect(candidate).toBeDefined();
expect(candidate.id).toBeGreaterThan(0); expect(candidate.id).toBeGreaterThan(0);
expect(candidate.threadId).toBe(thread.id); expect(candidate.threadId).toBe(thread.id);
expect(candidate.name).toBe("Tent A"); expect(candidate.name).toBe("Tent A");
expect(candidate.weightGrams).toBe(1200); expect(candidate.weightGrams).toBe(1200);
expect(candidate.priceCents).toBe(30000); expect(candidate.priceCents).toBe(30000);
expect(candidate.categoryId).toBe(1); expect(candidate.categoryId).toBe(1);
expect(candidate.notes).toBe("Ultralight 2-person"); expect(candidate.notes).toBe("Ultralight 2-person");
expect(candidate.productUrl).toBe("https://example.com/tent"); expect(candidate.productUrl).toBe("https://example.com/tent");
}); });
}); });
describe("updateCandidate", () => { describe("updateCandidate", () => {
it("updates candidate fields, returns updated candidate", () => { it("updates candidate fields, returns updated candidate", () => {
const thread = createThread(db, { name: "Test", categoryId: 1 }); const thread = createThread(db, { name: "Test", categoryId: 1 });
const candidate = createCandidate(db, thread.id, { const candidate = createCandidate(db, thread.id, {
name: "Original", name: "Original",
categoryId: 1, categoryId: 1,
}); });
const updated = updateCandidate(db, candidate.id, { const updated = updateCandidate(db, candidate.id, {
name: "Updated Name", name: "Updated Name",
priceCents: 15000, priceCents: 15000,
}); });
expect(updated).toBeDefined(); expect(updated).toBeDefined();
expect(updated!.name).toBe("Updated Name"); expect(updated?.name).toBe("Updated Name");
expect(updated!.priceCents).toBe(15000); expect(updated?.priceCents).toBe(15000);
}); });
it("returns null for non-existent candidate", () => { it("returns null for non-existent candidate", () => {
const result = updateCandidate(db, 9999, { name: "Ghost" }); const result = updateCandidate(db, 9999, { name: "Ghost" });
expect(result).toBeNull(); expect(result).toBeNull();
}); });
}); });
describe("deleteCandidate", () => { describe("deleteCandidate", () => {
it("removes candidate, returns deleted candidate", () => { it("removes candidate, returns deleted candidate", () => {
const thread = createThread(db, { name: "Test", categoryId: 1 }); const thread = createThread(db, { name: "Test", categoryId: 1 });
const candidate = createCandidate(db, thread.id, { const candidate = createCandidate(db, thread.id, {
name: "To Delete", name: "To Delete",
categoryId: 1, categoryId: 1,
}); });
const deleted = deleteCandidate(db, candidate.id); const deleted = deleteCandidate(db, candidate.id);
expect(deleted).toBeDefined(); expect(deleted).toBeDefined();
expect(deleted!.name).toBe("To Delete"); expect(deleted?.name).toBe("To Delete");
// Verify it's gone // Verify it's gone
const result = getThreadWithCandidates(db, thread.id); const result = getThreadWithCandidates(db, thread.id);
expect(result!.candidates).toHaveLength(0); expect(result?.candidates).toHaveLength(0);
}); });
it("returns null for non-existent candidate", () => { it("returns null for non-existent candidate", () => {
const result = deleteCandidate(db, 9999); const result = deleteCandidate(db, 9999);
expect(result).toBeNull(); expect(result).toBeNull();
}); });
}); });
describe("updateThread", () => { describe("updateThread", () => {
it("updates thread name", () => { it("updates thread name", () => {
const thread = createThread(db, { name: "Original", categoryId: 1 }); const thread = createThread(db, { name: "Original", categoryId: 1 });
const updated = updateThread(db, thread.id, { name: "Renamed" }); const updated = updateThread(db, thread.id, { name: "Renamed" });
expect(updated).toBeDefined(); expect(updated).toBeDefined();
expect(updated!.name).toBe("Renamed"); expect(updated?.name).toBe("Renamed");
}); });
it("returns null for non-existent thread", () => { it("returns null for non-existent thread", () => {
const result = updateThread(db, 9999, { name: "Ghost" }); const result = updateThread(db, 9999, { name: "Ghost" });
expect(result).toBeNull(); expect(result).toBeNull();
}); });
}); });
describe("deleteThread", () => { describe("deleteThread", () => {
it("removes thread and cascading candidates", () => { it("removes thread and cascading candidates", () => {
const thread = createThread(db, { name: "To Delete", categoryId: 1 }); const thread = createThread(db, { name: "To Delete", categoryId: 1 });
createCandidate(db, thread.id, { name: "Candidate", categoryId: 1 }); createCandidate(db, thread.id, { name: "Candidate", categoryId: 1 });
const deleted = deleteThread(db, thread.id); const deleted = deleteThread(db, thread.id);
expect(deleted).toBeDefined(); expect(deleted).toBeDefined();
expect(deleted!.name).toBe("To Delete"); expect(deleted?.name).toBe("To Delete");
// Thread and candidates gone // Thread and candidates gone
const result = getThreadWithCandidates(db, thread.id); const result = getThreadWithCandidates(db, thread.id);
expect(result).toBeNull(); expect(result).toBeNull();
}); });
it("returns null for non-existent thread", () => { it("returns null for non-existent thread", () => {
const result = deleteThread(db, 9999); const result = deleteThread(db, 9999);
expect(result).toBeNull(); expect(result).toBeNull();
}); });
}); });
describe("resolveThread", () => { describe("resolveThread", () => {
it("atomically creates collection item from candidate data and archives thread", () => { it("atomically creates collection item from candidate data and archives thread", () => {
const thread = createThread(db, { name: "Tent Decision", categoryId: 1 }); const thread = createThread(db, { name: "Tent Decision", categoryId: 1 });
const candidate = createCandidate(db, thread.id, { const candidate = createCandidate(db, thread.id, {
name: "Winner Tent", name: "Winner Tent",
weightGrams: 1200, weightGrams: 1200,
priceCents: 30000, priceCents: 30000,
categoryId: 1, categoryId: 1,
notes: "Best choice", notes: "Best choice",
productUrl: "https://example.com/tent", productUrl: "https://example.com/tent",
}); });
const result = resolveThread(db, thread.id, candidate.id); const result = resolveThread(db, thread.id, candidate.id);
expect(result.success).toBe(true); expect(result.success).toBe(true);
expect(result.item).toBeDefined(); expect(result.item).toBeDefined();
expect(result.item!.name).toBe("Winner Tent"); expect(result.item?.name).toBe("Winner Tent");
expect(result.item!.weightGrams).toBe(1200); expect(result.item?.weightGrams).toBe(1200);
expect(result.item!.priceCents).toBe(30000); expect(result.item?.priceCents).toBe(30000);
expect(result.item!.categoryId).toBe(1); expect(result.item?.categoryId).toBe(1);
expect(result.item!.notes).toBe("Best choice"); expect(result.item?.notes).toBe("Best choice");
expect(result.item!.productUrl).toBe("https://example.com/tent"); expect(result.item?.productUrl).toBe("https://example.com/tent");
// Thread should be resolved // Thread should be resolved
const resolved = getThreadWithCandidates(db, thread.id); const resolved = getThreadWithCandidates(db, thread.id);
expect(resolved!.status).toBe("resolved"); expect(resolved?.status).toBe("resolved");
expect(resolved!.resolvedCandidateId).toBe(candidate.id); expect(resolved?.resolvedCandidateId).toBe(candidate.id);
}); });
it("fails if thread is not active", () => { it("fails if thread is not active", () => {
const thread = createThread(db, { name: "Already Resolved", categoryId: 1 }); const thread = createThread(db, {
const candidate = createCandidate(db, thread.id, { name: "Already Resolved",
name: "Winner", categoryId: 1,
categoryId: 1, });
}); const candidate = createCandidate(db, thread.id, {
resolveThread(db, thread.id, candidate.id); name: "Winner",
categoryId: 1,
});
resolveThread(db, thread.id, candidate.id);
// Try to resolve again // Try to resolve again
const result = resolveThread(db, thread.id, candidate.id); const result = resolveThread(db, thread.id, candidate.id);
expect(result.success).toBe(false); expect(result.success).toBe(false);
expect(result.error).toBeDefined(); expect(result.error).toBeDefined();
}); });
it("fails if candidate is not in thread", () => { it("fails if candidate is not in thread", () => {
const thread1 = createThread(db, { name: "Thread 1", categoryId: 1 }); const thread1 = createThread(db, { name: "Thread 1", categoryId: 1 });
const thread2 = createThread(db, { name: "Thread 2", categoryId: 1 }); const thread2 = createThread(db, { name: "Thread 2", categoryId: 1 });
const candidate = createCandidate(db, thread2.id, { const candidate = createCandidate(db, thread2.id, {
name: "Wrong Thread", name: "Wrong Thread",
categoryId: 1, categoryId: 1,
}); });
const result = resolveThread(db, thread1.id, candidate.id); const result = resolveThread(db, thread1.id, candidate.id);
expect(result.success).toBe(false); expect(result.success).toBe(false);
expect(result.error).toBeDefined(); expect(result.error).toBeDefined();
}); });
it("fails if candidate not found", () => { it("fails if candidate not found", () => {
const thread = createThread(db, { name: "Test", categoryId: 1 }); const thread = createThread(db, { name: "Test", categoryId: 1 });
const result = resolveThread(db, thread.id, 9999); const result = resolveThread(db, thread.id, 9999);
expect(result.success).toBe(false); expect(result.success).toBe(false);
expect(result.error).toBeDefined(); expect(result.error).toBeDefined();
}); });
}); });
}); });

View File

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

View File

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

View File

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