14 Commits
v1.1 ... v1.2.0

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
48985b5eb2 feat: add Docker deployment and Gitea Actions CI/CD
Add multi-stage Dockerfile, docker-compose with persistent volumes,
and Gitea Actions workflows for CI (lint/test/build) and releases
(tag, Docker image push, changelog). Support DATABASE_PATH env var
for configurable SQLite location to enable proper volume mounting.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 19:36:23 +01:00
37c4272c08 chore: add CLAUDE.md, initial Drizzle migration, and update gitignore
Add project instructions for Claude Code, the initial database migration,
and ignore the .claude/ local config directory.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 18:57:40 +01:00
ad941ae281 chore: disable research workflow step in planning config
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 18:48:32 +01:00
87fe94037e feat: add external link confirmation dialog for product URLs
Show an external link icon on ItemCard and CandidateCard that opens a
confirmation dialog before navigating to product URLs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 18:48:27 +01:00
7c3740fc72 refactor: replace remaining emojis with Lucide icons
Replace all raw emoji characters in dashboard cards, empty states,
and onboarding wizard with LucideIcon components for visual consistency.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 18:47:50 +01:00
87 changed files with 6249 additions and 5103 deletions

10
.dockerignore Normal file
View File

@@ -0,0 +1,10 @@
node_modules
dist
gearbox.db*
uploads/*
!uploads/.gitkeep
.git
.idea
.claude
.gitea
.planning

28
.gitea/workflows/ci.yml Normal file
View File

@@ -0,0 +1,28 @@
name: CI
on:
push:
branches: [Develop]
pull_request:
branches: [Develop]
jobs:
ci:
runs-on: docker
container:
image: oven/bun:1
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install dependencies
run: bun install --frozen-lockfile --ignore-scripts
- name: Lint
run: bun run lint
- name: Test
run: bun test
- name: Build
run: bun run build

View File

@@ -0,0 +1,108 @@
name: Release
on:
workflow_dispatch:
inputs:
bump:
description: "Version bump type"
required: true
default: "patch"
type: choice
options:
- patch
- minor
- major
jobs:
ci:
runs-on: docker
container:
image: oven/bun:1
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install dependencies
run: bun install --frozen-lockfile --ignore-scripts
- name: Lint
run: bun run lint
- name: Test
run: bun test
- name: Build
run: bun run build
release:
needs: ci
runs-on: dind
steps:
- name: Clone repository
run: |
apk add --no-cache git curl jq docker-cli
git clone https://${{ secrets.GITEA_TOKEN }}@gitea.jeanlucmakiola.de/${{ gitea.repository }}.git repo
cd repo
git checkout ${{ gitea.ref_name }}
- name: Compute version
working-directory: repo
run: |
LATEST_TAG=$(git tag -l 'v*' --sort=-v:refname | head -n1)
if [ -z "$LATEST_TAG" ]; then
LATEST_TAG="v0.0.0"
fi
MAJOR=$(echo "$LATEST_TAG" | sed 's/v//' | cut -d. -f1)
MINOR=$(echo "$LATEST_TAG" | sed 's/v//' | cut -d. -f2)
PATCH=$(echo "$LATEST_TAG" | sed 's/v//' | cut -d. -f3)
case "${{ gitea.event.inputs.bump }}" in
major) MAJOR=$((MAJOR+1)); MINOR=0; PATCH=0 ;;
minor) MINOR=$((MINOR+1)); PATCH=0 ;;
patch) PATCH=$((PATCH+1)) ;;
esac
NEW_VERSION="v${MAJOR}.${MINOR}.${PATCH}"
echo "VERSION=$NEW_VERSION" >> "$GITHUB_ENV"
echo "PREV_TAG=$LATEST_TAG" >> "$GITHUB_ENV"
echo "New version: $NEW_VERSION"
- name: Generate changelog
working-directory: repo
run: |
if [ "$PREV_TAG" = "v0.0.0" ]; then
CHANGELOG=$(git log --pretty=format:"- %s" HEAD)
else
CHANGELOG=$(git log --pretty=format:"- %s" "${PREV_TAG}..HEAD")
fi
echo "CHANGELOG<<CHANGELOG_EOF" >> "$GITHUB_ENV"
echo "$CHANGELOG" >> "$GITHUB_ENV"
echo "CHANGELOG_EOF" >> "$GITHUB_ENV"
- name: Create and push tag
working-directory: repo
run: |
git config user.name "Gitea Actions"
git config user.email "actions@gitea.jeanlucmakiola.de"
git tag -a "$VERSION" -m "Release $VERSION"
git push origin "$VERSION"
- name: Build and push Docker image
working-directory: repo
run: |
REGISTRY="gitea.jeanlucmakiola.de"
IMAGE="${REGISTRY}/${{ gitea.repository_owner }}/gearbox"
docker build -t "${IMAGE}:${VERSION}" -t "${IMAGE}:latest" .
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login "$REGISTRY" -u "${{ gitea.repository_owner }}" --password-stdin
docker push "${IMAGE}:${VERSION}"
docker push "${IMAGE}:latest"
- name: Create Gitea release
run: |
API_URL="${GITHUB_SERVER_URL}/api/v1/repos/${{ gitea.repository }}/releases"
curl -s -X POST "$API_URL" \
-H "Authorization: token ${{ secrets.GITEA_TOKEN }}" \
-H "Content-Type: application/json" \
-d "$(jq -n \
--arg tag "$VERSION" \
--arg name "$VERSION" \
--arg body "$CHANGELOG" \
'{tag_name: $tag, name: $name, body: $body, draft: false, prerelease: false}')"

3
.gitignore vendored
View File

@@ -223,3 +223,6 @@ dist/
uploads/* uploads/*
!uploads/.gitkeep !uploads/.gitkeep
# Claude Code
.claude/

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": true, "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
} }
} }

70
CLAUDE.md Normal file
View File

@@ -0,0 +1,70 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
GearBox is a single-user web app for managing gear collections (bikepacking, sim racing, etc.), tracking weight/price, and planning purchases through research threads. Full-stack TypeScript monolith running on Bun.
## Commands
```bash
# Development (run both in separate terminals)
bun run dev:client # Vite dev server on :5173 (proxies /api to :3000)
bun run dev:server # Hono server on :3000 with hot reload
# Database
bun run db:generate # Generate Drizzle migration from schema changes
bun run db:push # Apply migrations to gearbox.db
# Testing
bun test # Run all tests
bun test tests/services/item.service.test.ts # Run single test file
# Lint & Format
bun run lint # Biome check (tabs, double quotes, organized imports)
# Build
bun run build # Vite build → dist/client/
```
## Architecture
**Stack**: React 19 + Hono + Drizzle ORM + SQLite, all running on Bun.
### Client (`src/client/`)
- **Routing**: TanStack Router with file-based routes in `src/client/routes/`. Route tree auto-generated to `routeTree.gen.ts` — never edit manually.
- **Data fetching**: TanStack React Query via custom hooks in `src/client/hooks/` (e.g., `useItems`, `useThreads`, `useSetups`). Mutations invalidate related query keys.
- **UI state**: Zustand store (`stores/uiStore.ts`) for panel/dialog state only — server data lives in React Query.
- **API calls**: Thin fetch wrapper in `lib/api.ts` (`apiGet`, `apiPost`, `apiPut`, `apiDelete`, `apiUpload`).
- **Styling**: Tailwind CSS v4.
### Server (`src/server/`)
- **Routes** (`routes/`): Hono handlers with Zod validation via `@hono/zod-validator`. Delegate to services.
- **Services** (`services/`): Pure business logic functions that take a db instance. No HTTP awareness — testable without mocking.
- Route registration in `src/server/index.ts` via `app.route("/api/...", routes)`.
### Shared (`src/shared/`)
- **`schemas.ts`**: Zod schemas for API request validation (source of truth for types).
- **`types.ts`**: Types inferred from Zod schemas + Drizzle table definitions. No manual type duplication.
### Database (`src/db/`)
- **Schema**: `schema.ts` — Drizzle table definitions for SQLite.
- **Prices stored as cents** (`priceCents: integer`) to avoid float rounding.
- **Timestamps**: stored as integers (unix epoch) with `{ mode: "timestamp" }`.
- Tables: `categories`, `items`, `threads`, `threadCandidates`, `setups`, `setupItems`, `settings`.
### Testing (`tests/`)
- Bun test runner. Tests at service level and route level.
- `tests/helpers/db.ts`: `createTestDb()` creates in-memory SQLite with full schema and seeds an "Uncategorized" category. When adding schema columns, update both `src/db/schema.ts` and the test helper's CREATE TABLE statements.
## Path Alias
`@/*` maps to `./src/*` (configured in tsconfig.json).
## Key Patterns
- **Thread resolution**: Resolving a thread copies the winning candidate's data into a new item in the collection, sets `resolvedCandidateId`, and changes status to "resolved".
- **Setup item sync**: `PUT /api/setups/:id/items` replaces all setup_items atomically (delete all, re-insert).
- **Image uploads**: `POST /api/images` saves to `./uploads/` with UUID filename, returned as `imageFilename` on item/candidate records.
- **Aggregates** (weight/cost totals): Computed via SQL on read, not stored on records.

26
Dockerfile Normal file
View File

@@ -0,0 +1,26 @@
FROM oven/bun:1 AS deps
WORKDIR /app
RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile
FROM deps AS build
COPY . .
RUN bun run build
FROM oven/bun:1-slim AS production
WORKDIR /app
ENV NODE_ENV=production
COPY --from=deps /app/node_modules ./node_modules
COPY --from=build /app/dist/client ./dist/client
COPY src/server ./src/server
COPY src/db ./src/db
COPY src/shared ./src/shared
COPY drizzle.config.ts package.json ./
COPY drizzle ./drizzle
COPY entrypoint.sh ./
RUN chmod +x entrypoint.sh && mkdir -p data uploads
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD bun -e "fetch('http://localhost:3000/api/health').then(r=>r.ok?process.exit(0):process.exit(1)).catch(()=>process.exit(1))"
ENTRYPOINT ["./entrypoint.sh"]

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": {

23
docker-compose.yml Normal file
View File

@@ -0,0 +1,23 @@
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:

View File

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

View File

@@ -0,0 +1,68 @@
CREATE TABLE `categories` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`name` text NOT NULL,
`emoji` text DEFAULT '📦' NOT NULL,
`created_at` integer NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `categories_name_unique` ON `categories` (`name`);--> statement-breakpoint
CREATE TABLE `items` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`name` text NOT NULL,
`weight_grams` real,
`price_cents` integer,
`category_id` integer NOT NULL,
`notes` text,
`product_url` text,
`image_filename` text,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL,
FOREIGN KEY (`category_id`) REFERENCES `categories`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE TABLE `settings` (
`key` text PRIMARY KEY NOT NULL,
`value` text NOT NULL
);
--> statement-breakpoint
CREATE TABLE `setup_items` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`setup_id` integer NOT NULL,
`item_id` integer NOT NULL,
FOREIGN KEY (`setup_id`) REFERENCES `setups`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`item_id`) REFERENCES `items`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `setups` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`name` text NOT NULL,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL
);
--> statement-breakpoint
CREATE TABLE `thread_candidates` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`thread_id` integer NOT NULL,
`name` text NOT NULL,
`weight_grams` real,
`price_cents` integer,
`category_id` integer NOT NULL,
`notes` text,
`product_url` text,
`image_filename` text,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL,
FOREIGN KEY (`thread_id`) REFERENCES `threads`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`category_id`) REFERENCES `categories`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE TABLE `threads` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`name` text NOT NULL,
`status` text DEFAULT 'active' NOT NULL,
`resolved_candidate_id` integer,
`category_id` integer NOT NULL,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL,
FOREIGN KEY (`category_id`) REFERENCES `categories`(`id`) ON UPDATE no action ON DELETE no action
);

View File

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

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
} }
] ]
} }

4
entrypoint.sh Executable file
View File

@@ -0,0 +1,4 @@
#!/bin/sh
set -e
bun run src/db/migrate.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

@@ -10,6 +10,7 @@ interface CandidateCardProps {
categoryName: string; categoryName: string;
categoryIcon: string; categoryIcon: string;
imageFilename: string | null; imageFilename: string | null;
productUrl?: string | null;
threadId: number; threadId: number;
isActive: boolean; isActive: boolean;
} }
@@ -22,6 +23,7 @@ export function CandidateCard({
categoryName, categoryName,
categoryIcon, categoryIcon,
imageFilename, imageFilename,
productUrl,
threadId, threadId,
isActive, isActive,
}: CandidateCardProps) { }: CandidateCardProps) {
@@ -30,9 +32,38 @@ export function CandidateCard({
(s) => s.openConfirmDeleteCandidate, (s) => s.openConfirmDeleteCandidate,
); );
const openResolveDialog = useUIStore((s) => s.openResolveDialog); const openResolveDialog = useUIStore((s) => s.openResolveDialog);
const openExternalLink = useUIStore((s) => s.openExternalLink);
return ( return (
<div className="bg-white rounded-xl border border-gray-100 hover:border-gray-200 hover:shadow-sm transition-all overflow-hidden"> <div className="relative bg-white rounded-xl border border-gray-100 hover:border-gray-200 hover:shadow-sm transition-all overflow-hidden group">
{productUrl && (
<span
role="button"
tabIndex={0}
onClick={() => openExternalLink(productUrl)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
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-gray-200 hover:text-gray-600 opacity-0 group-hover:opacity-100 transition-all cursor-pointer"
title="Open product link"
>
<svg
className="w-3.5 h-3.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6M15 3h6v6M10 14L21 3"
/>
</svg>
</span>
)}
<div className="aspect-[4/3] bg-gray-50"> <div className="aspect-[4/3] bg-gray-50">
{imageFilename ? ( {imageFilename ? (
<img <img
@@ -42,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>
@@ -52,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

@@ -1,50 +1,50 @@
import { Link } from "@tanstack/react-router"; import { Link } from "@tanstack/react-router";
import type { ReactNode } from "react"; import { LucideIcon } from "../lib/iconData";
interface DashboardCardProps { interface DashboardCardProps {
to: string; to: string;
search?: Record<string, string>; search?: Record<string, string>;
title: string; title: string;
icon: ReactNode; icon: string;
stats: Array<{ label: string; value: string }>; stats: Array<{ label: string; value: string }>;
emptyText?: string; emptyText?: string;
} }
export function DashboardCard({ export function DashboardCard({
to, to,
search, search,
title, title,
icon, icon,
stats, stats,
emptyText, emptyText,
}: DashboardCardProps) { }: DashboardCardProps) {
const allZero = stats.every( const allZero = stats.every(
(s) => s.value === "0" || s.value === "$0.00" || s.value === "0g", (s) => s.value === "0" || s.value === "$0.00" || s.value === "0g",
); );
return ( return (
<Link <Link
to={to} to={to}
search={search} search={search}
className="block bg-white rounded-xl border border-gray-100 hover:border-gray-200 hover:shadow-md transition-all p-6" className="block bg-white rounded-xl border border-gray-100 hover:border-gray-200 hover:shadow-md transition-all p-6"
> >
<div className="flex items-center gap-3 mb-4"> <div className="flex items-center gap-3 mb-4">
<span className="text-2xl">{icon}</span> <LucideIcon name={icon} size={24} className="text-gray-500" />
<h2 className="text-lg font-semibold text-gray-900">{title}</h2> <h2 className="text-lg font-semibold text-gray-900">{title}</h2>
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5">
{stats.map((stat) => ( {stats.map((stat) => (
<div key={stat.label} className="flex items-center justify-between"> <div key={stat.label} className="flex items-center justify-between">
<span className="text-sm text-gray-500">{stat.label}</span> <span className="text-sm text-gray-500">{stat.label}</span>
<span className="text-sm font-medium text-gray-700"> <span className="text-sm font-medium text-gray-700">
{stat.value} {stat.value}
</span> </span>
</div> </div>
))} ))}
</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

@@ -0,0 +1,63 @@
import { useEffect } from "react";
import { useUIStore } from "../stores/uiStore";
export function ExternalLinkDialog() {
const externalLinkUrl = useUIStore((s) => s.externalLinkUrl);
const closeExternalLink = useUIStore((s) => s.closeExternalLink);
useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") closeExternalLink();
}
if (externalLinkUrl) {
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}
}, [externalLinkUrl, closeExternalLink]);
if (!externalLinkUrl) return null;
function handleContinue() {
if (externalLinkUrl) {
window.open(externalLinkUrl, "_blank", "noopener,noreferrer");
}
closeExternalLink();
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div
className="absolute inset-0 bg-black/30"
onClick={closeExternalLink}
onKeyDown={(e) => {
if (e.key === "Escape") closeExternalLink();
}}
/>
<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">
You are about to leave GearBox
</h3>
<p className="text-sm text-gray-600 mb-1">You will be redirected to:</p>
<p className="text-sm text-gray-600 break-all mb-6">
{externalLinkUrl}
</p>
<div className="flex justify-end gap-3">
<button
type="button"
onClick={closeExternalLink}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors"
>
Cancel
</button>
<button
type="button"
onClick={handleContinue}
className="px-4 py-2 text-sm font-medium text-white bg-gray-700 hover:bg-gray-800 rounded-lg transition-colors"
>
Continue
</button>
</div>
</div>
</div>
);
}

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

@@ -10,6 +10,7 @@ interface ItemCardProps {
categoryName: string; categoryName: string;
categoryIcon: string; categoryIcon: string;
imageFilename: string | null; imageFilename: string | null;
productUrl?: string | null;
onRemove?: () => void; onRemove?: () => void;
} }
@@ -21,9 +22,11 @@ export function ItemCard({
categoryName, categoryName,
categoryIcon, categoryIcon,
imageFilename, imageFilename,
productUrl,
onRemove, onRemove,
}: ItemCardProps) { }: ItemCardProps) {
const openEditPanel = useUIStore((s) => s.openEditPanel); const openEditPanel = useUIStore((s) => s.openEditPanel);
const openExternalLink = useUIStore((s) => s.openExternalLink);
return ( return (
<button <button
@@ -31,6 +34,38 @@ export function ItemCard({
onClick={() => openEditPanel(id)} onClick={() => openEditPanel(id)}
className="relative w-full text-left bg-white rounded-xl border border-gray-100 hover:border-gray-200 hover:shadow-sm transition-all overflow-hidden group" className="relative w-full text-left bg-white rounded-xl border border-gray-100 hover:border-gray-200 hover:shadow-sm transition-all overflow-hidden group"
> >
{productUrl && (
<span
role="button"
tabIndex={0}
onClick={(e) => {
e.stopPropagation();
openExternalLink(productUrl);
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.stopPropagation();
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-gray-200 hover:text-gray-600 opacity-0 group-hover:opacity-100 transition-all cursor-pointer`}
title="Open product link"
>
<svg
className="w-3.5 h-3.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6M15 3h6v6M10 14L21 3"
/>
</svg>
</span>
)}
{onRemove && ( {onRemove && (
<span <span
role="button" role="button"
@@ -72,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>
@@ -82,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

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

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,319 +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 { 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 />
{/* Candidate Delete Confirm Dialog */} {/* External Link Confirmation Dialog */}
{confirmDeleteCandidateId != null && currentThreadId != null && ( <ExternalLinkDialog />
<CandidateDeleteDialog
candidateId={confirmDeleteCandidateId}
threadId={currentThreadId}
onClose={closeConfirmDeleteCandidate}
/>
)}
{/* Resolution Confirm Dialog */} {/* Candidate Delete Confirm Dialog */}
{resolveThreadId != null && resolveCandidateId != null && ( {confirmDeleteCandidateId != null && currentThreadId != null && (
<ResolveDialog <CandidateDeleteDialog
threadId={resolveThreadId} candidateId={confirmDeleteCandidateId}
candidateId={resolveCandidateId} threadId={currentThreadId}
onClose={closeResolveDialog} onClose={closeConfirmDeleteCandidate}
onResolved={() => { />
closeResolveDialog(); )}
navigate({ to: "/collection", search: { tab: "planning" } });
}}
/>
)}
{/* Floating Add Button - only on collection gear tab */} {/* Resolution Confirm Dialog */}
{showFab && ( {resolveThreadId != null && resolveCandidateId != null && (
<button <ResolveDialog
type="button" threadId={resolveThreadId}
onClick={openAddPanel} candidateId={resolveCandidateId}
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" onClose={closeResolveDialog}
title="Add new item" onResolved={() => {
> closeResolveDialog();
<svg navigate({ to: "/collection", search: { tab: "planning" } });
className="w-6 h-6" }}
fill="none" />
stroke="currentColor" )}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 4v16m8-8H4"
/>
</svg>
</button>
)}
{/* Onboarding Wizard */} {/* Floating Add Button - only on collection gear tab */}
{showWizard && ( {showFab && (
<OnboardingWizard onComplete={() => setWizardDismissed(true)} /> <button
)} type="button"
</div> onClick={openAddPanel}
); 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"
>
<svg
className="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 4v16m8-8H4"
/>
</svg>
</button>
)}
{/* Onboarding Wizard */}
{showWizard && (
<OnboardingWizard onComplete={() => setWizardDismissed(true)} />
)}
</div>
);
} }
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,16 +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 { 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/")({
@@ -25,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>
); );
@@ -61,7 +70,13 @@ function CollectionView() {
return ( return (
<div className="py-16 text-center"> <div className="py-16 text-center">
<div className="max-w-md mx-auto"> <div className="max-w-md mx-auto">
<div className="text-5xl mb-4">🎒</div> <div className="mb-4">
<LucideIcon
name="backpack"
size={48}
className="text-gray-400 mx-auto"
/>
</div>
<h2 className="text-xl font-semibold text-gray-900 mb-2"> <h2 className="text-xl font-semibold text-gray-900 mb-2">
Your collection is empty Your collection is empty
</h2> </h2>
@@ -72,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"
@@ -158,6 +173,7 @@ function CollectionView() {
categoryName={categoryName} categoryName={categoryName}
categoryIcon={categoryIcon} categoryIcon={categoryIcon}
imageFilename={item.imageFilename} imageFilename={item.imageFilename}
productUrl={item.productUrl}
/> />
))} ))}
</div> </div>
@@ -209,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"
@@ -238,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"
}`} }`}
> >
@@ -249,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"
}`} }`}
> >
@@ -263,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) => (
@@ -283,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>
@@ -294,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>
@@ -305,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>
@@ -319,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"
@@ -366,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

@@ -1,55 +1,57 @@
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { useTotals } from "../hooks/useTotals";
import { useThreads } from "../hooks/useThreads";
import { useSetups } from "../hooks/useSetups";
import { DashboardCard } from "../components/DashboardCard"; import { DashboardCard } from "../components/DashboardCard";
import { formatWeight, formatPrice } from "../lib/formatters"; import { useSetups } from "../hooks/useSetups";
import { useThreads } from "../hooks/useThreads";
import { useTotals } from "../hooks/useTotals";
import { formatPrice, formatWeight } from "../lib/formatters";
export const Route = createFileRoute("/")({ export const Route = createFileRoute("/")({
component: DashboardPage, component: DashboardPage,
}); });
function DashboardPage() { function DashboardPage() {
const { data: totals } = useTotals(); const { data: totals } = useTotals();
const { data: threads } = useThreads(false); const { data: threads } = useThreads(false);
const { data: setups } = useSetups(); const { data: setups } = useSetups();
const global = totals?.global; const global = totals?.global;
const activeThreadCount = threads?.length ?? 0; const activeThreadCount = threads?.length ?? 0;
const setupCount = setups?.length ?? 0; const setupCount = setups?.length ?? 0;
return ( return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div className="grid grid-cols-1 md:grid-cols-3 gap-6"> <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<DashboardCard <DashboardCard
to="/collection" to="/collection"
title="Collection" title="Collection"
icon="🎒" icon="backpack"
stats={[ stats={[
{ label: "Items", value: String(global?.itemCount ?? 0) }, { label: "Items", value: String(global?.itemCount ?? 0) },
{ label: "Weight", value: formatWeight(global?.totalWeight ?? null) }, {
{ label: "Cost", value: formatPrice(global?.totalCost ?? null) }, label: "Weight",
]} value: formatWeight(global?.totalWeight ?? null),
emptyText="Get started" },
/> { label: "Cost", value: formatPrice(global?.totalCost ?? null) },
<DashboardCard ]}
to="/collection" emptyText="Get started"
search={{ tab: "planning" }} />
title="Planning" <DashboardCard
icon="🔍" to="/collection"
stats={[ search={{ tab: "planning" }}
{ label: "Active threads", value: String(activeThreadCount) }, title="Planning"
]} icon="search"
/> stats={[
<DashboardCard { label: "Active threads", value: String(activeThreadCount) },
to="/setups" ]}
title="Setups" />
icon="🏕️" <DashboardCard
stats={[ to="/collection"
{ label: "Setups", value: String(setupCount) }, search={{ tab: "setups" }}
]} title="Setups"
/> icon="tent"
</div> stats={[{ label: "Setups", value: String(setupCount) }]}
</div> />
); </div>
</div>
);
} }

View File

@@ -1,268 +1,276 @@
import { useState } from "react";
import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { import { useState } from "react";
useSetup,
useDeleteSetup,
useRemoveSetupItem,
} from "../../hooks/useSetups";
import { CategoryHeader } from "../../components/CategoryHeader"; import { CategoryHeader } from "../../components/CategoryHeader";
import { ItemCard } from "../../components/ItemCard"; import { ItemCard } from "../../components/ItemCard";
import { ItemPicker } from "../../components/ItemPicker"; import { ItemPicker } from "../../components/ItemPicker";
import { formatWeight, formatPrice } from "../../lib/formatters"; import {
useDeleteSetup,
useRemoveSetupItem,
useSetup,
} from "../../hooks/useSetups";
import { formatPrice, formatWeight } from "../../lib/formatters";
import { LucideIcon } from "../../lib/iconData";
export const Route = createFileRoute("/setups/$setupId")({ export const Route = createFileRoute("/setups/$setupId")({
component: SetupDetailPage, component: SetupDetailPage,
}); });
function SetupDetailPage() { function SetupDetailPage() {
const { setupId } = Route.useParams(); const { setupId } = Route.useParams();
const navigate = useNavigate(); const navigate = useNavigate();
const numericId = Number(setupId); const numericId = Number(setupId);
const { data: setup, isLoading } = useSetup(numericId); const { data: setup, isLoading } = useSetup(numericId);
const deleteSetup = useDeleteSetup(); const deleteSetup = useDeleteSetup();
const removeItem = useRemoveSetupItem(numericId); const removeItem = useRemoveSetupItem(numericId);
const [pickerOpen, setPickerOpen] = useState(false); const [pickerOpen, setPickerOpen] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false); const [confirmDelete, setConfirmDelete] = useState(false);
if (isLoading) { if (isLoading) {
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">
<div className="animate-pulse space-y-6"> <div className="animate-pulse space-y-6">
<div className="h-8 bg-gray-200 rounded w-48" /> <div className="h-8 bg-gray-200 rounded w-48" />
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{[1, 2, 3].map((i) => ( {[1, 2, 3].map((i) => (
<div key={i} className="h-40 bg-gray-200 rounded-xl" /> <div key={i} className="h-40 bg-gray-200 rounded-xl" />
))} ))}
</div> </div>
</div> </div>
</div> </div>
); );
} }
if (!setup) { if (!setup) {
return ( return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16 text-center"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16 text-center">
<p className="text-gray-500">Setup not found.</p> <p className="text-gray-500">Setup not found.</p>
</div> </div>
); );
} }
// Compute totals from items // Compute totals from items
const totalWeight = setup.items.reduce( const totalWeight = setup.items.reduce(
(sum, item) => sum + (item.weightGrams ?? 0), (sum, item) => sum + (item.weightGrams ?? 0),
0, 0,
); );
const totalCost = setup.items.reduce( const totalCost = setup.items.reduce(
(sum, item) => sum + (item.priceCents ?? 0), (sum, item) => sum + (item.priceCents ?? 0),
0, 0,
); );
const itemCount = setup.items.length; const itemCount = setup.items.length;
const currentItemIds = setup.items.map((item) => item.id); const currentItemIds = setup.items.map((item) => item.id);
// Group items by category // Group items by category
const groupedItems = new Map< const groupedItems = new Map<
number, number,
{ {
items: typeof setup.items; items: typeof setup.items;
categoryName: string; categoryName: string;
categoryIcon: string; categoryIcon: string;
} }
>(); >();
for (const item of setup.items) { for (const item of setup.items) {
const group = groupedItems.get(item.categoryId); const group = groupedItems.get(item.categoryId);
if (group) { if (group) {
group.items.push(item); group.items.push(item);
} else { } else {
groupedItems.set(item.categoryId, { groupedItems.set(item.categoryId, {
items: [item], items: [item],
categoryName: item.categoryName, categoryName: item.categoryName,
categoryIcon: item.categoryIcon, categoryIcon: item.categoryIcon,
}); });
} }
} }
function handleDelete() { function handleDelete() {
deleteSetup.mutate(numericId, { deleteSetup.mutate(numericId, {
onSuccess: () => navigate({ to: "/setups" }), onSuccess: () => navigate({ to: "/setups" }),
}); });
} }
return ( return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
{/* Setup-specific sticky bar */} {/* Setup-specific sticky bar */}
<div className="sticky top-14 z-[9] bg-gray-50 border-b border-gray-100 -mx-4 sm:-mx-6 lg:-mx-8 px-4 sm:px-6 lg:px-8"> <div className="sticky top-14 z-[9] bg-gray-50 border-b border-gray-100 -mx-4 sm:-mx-6 lg:-mx-8 px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-12"> <div className="flex items-center justify-between h-12">
<h2 className="text-base font-semibold text-gray-900 truncate"> <h2 className="text-base font-semibold text-gray-900 truncate">
{setup.name} {setup.name}
</h2> </h2>
<div className="flex items-center gap-4 text-sm text-gray-500"> <div className="flex items-center gap-4 text-sm text-gray-500">
<span> <span>
<span className="font-medium text-gray-700">{itemCount}</span>{" "} <span className="font-medium text-gray-700">{itemCount}</span>{" "}
{itemCount === 1 ? "item" : "items"} {itemCount === 1 ? "item" : "items"}
</span> </span>
<span> <span>
<span className="font-medium text-gray-700"> <span className="font-medium text-gray-700">
{formatWeight(totalWeight)} {formatWeight(totalWeight)}
</span>{" "} </span>{" "}
total total
</span> </span>
<span> <span>
<span className="font-medium text-gray-700"> <span className="font-medium text-gray-700">
{formatPrice(totalCost)} {formatPrice(totalCost)}
</span>{" "} </span>{" "}
cost cost
</span> </span>
</div> </div>
</div> </div>
</div> </div>
{/* Actions */} {/* Actions */}
<div className="flex items-center gap-3 py-4"> <div className="flex items-center gap-3 py-4">
<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"
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>
Add Items Add Items
</button> </button>
<button <button
type="button" type="button"
onClick={() => setConfirmDelete(true)} onClick={() => setConfirmDelete(true)}
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-red-600 bg-red-50 hover:bg-red-100 rounded-lg transition-colors" className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-red-600 bg-red-50 hover:bg-red-100 rounded-lg transition-colors"
> >
Delete Setup Delete Setup
</button> </button>
</div> </div>
{/* Empty state */} {/* Empty state */}
{itemCount === 0 && ( {itemCount === 0 && (
<div className="py-16 text-center"> <div className="py-16 text-center">
<div className="max-w-md mx-auto"> <div className="max-w-md mx-auto">
<div className="text-5xl mb-4">📦</div> <div className="mb-4">
<h2 className="text-xl font-semibold text-gray-900 mb-2"> <LucideIcon
No items in this setup name="package"
</h2> size={48}
<p className="text-sm text-gray-500 mb-6"> className="text-gray-400 mx-auto"
Add items from your collection to build this loadout. />
</p> </div>
<button <h2 className="text-xl font-semibold text-gray-900 mb-2">
type="button" No items in this setup
onClick={() => setPickerOpen(true)} </h2>
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" <p className="text-sm text-gray-500 mb-6">
> Add items from your collection to build this loadout.
Add Items </p>
</button> <button
</div> type="button"
</div> onClick={() => setPickerOpen(true)}
)} 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
</button>
</div>
</div>
)}
{/* Items grouped by category */} {/* Items grouped by category */}
{itemCount > 0 && ( {itemCount > 0 && (
<div className="pb-6"> <div className="pb-6">
{Array.from(groupedItems.entries()).map( {Array.from(groupedItems.entries()).map(
([ ([
categoryId, categoryId,
{ items: categoryItems, categoryName, categoryIcon }, { items: categoryItems, categoryName, categoryIcon },
]) => { ]) => {
const catWeight = categoryItems.reduce( const catWeight = categoryItems.reduce(
(sum, item) => sum + (item.weightGrams ?? 0), (sum, item) => sum + (item.weightGrams ?? 0),
0, 0,
); );
const catCost = categoryItems.reduce( const catCost = categoryItems.reduce(
(sum, item) => sum + (item.priceCents ?? 0), (sum, item) => sum + (item.priceCents ?? 0),
0, 0,
); );
return ( return (
<div key={categoryId} className="mb-8"> <div key={categoryId} className="mb-8">
<CategoryHeader <CategoryHeader
categoryId={categoryId} categoryId={categoryId}
name={categoryName} name={categoryName}
icon={categoryIcon} icon={categoryIcon}
totalWeight={catWeight} totalWeight={catWeight}
totalCost={catCost} totalCost={catCost}
itemCount={categoryItems.length} itemCount={categoryItems.length}
/> />
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{categoryItems.map((item) => ( {categoryItems.map((item) => (
<ItemCard <ItemCard
key={item.id} key={item.id}
id={item.id} id={item.id}
name={item.name} name={item.name}
weightGrams={item.weightGrams} weightGrams={item.weightGrams}
priceCents={item.priceCents} priceCents={item.priceCents}
categoryName={categoryName} categoryName={categoryName}
categoryIcon={categoryIcon} categoryIcon={categoryIcon}
imageFilename={item.imageFilename} imageFilename={item.imageFilename}
onRemove={() => removeItem.mutate(item.id)} productUrl={item.productUrl}
/> onRemove={() => removeItem.mutate(item.id)}
))} />
</div> ))}
</div> </div>
); </div>
}, );
)} },
</div> )}
)} </div>
)}
{/* Item Picker */} {/* Item Picker */}
<ItemPicker <ItemPicker
setupId={numericId} setupId={numericId}
currentItemIds={currentItemIds} currentItemIds={currentItemIds}
isOpen={pickerOpen} isOpen={pickerOpen}
onClose={() => setPickerOpen(false)} onClose={() => setPickerOpen(false)}
/> />
{/* Delete Confirmation Dialog */} {/* Delete Confirmation Dialog */}
{confirmDelete && ( {confirmDelete && (
<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={() => setConfirmDelete(false)} onClick={() => setConfirmDelete(false)}
/> />
<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 Setup Delete Setup
</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">{setup.name}</span>? This will not <span className="font-medium">{setup.name}</span>? This will not
remove items from your collection. remove items from your collection.
</p> </p>
<div className="flex justify-end gap-3"> <div className="flex justify-end gap-3">
<button <button
type="button" type="button"
onClick={() => setConfirmDelete(false)} onClick={() => setConfirmDelete(false)}
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={deleteSetup.isPending} disabled={deleteSetup.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"
> >
{deleteSetup.isPending ? "Deleting..." : "Delete"} {deleteSetup.isPending ? "Deleting..." : "Delete"}
</button> </button>
</div> </div>
</div> </div>
</div> </div>
)} )}
</div> </div>
); );
} }

View File

@@ -1,86 +0,0 @@
import { useState } from "react";
import { createFileRoute } from "@tanstack/react-router";
import { useSetups, useCreateSetup } from "../../hooks/useSetups";
import { SetupCard } from "../../components/SetupCard";
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="text-5xl mb-4">🏕</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

@@ -1,147 +1,153 @@
import { createFileRoute, Link } from "@tanstack/react-router"; import { createFileRoute, Link } from "@tanstack/react-router";
import { useThread } from "../../hooks/useThreads";
import { CandidateCard } from "../../components/CandidateCard"; import { CandidateCard } from "../../components/CandidateCard";
import { useThread } from "../../hooks/useThreads";
import { LucideIcon } from "../../lib/iconData";
import { useUIStore } from "../../stores/uiStore"; import { useUIStore } from "../../stores/uiStore";
export const Route = createFileRoute("/threads/$threadId")({ export const Route = createFileRoute("/threads/$threadId")({
component: ThreadDetailPage, component: ThreadDetailPage,
}); });
function ThreadDetailPage() { function ThreadDetailPage() {
const { threadId: threadIdParam } = Route.useParams(); const { threadId: threadIdParam } = Route.useParams();
const threadId = Number(threadIdParam); const threadId = Number(threadIdParam);
const { data: thread, isLoading, isError } = useThread(threadId); const { data: thread, isLoading, isError } = useThread(threadId);
const openCandidateAddPanel = useUIStore((s) => s.openCandidateAddPanel); const openCandidateAddPanel = useUIStore((s) => s.openCandidateAddPanel);
if (isLoading) { if (isLoading) {
return ( return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="animate-pulse space-y-6"> <div className="animate-pulse space-y-6">
<div className="h-6 bg-gray-200 rounded w-48" /> <div className="h-6 bg-gray-200 rounded w-48" />
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{[1, 2, 3].map((i) => ( {[1, 2, 3].map((i) => (
<div key={i} className="h-40 bg-gray-200 rounded-xl" /> <div key={i} className="h-40 bg-gray-200 rounded-xl" />
))} ))}
</div> </div>
</div> </div>
</div> </div>
); );
} }
if (isError || !thread) { if (isError || !thread) {
return ( return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16 text-center"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16 text-center">
<h2 className="text-xl font-semibold text-gray-900 mb-2"> <h2 className="text-xl font-semibold text-gray-900 mb-2">
Thread not found Thread not found
</h2> </h2>
<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>
</div> </div>
); );
} }
const isActive = thread.status === "active"; const isActive = thread.status === "active";
const winningCandidate = thread.resolvedCandidateId const winningCandidate = thread.resolvedCandidateId
? thread.candidates.find((c) => c.id === thread.resolvedCandidateId) ? thread.candidates.find((c) => c.id === thread.resolvedCandidateId)
: null; : null;
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">
{/* Header */} {/* Header */}
<div className="mb-6"> <div className="mb-6">
<Link <Link
to="/" to="/"
search={{ tab: "planning" }} search={{ tab: "planning" }}
className="text-sm text-gray-500 hover:text-gray-700 mb-2 inline-block" className="text-sm text-gray-500 hover:text-gray-700 mb-2 inline-block"
> >
&larr; Back to planning &larr; Back to planning
</Link> </Link>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<h1 className="text-xl font-semibold text-gray-900"> <h1 className="text-xl font-semibold text-gray-900">{thread.name}</h1>
{thread.name} <span
</h1> className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
<span isActive
className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${ ? "bg-gray-100 text-gray-600"
isActive : "bg-gray-100 text-gray-500"
? "bg-blue-50 text-blue-700" }`}
: "bg-gray-100 text-gray-500" >
}`} {isActive ? "Active" : "Resolved"}
> </span>
{isActive ? "Active" : "Resolved"} </div>
</span> </div>
</div>
</div>
{/* Resolution banner */} {/* Resolution banner */}
{!isActive && winningCandidate && ( {!isActive && winningCandidate && (
<div className="mb-6 p-4 bg-amber-50 border border-amber-200 rounded-xl"> <div className="mb-6 p-4 bg-amber-50 border border-amber-200 rounded-xl">
<p className="text-sm text-amber-800"> <p className="text-sm text-amber-800">
<span className="font-medium">{winningCandidate.name}</span> was <span className="font-medium">{winningCandidate.name}</span> was
picked as the winner and added to your collection. picked as the winner and added to your collection.
</p> </p>
</div> </div>
)} )}
{/* Add candidate button */} {/* Add candidate button */}
{isActive && ( {isActive && (
<div className="mb-6"> <div className="mb-6">
<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"
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>
Add Candidate Add Candidate
</button> </button>
</div> </div>
)} )}
{/* Candidate grid */} {/* Candidate grid */}
{thread.candidates.length === 0 ? ( {thread.candidates.length === 0 ? (
<div className="py-12 text-center"> <div className="py-12 text-center">
<div className="text-4xl mb-3">🏷</div> <div className="mb-3">
<h3 className="text-lg font-semibold text-gray-900 mb-1"> <LucideIcon
No candidates yet name="tag"
</h3> size={48}
<p className="text-sm text-gray-500"> className="text-gray-400 mx-auto"
Add your first candidate to start comparing. />
</p> </div>
</div> <h3 className="text-lg font-semibold text-gray-900 mb-1">
) : ( No candidates yet
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> </h3>
{thread.candidates.map((candidate) => ( <p className="text-sm text-gray-500">
<CandidateCard Add your first candidate to start comparing.
key={candidate.id} </p>
id={candidate.id} </div>
name={candidate.name} ) : (
weightGrams={candidate.weightGrams} <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
priceCents={candidate.priceCents} {thread.candidates.map((candidate) => (
categoryName={candidate.categoryName} <CandidateCard
categoryIcon={candidate.categoryIcon} key={candidate.id}
imageFilename={candidate.imageFilename} id={candidate.id}
threadId={threadId} name={candidate.name}
isActive={isActive} weightGrams={candidate.weightGrams}
/> priceCents={candidate.priceCents}
))} categoryName={candidate.categoryName}
</div> categoryIcon={candidate.categoryIcon}
)} imageFilename={candidate.imageFilename}
</div> productUrl={candidate.productUrl}
); threadId={threadId}
isActive={isActive}
/>
))}
</div>
)}
</div>
);
} }

View File

@@ -43,6 +43,11 @@ interface UIState {
createThreadModalOpen: boolean; createThreadModalOpen: boolean;
openCreateThreadModal: () => void; openCreateThreadModal: () => void;
closeCreateThreadModal: () => void; closeCreateThreadModal: () => void;
// External link dialog
externalLinkUrl: string | null;
openExternalLink: (url: string) => void;
closeExternalLink: () => void;
} }
export const useUIStore = create<UIState>((set) => ({ export const useUIStore = create<UIState>((set) => ({
@@ -93,4 +98,9 @@ export const useUIStore = create<UIState>((set) => ({
createThreadModalOpen: false, createThreadModalOpen: false,
openCreateThreadModal: () => set({ createThreadModalOpen: true }), openCreateThreadModal: () => set({ createThreadModalOpen: true }),
closeCreateThreadModal: () => set({ createThreadModalOpen: false }), closeCreateThreadModal: () => set({ createThreadModalOpen: false }),
// External link dialog
externalLinkUrl: null,
openExternalLink: (url) => set({ externalLinkUrl: url }),
closeExternalLink: () => set({ externalLinkUrl: null }),
})); }));

View File

@@ -2,7 +2,7 @@ import { Database } from "bun:sqlite";
import { drizzle } from "drizzle-orm/bun-sqlite"; import { drizzle } from "drizzle-orm/bun-sqlite";
import * as schema from "./schema.ts"; import * as schema from "./schema.ts";
const sqlite = new Database("gearbox.db"); const sqlite = new Database(process.env.DATABASE_PATH || "gearbox.db");
sqlite.run("PRAGMA journal_mode = WAL"); sqlite.run("PRAGMA journal_mode = WAL");
sqlite.run("PRAGMA foreign_keys = ON"); sqlite.run("PRAGMA foreign_keys = ON");

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",
}, },
}); });