38 Commits

Author SHA1 Message Date
fb925a9dce fix: include quantity in getAllItems select, createItem values, and updateItem type
All checks were successful
CI / ci (push) Successful in 24s
CI / ci (pull_request) Successful in 25s
CI / e2e (push) Successful in 1m3s
CI / e2e (pull_request) Successful in 1m1s
Quantity was missing from three places in item.service.ts:
- getAllItems didn't select it (API returned undefined)
- createItem didn't pass it to insert (always used DB default of 1)
- updateItem type didn't include it (silently stripped from updates)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 19:57:25 +02:00
70e7cd2f0f fix: show Add Candidate button in comparison view
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 18:51:49 +02:00
33f735af67 fix: remove scale/shadow whileDrag effect that stuck after release
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 18:50:49 +02:00
f8a1a00e0a fix: prevent snap-back after drag and click-opens-edit during drag
Two fixes:
- Remove onSettled clearing tempItems before refetch completes,
  let useEffect clear it when fresh server data arrives
- Track isDragging ref to suppress edit panel click after drag
- Remove layout="position" which interfered with reorder detection

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 18:49:39 +02:00
27c36b6b9a fix: make entire candidate row draggable instead of handle-only
Remove dragControls/dragListener pattern which prevented onReorder
from firing. The whole row is now the drag target with visual feedback
(scale + shadow). Grip icon remains as a visual indicator.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 18:46:52 +02:00
684cfd3789 fix: stabilize drag-to-reorder with layout animation fixes
- Remove transition-all from list items (fights framer-motion layout)
- Add layout="position" to Reorder.Item for proper sibling tracking
- Replace CSS gap with marginBottom (gap confuses layout engine)
- Add explicit short transition duration for snappy reorder

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 18:44:39 +02:00
52751ae9d4 fix: use onDragEnd on Reorder.Item instead of onPointerUp on Group
The previous approach used onPointerUp on the Reorder.Group which
fired unreliably — triggering on non-drag clicks and sometimes not
at all after a drag. Moving to onDragEnd on each Reorder.Item gives
clean, predictable drag-to-reorder behavior.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 18:42:16 +02:00
3fc737c872 fix: add tab navigation to collection page and skip 404 retries
Adds Gear/Planning/Setups pill tabs to the collection page so users
can switch tabs without going back to the dashboard. Also skips
React Query retries on 404 responses for immediate error display.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 18:31:57 +02:00
b993a0a831 fix: skip retries on 404 for single-resource queries
Prevents 10-second loading skeleton when navigating to non-existent
threads, setups, or items. Shows error/not-found state immediately.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 18:28:04 +02:00
a8696c2a85 fix: commit missing migration metadata and run CI on all branches
All checks were successful
CI / ci (push) Successful in 25s
CI / ci (pull_request) Successful in 26s
CI / e2e (push) Successful in 1m24s
CI / e2e (pull_request) Successful in 1m23s
The Drizzle migration journal and snapshot for 0008 (quantity column)
were not committed, causing test failures in CI. Also updates CI to
trigger on all branches, not just Develop.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 18:18:12 +02:00
15f146ee89 feat: add CSV import/export for gear collection
Some checks failed
CI / ci (pull_request) Failing after 22s
CI / e2e (pull_request) Has been skipped
Adds export (GET /api/items/export) and import (POST /api/items/import) routes
backed by a pure csv.service with no external deps, plus useExportItems/useImportItems
hooks and an Import/Export section in the Settings page.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 18:12:07 +02:00
8c1fe47a99 feat: add setup impact preview UI with delta badges across all views
Adds SetupImpactSelector dropdown and ImpactDeltaBadge inline badge, wired into the thread detail page. Delta badges appear on CandidateListItem, CandidateCard, and ComparisonTable (Weight Impact / Price Impact rows) whenever a setup is selected for comparison.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 18:11:57 +02:00
b9a06dd244 feat: add item duplication with copy-and-edit workflow
Adds POST /api/items/:id/duplicate endpoint, useDuplicateItem hook, and a
Duplicate button on ItemCard (collection view only) that opens the new item
for editing immediately after creation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 18:07:20 +02:00
818db73432 feat: add impact delta computation with TDD tests
Implements computeImpactDeltas pure function with 8 TDD tests covering replace/add/none modes and null weight/price handling. Adds useImpactDeltas hook, categoryId to ThreadWithCandidates, and selectedSetupId state to uiStore.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 18:06:46 +02:00
1a5e6a303e feat: add quantity support to totals, UI, and thread resolution
- totals.service: multiply weight/cost sums by quantity in category and global totals
- setup.service: multiply by quantity in getAllSetups SQL subqueries; expose quantity in getSetupWithItems item list
- thread.service: explicitly pass quantity: 1 when inserting resolved item
- ItemForm: add Quantity number input (min=1, default=1) after price field
- ItemCard: show ×N badge next to item name when quantity > 1
- CollectionView: pass quantity prop to ItemCard in both filtered and grouped views
- $setupId.tsx: pass quantity to ItemCard; multiply by quantity in client-side per-setup totals
- WeightSummaryCard: multiply by quantity in all chart and legend weight calculations
- useItems / useSetups: add quantity to ItemWithCategory / SetupItemWithCategory interfaces

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 18:04:27 +02:00
923a0f66b0 feat: add quantity field to items schema
Add integer quantity column (default 1) to the items table, generate
the corresponding Drizzle migration, and extend createItemSchema /
updateItemSchema with an optional positive-integer quantity field.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 18:04:15 +02:00
1b492f2ac2 docs: add v1.4 Collection Tools design spec
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 18:00:02 +02:00
70466a9a1c fix(ci): install unzip before Bun in E2E job
All checks were successful
CI / ci (push) Successful in 25s
CI / e2e (push) Successful in 1m21s
The Playwright Docker image lacks unzip, which Bun's install script requires.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 17:47:50 +02:00
5e0771d929 ci: add Playwright E2E tests to CI pipeline
Some checks failed
CI / ci (push) Successful in 25s
CI / e2e (push) Failing after 55s
Runs as a separate job after unit tests pass, using the official
Playwright Docker image with Chromium pre-installed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 17:45:52 +02:00
70211bdc57 fix: add bunfig.toml to scope bun test to tests/ directory
All checks were successful
CI / ci (push) Successful in 26s
Prevents bare `bun test` from picking up Playwright .spec.ts files
in the e2e/ directory.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 17:43:53 +02:00
35989f8120 docs: update CLAUDE.md with branching workflow, E2E testing commands
Some checks failed
CI / ci (push) Failing after 22s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 17:42:01 +02:00
b974675b11 fix: scope bun test to tests/ directory to exclude Playwright files
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 16:25:56 +02:00
c4ce96ce4f test: add E2E tests for threads, auth, and error handling
Also fix CandidateListItem to not use Reorder.Item when isActive=false,
which caused a framer-motion crash on resolved thread detail pages.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 16:23:26 +02:00
60db8bd9de test: add E2E tests for dashboard and collection views
Covers dashboard card rendering (item count, nav links, active thread/setup counts)
and collection page (gear display, search, category filter, tab switching).
Updates playwright config to serve production build with pre-seeded test DB.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 16:14:07 +02:00
ecbfbc00e9 test: add E2E database seed and Playwright global setup
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 16:10:12 +02:00
f7ce380104 chore: install Playwright and add E2E test configuration
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 16:07:50 +02:00
0d7c4f476a test: add unit tests for rate limiter middleware 2026-04-03 16:05:54 +02:00
86a4a747b5 test: add unit tests for parseId helper
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 16:05:35 +02:00
e9d33e59e9 refactor: add useFormatters hook to reduce boilerplate across 14 components
Created useFormatters() combining useWeightUnit + useCurrency + formatWeight/formatPrice
into a single hook returning weight(grams) and price(cents) bound functions plus
raw unit and currency values. Updated all 14 consumer files to use the new hook,
removing the repeated 4-import + 2-hook-call pattern from each.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 15:49:16 +02:00
5308991123 refactor: replace hand-written test SQL with Drizzle migration runner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 15:44:42 +02:00
a6e7035aab chore: mark planning category filter todo as done
The icon-aware CategoryFilterDropdown was already wired into PlanningView
during Phase 8 (v1.2), replacing the native <select>.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 15:43:45 +02:00
0eaf401cce docs: update PROJECT.md constraints to reflect auth implementation
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 15:38:27 +02:00
a3061b22ca refactor: extract tab views from collection route into separate components
Moves CollectionView, PlanningView, and SetupsView out of the 634-line collection/index.tsx into dedicated component files. Pure extraction — zero logic changes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 15:37:44 +02:00
1dff6abb3b feat: add error boundary to root route for crash resilience
Adds a TanStack Router error boundary to the root route so rendering errors or uncaught React Query failures show a friendly error page instead of white-screening the app. The error boundary displays a professional error message with a "Try again" button that resets state and invalidates router data.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 15:36:11 +02:00
2dddba9a08 feat: add rate limiting on login and setup endpoints
Implement in-memory rate limiter with 5 attempts per 15-minute window per IP address. Protects brute-force attacks on credential endpoints.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 15:36:03 +02:00
41a2910aeb fix: add centralized error handler for unhandled exceptions
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 15:34:51 +02:00
ecff58500e fix: validate route ID parameters, return 400 for invalid IDs
Adds parseId helper in src/server/lib/params.ts and applies it across
all route files so non-positive-integer IDs return 400 instead of
silently passing NaN to services.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 15:34:06 +02:00
3016eb1a1a fix: add explicit DB context middleware for all API routes
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 15:31:11 +02:00
80 changed files with 5801 additions and 879 deletions

View File

@@ -2,9 +2,7 @@ name: CI
on:
push:
branches: [Develop]
pull_request:
branches: [Develop]
jobs:
ci:
@@ -26,3 +24,33 @@ jobs:
- name: Build
run: bun run build
e2e:
needs: ci
runs-on: docker
container:
image: mcr.microsoft.com/playwright:v1.59.1-noble
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install Bun
run: |
apt-get update && apt-get install -y unzip
curl -fsSL https://bun.sh/install | bash
echo "$HOME/.bun/bin" >> $GITHUB_PATH
- name: Install dependencies
run: |
export PATH="$HOME/.bun/bin:$PATH"
bun install --frozen-lockfile --ignore-scripts
- name: Build client
run: |
export PATH="$HOME/.bun/bin:$PATH"
bun run build
- name: Run E2E tests
run: |
export PATH="$HOME/.bun/bin:$PATH"
CI=true bun run test:e2e

5
.gitignore vendored
View File

@@ -226,6 +226,11 @@ uploads/*
# Worktrees
.worktrees/
# Playwright
e2e/test.db
test-results/
playwright-report/
# Claude Code
.claude/

View File

@@ -80,13 +80,13 @@ Replaces spreadsheet-based gear tracking workflow.
- **Runtime**: Bun — used as package manager and runtime
- **Design**: Light, airy, minimalist — white/light backgrounds, lots of whitespace, no visual clutter
- **Navigation**: Dashboard-based home page, not sidebar or top-nav tabs
- **Scope**: No auth, single user for v1
- **Scope**: Single user with cookie/API key auth
## Key Decisions
| Decision | Rationale | Outcome |
|----------|-----------|---------|
| No auth for v1 | Single user, simplicity first | ✓ Good |
| Cookie/API key auth | Single user, public read + authenticated write | ✓ Good |
| Generic data model | Support any hobby, not just bikepacking | ✓ Good |
| Dashboard navigation | Clean entry point, not persistent nav | ✓ Good |
| Bun runtime | User preference | ✓ Good |

View File

@@ -19,8 +19,10 @@ 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 # Run all unit/integration tests
bun test tests/services/item.service.test.ts # Run single test file
bun run test:e2e # Run Playwright E2E tests
bun run test:e2e:ui # Playwright UI mode for debugging
# Lint & Format
bun run lint # Biome check (tabs, double quotes, organized imports)
@@ -55,9 +57,16 @@ bun run build # Vite build → dist/client/
- **Timestamps**: stored as integers (unix epoch) with `{ mode: "timestamp" }`.
- Tables: `categories`, `items`, `threads`, `threadCandidates`, `setups`, `setupItems`, `settings`, `users`, `sessions`, `apiKeys`.
### 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.
### Testing (`tests/` and `e2e/`)
- **Unit/integration**: Bun test runner (`bun test`). Tests at service level and route level.
- `tests/helpers/db.ts`: `createTestDb()` creates in-memory SQLite via Drizzle migrations and seeds an "Uncategorized" category.
- **E2E**: Playwright (`bun run test:e2e`). Tests in `e2e/` run against a seeded SQLite database with the server in production mode. Seed script: `e2e/seed.ts`.
## Branching
- **Develop** is the main branch. Keep it clean — don't commit large feature work directly.
- For each new brainstorming/implementation session, create a feature branch off Develop (e.g., `feature/setup-impact-preview`, `fix/error-handling`).
- Merge back to Develop via PR or fast-forward merge when the work is complete and verified.
## Path Alias

View File

@@ -24,6 +24,7 @@
},
"devDependencies": {
"@biomejs/biome": "^2.4.7",
"@playwright/test": "^1.59.1",
"@tanstack/react-query-devtools": "^5.91.3",
"@tanstack/react-router-devtools": "^1.166.7",
"@tanstack/router-plugin": "^1.166.9",
@@ -185,6 +186,8 @@
"@oxc-project/types": ["@oxc-project/types@0.115.0", "", {}, "sha512-4n91DKnebUS4yjUHl2g3/b2T+IUdCfmoZGhmwsovZCDaJSs+QkVAM+0AqqTxHSsHfeiMuueT75cZaZcT/m0pSw=="],
"@playwright/test": ["@playwright/test@1.59.1", "", { "dependencies": { "playwright": "1.59.1" }, "bin": { "playwright": "cli.js" } }, "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg=="],
"@reduxjs/toolkit": ["@reduxjs/toolkit@2.11.2", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@standard-schema/utils": "^0.3.0", "immer": "^11.0.0", "redux": "^5.0.1", "redux-thunk": "^3.1.0", "reselect": "^5.1.0" }, "peerDependencies": { "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" }, "optionalPeers": ["react", "react-redux"] }, "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ=="],
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.9", "", { "os": "android", "cpu": "arm64" }, "sha512-lcJL0bN5hpgJfSIz/8PIf02irmyL43P+j1pTCfbD1DbLkmGRuFIA4DD3B3ZOvGqG0XiVvRznbKtN0COQVaKUTg=="],
@@ -675,6 +678,10 @@
"pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="],
"playwright": ["playwright@1.59.1", "", { "dependencies": { "playwright-core": "1.59.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw=="],
"playwright-core": ["playwright-core@1.59.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg=="],
"postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="],
"prebuild-install": ["prebuild-install@7.1.3", "", { "dependencies": { "detect-libc": "^2.0.0", "expand-template": "^2.0.3", "github-from-package": "0.0.0", "minimist": "^1.2.3", "mkdirp-classic": "^0.5.3", "napi-build-utils": "^2.0.0", "node-abi": "^3.3.0", "pump": "^3.0.0", "rc": "^1.2.7", "simple-get": "^4.0.0", "tar-fs": "^2.0.0", "tunnel-agent": "^0.6.0" }, "bin": { "prebuild-install": "bin.js" } }, "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug=="],
@@ -879,6 +886,8 @@
"node-abi/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
"playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
"readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"recast/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],

2
bunfig.toml Normal file
View File

@@ -0,0 +1,2 @@
[test]
root = "tests/"

View File

@@ -0,0 +1,661 @@
# Codebase Improvements Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Harden the server (explicit DB context, param validation, error handling, rate limiting), add client error boundaries, split the oversized collection route into focused components, and fix stale docs.
**Architecture:** Server changes are middleware-level (DB context, error handler, rate limiter) plus a small utility for param parsing. Client changes are a TanStack Router error boundary on the root route and extracting three tab components from the 634-line collection route. Docs change is a one-line fix in PROJECT.md.
**Tech Stack:** Hono middleware, TanStack Router errorComponent, React, TypeScript
---
### Task 1: Explicit DB Context Middleware
**Files:**
- Modify: `src/server/index.ts:1-59`
- Modify: `src/server/routes/settings.ts:3,12` (remove prodDb fallback)
- [ ] **Step 1: Add DB import and middleware to server index**
In `src/server/index.ts`, add the import for the production database at the top, alongside existing imports:
```ts
import { db as prodDb } from "../db/index.ts";
```
Then add a middleware **before** the auth middleware (before line 26) that sets the DB on every API request:
```ts
// Inject production database into request context
app.use("/api/*", async (c, next) => {
c.set("db", prodDb);
return next();
});
```
- [ ] **Step 2: Fix auth middleware comment**
In the same file, update the comment on the auth middleware from:
```ts
// Auth middleware for write operations (POST/PUT/DELETE) on non-auth routes
```
to:
```ts
// Auth middleware for write operations (POST/PUT/PATCH/DELETE) on non-auth routes
```
- [ ] **Step 3: Remove prodDb fallback from settings route**
In `src/server/routes/settings.ts`, remove the `prodDb` import and fallback. Change:
```ts
import { db as prodDb } from "../../db/index.ts";
```
Remove this import entirely.
Change both occurrences of:
```ts
const database = c.get("db") ?? prodDb;
```
to:
```ts
const database = c.get("db");
```
- [ ] **Step 4: Run tests**
Run: `bun test`
Expected: All 183 tests pass. Tests already set `c.set("db", testDb)` so this change doesn't affect them.
- [ ] **Step 5: Run lint**
Run: `bun run lint`
Expected: No errors.
- [ ] **Step 6: Commit**
```bash
git add src/server/index.ts src/server/routes/settings.ts
git commit -m "fix: add explicit DB context middleware for all API routes"
```
---
### Task 2: Route Parameter Validation
**Files:**
- Create: `src/server/lib/params.ts`
- Modify: `src/server/routes/items.ts`
- Modify: `src/server/routes/categories.ts`
- Modify: `src/server/routes/threads.ts`
- Modify: `src/server/routes/setups.ts`
- Modify: `src/server/routes/auth.ts:187-189`
- [ ] **Step 1: Create parseId helper**
Create `src/server/lib/params.ts`:
```ts
/**
* Parse a route parameter as a positive integer ID.
* Returns the number if valid, or null if the string is not a positive integer.
*/
export function parseId(raw: string): number | null {
const id = Number(raw);
if (!Number.isInteger(id) || id <= 0) return null;
return id;
}
```
- [ ] **Step 2: Update items routes**
In `src/server/routes/items.ts`, add the import:
```ts
import { parseId } from "../lib/params.ts";
```
Replace all `Number(c.req.param("id"))` patterns. For each route that uses an ID param, add validation. Example for `GET /:id`:
```ts
app.get("/:id", (c) => {
const db = c.get("db");
const id = parseId(c.req.param("id"));
if (!id) return c.json({ error: "Invalid item ID" }, 400);
const item = getItemById(db, id);
if (!item) return c.json({ error: "Item not found" }, 404);
return c.json(item);
});
```
Apply the same pattern to `PUT /:id` and `DELETE /:id`. In each case, add `const id = parseId(...)` + the null check returning 400 right after.
- [ ] **Step 3: Update categories routes**
In `src/server/routes/categories.ts`, add the import:
```ts
import { parseId } from "../lib/params.ts";
```
Replace `Number(c.req.param("id"))` with `parseId(c.req.param("id"))` in `PUT /:id` and `DELETE /:id`, adding the null check:
```ts
const id = parseId(c.req.param("id"));
if (!id) return c.json({ error: "Invalid category ID" }, 400);
```
- [ ] **Step 4: Update threads routes**
In `src/server/routes/threads.ts`, add the import:
```ts
import { parseId } from "../lib/params.ts";
```
Replace all `Number(c.req.param(...))` calls. There are 8 occurrences across these handlers:
- `GET /:id``const id = parseId(c.req.param("id"))`
- `PUT /:id` — same
- `DELETE /:id` — same
- `POST /:id/candidates``const threadId = parseId(c.req.param("id"))`
- `PUT /:threadId/candidates/:candidateId``const candidateId = parseId(c.req.param("candidateId"))`
- `DELETE /:threadId/candidates/:candidateId` — same
- `PATCH /:id/candidates/reorder``const threadId = parseId(c.req.param("id"))`
- `POST /:id/resolve``const threadId = parseId(c.req.param("id"))`
For each, add the null check returning 400 with a descriptive message like `"Invalid thread ID"` or `"Invalid candidate ID"`.
- [ ] **Step 5: Update setups routes**
In `src/server/routes/setups.ts`, add the import:
```ts
import { parseId } from "../lib/params.ts";
```
Replace all `Number(c.req.param(...))` calls. There are 6 occurrences:
- `GET /:id``const id = parseId(c.req.param("id"))`
- `PUT /:id` — same
- `DELETE /:id` — same
- `PUT /:id/items` — same
- `PATCH /:id/items/:itemId/classification` — both `setupId` and `itemId`
- `DELETE /:id/items/:itemId` — both `setupId` and `itemId`
For the classification and item removal routes with two params:
```ts
const setupId = parseId(c.req.param("id"));
const itemId = parseId(c.req.param("itemId"));
if (!setupId || !itemId) return c.json({ error: "Invalid ID" }, 400);
```
- [ ] **Step 6: Update auth routes**
In `src/server/routes/auth.ts`, add the import:
```ts
import { parseId } from "../lib/params.ts";
```
Update `DELETE /keys/:id` (line 187-189):
```ts
app.delete("/keys/:id", requireAuth, (c) => {
const db = c.get("db");
const id = parseId(c.req.param("id"));
if (!id) return c.json({ error: "Invalid key ID" }, 400);
deleteApiKey(db, id);
return c.json({ ok: true });
});
```
- [ ] **Step 7: Run tests**
Run: `bun test`
Expected: All 183 tests pass. Existing tests use valid integer IDs so no breakage.
- [ ] **Step 8: Run lint**
Run: `bun run lint`
Expected: No errors.
- [ ] **Step 9: Commit**
```bash
git add src/server/lib/params.ts src/server/routes/items.ts src/server/routes/categories.ts src/server/routes/threads.ts src/server/routes/setups.ts src/server/routes/auth.ts
git commit -m "fix: validate route ID parameters, return 400 for invalid IDs"
```
---
### Task 3: Centralized Error Handler
**Files:**
- Modify: `src/server/index.ts`
- [ ] **Step 1: Add onError handler**
In `src/server/index.ts`, add the error handler after the app is created (after `const app = new Hono()`) but before any routes:
```ts
// Centralized error handler
app.onError((err, c) => {
console.error(`[${c.req.method}] ${c.req.path}:`, err);
const message =
process.env.NODE_ENV === "production"
? "Internal server error"
: err.message || "Internal server error";
return c.json({ error: message }, 500);
});
```
- [ ] **Step 2: Run tests**
Run: `bun test`
Expected: All 183 tests pass.
- [ ] **Step 3: Commit**
```bash
git add src/server/index.ts
git commit -m "fix: add centralized error handler for unhandled exceptions"
```
---
### Task 4: Rate Limiting on Auth Endpoints
**Files:**
- Create: `src/server/middleware/rateLimit.ts`
- Modify: `src/server/routes/auth.ts`
- [ ] **Step 1: Create rate limiter middleware**
Create `src/server/middleware/rateLimit.ts`:
```ts
import type { Context, Next } from "hono";
interface RateLimitEntry {
count: number;
resetAt: number;
}
const store = new Map<string, RateLimitEntry>();
const MAX_ATTEMPTS = 5;
const WINDOW_MS = 15 * 60 * 1000; // 15 minutes
function getClientIp(c: Context): string {
return c.req.header("x-forwarded-for")?.split(",")[0]?.trim() || "unknown";
}
function cleanup() {
const now = Date.now();
for (const [key, entry] of store) {
if (now >= entry.resetAt) {
store.delete(key);
}
}
}
export async function rateLimit(c: Context, next: Next) {
cleanup();
const ip = getClientIp(c);
const key = `${ip}:${c.req.path}`;
const now = Date.now();
const entry = store.get(key);
if (!entry || now >= entry.resetAt) {
store.set(key, { count: 1, resetAt: now + WINDOW_MS });
return next();
}
if (entry.count >= MAX_ATTEMPTS) {
const retryAfter = Math.ceil((entry.resetAt - now) / 1000);
c.header("Retry-After", String(retryAfter));
return c.json({ error: "Too many attempts. Try again later." }, 429);
}
entry.count++;
return next();
}
```
- [ ] **Step 2: Apply rate limiter to auth routes**
In `src/server/routes/auth.ts`, add the import:
```ts
import { rateLimit } from "../middleware/rateLimit.ts";
```
Update the `POST /setup` handler to include the rate limiter:
```ts
app.post("/setup", rateLimit, zValidator("json", setupSchema), async (c) => {
```
Update the `POST /login` handler to include the rate limiter:
```ts
app.post("/login", rateLimit, zValidator("json", loginSchema), async (c) => {
```
- [ ] **Step 3: Run tests**
Run: `bun test`
Expected: All 183 tests pass. Auth tests make fewer than 5 requests per endpoint so rate limiting won't trigger.
- [ ] **Step 4: Run lint**
Run: `bun run lint`
Expected: No errors.
- [ ] **Step 5: Commit**
```bash
git add src/server/middleware/rateLimit.ts src/server/routes/auth.ts
git commit -m "feat: add rate limiting on login and setup endpoints"
```
---
### Task 5: Client Error Boundary
**Files:**
- Modify: `src/client/routes/__root.tsx`
- [ ] **Step 1: Add error boundary component and wire it up**
In `src/client/routes/__root.tsx`, add the import for `useRouter` at the top (add to existing import from `@tanstack/react-router`):
```ts
import {
createRootRoute,
Outlet,
useMatchRoute,
useNavigate,
useRouter,
type ErrorComponentProps,
} from "@tanstack/react-router";
```
Add the `errorComponent` to the route definition:
```ts
export const Route = createRootRoute({
component: RootLayout,
errorComponent: RootErrorBoundary,
});
```
Add the `RootErrorBoundary` function before `RootLayout`:
```tsx
function RootErrorBoundary({ error, reset }: ErrorComponentProps) {
const router = useRouter();
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="max-w-md mx-auto text-center px-4">
<div className="w-12 h-12 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-4">
<svg
className="w-6 h-6 text-red-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z"
/>
</svg>
</div>
<h1 className="text-xl font-semibold text-gray-900 mb-2">
Something went wrong
</h1>
<p className="text-sm text-gray-500 mb-6">
{error instanceof Error ? error.message : "An unexpected error occurred"}
</p>
<button
type="button"
onClick={() => {
reset();
router.invalidate();
}}
className="px-5 py-2.5 bg-gray-700 hover:bg-gray-800 text-white text-sm font-medium rounded-lg transition-colors"
>
Try again
</button>
</div>
</div>
);
}
```
- [ ] **Step 2: Run lint**
Run: `bun run lint`
Expected: No errors.
- [ ] **Step 3: Run tests**
Run: `bun test`
Expected: All 183 tests pass.
- [ ] **Step 4: Commit**
```bash
git add src/client/routes/__root.tsx
git commit -m "feat: add error boundary to root route for crash resilience"
```
---
### Task 6: Split Collection Route into Tab Components
**Files:**
- Create: `src/client/components/CollectionView.tsx`
- Create: `src/client/components/PlanningView.tsx`
- Create: `src/client/components/SetupsView.tsx`
- Modify: `src/client/routes/collection/index.tsx`
- [ ] **Step 1: Create CollectionView component**
Create `src/client/components/CollectionView.tsx` with the `CollectionView` function extracted from `collection/index.tsx` (lines 72-334). The component needs these imports:
```tsx
import { useMemo, useState } from "react";
import { CategoryFilterDropdown } from "./CategoryFilterDropdown";
import { CategoryHeader } from "./CategoryHeader";
import { ItemCard } from "./ItemCard";
import { useCategories } from "../hooks/useCategories";
import { useCurrency } from "../hooks/useCurrency";
import { useItems } from "../hooks/useItems";
import { useTotals } from "../hooks/useTotals";
import { useWeightUnit } from "../hooks/useWeightUnit";
import { formatPrice, formatWeight } from "../lib/formatters";
import { LucideIcon } from "../lib/iconData";
import { useUIStore } from "../stores/uiStore";
export function CollectionView() {
// ... exact same function body as lines 73-334 of collection/index.tsx
}
```
Copy the entire `CollectionView` function body as-is. No logic changes.
- [ ] **Step 2: Create PlanningView component**
Create `src/client/components/PlanningView.tsx` with the `PlanningView` function extracted from `collection/index.tsx` (lines 337-523):
```tsx
import { useState } from "react";
import { CategoryFilterDropdown } from "./CategoryFilterDropdown";
import { CreateThreadModal } from "./CreateThreadModal";
import { ThreadCard } from "./ThreadCard";
import { useCategories } from "../hooks/useCategories";
import { useThreads } from "../hooks/useThreads";
import { useUIStore } from "../stores/uiStore";
export function PlanningView() {
// ... exact same function body as lines 338-523 of collection/index.tsx
}
```
Copy the entire `PlanningView` function body as-is. No logic changes.
- [ ] **Step 3: Create SetupsView component**
Create `src/client/components/SetupsView.tsx` with the `SetupsView` function extracted from `collection/index.tsx` (lines 526-633):
```tsx
import { useState } from "react";
import { SetupCard } from "./SetupCard";
import { useCreateSetup, useSetups } from "../hooks/useSetups";
export function SetupsView() {
// ... exact same function body as lines 527-633 of collection/index.tsx
}
```
Copy the entire `SetupsView` function body as-is. No logic changes.
- [ ] **Step 4: Update collection/index.tsx**
Replace the entire file content. Keep only the route definition, tab switching logic, animation constants, and imports from the new components:
```tsx
import { createFileRoute } from "@tanstack/react-router";
import { AnimatePresence, motion } from "framer-motion";
import { useRef } from "react";
import { z } from "zod";
import { CollectionView } from "../../components/CollectionView";
import { PlanningView } from "../../components/PlanningView";
import { SetupsView } from "../../components/SetupsView";
const searchSchema = z.object({
tab: z.enum(["gear", "planning", "setups"]).catch("gear"),
});
export const Route = createFileRoute("/collection/")({
validateSearch: searchSchema,
component: CollectionPage,
});
const TAB_ORDER = ["gear", "planning", "setups"] as const;
const slideVariants = {
enter: (dir: number) => ({ x: `${dir * 15}%`, opacity: 0 }),
center: { x: 0, opacity: 1 },
exit: (dir: number) => ({ x: `${dir * -15}%`, opacity: 0 }),
};
function CollectionPage() {
const { tab } = Route.useSearch();
const prevTab = useRef(tab);
const direction =
TAB_ORDER.indexOf(tab) >= TAB_ORDER.indexOf(prevTab.current) ? 1 : -1;
prevTab.current = tab;
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 overflow-x-hidden">
<AnimatePresence mode="wait" initial={false} custom={direction}>
<motion.div
key={tab}
custom={direction}
variants={slideVariants}
initial="enter"
animate="center"
exit="exit"
transition={{ duration: 0.12, ease: "easeInOut" }}
>
{tab === "gear" ? (
<CollectionView />
) : tab === "planning" ? (
<PlanningView />
) : (
<SetupsView />
)}
</motion.div>
</AnimatePresence>
</div>
);
}
```
- [ ] **Step 5: Run lint**
Run: `bun run lint`
Expected: No errors. (Biome may flag import organization — fix if needed.)
- [ ] **Step 6: Run tests**
Run: `bun test`
Expected: All 183 tests pass.
- [ ] **Step 7: Commit**
```bash
git add src/client/components/CollectionView.tsx src/client/components/PlanningView.tsx src/client/components/SetupsView.tsx src/client/routes/collection/index.tsx
git commit -m "refactor: extract tab views from collection route into separate components"
```
---
### Task 7: Docs Cleanup
**Files:**
- Modify: `.planning/PROJECT.md:84`
- [ ] **Step 1: Update stale constraint**
In `.planning/PROJECT.md`, change line 84 from:
```
- **Scope**: No auth, single user for v1
```
to:
```
- **Scope**: Single user with cookie/API key auth
```
- [ ] **Step 2: Commit**
```bash
git add .planning/PROJECT.md
git commit -m "docs: update PROJECT.md constraints to reflect auth implementation"
```
---
### Task 8: Final Verification
- [ ] **Step 1: Run full test suite**
Run: `bun test`
Expected: All 183 tests pass.
- [ ] **Step 2: Run lint**
Run: `bun run lint`
Expected: No errors.
- [ ] **Step 3: Verify dev server starts**
Run: `bun run dev:server &` then `curl http://localhost:3000/api/health`
Expected: `{"status":"ok"}`
Then kill the background server.

View File

@@ -0,0 +1,934 @@
# Testing Improvements Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add unit tests for new server code (parseId, rate limiter, param validation routes), set up Playwright E2E testing with a seeded database, and write E2E tests covering dashboard, collection, threads, auth, and error handling.
**Architecture:** Unit tests use existing Bun test runner + Hono `app.request()` pattern. E2E tests use Playwright against a real server with a pre-seeded SQLite database. A global-setup script creates the test DB using Drizzle migrations + direct inserts before Playwright runs.
**Tech Stack:** Bun test runner, Playwright (Chromium only), Drizzle ORM migrations, Hono
---
### Task 1: Unit Tests for parseId
**Files:**
- Create: `tests/lib/params.test.ts`
- [ ] **Step 1: Write tests**
Create `tests/lib/params.test.ts`:
```ts
import { describe, expect, it } from "bun:test";
import { parseId } from "../../src/server/lib/params";
describe("parseId", () => {
it("returns number for valid positive integers", () => {
expect(parseId("1")).toBe(1);
expect(parseId("42")).toBe(42);
expect(parseId("999")).toBe(999);
});
it("returns null for zero", () => {
expect(parseId("0")).toBeNull();
});
it("returns null for negative numbers", () => {
expect(parseId("-1")).toBeNull();
expect(parseId("-100")).toBeNull();
});
it("returns null for decimals", () => {
expect(parseId("1.5")).toBeNull();
expect(parseId("3.14")).toBeNull();
});
it("returns null for non-numeric strings", () => {
expect(parseId("abc")).toBeNull();
expect(parseId("")).toBeNull();
expect(parseId("hello")).toBeNull();
expect(parseId("12abc")).toBeNull();
});
it("returns null for special values", () => {
expect(parseId("NaN")).toBeNull();
expect(parseId("Infinity")).toBeNull();
expect(parseId("-Infinity")).toBeNull();
});
});
```
- [ ] **Step 2: Run tests**
Run: `bun test tests/lib/params.test.ts`
Expected: All tests pass.
- [ ] **Step 3: Commit**
```bash
git add tests/lib/params.test.ts
git commit -m "test: add unit tests for parseId helper"
```
---
### Task 2: Unit Tests for Rate Limiter
**Files:**
- Modify: `src/server/middleware/rateLimit.ts` (add test reset function)
- Create: `tests/middleware/rateLimit.test.ts`
- [ ] **Step 1: Add test reset function to rate limiter**
In `src/server/middleware/rateLimit.ts`, add at the end of the file:
```ts
/** @internal — only for testing */
export function _resetForTesting() {
store.clear();
}
```
- [ ] **Step 2: Write tests**
Create `tests/middleware/rateLimit.test.ts`:
```ts
import { beforeEach, describe, expect, it } from "bun:test";
import { Hono } from "hono";
import { _resetForTesting, rateLimit } from "../../src/server/middleware/rateLimit";
function createApp() {
const app = new Hono();
app.post("/login", rateLimit, (c) => c.json({ ok: true }));
app.post("/setup", rateLimit, (c) => c.json({ ok: true }));
return app;
}
function makeRequest(app: Hono, path: string, ip = "127.0.0.1") {
return app.request(path, {
method: "POST",
headers: { "x-forwarded-for": ip },
});
}
describe("rateLimit middleware", () => {
beforeEach(() => {
_resetForTesting();
});
it("allows first request through", async () => {
const app = createApp();
const res = await makeRequest(app, "/login");
expect(res.status).toBe(200);
});
it("allows up to 5 requests", async () => {
const app = createApp();
for (let i = 0; i < 5; i++) {
const res = await makeRequest(app, "/login");
expect(res.status).toBe(200);
}
});
it("returns 429 after 5 requests", async () => {
const app = createApp();
for (let i = 0; i < 5; i++) {
await makeRequest(app, "/login");
}
const res = await makeRequest(app, "/login");
expect(res.status).toBe(429);
const body = await res.json();
expect(body.error).toBe("Too many attempts. Try again later.");
});
it("includes Retry-After header on 429", async () => {
const app = createApp();
for (let i = 0; i < 5; i++) {
await makeRequest(app, "/login");
}
const res = await makeRequest(app, "/login");
expect(res.status).toBe(429);
const retryAfter = res.headers.get("Retry-After");
expect(retryAfter).toBeTruthy();
expect(Number(retryAfter)).toBeGreaterThan(0);
});
it("tracks different IPs independently", async () => {
const app = createApp();
// Fill up IP 1
for (let i = 0; i < 5; i++) {
await makeRequest(app, "/login", "10.0.0.1");
}
// IP 1 is blocked
const blocked = await makeRequest(app, "/login", "10.0.0.1");
expect(blocked.status).toBe(429);
// IP 2 still works
const allowed = await makeRequest(app, "/login", "10.0.0.2");
expect(allowed.status).toBe(200);
});
it("tracks different paths independently", async () => {
const app = createApp();
// Fill up /login
for (let i = 0; i < 5; i++) {
await makeRequest(app, "/login");
}
const blockedLogin = await makeRequest(app, "/login");
expect(blockedLogin.status).toBe(429);
// /setup still works
const allowedSetup = await makeRequest(app, "/setup");
expect(allowedSetup.status).toBe(200);
});
});
```
- [ ] **Step 3: Run tests**
Run: `bun test tests/middleware/rateLimit.test.ts`
Expected: All tests pass.
- [ ] **Step 4: Run full test suite**
Run: `bun test`
Expected: All tests pass (previous 183 + new ones).
- [ ] **Step 5: Commit**
```bash
git add src/server/middleware/rateLimit.ts tests/middleware/rateLimit.test.ts
git commit -m "test: add unit tests for rate limiter middleware"
```
---
### Task 3: Route-Level Tests for Invalid ID Params
**Files:**
- Create: `tests/routes/params.test.ts`
- [ ] **Step 1: Write tests**
Create `tests/routes/params.test.ts`:
```ts
import { beforeEach, describe, expect, it } from "bun:test";
import { Hono } from "hono";
import { categoryRoutes } from "../../src/server/routes/categories";
import { itemRoutes } from "../../src/server/routes/items";
import { setupRoutes } from "../../src/server/routes/setups";
import { threadRoutes } from "../../src/server/routes/threads";
import { createTestDb } from "../helpers/db";
function createTestApp() {
const db = createTestDb();
const app = new Hono();
app.use("*", async (c, next) => {
c.set("db", db);
await next();
});
app.route("/api/items", itemRoutes);
app.route("/api/categories", categoryRoutes);
app.route("/api/threads", threadRoutes);
app.route("/api/setups", setupRoutes);
return app;
}
describe("Invalid ID parameter handling", () => {
let app: Hono;
beforeEach(() => {
app = createTestApp();
});
describe("items", () => {
it("GET /api/items/abc returns 400", async () => {
const res = await app.request("/api/items/abc");
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toContain("Invalid");
});
it("GET /api/items/0 returns 400", async () => {
const res = await app.request("/api/items/0");
expect(res.status).toBe(400);
});
it("GET /api/items/-1 returns 400", async () => {
const res = await app.request("/api/items/-1");
expect(res.status).toBe(400);
});
});
describe("categories", () => {
it("DELETE /api/categories/abc returns 400", async () => {
const res = await app.request("/api/categories/abc", {
method: "DELETE",
});
expect(res.status).toBe(400);
});
});
describe("threads", () => {
it("GET /api/threads/abc returns 400", async () => {
const res = await app.request("/api/threads/abc");
expect(res.status).toBe(400);
});
it("GET /api/threads/1.5 returns 400", async () => {
const res = await app.request("/api/threads/1.5");
expect(res.status).toBe(400);
});
});
describe("setups", () => {
it("GET /api/setups/abc returns 400", async () => {
const res = await app.request("/api/setups/abc");
expect(res.status).toBe(400);
});
it("GET /api/setups/0 returns 400", async () => {
const res = await app.request("/api/setups/0");
expect(res.status).toBe(400);
});
});
});
```
- [ ] **Step 2: Run tests**
Run: `bun test tests/routes/params.test.ts`
Expected: All tests pass.
- [ ] **Step 3: Commit**
```bash
git add tests/routes/params.test.ts
git commit -m "test: add route-level tests for invalid ID parameter handling"
```
---
### Task 4: Install Playwright and Create Config
**Files:**
- Modify: `package.json` (add dep + scripts)
- Create: `playwright.config.ts`
- Modify: `.gitignore`
- [ ] **Step 1: Install Playwright**
```bash
bun add -d @playwright/test
bunx playwright install chromium
```
- [ ] **Step 2: Create playwright.config.ts**
Create `playwright.config.ts` at project root:
```ts
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./e2e",
fullyParallel: false,
retries: 0,
workers: 1,
reporter: "list",
globalSetup: "./e2e/global-setup.ts",
use: {
baseURL: "http://localhost:3000",
trace: "on-first-retry",
},
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
],
webServer: {
command: "DATABASE_PATH=./e2e/test.db bun run dev:server",
port: 3000,
reuseExistingServer: !process.env.CI,
timeout: 10000,
},
});
```
- [ ] **Step 3: Add scripts to package.json**
Add these to the `"scripts"` section in `package.json`:
```json
"test:e2e": "bunx playwright test",
"test:e2e:ui": "bunx playwright test --ui"
```
- [ ] **Step 4: Update .gitignore**
Append to `.gitignore`:
```
# Playwright
e2e/test.db
test-results/
playwright-report/
```
- [ ] **Step 5: Run lint**
Run: `bun run lint`
Expected: Clean.
- [ ] **Step 6: Commit**
```bash
git add package.json bun.lock playwright.config.ts .gitignore
git commit -m "chore: install Playwright and add E2E test configuration"
```
---
### Task 5: E2E Database Seed and Global Setup
**Files:**
- Create: `e2e/seed.ts`
- Create: `e2e/global-setup.ts`
- [ ] **Step 1: Create seed script**
Create `e2e/seed.ts`:
```ts
import { Database } from "bun:sqlite";
import { drizzle } from "drizzle-orm/bun-sqlite";
import { migrate } from "drizzle-orm/bun-sqlite/migrator";
import * as schema from "../src/db/schema";
const DB_PATH = "./e2e/test.db";
export async function seedTestDatabase() {
// Remove old test DB if it exists
try {
await Bun.file(DB_PATH).exists() &&
(await import("node:fs/promises")).then((fs) => fs.unlink(DB_PATH));
} catch {
// File doesn't exist, that's fine
}
const sqlite = new Database(DB_PATH);
sqlite.run("PRAGMA journal_mode = WAL");
sqlite.run("PRAGMA foreign_keys = ON");
const db = drizzle(sqlite, { schema });
// Apply all migrations
migrate(db, { migrationsFolder: "./drizzle" });
// ── Seed Categories ──
const [uncategorized] = db
.insert(schema.categories)
.values({ name: "Uncategorized", icon: "package" })
.returning()
.all();
const [shelter] = db
.insert(schema.categories)
.values({ name: "Shelter", icon: "tent" })
.returning()
.all();
const [sleep] = db
.insert(schema.categories)
.values({ name: "Sleep System", icon: "moon" })
.returning()
.all();
const [cook] = db
.insert(schema.categories)
.values({ name: "Cook Kit", icon: "flame" })
.returning()
.all();
// ── Seed Items ──
const tent = db
.insert(schema.items)
.values({
name: "Zpacks Duplex",
weightGrams: 539,
priceCents: 67900,
categoryId: shelter.id,
notes: "DCF shelter, 2-person",
})
.returning()
.get();
const tarp = db
.insert(schema.items)
.values({
name: "Borah Gear Tarp",
weightGrams: 156,
priceCents: 11000,
categoryId: shelter.id,
})
.returning()
.get();
const quilt = db
.insert(schema.items)
.values({
name: "Enlightened Equipment Enigma 20",
weightGrams: 595,
priceCents: 34000,
categoryId: sleep.id,
notes: "20F quilt",
})
.returning()
.get();
const pad = db
.insert(schema.items)
.values({
name: "Therm-a-Rest NeoAir XLite",
weightGrams: 354,
priceCents: 20999,
categoryId: sleep.id,
})
.returning()
.get();
const stove = db
.insert(schema.items)
.values({
name: "BRS-3000T Stove",
weightGrams: 25,
priceCents: 2000,
categoryId: cook.id,
})
.returning()
.get();
const pot = db
.insert(schema.items)
.values({
name: "Toaks 750ml Pot",
weightGrams: 103,
priceCents: 3000,
categoryId: cook.id,
})
.returning()
.get();
// ── Seed Active Thread with 3 Candidates ──
const activeThread = db
.insert(schema.threads)
.values({
name: "New Backpack",
status: "active",
categoryId: uncategorized.id,
})
.returning()
.get();
db.insert(schema.threadCandidates)
.values({
threadId: activeThread.id,
name: "ULA Circuit",
weightGrams: 1077,
priceCents: 27500,
categoryId: uncategorized.id,
pros: "Great hip belt\nLarge capacity",
cons: "Heavier than competitors",
sortOrder: 1000,
status: "researching",
})
.run();
db.insert(schema.threadCandidates)
.values({
threadId: activeThread.id,
name: "Gossamer Gear Mariposa",
weightGrams: 737,
priceCents: 28500,
categoryId: uncategorized.id,
pros: "Very lightweight\nGood ventilation",
cons: "Smaller hip belt pockets",
sortOrder: 2000,
status: "researching",
})
.run();
db.insert(schema.threadCandidates)
.values({
threadId: activeThread.id,
name: "Granite Gear Crown2 38",
weightGrams: 850,
priceCents: 18000,
categoryId: uncategorized.id,
sortOrder: 3000,
status: "ordered",
})
.run();
// ── Seed Resolved Thread ──
const resolvedThread = db
.insert(schema.threads)
.values({
name: "Camp Stove",
status: "resolved",
categoryId: cook.id,
resolvedCandidateId: 1,
})
.returning()
.get();
db.insert(schema.threadCandidates)
.values({
threadId: resolvedThread.id,
name: "BRS-3000T",
weightGrams: 25,
priceCents: 2000,
categoryId: cook.id,
sortOrder: 1000,
status: "arrived",
})
.run();
// ── Seed Setup with Items ──
const setup = db
.insert(schema.setups)
.values({ name: "Weekend Overnighter" })
.returning()
.get();
db.insert(schema.setupItems)
.values([
{ setupId: setup.id, itemId: tent.id, classification: "base" },
{ setupId: setup.id, itemId: quilt.id, classification: "base" },
{ setupId: setup.id, itemId: pad.id, classification: "base" },
{ setupId: setup.id, itemId: stove.id, classification: "consumable" },
])
.run();
// ── Seed User ──
const passwordHash = await Bun.password.hash("password123");
db.insert(schema.users)
.values({ username: "admin", passwordHash })
.run();
// ── Seed Settings ──
db.insert(schema.settings)
.values([
{ key: "weightUnit", value: "g" },
{ key: "currency", value: "USD" },
{ key: "onboardingComplete", value: "true" },
])
.run();
sqlite.close();
console.log("E2E test database seeded at", DB_PATH);
}
```
- [ ] **Step 2: Create global-setup**
Create `e2e/global-setup.ts`:
```ts
import { seedTestDatabase } from "./seed";
export default async function globalSetup() {
await seedTestDatabase();
}
```
- [ ] **Step 3: Verify seed works**
Run: `bun run e2e/global-setup.ts`
Expected: Prints "E2E test database seeded at ./e2e/test.db" and the file exists.
Then clean up: `rm -f e2e/test.db`
- [ ] **Step 4: Commit**
```bash
git add e2e/seed.ts e2e/global-setup.ts
git commit -m "test: add E2E database seed and Playwright global setup"
```
---
### Task 6: E2E Tests — Dashboard and Collection
**Files:**
- Create: `e2e/dashboard.spec.ts`
- Create: `e2e/collection.spec.ts`
- [ ] **Step 1: Create dashboard tests**
Create `e2e/dashboard.spec.ts`:
```ts
import { expect, test } from "@playwright/test";
test.describe("Dashboard", () => {
test("loads and shows summary cards", async ({ page }) => {
await page.goto("/");
await expect(page.locator("text=GearBox")).toBeVisible();
// Should show item count (we seeded 6 items)
await expect(page.locator("text=6")).toBeVisible();
});
test("has navigation to collection", async ({ page }) => {
await page.goto("/");
// Click on a dashboard card or link that goes to collection
const collectionLink = page.locator('a[href*="collection"]').first();
if (await collectionLink.isVisible()) {
await collectionLink.click();
await expect(page).toHaveURL(/collection/);
}
});
});
```
- [ ] **Step 2: Create collection tests**
Create `e2e/collection.spec.ts`:
```ts
import { expect, test } from "@playwright/test";
test.describe("Collection", () => {
test("gear tab shows items grouped by category", async ({ page }) => {
await page.goto("/collection?tab=gear");
// Should see seeded items
await expect(page.locator("text=Zpacks Duplex")).toBeVisible();
await expect(page.locator("text=BRS-3000T Stove")).toBeVisible();
// Should see category headers
await expect(page.locator("text=Shelter")).toBeVisible();
await expect(page.locator("text=Cook Kit")).toBeVisible();
});
test("search filters items by name", async ({ page }) => {
await page.goto("/collection?tab=gear");
const searchInput = page.locator('input[placeholder*="Search"]');
await searchInput.fill("Zpacks");
// Should show matching item
await expect(page.locator("text=Zpacks Duplex")).toBeVisible();
// Should hide non-matching items
await expect(page.locator("text=BRS-3000T Stove")).not.toBeVisible();
});
test("tab switching works", async ({ page }) => {
await page.goto("/collection?tab=gear");
await expect(page.locator("text=Zpacks Duplex")).toBeVisible();
// Switch to planning tab
await page.goto("/collection?tab=planning");
await expect(page.locator("text=Planning Threads")).toBeVisible();
await expect(page.locator("text=New Backpack")).toBeVisible();
// Switch to setups tab
await page.goto("/collection?tab=setups");
await expect(page.locator("text=Weekend Overnighter")).toBeVisible();
});
test("category filter dropdown works", async ({ page }) => {
await page.goto("/collection?tab=gear");
// Open category filter
const filterButton = page.locator("text=All categories");
await filterButton.click();
// Select "Shelter"
await page.locator("li").filter({ hasText: "Shelter" }).click();
// Should show only shelter items
await expect(page.locator("text=Zpacks Duplex")).toBeVisible();
await expect(page.locator("text=BRS-3000T Stove")).not.toBeVisible();
});
});
```
- [ ] **Step 3: Run E2E tests**
Run: `bun run test:e2e`
Expected: All tests pass. If any fail due to selector issues, adjust selectors based on actual DOM.
- [ ] **Step 4: Commit**
```bash
git add e2e/dashboard.spec.ts e2e/collection.spec.ts
git commit -m "test: add E2E tests for dashboard and collection views"
```
---
### Task 7: E2E Tests — Threads, Auth, Error Handling
**Files:**
- Create: `e2e/threads.spec.ts`
- Create: `e2e/auth.spec.ts`
- Create: `e2e/error-handling.spec.ts`
- [ ] **Step 1: Create threads tests**
Create `e2e/threads.spec.ts`:
```ts
import { expect, test } from "@playwright/test";
test.describe("Threads", () => {
test("thread detail page shows candidates", async ({ page }) => {
// Navigate to the active thread
await page.goto("/collection?tab=planning");
await page.locator("text=New Backpack").click();
// Should see candidates
await expect(page.locator("text=ULA Circuit")).toBeVisible();
await expect(page.locator("text=Gossamer Gear Mariposa")).toBeVisible();
await expect(page.locator("text=Granite Gear Crown2 38")).toBeVisible();
});
test("rank badges are visible on candidates", async ({ page }) => {
await page.goto("/collection?tab=planning");
await page.locator("text=New Backpack").click();
// Should see rank badges (gold, silver, bronze for top 3)
// The rank badges use specific colors: #D4AF37 (gold), #C0C0C0 (silver), #CD7F32 (bronze)
await expect(page.locator("text=#1").first()).toBeVisible();
});
test("comparison view toggles on", async ({ page }) => {
await page.goto("/collection?tab=planning");
await page.locator("text=New Backpack").click();
// Find and click the compare toggle
const compareButton = page.locator("button", { hasText: /compare/i });
if (await compareButton.isVisible()) {
await compareButton.click();
// Comparison table should appear with attribute rows
await expect(page.locator("text=Weight")).toBeVisible();
await expect(page.locator("text=Price")).toBeVisible();
}
});
test("resolved thread shows winner", async ({ page }) => {
await page.goto("/collection?tab=planning");
// Switch to resolved tab
await page.locator("button", { hasText: "Resolved" }).click();
await page.locator("text=Camp Stove").click();
// Should indicate resolved state
await expect(page.locator("text=BRS-3000T")).toBeVisible();
});
});
```
- [ ] **Step 2: Create auth tests**
Create `e2e/auth.spec.ts`:
```ts
import { expect, test } from "@playwright/test";
test.describe("Auth", () => {
test("login page renders", async ({ page }) => {
await page.goto("/login");
await expect(page.locator("text=Log in")).toBeVisible();
});
test("login with valid credentials succeeds", async ({ page }) => {
await page.goto("/login");
await page.locator('input[name="username"], input[placeholder*="sername"]').fill("admin");
await page.locator('input[type="password"]').fill("password123");
await page.locator('button[type="submit"]').click();
// Should redirect away from login
await page.waitForURL((url) => !url.pathname.includes("/login"), {
timeout: 5000,
});
});
test("login with wrong password shows error", async ({ page }) => {
await page.goto("/login");
await page.locator('input[name="username"], input[placeholder*="sername"]').fill("admin");
await page.locator('input[type="password"]').fill("wrongpassword");
await page.locator('button[type="submit"]').click();
// Should show error message
await expect(page.locator("text=Invalid credentials").or(page.locator('[role="alert"]'))).toBeVisible({
timeout: 3000,
});
});
});
```
- [ ] **Step 3: Create error handling tests**
Create `e2e/error-handling.spec.ts`:
```ts
import { expect, test } from "@playwright/test";
test.describe("Error handling", () => {
test("non-existent thread shows not found or error", async ({ page }) => {
await page.goto("/threads/99999");
// Should not white-screen — should show some content
const body = page.locator("body");
await expect(body).not.toBeEmpty();
// Either shows error boundary or "not found" text
const hasContent = await page
.locator("text=Something went wrong")
.or(page.locator("text=not found"))
.or(page.locator("text=Not Found"))
.isVisible()
.catch(() => false);
// At minimum, the page should not be blank
const bodyText = await body.textContent();
expect(bodyText?.length).toBeGreaterThan(0);
});
test("non-existent setup shows not found or error", async ({ page }) => {
await page.goto("/setups/99999");
const body = page.locator("body");
await expect(body).not.toBeEmpty();
const bodyText = await body.textContent();
expect(bodyText?.length).toBeGreaterThan(0);
});
test("app recovers from navigation errors", async ({ page }) => {
// Navigate to a bad route, then back to a good one
await page.goto("/threads/99999");
await page.goto("/");
// Dashboard should load fine
await expect(page.locator("text=GearBox")).toBeVisible();
});
});
```
- [ ] **Step 4: Run all E2E tests**
Run: `bun run test:e2e`
Expected: All tests pass.
- [ ] **Step 5: Commit**
```bash
git add e2e/threads.spec.ts e2e/auth.spec.ts e2e/error-handling.spec.ts
git commit -m "test: add E2E tests for threads, auth, and error handling"
```
---
### Task 8: Final Verification
- [ ] **Step 1: Run unit tests**
Run: `bun test`
Expected: All tests pass (previous 183 + new parseId + rate limiter + param routes).
- [ ] **Step 2: Run E2E tests**
Run: `bun run test:e2e`
Expected: All E2E tests pass.
- [ ] **Step 3: Run lint**
Run: `bun run lint`
Expected: Clean.

View File

@@ -0,0 +1,74 @@
# Code Quality Improvements (Round 2) Design
**Date:** 2026-04-03
**Scope:** Combined formatters hook, test helper schema generation, stale todo cleanup
## 1. useFormatters Combined Hook
**Problem:** 14 component files import the same 3-4 lines: `useWeightUnit`, `useCurrency`, `formatWeight`, `formatPrice`. This is repetitive boilerplate.
**Solution:** Create `src/client/hooks/useFormatters.ts` that returns pre-bound formatting functions:
```ts
export function useFormatters() {
const unit = useWeightUnit();
const currency = useCurrency();
return {
weight: (grams: number | null) => formatWeight(grams, unit),
price: (cents: number | null) => formatPrice(cents, currency),
unit,
currency,
};
}
```
**Consumer files to update (14):**
- CollectionView.tsx
- setups/$setupId.tsx
- routes/index.tsx
- WeightSummaryCard.tsx
- TotalsBar.tsx
- settings.tsx
- ThreadCard.tsx
- SetupCard.tsx
- ItemPicker.tsx
- ItemCard.tsx
- ComparisonTable.tsx
- CandidateCard.tsx
- CandidateListItem.tsx
- CategoryHeader.tsx
Each file replaces 3-4 imports + 2 hook calls with 1 import + 1 destructured hook call. Components that need raw `unit` or `currency` (e.g., WeightSummaryCard uses `unit` as a type, TotalsBar has a unit toggle) get them from the return object.
## 2. Test Helper Schema Generation
**Problem:** `tests/helpers/db.ts` has 120 lines of hand-written CREATE TABLE SQL that must manually mirror `src/db/schema.ts`. Any schema change requires updating both files — a known source of `SqliteError: no such column` failures.
**Solution:** Replace hand-written SQL with Drizzle's migration runner:
```ts
import { migrate } from "drizzle-orm/bun-sqlite/migrator";
export function createTestDb() {
const sqlite = new Database(":memory:");
sqlite.run("PRAGMA foreign_keys = ON");
const db = drizzle(sqlite, { schema });
migrate(db, { migrationsFolder: "./drizzle" });
db.insert(schema.categories).values({ name: "Uncategorized", icon: "package" }).run();
return db;
}
```
This reduces the file from ~128 lines to ~15 lines and eliminates all future manual sync.
## 3. Stale Todo Cleanup
**Problem:** Pending todo "Replace planning category filter select with icon-aware dropdown" from 2026-03-15 is already resolved — `PlanningView.tsx` uses `<CategoryFilterDropdown>` which renders Lucide icons.
**Solution:** Move the todo file from `pending/` to `done/`.
## Commit Strategy
1. **useFormatters hook** — create hook + update all 14 consumer files
2. **Test helper migration** — replace hand-written SQL with migrate()
3. **Todo cleanup** — move stale todo to done

View File

@@ -0,0 +1,152 @@
# Codebase Improvements Design
**Date:** 2026-04--03
**Scope:** General code quality, error handling, resilience, and maintainability improvements
## 1. Server Hardening
### 1a. Explicit DB Context Middleware
**File:** `src/server/index.ts`
Add middleware that explicitly sets `c.set("db", prodDb)` for all API routes. Currently routes call `c.get("db")` but nothing sets it in production — services silently fall back to `prodDb` via default parameters. This makes production behavior match the test pattern.
```ts
import { db as prodDb } from "../db/index.ts";
app.use("/api/*", async (c, next) => {
c.set("db", prodDb);
return next();
});
```
Place this **before** the auth middleware so `db` is available when auth checks run.
### 1b. Route Parameter Validation
**New file:** `src/server/lib/params.ts`
Create a helper that validates numeric route params:
```ts
export function parseId(raw: string): number | null {
const id = Number(raw);
if (!Number.isInteger(id) || id <= 0) return null;
return id;
}
```
Update all route files (`items.ts`, `threads.ts`, `categories.ts`, `setups.ts`) to replace `Number(c.req.param("id"))` with `parseId()`, returning 400 for invalid IDs.
### 1c. Centralized Error Handling
**File:** `src/server/index.ts`
Add Hono's `onError` handler:
```ts
app.onError((err, c) => {
console.error(`[${c.req.method}] ${c.req.path}:`, err);
const status = err instanceof HTTPException ? err.status : 500;
const message = process.env.NODE_ENV === "production"
? "Internal server error"
: err.message;
return c.json({ error: message }, status);
});
```
### 1d. Auth Comment Fix
**File:** `src/server/index.ts`
Change comment from:
```
// Auth middleware for write operations (POST/PUT/DELETE) on non-auth routes
```
To:
```
// Auth middleware for write operations (POST/PUT/PATCH/DELETE) on non-auth routes
```
### 1e. Rate Limiting on Auth Endpoints
**New file:** `src/server/middleware/rateLimit.ts`
In-memory rate limiter using a `Map<string, { count: number; resetAt: number }>`:
- Tracks by IP (`c.req.header("x-forwarded-for") || "unknown"`)
- 5 attempts per 15-minute window
- Returns 429 with `{ error: "Too many attempts. Try again later." }` and `Retry-After` header
- Stale entries cleaned on each check
- Applied to `POST /api/auth/login` and `POST /api/auth/setup`
## 2. Client Resilience
### Error Boundary
**File:** `src/client/routes/__root.tsx`
Add `errorComponent` to the root route definition:
```ts
export const Route = createRootRoute({
component: RootLayout,
errorComponent: RootErrorBoundary,
});
```
`RootErrorBoundary` renders a centered error message with:
- "Something went wrong" heading
- Error message in dev mode
- "Try again" button that calls `router.invalidate()` + `reset()`
Uses TanStack Router's `ErrorComponentProps` which provides `error` and `reset`.
## 3. Client Refactor
### Split collection/index.tsx
Extract the three tab-level functions into separate component files:
| Source function | New file | Approx lines |
|----------------|----------|-------------|
| `CollectionView()` | `src/client/components/CollectionView.tsx` | ~260 |
| `PlanningView()` | `src/client/components/PlanningView.tsx` | ~190 |
| `SetupsView()` | `src/client/components/SetupsView.tsx` | ~110 |
`collection/index.tsx` keeps:
- Route definition with `searchSchema` and `validateSearch`
- `CollectionPage` function (tab switcher + AnimatePresence)
- `TAB_ORDER` and `slideVariants` constants
- Imports from the three new component files
Each extracted component is a named export, self-contained with its own hooks and local state.
## 4. Docs Cleanup
### PROJECT.md
**File:** `.planning/PROJECT.md`
Update Constraints section line:
```
- **Scope**: No auth, single user for v1
```
To:
```
- **Scope**: Single user with cookie/API key auth
```
## Commit Strategy
Group into 3-4 commits by area:
1. **Server hardening**: DB middleware, param validation, error handler, rate limiter, comment fix
2. **Client resilience + refactor**: Error boundary, split collection route
3. **Docs cleanup**: PROJECT.md update
## Testing
- All 183 existing tests must continue to pass
- Rate limiter: manual verification (no automated test needed for in-memory rate limiting in a single-user app)
- Error boundary: manual verification by triggering a render error
- Param validation: existing route tests cover happy paths; invalid IDs are a new edge case but won't break existing tests

View File

@@ -0,0 +1,128 @@
# Testing Improvements Design
**Date:** 2026-04-03
**Scope:** Unit tests for new server code + Playwright E2E test setup with seeded database
## Part 1: Unit/Integration Tests (Bun test runner)
### tests/lib/params.test.ts
Tests for `parseId` helper in `src/server/lib/params.ts`:
- Valid positive integers (1, 42, 999) return the number
- Zero returns null
- Negative numbers (-1, -100) return null
- Decimals (1.5, 3.14) return null
- Non-numeric strings ("abc", "", "hello") return null
- NaN-producing values return null
### tests/middleware/rateLimit.test.ts
Tests for rate limiter in `src/server/middleware/rateLimit.ts`:
- First request passes through (200)
- 5 requests succeed, 6th returns 429
- 429 response includes `Retry-After` header
- Different IPs tracked independently
- After window expires, requests succeed again
Since the rate limiter uses a module-level `Map`, tests need to either:
- Reset the store between tests (export a `resetStore` for testing), OR
- Use unique paths/IPs per test to avoid interference
Recommended: export a `_resetForTesting()` function from rateLimit.ts that clears the store. Only used in tests.
### tests/routes/params.test.ts
Route-level integration tests verifying 400 responses for invalid IDs:
- `GET /api/items/abc` → 400
- `GET /api/items/-1` → 400
- `GET /api/items/0` → 400
- `DELETE /api/categories/notanumber` → 400
- `GET /api/threads/abc` → 400
- `GET /api/setups/abc` → 400
Uses existing test app pattern with in-memory DB.
## Part 2: Playwright E2E Setup
### Installation
- `bun add -d @playwright/test`
- `bunx playwright install chromium` (only Chromium needed)
### Configuration: playwright.config.ts
```ts
export default defineConfig({
testDir: "./e2e",
webServer: {
command: "DATABASE_PATH=./e2e/test.db bun run dev:server",
port: 3000,
reuseExistingServer: !process.env.CI,
},
use: {
baseURL: "http://localhost:3000",
},
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
});
```
### Database Seeding: e2e/seed.ts
Script that creates `e2e/test.db` with:
- Run Drizzle migrations against the file
- Seed data:
- 1 user (username: "admin", password: "password123")
- 3 categories: Shelter, Sleep System, Cook Kit
- 6 items across categories with realistic weights/prices
- 1 active thread with 3 candidates (with pros/cons, sort_order)
- 1 resolved thread
- 1 setup with 4 items (mixed classifications)
- Settings: weightUnit=g, currency=USD, onboardingComplete=true
Run before E2E tests via `e2e/global-setup.ts` (Playwright globalSetup).
### E2E Test Files
**e2e/dashboard.spec.ts**
- Dashboard page loads
- Summary cards show item count, weight, cost
- Navigation links to collection work
**e2e/collection.spec.ts**
- Gear tab renders items grouped by category
- Search input filters items by name
- Category filter dropdown works
- Tab switching between gear/planning/setups
**e2e/threads.spec.ts**
- Thread detail page loads with candidates
- Comparison view toggle works (shows table)
- Rank badges visible on candidates
**e2e/auth.spec.ts**
- Login page renders
- Login with valid credentials succeeds
- Login with wrong password shows error
- Rate limiting returns error after 5 attempts
**e2e/error-boundary.spec.ts**
- App doesn't white-screen on unknown routes
- Navigating to a non-existent thread/setup shows appropriate error
### Scripts
Add to package.json:
- `"test:e2e": "bunx playwright test"`
- `"test:e2e:ui": "bunx playwright test --ui"` (for debugging)
### Files to .gitignore
- `e2e/test.db`
- `test-results/`
- `playwright-report/`
## Commit Strategy
1. Unit tests for parseId, rate limiter, route params
2. Playwright setup (install, config, seed, global-setup)
3. Playwright E2E test files

View File

@@ -0,0 +1,114 @@
# v1.4 Collection Tools Design
**Date:** 2026-04-03
**Milestone:** v1.4 Collection Tools
**Scope:** Setup impact preview, item quantity, CSV import/export, item duplication
## Feature 1: Setup Impact Preview
Already fully designed in `.planning/phases/13-setup-impact-preview/`. Two plans exist:
- **13-01**: Pure `computeImpactDeltas` function + `useImpactDeltas` hook + uiStore state (TDD)
- **13-02**: `SetupImpactSelector` + `ImpactDeltaBadge` components wired into thread detail
Execute the existing plans as-is. No design changes needed.
## Feature 2: Item Quantity
### Schema
Add `quantity INTEGER NOT NULL DEFAULT 1` to `items` table via Drizzle migration.
```ts
quantity: integer("quantity").notNull().default(1),
```
### Validation
Add to `createItemSchema` in `src/shared/schemas.ts`:
```ts
quantity: z.number().int().positive().optional(),
```
Flows to `updateItemSchema` via `.partial()` automatically.
### Service Layer
No special business logic — quantity is a stored field.
**Totals computation changes:**
- `totals.service.ts`: `getCategoryTotals()` and `getGlobalTotals()` must multiply `weightGrams * quantity` and `priceCents * quantity` in their SQL SUM aggregations.
- `setup.service.ts`: `getSetupWithItems()` and `getAllSetups()` — when computing setup totals, multiply item weight/price by the item's quantity.
### UI
- **ItemForm**: Number input for quantity (min=1), placed below price field. Defaults to 1.
- **ItemCard**: Show "x2" badge next to item name when quantity > 1. No badge when quantity is 1.
- **Totals**: Already computed server-side with the quantity multiplication. No client-side changes for totals.
- **Setup weight/cost**: The item's quantity determines its weight/cost contribution when included in a setup (one `setup_items` row, but totals reflect quantity).
### Thread Resolution
When a thread is resolved and a candidate is copied to an item, the new item gets `quantity: 1` (default). No special handling needed.
## Feature 3: CSV Import/Export
### Export
**Endpoint:** `GET /api/items/export`
- Returns CSV with headers: `name,quantity,weightGrams,priceCents,category,notes,productUrl`
- `Content-Type: text/csv`
- `Content-Disposition: attachment; filename="gearbox-export.csv"`
- Weight in grams, price in cents (raw values, no formatting)
- Category column contains category name (not ID)
**Service:** `exportItemsCsv(db)` returns a CSV string. Joins items with categories for name lookup.
### Import
**Endpoint:** `POST /api/items/import`
- Accepts multipart form upload (CSV file)
- Parses rows, validates required fields (name is required, others optional)
- Category matching: looks up by name (case-insensitive). Creates new category if not found.
- Quantity defaults to 1 if not present in CSV
- Returns `{ imported: number, created_categories: string[], errors: string[] }`
- Skips rows with errors, continues processing remaining rows
**Service:** `importItemsCsv(db, csvContent: string)` parses and inserts items.
### UI
Settings page gets an "Import/Export" section:
- "Export CSV" button — triggers download via `GET /api/items/export`
- "Import CSV" file input — accepts .csv files, shows count of parsed rows, confirm button to upload
- Success/error feedback after import completes
## Feature 4: Item Duplication
### API
**Endpoint:** `POST /api/items/:id/duplicate`
- Copies all fields from source item: name, weightGrams, priceCents, categoryId, notes, productUrl, imageFilename, imageSourceUrl, quantity
- Appends " (copy)" to the name
- New `createdAt`/`updatedAt` timestamps
- Returns the new item
**Service:** `duplicateItem(db, id)` — fetches source item, inserts copy, returns new item.
### UI
- Add "Duplicate" action to ItemCard (alongside existing edit/delete actions)
- Duplicating opens the edit panel pre-filled with the new item so the user can rename or adjust
## Phase Ordering
1. **Item Quantity** — schema change first since CSV import/export and totals depend on it
2. **Setup Impact Preview** — execute existing Phase 13 plans
3. **Item Duplication** — small, self-contained
4. **CSV Import/Export** — depends on quantity field existing in schema
## Out of Scope
- Quantity per setup (setup_items.quantity) — items table quantity is sufficient for v1.4
- CSV export with formatted weights/prices — raw values are more portable
- Image export/import via CSV — images are local files, not CSV-compatible
- Bulk edit from CSV preview — import creates, doesn't update existing items

View File

@@ -0,0 +1 @@
ALTER TABLE `items` ADD `quantity` integer DEFAULT 1 NOT NULL;

View File

@@ -0,0 +1,663 @@
{
"version": "6",
"dialect": "sqlite",
"id": "ede9f482-7af0-42bc-9672-43f5fba289d0",
"prevId": "738e67c5-ebad-46c1-9261-6ab60ec4bdb1",
"tables": {
"api_keys": {
"name": "api_keys",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"key_hash": {
"name": "key_hash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"key_prefix": {
"name": "key_prefix",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"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
},
"icon": {
"name": "icon",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'package'"
},
"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
},
"image_source_url": {
"name": "image_source_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"quantity": {
"name": "quantity",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 1
},
"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": {}
},
"sessions": {
"name": "sessions",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"sessions_user_id_users_id_fk": {
"name": "sessions_user_id_users_id_fk",
"tableFrom": "sessions",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"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
},
"classification": {
"name": "classification",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'base'"
}
},
"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
},
"image_source_url": {
"name": "image_source_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'researching'"
},
"pros": {
"name": "pros",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"cons": {
"name": "cons",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"sort_order": {
"name": "sort_order",
"type": "real",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"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": {}
},
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"password_hash": {
"name": "password_hash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"users_username_unique": {
"name": "users_username_unique",
"columns": [
"username"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -57,6 +57,13 @@
"when": 1775215076284,
"tag": "0007_icy_prodigy",
"breakpoints": true
},
{
"idx": 8,
"version": "6",
"when": 1775232090363,
"tag": "0008_loving_colossus",
"breakpoints": true
}
]
}

47
e2e/auth.spec.ts Normal file
View File

@@ -0,0 +1,47 @@
import { expect, test } from "@playwright/test";
test.describe("Authentication", () => {
test("login page renders at /login", async ({ page }) => {
await page.goto("/login");
await page.waitForLoadState("networkidle");
// Should show the Sign In heading
await expect(page.getByRole("heading", { name: "Sign In" })).toBeVisible({
timeout: 5000,
});
// Should have username and password inputs
await expect(page.locator("#username")).toBeVisible({ timeout: 5000 });
await expect(page.locator("#password")).toBeVisible({ timeout: 5000 });
});
test("login with valid credentials succeeds and redirects away from /login", async ({
page,
}) => {
await page.goto("/login");
await page.waitForLoadState("networkidle");
await page.locator("#username").fill("admin");
await page.locator("#password").fill("password123");
await page.getByRole("button", { name: "Sign In" }).click();
// After successful login, should redirect to / (dashboard)
await page.waitForURL("/", { timeout: 5000 });
await expect(page).not.toHaveURL(/\/login/);
await expect(page.getByText("GearBox")).toBeVisible({ timeout: 5000 });
});
test("login with wrong password shows error", async ({ page }) => {
await page.goto("/login");
await page.waitForLoadState("networkidle");
await page.locator("#username").fill("admin");
await page.locator("#password").fill("wrongpassword");
await page.getByRole("button", { name: "Sign In" }).click();
// Should stay on the login page and show an error message
await expect(page).toHaveURL(/\/login/, { timeout: 5000 });
// The error paragraph should be visible (login.tsx renders <p className="text-sm text-red-600">{error}</p>)
await expect(page.locator(".text-red-600")).toBeVisible({ timeout: 5000 });
});
});

89
e2e/collection.spec.ts Normal file
View File

@@ -0,0 +1,89 @@
import { expect, test } from "@playwright/test";
test.describe("Collection page", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/collection");
await page.waitForLoadState("networkidle");
});
test.describe("Gear tab", () => {
test("shows seeded items", async ({ page }) => {
await expect(page.getByText("Zpacks Duplex")).toBeVisible();
await expect(page.getByText("BRS-3000T Stove")).toBeVisible();
});
test("search filters items by name", async ({ page }) => {
const searchInput = page.getByPlaceholder("Search items...");
await searchInput.fill("Zpacks");
await expect(page.getByText("Zpacks Duplex")).toBeVisible();
// Other items should not be visible
await expect(page.getByText("BRS-3000T Stove")).not.toBeVisible();
});
test("clearing search restores all items", async ({ page }) => {
const searchInput = page.getByPlaceholder("Search items...");
await searchInput.fill("Zpacks");
await expect(page.getByText("BRS-3000T Stove")).not.toBeVisible();
// Clear the search
await searchInput.clear();
await expect(page.getByText("BRS-3000T Stove")).toBeVisible();
});
test("category filter dropdown opens and lists categories", async ({
page,
}) => {
const filterButton = page.getByRole("button", {
name: /all categories/i,
});
await filterButton.click();
// Dropdown list (ul) contains the category options
const dropdown = page.locator("ul");
await expect(
dropdown.getByRole("button", { name: "Shelter" }),
).toBeVisible();
await expect(
dropdown.getByRole("button", { name: "Cook Kit" }),
).toBeVisible();
});
test("category filter shows only items in selected category", async ({
page,
}) => {
// Open filter dropdown
const filterButton = page.getByRole("button", {
name: /all categories/i,
});
await filterButton.click();
// Select "Shelter" from the dropdown list
const dropdown = page.locator("ul");
await dropdown.getByRole("button", { name: "Shelter" }).click();
await expect(page.getByText("Zpacks Duplex")).toBeVisible();
// Items from other categories should not be visible
await expect(page.getByText("BRS-3000T Stove")).not.toBeVisible();
});
});
test.describe("Tab switching", () => {
test("navigates to planning tab", async ({ page }) => {
await page.goto("/collection?tab=planning");
await page.waitForLoadState("networkidle");
// Planning tab shows the active thread
await expect(page.getByText("New Backpack")).toBeVisible();
});
test("navigates to setups tab", async ({ page }) => {
await page.goto("/collection?tab=setups");
await page.waitForLoadState("networkidle");
// Setups tab shows the seeded setup
await expect(page.getByText("Weekend Overnighter")).toBeVisible();
});
test("gear tab is default and shows items", async ({ page }) => {
// Default tab (no ?tab param) shows gear
await expect(page.getByText("Zpacks Duplex")).toBeVisible();
});
});
});

56
e2e/dashboard.spec.ts Normal file
View File

@@ -0,0 +1,56 @@
import { expect, test } from "@playwright/test";
test.describe("Dashboard", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/");
await page.waitForLoadState("networkidle");
});
test("shows GearBox heading", async ({ page }) => {
await expect(page.getByText("GearBox")).toBeVisible();
});
test("shows collection card with item count of 6", async ({ page }) => {
// The Collection card link contains "Items" label and value "6"
const collectionCard = page
.getByRole("link", { name: /collection/i })
.first();
await expect(collectionCard).toBeVisible();
await expect(collectionCard.getByText("6")).toBeVisible();
});
test("shows Collection, Planning, and Setups card headings", async ({
page,
}) => {
await expect(
page.getByRole("heading", { name: "Collection" }),
).toBeVisible();
await expect(page.getByRole("heading", { name: "Planning" })).toBeVisible();
await expect(page.getByRole("heading", { name: "Setups" })).toBeVisible();
});
test("Collection card links to /collection", async ({ page }) => {
const collectionLink = page
.getByRole("link", { name: /collection/i })
.first();
await collectionLink.click();
await page.waitForLoadState("networkidle");
await expect(page).toHaveURL(/\/collection/);
});
test("shows active thread count on Planning card", async ({ page }) => {
// The Planning card is a link containing "Active threads"
const planningCard = page.getByRole("link", { name: /planning/i });
await expect(planningCard.getByText("Active threads")).toBeVisible();
// Seed has 1 active thread
await expect(planningCard.getByText("1")).toBeVisible();
});
test("shows setup count on Setups card", async ({ page }) => {
// The Setups card has a heading "Setups"
await expect(page.getByRole("heading", { name: "Setups" })).toBeVisible();
// Seed has 1 setup
const setupsCard = page.getByRole("link", { name: /setups/i }).last();
await expect(setupsCard.getByText("1")).toBeVisible();
});
});

View File

@@ -0,0 +1,59 @@
import { expect, test } from "@playwright/test";
test.describe("Error handling — non-existent routes", () => {
test("non-existent thread does not white-screen", async ({ page }) => {
await page.goto("/threads/99999");
// React Query retries failed requests (default 3 times with backoff) before
// setting isError=true. Wait for the page to leave loading state and show content.
// The thread detail page renders "Thread not found" + "Back to planning" link on error.
await expect(page.getByText("Back to planning")).toBeVisible({
timeout: 30000,
});
await expect(page.getByText("Thread not found")).toBeVisible({
timeout: 5000,
});
});
test("non-existent setup does not white-screen", async ({ page }) => {
await page.goto("/setups/99999");
await page.waitForLoadState("networkidle");
// Setup detail shows "Setup not found." when data is null (no retry — isLoading=false, data=undefined)
// The setup query resolves with undefined rather than throwing for missing items.
// Check that the page has content (not a blank screen).
const body = page.locator("body");
await expect(body).not.toBeEmpty();
// Navigation header should be visible (app did not crash)
await expect(page.getByText("GearBox")).toBeVisible({ timeout: 5000 });
// Wait for setup data to load; it will show "Setup not found." when done
await expect(page.getByText("Setup not found.")).toBeVisible({
timeout: 30000,
});
});
test("app recovers after bad route — dashboard loads fine", async ({
page,
}) => {
// Navigate to a non-existent thread first
await page.goto("/threads/99999");
// The page should render without crashing — wait for the error state
// (React Query retries before showing isError state, so use a long timeout)
await expect(page.getByText("Back to planning")).toBeVisible({
timeout: 30000,
});
// Now navigate to the dashboard
await page.goto("/");
await page.waitForLoadState("networkidle");
// Dashboard should load normally
await expect(page.getByText("GearBox")).toBeVisible({ timeout: 5000 });
await expect(page.getByRole("heading", { name: "Collection" })).toBeVisible(
{ timeout: 5000 },
);
});
});

10
e2e/global-setup.ts Normal file
View File

@@ -0,0 +1,10 @@
import { seedTestDatabase } from "./seed";
export default async function globalSetup() {
await seedTestDatabase();
}
// Allow direct invocation: bun run e2e/global-setup.ts
if (import.meta.main) {
await globalSetup();
}

220
e2e/seed.ts Normal file
View File

@@ -0,0 +1,220 @@
import { Database } from "bun:sqlite";
import { unlink } from "node:fs/promises";
import { drizzle } from "drizzle-orm/bun-sqlite";
import { migrate } from "drizzle-orm/bun-sqlite/migrator";
import * as schema from "../src/db/schema";
const DB_PATH = "./e2e/test.db";
export async function seedTestDatabase() {
// Remove old test DB if it exists
try {
await unlink(DB_PATH);
} catch {
// File doesn't exist, that's fine
}
const sqlite = new Database(DB_PATH);
sqlite.run("PRAGMA journal_mode = WAL");
sqlite.run("PRAGMA foreign_keys = ON");
const db = drizzle(sqlite, { schema });
migrate(db, { migrationsFolder: "./drizzle" });
// ── Categories ──
const [uncategorized] = db
.insert(schema.categories)
.values({ name: "Uncategorized", icon: "package" })
.returning()
.all();
const [shelter] = db
.insert(schema.categories)
.values({ name: "Shelter", icon: "tent" })
.returning()
.all();
const [sleep] = db
.insert(schema.categories)
.values({ name: "Sleep System", icon: "moon" })
.returning()
.all();
const [cook] = db
.insert(schema.categories)
.values({ name: "Cook Kit", icon: "flame" })
.returning()
.all();
// ── Items ──
const tent = db
.insert(schema.items)
.values({
name: "Zpacks Duplex",
weightGrams: 539,
priceCents: 67900,
categoryId: shelter.id,
notes: "DCF shelter, 2-person",
})
.returning()
.get();
db.insert(schema.items)
.values({
name: "Borah Gear Tarp",
weightGrams: 156,
priceCents: 11000,
categoryId: shelter.id,
})
.run();
const quilt = db
.insert(schema.items)
.values({
name: "Enlightened Equipment Enigma 20",
weightGrams: 595,
priceCents: 34000,
categoryId: sleep.id,
notes: "20F quilt",
})
.returning()
.get();
const pad = db
.insert(schema.items)
.values({
name: "Therm-a-Rest NeoAir XLite",
weightGrams: 354,
priceCents: 20999,
categoryId: sleep.id,
})
.returning()
.get();
const stove = db
.insert(schema.items)
.values({
name: "BRS-3000T Stove",
weightGrams: 25,
priceCents: 2000,
categoryId: cook.id,
})
.returning()
.get();
db.insert(schema.items)
.values({
name: "Toaks 750ml Pot",
weightGrams: 103,
priceCents: 3000,
categoryId: cook.id,
})
.run();
// ── Active Thread with 3 Candidates ──
const activeThread = db
.insert(schema.threads)
.values({
name: "New Backpack",
status: "active",
categoryId: uncategorized.id,
})
.returning()
.get();
db.insert(schema.threadCandidates)
.values({
threadId: activeThread.id,
name: "ULA Circuit",
weightGrams: 1077,
priceCents: 27500,
categoryId: uncategorized.id,
pros: "Great hip belt\nLarge capacity",
cons: "Heavier than competitors",
sortOrder: 1000,
status: "researching",
})
.run();
db.insert(schema.threadCandidates)
.values({
threadId: activeThread.id,
name: "Gossamer Gear Mariposa",
weightGrams: 737,
priceCents: 28500,
categoryId: uncategorized.id,
pros: "Very lightweight\nGood ventilation",
cons: "Smaller hip belt pockets",
sortOrder: 2000,
status: "researching",
})
.run();
db.insert(schema.threadCandidates)
.values({
threadId: activeThread.id,
name: "Granite Gear Crown2 38",
weightGrams: 850,
priceCents: 18000,
categoryId: uncategorized.id,
sortOrder: 3000,
status: "ordered",
})
.run();
// ── Resolved Thread ──
const resolvedThread = db
.insert(schema.threads)
.values({
name: "Camp Stove",
status: "resolved",
categoryId: cook.id,
resolvedCandidateId: 1,
})
.returning()
.get();
db.insert(schema.threadCandidates)
.values({
threadId: resolvedThread.id,
name: "BRS-3000T",
weightGrams: 25,
priceCents: 2000,
categoryId: cook.id,
sortOrder: 1000,
status: "arrived",
})
.run();
// ── Setup with Items ──
const setup = db
.insert(schema.setups)
.values({ name: "Weekend Overnighter" })
.returning()
.get();
db.insert(schema.setupItems)
.values([
{ setupId: setup.id, itemId: tent.id, classification: "base" },
{ setupId: setup.id, itemId: quilt.id, classification: "base" },
{ setupId: setup.id, itemId: pad.id, classification: "base" },
{ setupId: setup.id, itemId: stove.id, classification: "consumable" },
])
.run();
// ── User ──
const passwordHash = await Bun.password.hash("password123");
db.insert(schema.users).values({ username: "admin", passwordHash }).run();
// ── Settings ──
db.insert(schema.settings)
.values([
{ key: "weightUnit", value: "g" },
{ key: "currency", value: "USD" },
{ key: "onboardingComplete", value: "true" },
])
.run();
sqlite.close();
console.log("E2E test database seeded at", DB_PATH);
}

4
e2e/start-test-server.sh Executable file
View File

@@ -0,0 +1,4 @@
#!/bin/sh
# Seed the test database, then start the production server
bun run e2e/global-setup.ts
NODE_ENV=production DATABASE_PATH=./e2e/test.db bun run src/server/index.ts

113
e2e/threads.spec.ts Normal file
View File

@@ -0,0 +1,113 @@
import { expect, test } from "@playwright/test";
test.describe("Thread detail page", () => {
test("loads with candidates visible", async ({ page }) => {
await page.goto("/collection?tab=planning");
await page.waitForLoadState("networkidle");
// Click the "New Backpack" thread card
await page.getByText("New Backpack").click();
await page.waitForLoadState("networkidle");
// Thread detail page should show the thread name
await expect(
page.getByRole("heading", { name: "New Backpack" }),
).toBeVisible({ timeout: 5000 });
// Candidates should be visible
await expect(page.getByText("ULA Circuit")).toBeVisible({ timeout: 5000 });
await expect(page.getByText("Gossamer Gear Mariposa")).toBeVisible({
timeout: 5000,
});
await expect(page.getByText("Granite Gear Crown2 38")).toBeVisible({
timeout: 5000,
});
});
test("rank badges are visible for top 3 candidates", async ({ page }) => {
await page.goto("/collection?tab=planning");
await page.waitForLoadState("networkidle");
await page.getByText("New Backpack").click();
await page.waitForLoadState("networkidle");
// Rank badges are medal icons — the component renders LucideIcon with name="medal"
// for ranks 1, 2, 3. We can verify via SVG elements or by checking the page has
// the expected number of medal icons (one per top candidate).
// The list view is default, which renders CandidateListItem with RankBadge.
// With 3 candidates all in top 3, all 3 get medal icons.
await expect(page.locator("text=ULA Circuit")).toBeVisible({
timeout: 5000,
});
await expect(page.locator("text=Gossamer Gear Mariposa")).toBeVisible({
timeout: 5000,
});
await expect(page.locator("text=Granite Gear Crown2 38")).toBeVisible({
timeout: 5000,
});
// Verify candidates are rendered (rank badges are SVG medals, not text)
// Check that at least the 3 candidates are present in the list
const candidateRows = page.locator(".bg-white.rounded-xl.border");
await expect(candidateRows).toHaveCount(3, { timeout: 5000 });
});
test("comparison view toggle works", async ({ page }) => {
await page.goto("/collection?tab=planning");
await page.waitForLoadState("networkidle");
await page.getByText("New Backpack").click();
await page.waitForLoadState("networkidle");
await expect(page.getByText("ULA Circuit")).toBeVisible({ timeout: 5000 });
// The compare button is a button with title="Compare view" (icon button with columns-3 icon)
const compareButton = page.getByRole("button", { name: "Compare view" });
await expect(compareButton).toBeVisible({ timeout: 5000 });
await compareButton.click();
// After clicking, a table should appear with Weight and Price row labels
await expect(page.locator("table")).toBeVisible({ timeout: 5000 });
// The comparison table renders row labels in sticky <td> cells (exact match to avoid
// matching candidate notes that contain the word "weight" or "price")
await expect(
page.getByRole("cell", { name: "Weight", exact: true }),
).toBeVisible({ timeout: 5000 });
await expect(
page.getByRole("cell", { name: "Price", exact: true }),
).toBeVisible({ timeout: 5000 });
// All 3 candidates should appear as table column headers (in <thead>)
await expect(page.locator("thead").getByText("ULA Circuit")).toBeVisible({
timeout: 5000,
});
await expect(
page.locator("thead").getByText("Gossamer Gear Mariposa"),
).toBeVisible({ timeout: 5000 });
});
test("resolved thread shows winner banner", async ({ page }) => {
await page.goto("/collection?tab=planning");
await page.waitForLoadState("networkidle");
// Click the "Resolved" tab pill button in the planning view
await page.getByRole("button", { name: "Resolved" }).click();
await page.waitForLoadState("networkidle");
// Camp Stove resolved thread should appear
await expect(page.getByText("Camp Stove")).toBeVisible({ timeout: 5000 });
// Click the Camp Stove thread card and wait for URL to change to thread detail
await page.getByText("Camp Stove").click();
await page.waitForURL(/\/threads\/\d+/, { timeout: 5000 });
await page.waitForLoadState("networkidle");
// Should show the resolved thread heading
await expect(page.getByRole("heading", { name: "Camp Stove" })).toBeVisible(
{ timeout: 5000 },
);
// The winner candidate (BRS-3000T) should be visible in the candidate list
await expect(page.getByText("BRS-3000T")).toBeVisible({ timeout: 5000 });
});
});

View File

@@ -10,11 +10,14 @@
"build": "vite build",
"db:generate": "bunx drizzle-kit generate",
"db:push": "bunx drizzle-kit push",
"test": "bun test",
"test": "bun test tests/",
"test:e2e": "bunx playwright test",
"test:e2e:ui": "bunx playwright test --ui",
"lint": "bunx @biomejs/biome check ."
},
"devDependencies": {
"@biomejs/biome": "^2.4.7",
"@playwright/test": "^1.59.1",
"@tanstack/react-query-devtools": "^5.91.3",
"@tanstack/react-router-devtools": "^1.166.7",
"@tanstack/router-plugin": "^1.166.9",

25
playwright.config.ts Normal file
View File

@@ -0,0 +1,25 @@
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./e2e",
fullyParallel: false,
retries: 0,
workers: 1,
reporter: "list",
use: {
baseURL: "http://localhost:3000",
trace: "on-first-retry",
},
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
],
webServer: {
command: "sh e2e/start-test-server.sh",
port: 3000,
reuseExistingServer: !process.env.CI,
timeout: 30000,
},
});

View File

@@ -1,9 +1,9 @@
import { useCurrency } from "../hooks/useCurrency";
import { useWeightUnit } from "../hooks/useWeightUnit";
import { formatPrice, formatWeight } from "../lib/formatters";
import { useFormatters } from "../hooks/useFormatters";
import type { CandidateDelta } from "../hooks/useImpactDeltas";
import { LucideIcon } from "../lib/iconData";
import { useUIStore } from "../stores/uiStore";
import { RankBadge } from "./CandidateListItem";
import { ImpactDeltaBadge } from "./ImpactDeltaBadge";
import { StatusBadge } from "./StatusBadge";
interface CandidateCardProps {
@@ -22,6 +22,7 @@ interface CandidateCardProps {
pros?: string | null;
cons?: string | null;
rank?: number;
delta?: CandidateDelta;
}
export function CandidateCard({
@@ -40,9 +41,9 @@ export function CandidateCard({
pros,
cons,
rank,
delta,
}: CandidateCardProps) {
const unit = useWeightUnit();
const currency = useCurrency();
const { weight, price } = useFormatters();
const openCandidateEditPanel = useUIStore((s) => s.openCandidateEditPanel);
const openConfirmDeleteCandidate = useUIStore(
(s) => s.openConfirmDeleteCandidate,
@@ -165,14 +166,16 @@ export function CandidateCard({
{rank != null && <RankBadge rank={rank} />}
{weightGrams != null && (
<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, unit)}
{weight(weightGrams)}
</span>
)}
<ImpactDeltaBadge delta={delta} type="weight" formatFn={weight} />
{priceCents != null && (
<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, currency)}
{price(priceCents)}
</span>
)}
<ImpactDeltaBadge delta={delta} type="price" formatFn={price} />
<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}

View File

@@ -1,9 +1,10 @@
import { Reorder, useDragControls } from "framer-motion";
import { useCurrency } from "../hooks/useCurrency";
import { useWeightUnit } from "../hooks/useWeightUnit";
import { formatPrice, formatWeight } from "../lib/formatters";
import { Reorder } from "framer-motion";
import { useRef } from "react";
import { useFormatters } from "../hooks/useFormatters";
import type { CandidateDelta } from "../hooks/useImpactDeltas";
import { LucideIcon } from "../lib/iconData";
import { useUIStore } from "../stores/uiStore";
import { ImpactDeltaBadge } from "./ImpactDeltaBadge";
import { StatusBadge } from "./StatusBadge";
interface CandidateWithCategory {
@@ -30,6 +31,8 @@ interface CandidateListItemProps {
rank: number;
isActive: boolean;
onStatusChange: (status: "researching" | "ordered" | "arrived") => void;
delta?: CandidateDelta;
onDragEnd?: () => void;
}
const RANK_COLORS = ["#D4AF37", "#C0C0C0", "#CD7F32"]; // gold, silver, bronze
@@ -51,10 +54,11 @@ export function CandidateListItem({
rank,
isActive,
onStatusChange,
delta,
onDragEnd,
}: CandidateListItemProps) {
const controls = useDragControls();
const unit = useWeightUnit();
const currency = useCurrency();
const isDragging = useRef(false);
const { weight, price } = useFormatters();
const openCandidateEditPanel = useUIStore((s) => s.openCandidateEditPanel);
const openConfirmDeleteCandidate = useUIStore(
(s) => s.openConfirmDeleteCandidate,
@@ -62,23 +66,16 @@ export function CandidateListItem({
const openResolveDialog = useUIStore((s) => s.openResolveDialog);
const openExternalLink = useUIStore((s) => s.openExternalLink);
return (
<Reorder.Item
value={candidate}
dragControls={controls}
dragListener={false}
className="flex items-center gap-3 bg-white rounded-xl border border-gray-100 p-3 hover:border-gray-200 hover:shadow-sm transition-all group cursor-default"
>
{/* Drag handle */}
const sharedClassName =
"flex items-center gap-3 bg-white rounded-xl border border-gray-100 p-3 hover:border-gray-200 hover:shadow-sm group cursor-default";
const innerContent = (
<>
{/* Drag handle indicator */}
{isActive && (
<button
type="button"
onPointerDown={(e) => controls.start(e)}
className="cursor-grab active:cursor-grabbing text-gray-300 hover:text-gray-500 touch-none shrink-0"
title="Drag to reorder"
>
<span className="text-gray-300 shrink-0">
<LucideIcon name="grip-vertical" size={16} />
</button>
</span>
)}
{/* Rank badge */}
@@ -104,7 +101,10 @@ export function CandidateListItem({
{/* Name + badges */}
<button
type="button"
onClick={() => openCandidateEditPanel(candidate.id)}
onClick={() => {
if (isDragging.current) return;
openCandidateEditPanel(candidate.id);
}}
className="flex-1 min-w-0 text-left"
>
<p className="text-sm font-semibold text-gray-900 truncate">
@@ -113,14 +113,16 @@ export function CandidateListItem({
<div className="flex flex-wrap gap-1.5 mt-1">
{candidate.weightGrams != null && (
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-50 text-blue-400">
{formatWeight(candidate.weightGrams, unit)}
{weight(candidate.weightGrams)}
</span>
)}
<ImpactDeltaBadge delta={delta} type="weight" formatFn={weight} />
{candidate.priceCents != null && (
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-50 text-green-500">
{formatPrice(candidate.priceCents, currency)}
{price(candidate.priceCents)}
</span>
)}
<ImpactDeltaBadge delta={delta} type="price" formatFn={price} />
<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={candidate.categoryIcon}
@@ -206,6 +208,31 @@ export function CandidateListItem({
</svg>
</button>
</div>
</>
);
// Reorder.Item requires a Reorder.Group parent — only use it in active threads
if (isActive) {
return (
<Reorder.Item
value={candidate}
onDragStart={() => {
isDragging.current = true;
}}
onDragEnd={() => {
setTimeout(() => {
isDragging.current = false;
}, 0);
onDragEnd?.();
}}
whileDrag={{ cursor: "grabbing" }}
style={{ marginBottom: 8, cursor: "grab" }}
className={sharedClassName}
>
{innerContent}
</Reorder.Item>
);
}
return <div className={sharedClassName}>{innerContent}</div>;
}

View File

@@ -1,8 +1,6 @@
import { useState } from "react";
import { useDeleteCategory, useUpdateCategory } from "../hooks/useCategories";
import { useCurrency } from "../hooks/useCurrency";
import { useWeightUnit } from "../hooks/useWeightUnit";
import { formatPrice, formatWeight } from "../lib/formatters";
import { useFormatters } from "../hooks/useFormatters";
import { LucideIcon } from "../lib/iconData";
import { IconPicker } from "./IconPicker";
@@ -23,8 +21,7 @@ export function CategoryHeader({
totalCost,
itemCount,
}: CategoryHeaderProps) {
const unit = useWeightUnit();
const currency = useCurrency();
const { weight, price } = useFormatters();
const [isEditing, setIsEditing] = useState(false);
const [editName, setEditName] = useState(name);
const [editIcon, setEditIcon] = useState(icon);
@@ -88,8 +85,8 @@ export function CategoryHeader({
<LucideIcon name={icon} size={22} className="text-gray-500" />
<h2 className="text-lg font-semibold text-gray-900">{name}</h2>
<span className="text-sm text-gray-400">
{itemCount} {itemCount === 1 ? "item" : "items"} ·{" "}
{formatWeight(totalWeight, unit)} · {formatPrice(totalCost, currency)}
{itemCount} {itemCount === 1 ? "item" : "items"} · {weight(totalWeight)}{" "}
· {price(totalCost)}
</span>
{!isUncategorized && (
<div className="ml-auto flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">

View File

@@ -0,0 +1,276 @@
import { useMemo, useState } from "react";
import { useCategories } from "../hooks/useCategories";
import { useFormatters } from "../hooks/useFormatters";
import { useItems } from "../hooks/useItems";
import { useTotals } from "../hooks/useTotals";
import { LucideIcon } from "../lib/iconData";
import { useUIStore } from "../stores/uiStore";
import { CategoryFilterDropdown } from "./CategoryFilterDropdown";
import { CategoryHeader } from "./CategoryHeader";
import { ItemCard } from "./ItemCard";
export function CollectionView() {
const { data: items, isLoading: itemsLoading } = useItems();
const { data: totals } = useTotals();
const { data: categories } = useCategories();
const { weight, price } = useFormatters();
const openAddPanel = useUIStore((s) => s.openAddPanel);
const [searchText, setSearchText] = useState("");
const [categoryFilter, setCategoryFilter] = useState<number | null>(null);
const filteredItems = useMemo(() => {
if (!items) return [];
return items.filter((item) => {
const matchesSearch =
searchText === "" ||
item.name.toLowerCase().includes(searchText.toLowerCase());
const matchesCategory =
categoryFilter === null || item.categoryId === categoryFilter;
return matchesSearch && matchesCategory;
});
}, [items, searchText, categoryFilter]);
const hasActiveFilters = searchText !== "" || categoryFilter !== null;
if (itemsLoading) {
return (
<div className="animate-pulse space-y-6">
<div className="h-6 bg-gray-200 rounded w-48" />
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{[1, 2, 3].map((i) => (
<div key={i} className="h-40 bg-gray-200 rounded-xl" />
))}
</div>
</div>
);
}
if (!items || items.length === 0) {
return (
<div className="py-16 text-center">
<div className="max-w-md mx-auto">
<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">
Your collection is empty
</h2>
<p className="text-sm text-gray-500 mb-6">
Start cataloging your gear by adding your first item. Track weight,
price, and organize by category.
</p>
<button
type="button"
onClick={openAddPanel}
className="inline-flex items-center gap-2 px-5 py-2.5 bg-gray-700 hover:bg-gray-800 text-white text-sm font-medium rounded-lg transition-colors"
>
<svg
aria-hidden="true"
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 4v16m8-8H4"
/>
</svg>
Add your first item
</button>
</div>
</div>
);
}
// Build category totals lookup
const categoryTotalsMap = new Map<
number,
{ totalWeight: number; totalCost: number; itemCount: number }
>();
if (totals?.categories) {
for (const ct of totals.categories) {
categoryTotalsMap.set(ct.categoryId, {
totalWeight: ct.totalWeight,
totalCost: ct.totalCost,
itemCount: ct.itemCount,
});
}
}
// Group filtered items by categoryId (used when no active filters)
const groupedItems = new Map<
number,
{
items: typeof filteredItems;
categoryName: string;
categoryIcon: string;
}
>();
for (const item of filteredItems) {
const group = groupedItems.get(item.categoryId);
if (group) {
group.items.push(item);
} else {
groupedItems.set(item.categoryId, {
items: [item],
categoryName: item.categoryName,
categoryIcon: item.categoryIcon,
});
}
}
return (
<>
{/* Collection stats card */}
{totals?.global && (
<div className="bg-white rounded-xl border border-gray-100 p-5 mb-6">
<div className="flex items-center gap-8">
<div className="flex flex-col items-center gap-1">
<LucideIcon name="layers" size={14} className="text-gray-400" />
<span className="text-xs text-gray-500">Items</span>
<span className="text-sm font-semibold text-gray-900">
{totals.global.itemCount}
</span>
</div>
<div className="flex flex-col items-center gap-1">
<LucideIcon name="weight" size={14} className="text-gray-400" />
<span className="text-xs text-gray-500">Total Weight</span>
<span className="text-sm font-semibold text-gray-900">
{weight(totals.global.totalWeight)}
</span>
</div>
<div className="flex flex-col items-center gap-1">
<LucideIcon
name="credit-card"
size={14}
className="text-gray-400"
/>
<span className="text-xs text-gray-500">Total Spent</span>
<span className="text-sm font-semibold text-gray-900">
{price(totals.global.totalCost)}
</span>
</div>
</div>
</div>
)}
{/* Search/filter toolbar */}
<div className="sticky top-0 z-10 bg-gray-50/95 backdrop-blur-sm border-b border-gray-100 -mx-4 px-4 py-3 sm:-mx-6 sm:px-6 lg:-mx-8 lg:px-8 mb-6">
<div className="flex gap-3 items-center">
<div className="relative flex-1">
<input
type="text"
placeholder="Search items..."
value={searchText}
onChange={(e) => setSearchText(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-gray-400 focus:border-transparent"
/>
{searchText && (
<button
type="button"
onClick={() => setSearchText("")}
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
)}
</div>
<CategoryFilterDropdown
value={categoryFilter}
onChange={setCategoryFilter}
categories={categories ?? []}
/>
</div>
{hasActiveFilters && (
<p className="text-xs text-gray-500 mt-2">
Showing {filteredItems.length} of {items.length} items
</p>
)}
</div>
{/* Filtered results */}
{hasActiveFilters ? (
filteredItems.length === 0 ? (
<div className="py-12 text-center">
<p className="text-sm text-gray-500">No items match your search</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filteredItems.map((item) => (
<ItemCard
key={item.id}
id={item.id}
name={item.name}
weightGrams={item.weightGrams}
priceCents={item.priceCents}
quantity={item.quantity}
categoryName={item.categoryName}
categoryIcon={item.categoryIcon}
imageFilename={item.imageFilename}
productUrl={item.productUrl}
/>
))}
</div>
)
) : (
Array.from(groupedItems.entries()).map(
([
categoryId,
{ items: categoryItems, categoryName, categoryIcon },
]) => {
const catTotals = categoryTotalsMap.get(categoryId);
return (
<div key={categoryId} className="mb-8">
<CategoryHeader
categoryId={categoryId}
name={categoryName}
icon={categoryIcon}
totalWeight={catTotals?.totalWeight ?? 0}
totalCost={catTotals?.totalCost ?? 0}
itemCount={catTotals?.itemCount ?? categoryItems.length}
/>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{categoryItems.map((item) => (
<ItemCard
key={item.id}
id={item.id}
name={item.name}
weightGrams={item.weightGrams}
priceCents={item.priceCents}
quantity={item.quantity}
categoryName={categoryName}
categoryIcon={categoryIcon}
imageFilename={item.imageFilename}
productUrl={item.productUrl}
/>
))}
</div>
</div>
);
},
)
)}
</>
);
}

View File

@@ -1,10 +1,10 @@
import { useMemo } from "react";
import { useCurrency } from "../hooks/useCurrency";
import { useWeightUnit } from "../hooks/useWeightUnit";
import { formatPrice, formatWeight } from "../lib/formatters";
import { useFormatters } from "../hooks/useFormatters";
import type { CandidateDelta } from "../hooks/useImpactDeltas";
import { LucideIcon } from "../lib/iconData";
import { useUIStore } from "../stores/uiStore";
import { RankBadge } from "./CandidateListItem";
import { ImpactDeltaBadge } from "./ImpactDeltaBadge";
interface CandidateWithCategory {
id: number;
@@ -28,6 +28,7 @@ interface CandidateWithCategory {
interface ComparisonTableProps {
candidates: CandidateWithCategory[];
resolvedCandidateId: number | null;
deltas?: Record<number, CandidateDelta>;
}
const STATUS_LABELS: Record<"researching" | "ordered" | "arrived", string> = {
@@ -39,9 +40,9 @@ const STATUS_LABELS: Record<"researching" | "ordered" | "arrived", string> = {
export function ComparisonTable({
candidates,
resolvedCandidateId,
deltas,
}: ComparisonTableProps) {
const unit = useWeightUnit();
const currency = useCurrency();
const { weight, price } = useFormatters();
const openExternalLink = useUIStore((s) => s.openExternalLink);
const { bestWeightId, bestPriceId, weightDeltas, priceDeltas } =
@@ -62,8 +63,7 @@ export function ComparisonTable({
weightDeltas[c.id] = null;
} else {
const delta = c.weightGrams - minWeight;
weightDeltas[c.id] =
delta === 0 ? null : `+${formatWeight(delta, unit)}`;
weightDeltas[c.id] = delta === 0 ? null : `+${weight(delta)}`;
}
}
} else {
@@ -88,8 +88,7 @@ export function ComparisonTable({
priceDeltas[c.id] = null;
} else {
const delta = c.priceCents - minPrice;
priceDeltas[c.id] =
delta === 0 ? null : `+${formatPrice(delta, currency)}`;
priceDeltas[c.id] = delta === 0 ? null : `+${price(delta)}`;
}
}
} else {
@@ -99,7 +98,7 @@ export function ComparisonTable({
}
return { bestWeightId, bestPriceId, weightDeltas, priceDeltas };
}, [candidates, unit, currency]);
}, [candidates, weight, price]);
const ATTRIBUTE_ROWS: Array<{
key: string;
@@ -155,7 +154,7 @@ export function ComparisonTable({
return (
<div>
<span className="font-medium text-gray-900">
{formatWeight(c.weightGrams, unit)}
{weight(c.weightGrams)}
</span>
{!isBest && delta && (
<div className="text-xs text-gray-400">{delta}</div>
@@ -181,7 +180,7 @@ export function ComparisonTable({
return (
<div>
<span className="font-medium text-gray-900">
{formatPrice(c.priceCents, currency)}
{price(c.priceCents)}
</span>
{!isBest && delta && (
<div className="text-xs text-gray-400">{delta}</div>
@@ -268,6 +267,10 @@ export function ComparisonTable({
},
];
// Determine if impact rows should be shown
const firstDelta = deltas ? Object.values(deltas)[0] : undefined;
const showImpact = !!deltas && !!firstDelta && firstDelta.mode !== "none";
const tableMinWidth = Math.max(400, candidates.length * 180);
return (
@@ -329,6 +332,50 @@ export function ComparisonTable({
})}
</tr>
))}
{showImpact && (
<>
<tr className="border-b border-gray-50">
<td className="sticky left-0 z-10 bg-white px-4 py-3 text-xs font-medium text-gray-500 uppercase tracking-wide w-28">
Weight Impact
</td>
{candidates.map((candidate) => {
const isWinner = candidate.id === resolvedCandidateId;
return (
<td
key={candidate.id}
className={`px-4 py-3 min-w-[160px] ${isWinner ? "bg-amber-50/50" : ""}`}
>
<ImpactDeltaBadge
delta={deltas?.[candidate.id]}
type="weight"
formatFn={weight}
/>
</td>
);
})}
</tr>
<tr className="border-b border-gray-50">
<td className="sticky left-0 z-10 bg-white px-4 py-3 text-xs font-medium text-gray-500 uppercase tracking-wide w-28">
Price Impact
</td>
{candidates.map((candidate) => {
const isWinner = candidate.id === resolvedCandidateId;
return (
<td
key={candidate.id}
className={`px-4 py-3 min-w-[160px] ${isWinner ? "bg-amber-50/50" : ""}`}
>
<ImpactDeltaBadge
delta={deltas?.[candidate.id]}
type="price"
formatFn={price}
/>
</td>
);
})}
</tr>
</>
)}
</tbody>
</table>
</div>

View File

@@ -0,0 +1,39 @@
import type { CandidateDelta } from "../hooks/useImpactDeltas";
interface ImpactDeltaBadgeProps {
delta: CandidateDelta | undefined;
type: "weight" | "price";
formatFn: (value: number) => string;
}
export function ImpactDeltaBadge({
delta,
type,
formatFn,
}: ImpactDeltaBadgeProps) {
if (!delta || delta.mode === "none") return null;
const value = type === "weight" ? delta.weightDelta : delta.priceDelta;
if (value === null) {
return <span className="text-xs text-gray-400"></span>;
}
if (value === 0) {
return <span className="text-xs text-gray-400">±0</span>;
}
if (value > 0) {
return (
<span className="text-xs text-green-600">
+{formatFn(value)}
{delta.mode === "add" && (
<span className="ml-0.5 text-green-500">(add)</span>
)}
</span>
);
}
// value < 0
return <span className="text-xs text-red-500">{formatFn(value)}</span>;
}

View File

@@ -1,6 +1,5 @@
import { useCurrency } from "../hooks/useCurrency";
import { useWeightUnit } from "../hooks/useWeightUnit";
import { formatPrice, formatWeight } from "../lib/formatters";
import { useFormatters } from "../hooks/useFormatters";
import { useDuplicateItem } from "../hooks/useItems";
import { LucideIcon } from "../lib/iconData";
import { useUIStore } from "../stores/uiStore";
import { ClassificationBadge } from "./ClassificationBadge";
@@ -10,6 +9,7 @@ interface ItemCardProps {
name: string;
weightGrams: number | null;
priceCents: number | null;
quantity?: number;
categoryName: string;
categoryIcon: string;
imageFilename: string | null;
@@ -24,6 +24,7 @@ export function ItemCard({
name,
weightGrams,
priceCents,
quantity,
categoryName,
categoryIcon,
imageFilename,
@@ -32,10 +33,10 @@ export function ItemCard({
classification,
onClassificationCycle,
}: ItemCardProps) {
const unit = useWeightUnit();
const currency = useCurrency();
const { weight, price } = useFormatters();
const openEditPanel = useUIStore((s) => s.openEditPanel);
const openExternalLink = useUIStore((s) => s.openExternalLink);
const duplicateItem = useDuplicateItem();
return (
<button
@@ -43,6 +44,46 @@ export function ItemCard({
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"
>
{!onRemove && (
<span
role="button"
tabIndex={0}
onClick={(e) => {
e.stopPropagation();
duplicateItem.mutate(id, {
onSuccess: (newItem) => {
openEditPanel(newItem.id);
},
});
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.stopPropagation();
duplicateItem.mutate(id, {
onSuccess: (newItem) => {
openEditPanel(newItem.id);
},
});
}
}}
className={`absolute top-2 ${productUrl ? "right-10" : "right-2"} z-10 w-6 h-6 flex items-center justify-center rounded-full bg-gray-100/80 text-gray-400 hover:bg-blue-100 hover:text-blue-500 opacity-0 group-hover:opacity-100 transition-all cursor-pointer`}
title="Duplicate item"
>
<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="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
/>
</svg>
</span>
)}
{productUrl && (
<span
role="button"
@@ -125,18 +166,25 @@ export function ItemCard({
)}
</div>
<div className="p-4">
<h3 className="text-sm font-semibold text-gray-900 mb-2 truncate">
<div className="flex items-center gap-1.5 mb-2">
<h3 className="text-sm font-semibold text-gray-900 truncate min-w-0">
{name}
</h3>
{quantity != null && quantity > 1 && (
<span className="shrink-0 inline-flex items-center px-1.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-600">
&times;{quantity}
</span>
)}
</div>
<div className="flex flex-wrap gap-1.5">
{weightGrams != null && (
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-50 text-blue-400">
{formatWeight(weightGrams, unit)}
{weight(weightGrams)}
</span>
)}
{priceCents != null && (
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-50 text-green-500">
{formatPrice(priceCents, currency)}
{price(priceCents)}
</span>
)}
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-50 text-gray-600">

View File

@@ -13,6 +13,7 @@ interface FormData {
name: string;
weightGrams: string;
priceDollars: string;
quantity: number;
categoryId: number;
notes: string;
productUrl: string;
@@ -23,6 +24,7 @@ const INITIAL_FORM: FormData = {
name: "",
weightGrams: "",
priceDollars: "",
quantity: 1,
categoryId: 1,
notes: "",
productUrl: "",
@@ -49,6 +51,7 @@ export function ItemForm({ mode, itemId }: ItemFormProps) {
weightGrams: item.weightGrams != null ? String(item.weightGrams) : "",
priceDollars:
item.priceCents != null ? (item.priceCents / 100).toFixed(2) : "",
quantity: item.quantity ?? 1,
categoryId: item.categoryId,
notes: item.notes ?? "",
productUrl: item.productUrl ?? "",
@@ -98,6 +101,7 @@ export function ItemForm({ mode, itemId }: ItemFormProps) {
priceCents: form.priceDollars
? Math.round(Number(form.priceDollars) * 100)
: undefined,
quantity: form.quantity,
categoryId: form.categoryId,
notes: form.notes.trim() || undefined,
productUrl: form.productUrl.trim() || undefined,
@@ -202,6 +206,30 @@ export function ItemForm({ mode, itemId }: ItemFormProps) {
)}
</div>
{/* Quantity */}
<div>
<label
htmlFor="item-quantity"
className="block text-sm font-medium text-gray-700 mb-1"
>
Quantity
</label>
<input
id="item-quantity"
type="number"
min="1"
step="1"
value={form.quantity}
onChange={(e) =>
setForm((f) => ({
...f,
quantity: Math.max(1, Number(e.target.value) || 1),
}))
}
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>
{/* Category */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">

View File

@@ -1,9 +1,7 @@
import { useEffect, useState } from "react";
import { useCurrency } from "../hooks/useCurrency";
import { useFormatters } from "../hooks/useFormatters";
import { useItems } from "../hooks/useItems";
import { useSyncSetupItems } from "../hooks/useSetups";
import { useWeightUnit } from "../hooks/useWeightUnit";
import { formatPrice, formatWeight } from "../lib/formatters";
import { LucideIcon } from "../lib/iconData";
import { SlideOutPanel } from "./SlideOutPanel";
@@ -22,8 +20,7 @@ export function ItemPicker({
}: ItemPickerProps) {
const { data: items } = useItems();
const syncItems = useSyncSetupItems(setupId);
const unit = useWeightUnit();
const currency = useCurrency();
const { weight, price } = useFormatters();
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
// Reset selected IDs when panel opens
@@ -117,13 +114,11 @@ export function ItemPicker({
{item.name}
</span>
<span className="text-xs text-gray-400 shrink-0">
{item.weightGrams != null &&
formatWeight(item.weightGrams, unit)}
{item.weightGrams != null && weight(item.weightGrams)}
{item.weightGrams != null &&
item.priceCents != null &&
" · "}
{item.priceCents != null &&
formatPrice(item.priceCents, currency)}
{item.priceCents != null && price(item.priceCents)}
</span>
</label>
))}

View File

@@ -0,0 +1,196 @@
import { useState } from "react";
import { useCategories } from "../hooks/useCategories";
import { useThreads } from "../hooks/useThreads";
import { useUIStore } from "../stores/uiStore";
import { CategoryFilterDropdown } from "./CategoryFilterDropdown";
import { CreateThreadModal } from "./CreateThreadModal";
import { ThreadCard } from "./ThreadCard";
export function PlanningView() {
const [activeTab, setActiveTab] = useState<"active" | "resolved">("active");
const [categoryFilter, setCategoryFilter] = useState<number | null>(null);
const openCreateThreadModal = useUIStore((s) => s.openCreateThreadModal);
const { data: categories } = useCategories();
const { data: threads, isLoading } = useThreads(activeTab === "resolved");
if (isLoading) {
return (
<div className="animate-pulse space-y-4">
{[1, 2].map((i) => (
<div key={i} className="h-24 bg-gray-200 rounded-xl" />
))}
</div>
);
}
// Filter threads by active tab and category
const filteredThreads = (threads ?? [])
.filter((t) => t.status === activeTab)
.filter((t) => (categoryFilter ? t.categoryId === categoryFilter : true));
// Determine if we should show the educational empty state
const isEmptyNoFilters =
filteredThreads.length === 0 &&
activeTab === "active" &&
categoryFilter === null &&
(!threads || threads.length === 0);
return (
<div>
{/* Header row */}
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-gray-900">
Planning Threads
</h2>
<button
type="button"
onClick={openCreateThreadModal}
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
aria-hidden="true"
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 4v16m8-8H4"
/>
</svg>
New Thread
</button>
</div>
{/* Filter row */}
<div className="flex items-center justify-between mb-6">
{/* Pill tabs */}
<div className="flex bg-gray-100 rounded-full p-0.5 gap-0.5">
<button
type="button"
onClick={() => setActiveTab("active")}
className={`px-4 py-1.5 text-sm font-medium rounded-full transition-colors ${
activeTab === "active"
? "bg-gray-700 text-white"
: "text-gray-600 hover:bg-gray-200"
}`}
>
Active
</button>
<button
type="button"
onClick={() => setActiveTab("resolved")}
className={`px-4 py-1.5 text-sm font-medium rounded-full transition-colors ${
activeTab === "resolved"
? "bg-gray-700 text-white"
: "text-gray-600 hover:bg-gray-200"
}`}
>
Resolved
</button>
</div>
{/* Category filter */}
<CategoryFilterDropdown
value={categoryFilter}
onChange={setCategoryFilter}
categories={categories ?? []}
/>
</div>
{/* Content: empty state or thread grid */}
{isEmptyNoFilters ? (
<div className="py-16">
<div className="max-w-lg mx-auto text-center">
<h2 className="text-xl font-semibold text-gray-900 mb-8">
Plan your next purchase
</h2>
<div className="space-y-6 text-left mb-10">
<div className="flex items-start gap-4">
<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
</div>
<div>
<p className="font-medium text-gray-900">Create a thread</p>
<p className="text-sm text-gray-500">
Start a research thread for gear you're considering
</p>
</div>
</div>
<div className="flex items-start gap-4">
<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
</div>
<div>
<p className="font-medium text-gray-900">Add candidates</p>
<p className="text-sm text-gray-500">
Add products you're comparing with prices and weights
</p>
</div>
</div>
<div className="flex items-start gap-4">
<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
</div>
<div>
<p className="font-medium text-gray-900">Pick a winner</p>
<p className="text-sm text-gray-500">
Resolve the thread and the winner joins your collection
</p>
</div>
</div>
</div>
<button
type="button"
onClick={openCreateThreadModal}
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
aria-hidden="true"
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 4v16m8-8H4"
/>
</svg>
Create your first thread
</button>
</div>
</div>
) : filteredThreads.length === 0 ? (
<div className="py-12 text-center">
<p className="text-sm text-gray-500">No threads found</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filteredThreads.map((thread) => (
<ThreadCard
key={thread.id}
id={thread.id}
name={thread.name}
candidateCount={thread.candidateCount}
minPriceCents={thread.minPriceCents}
maxPriceCents={thread.maxPriceCents}
createdAt={thread.createdAt}
status={thread.status}
categoryName={thread.categoryName}
categoryIcon={thread.categoryIcon}
/>
))}
</div>
)}
<CreateThreadModal />
</div>
);
}

View File

@@ -1,7 +1,5 @@
import { Link } from "@tanstack/react-router";
import { useCurrency } from "../hooks/useCurrency";
import { useWeightUnit } from "../hooks/useWeightUnit";
import { formatPrice, formatWeight } from "../lib/formatters";
import { useFormatters } from "../hooks/useFormatters";
interface SetupCardProps {
id: number;
@@ -18,8 +16,7 @@ export function SetupCard({
totalWeight,
totalCost,
}: SetupCardProps) {
const unit = useWeightUnit();
const currency = useCurrency();
const { weight, price } = useFormatters();
return (
<Link
to="/setups/$setupId"
@@ -34,10 +31,10 @@ export function SetupCard({
</div>
<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-400">
{formatWeight(totalWeight, unit)}
{weight(totalWeight)}
</span>
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-50 text-green-500">
{formatPrice(totalCost, currency)}
{price(totalCost)}
</span>
</div>
</Link>

View File

@@ -0,0 +1,34 @@
import { useSetups } from "../hooks/useSetups";
import { useUIStore } from "../stores/uiStore";
interface SetupImpactSelectorProps {
threadStatus: "active" | "resolved";
}
export function SetupImpactSelector({
threadStatus,
}: SetupImpactSelectorProps) {
const { data: setups } = useSetups();
const selectedSetupId = useUIStore((s) => s.selectedSetupId);
const setSelectedSetupId = useUIStore((s) => s.setSelectedSetupId);
if (threadStatus !== "active") return null;
if (!setups || setups.length === 0) return null;
return (
<select
value={selectedSetupId ?? ""}
onChange={(e) =>
setSelectedSetupId(e.target.value ? Number(e.target.value) : null)
}
className="border border-gray-200 rounded-lg text-sm px-3 py-1.5 text-gray-700 bg-white focus:outline-none focus:ring-2 focus:ring-gray-300"
>
<option value="">Compare with setup...</option>
{setups.map((setup) => (
<option key={setup.id} value={setup.id}>
{setup.name}
</option>
))}
</select>
);
}

View File

@@ -0,0 +1,112 @@
import { useState } from "react";
import { useCreateSetup, useSetups } from "../hooks/useSetups";
import { SetupCard } from "./SetupCard";
export 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">
<div className="max-w-lg mx-auto text-center">
<h2 className="text-xl font-semibold text-gray-900 mb-8">
Build your perfect loadout
</h2>
<div className="space-y-6 text-left mb-10">
<div className="flex items-start gap-4">
<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
</div>
<div>
<p className="font-medium text-gray-900">Create a setup</p>
<p className="text-sm text-gray-500">
Name your loadout for a specific trip or activity
</p>
</div>
</div>
<div className="flex items-start gap-4">
<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
</div>
<div>
<p className="font-medium text-gray-900">Add items</p>
<p className="text-sm text-gray-500">
Pick gear from your collection to include in the setup
</p>
</div>
</div>
<div className="flex items-start gap-4">
<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
</div>
<div>
<p className="font-medium text-gray-900">Track weight</p>
<p className="text-sm text-gray-500">
See weight breakdown and optimize your pack
</p>
</div>
</div>
</div>
</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,6 +1,5 @@
import { useNavigate } from "@tanstack/react-router";
import { useCurrency } from "../hooks/useCurrency";
import { formatPrice } from "../lib/formatters";
import { useFormatters } from "../hooks/useFormatters";
import { LucideIcon } from "../lib/iconData";
interface ThreadCardProps {
@@ -20,16 +19,6 @@ function formatDate(iso: string): string {
return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
}
function formatPriceRange(
min: number | null,
max: number | null,
currency: Parameters<typeof formatPrice>[1],
): string | null {
if (min == null && max == null) return null;
if (min === max) return formatPrice(min, currency);
return `${formatPrice(min, currency)} - ${formatPrice(max, currency)}`;
}
export function ThreadCard({
id,
name,
@@ -42,10 +31,19 @@ export function ThreadCard({
categoryIcon,
}: ThreadCardProps) {
const navigate = useNavigate();
const currency = useCurrency();
const { price } = useFormatters();
function formatPriceRange(
min: number | null,
max: number | null,
): string | null {
if (min == null && max == null) return null;
if (min === max) return price(min);
return `${price(min)} - ${price(max)}`;
}
const isResolved = status === "resolved";
const priceRange = formatPriceRange(minPriceCents, maxPriceCents, currency);
const priceRange = formatPriceRange(minPriceCents, maxPriceCents);
return (
<button

View File

@@ -1,9 +1,9 @@
import { Link } from "@tanstack/react-router";
import { useAuth, useLogout } from "../hooks/useAuth";
import { useFormatters } from "../hooks/useFormatters";
import { useUpdateSetting } from "../hooks/useSettings";
import { useTotals } from "../hooks/useTotals";
import { useWeightUnit } from "../hooks/useWeightUnit";
import { formatPrice, formatWeight, type WeightUnit } from "../lib/formatters";
import type { WeightUnit } from "../lib/formatters";
import { LucideIcon } from "../lib/iconData";
const UNITS: WeightUnit[] = ["g", "oz", "lb", "kg"];
@@ -23,7 +23,7 @@ export function TotalsBar({
const { data: auth } = useAuth();
const logout = useLogout();
const isAuthenticated = !!auth?.user;
const unit = useWeightUnit();
const { weight, price, unit } = useFormatters();
const updateSetting = useUpdateSetting();
// When no stats provided, use global totals (backward compatible)
@@ -34,14 +34,14 @@ export function TotalsBar({
{ label: "items", value: String(data.global.itemCount) },
{
label: "total",
value: formatWeight(data.global.totalWeight, unit),
value: weight(data.global.totalWeight),
},
{ label: "spent", value: formatPrice(data.global.totalCost) },
{ label: "spent", value: price(data.global.totalCost) },
]
: [
{ label: "items", value: "0" },
{ label: "total", value: formatWeight(null, unit) },
{ label: "spent", value: formatPrice(null) },
{ label: "total", value: weight(null) },
{ label: "spent", value: price(null) },
]);
const titleContent = (

View File

@@ -7,8 +7,8 @@ import {
ResponsiveContainer,
Tooltip,
} from "recharts";
import { useFormatters } from "../hooks/useFormatters";
import type { SetupItemWithCategory } from "../hooks/useSetups";
import { useWeightUnit } from "../hooks/useWeightUnit";
import { formatWeight, type WeightUnit } from "../lib/formatters";
import { LucideIcon } from "../lib/iconData";
@@ -55,9 +55,15 @@ function buildCategoryChartData(items: SetupItemWithCategory[]): ChartDatum[] {
const groups = new Map<string, number>();
for (const item of items) {
const current = groups.get(item.categoryName) ?? 0;
groups.set(item.categoryName, current + (item.weightGrams ?? 0));
groups.set(
item.categoryName,
current + (item.weightGrams ?? 0) * (item.quantity ?? 1),
);
}
const total = items.reduce((sum, i) => sum + (i.weightGrams ?? 0), 0);
const total = items.reduce(
(sum, i) => sum + (i.weightGrams ?? 0) * (i.quantity ?? 1),
0,
);
return Array.from(groups.entries())
.filter(([, weight]) => weight > 0)
.map(([name, weight]) => ({
@@ -76,7 +82,8 @@ function buildClassificationChartData(
consumable: 0,
};
for (const item of items) {
groups[item.classification] += item.weightGrams ?? 0;
groups[item.classification] +=
(item.weightGrams ?? 0) * (item.quantity ?? 1);
}
const total = Object.values(groups).reduce((a, b) => a + b, 0);
return Object.entries(groups)
@@ -143,22 +150,28 @@ function LegendRow({
}
export function WeightSummaryCard({ items }: WeightSummaryCardProps) {
const unit = useWeightUnit();
const { unit } = useFormatters();
const [viewMode, setViewMode] = useState<ViewMode>("category");
const baseWeight = items.reduce(
(sum, i) =>
i.classification === "base" ? sum + (i.weightGrams ?? 0) : sum,
i.classification === "base"
? sum + (i.weightGrams ?? 0) * (i.quantity ?? 1)
: sum,
0,
);
const wornWeight = items.reduce(
(sum, i) =>
i.classification === "worn" ? sum + (i.weightGrams ?? 0) : sum,
i.classification === "worn"
? sum + (i.weightGrams ?? 0) * (i.quantity ?? 1)
: sum,
0,
);
const consumableWeight = items.reduce(
(sum, i) =>
i.classification === "consumable" ? sum + (i.weightGrams ?? 0) : sum,
i.classification === "consumable"
? sum + (i.weightGrams ?? 0) * (i.quantity ?? 1)
: sum,
0,
);
const totalWeight = baseWeight + wornWeight + consumableWeight;

View File

@@ -0,0 +1,14 @@
import { formatPrice, formatWeight } from "../lib/formatters";
import { useCurrency } from "./useCurrency";
import { useWeightUnit } from "./useWeightUnit";
export function useFormatters() {
const unit = useWeightUnit();
const currency = useCurrency();
return {
weight: (grams: number | null) => formatWeight(grams, unit),
price: (cents: number | null) => formatPrice(cents, currency),
unit,
currency,
};
}

View File

@@ -0,0 +1,22 @@
import { useMemo } from "react";
import {
type CandidateDelta,
type CandidateInput,
computeImpactDeltas,
type DeltaMode,
type ImpactDeltas,
type SetupItemInput,
} from "../lib/impactDeltas";
export type { CandidateDelta, DeltaMode, ImpactDeltas };
export function useImpactDeltas(
candidates: CandidateInput[],
setupItems: SetupItemInput[] | undefined,
threadCategoryId: number,
): ImpactDeltas {
return useMemo(
() => computeImpactDeltas(candidates, setupItems, threadCategoryId),
[candidates, setupItems, threadCategoryId],
);
}

View File

@@ -1,12 +1,35 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { CreateItem } from "../../shared/types";
import { apiDelete, apiGet, apiPost, apiPut } from "../lib/api";
import {
ApiError,
apiDelete,
apiGet,
apiPost,
apiPut,
apiUpload,
} from "../lib/api";
interface Item {
id: number;
name: string;
weightGrams: number | null;
priceCents: number | null;
quantity: number;
categoryId: number;
notes: string | null;
productUrl: string | null;
imageFilename: string | null;
imageSourceUrl: string | null;
createdAt: string;
updatedAt: string;
}
interface ItemWithCategory {
id: number;
name: string;
weightGrams: number | null;
priceCents: number | null;
quantity: number;
categoryId: number;
notes: string | null;
productUrl: string | null;
@@ -29,6 +52,8 @@ export function useItem(id: number | null) {
queryKey: ["items", id],
queryFn: () => apiGet<ItemWithCategory>(`/api/items/${id}`),
enabled: id != null,
retry: (count, error) =>
error instanceof ApiError && error.status === 404 ? false : count < 3,
});
}
@@ -69,3 +94,38 @@ export function useDeleteItem() {
},
});
}
export function useDuplicateItem() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) => apiPost<Item>(`/api/items/${id}/duplicate`, {}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["items"] });
queryClient.invalidateQueries({ queryKey: ["totals"] });
},
});
}
export function useExportItems() {
return function exportItems() {
window.location.href = "/api/items/export";
};
}
interface ImportResult {
imported: number;
createdCategories: string[];
errors: string[];
}
export function useImportItems() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (file: File) =>
apiUpload<ImportResult>("/api/items/import", file),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["items"] });
queryClient.invalidateQueries({ queryKey: ["totals"] });
},
});
}

View File

@@ -1,5 +1,12 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiDelete, apiGet, apiPatch, apiPost, apiPut } from "../lib/api";
import {
ApiError,
apiDelete,
apiGet,
apiPatch,
apiPost,
apiPut,
} from "../lib/api";
interface SetupListItem {
id: number;
@@ -16,6 +23,7 @@ interface SetupItemWithCategory {
name: string;
weightGrams: number | null;
priceCents: number | null;
quantity: number;
categoryId: number;
notes: string | null;
productUrl: string | null;
@@ -49,6 +57,8 @@ export function useSetup(setupId: number | null) {
queryKey: ["setups", setupId],
queryFn: () => apiGet<SetupWithItems>(`/api/setups/${setupId}`),
enabled: setupId != null,
retry: (count, error) =>
error instanceof ApiError && error.status === 404 ? false : count < 3,
});
}

View File

@@ -1,5 +1,5 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiDelete, apiGet, apiPost, apiPut } from "../lib/api";
import { ApiError, apiDelete, apiGet, apiPost, apiPut } from "../lib/api";
interface ThreadListItem {
id: number;
@@ -40,6 +40,7 @@ interface ThreadWithCandidates {
name: string;
status: "active" | "resolved";
resolvedCandidateId: number | null;
categoryId: number;
createdAt: string;
updatedAt: string;
candidates: CandidateWithCategory[];
@@ -60,6 +61,8 @@ export function useThread(threadId: number | null) {
queryKey: ["threads", threadId],
queryFn: () => apiGet<ThreadWithCandidates>(`/api/threads/${threadId}`),
enabled: threadId != null,
retry: (count, error) =>
error instanceof ApiError && error.status === 404 ? false : count < 3,
});
}

View File

@@ -1,4 +1,4 @@
class ApiError extends Error {
export class ApiError extends Error {
constructor(
message: string,
public status: number,

View File

@@ -0,0 +1,69 @@
export interface CandidateInput {
id: number;
weightGrams: number | null;
priceCents: number | null;
}
export interface SetupItemInput {
categoryId: number;
weightGrams: number | null;
priceCents: number | null;
name: string;
}
export type DeltaMode = "replace" | "add" | "none";
export interface CandidateDelta {
candidateId: number;
mode: DeltaMode;
weightDelta: number | null;
priceDelta: number | null;
replacedItemName: string | null;
}
export interface ImpactDeltas {
mode: DeltaMode;
deltas: Record<number, CandidateDelta>;
}
export function computeImpactDeltas(
candidates: CandidateInput[],
setupItems: SetupItemInput[] | undefined,
threadCategoryId: number,
): ImpactDeltas {
if (!setupItems) return { mode: "none", deltas: {} };
const replacedItem =
setupItems.find((item) => item.categoryId === threadCategoryId) ?? null;
const mode: DeltaMode = replacedItem ? "replace" : "add";
const deltas: Record<number, CandidateDelta> = {};
for (const candidate of candidates) {
let weightDelta: number | null = null;
let priceDelta: number | null = null;
if (candidate.weightGrams != null) {
weightDelta =
replacedItem?.weightGrams != null
? candidate.weightGrams - replacedItem.weightGrams
: candidate.weightGrams;
}
if (candidate.priceCents != null) {
priceDelta =
replacedItem?.priceCents != null
? candidate.priceCents - replacedItem.priceCents
: candidate.priceCents;
}
deltas[candidate.id] = {
candidateId: candidate.id,
mode,
weightDelta,
priceDelta,
replacedItemName: replacedItem?.name ?? null,
};
}
return { mode, deltas };
}

View File

@@ -1,8 +1,10 @@
import {
createRootRoute,
type ErrorComponentProps,
Outlet,
useMatchRoute,
useNavigate,
useRouter,
} from "@tanstack/react-router";
import { useState } from "react";
import "../app.css";
@@ -21,8 +23,53 @@ import { useUIStore } from "../stores/uiStore";
export const Route = createRootRoute({
component: RootLayout,
errorComponent: RootErrorBoundary,
});
function RootErrorBoundary({ error, reset }: ErrorComponentProps) {
const router = useRouter();
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="max-w-md mx-auto text-center px-4">
<div className="w-12 h-12 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-4">
<svg
className="w-6 h-6 text-red-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z"
/>
</svg>
</div>
<h1 className="text-xl font-semibold text-gray-900 mb-2">
Something went wrong
</h1>
<p className="text-sm text-gray-500 mb-6">
{error instanceof Error
? error.message
: "An unexpected error occurred"}
</p>
<button
type="button"
onClick={() => {
reset();
router.invalidate();
}}
className="px-5 py-2.5 bg-gray-700 hover:bg-gray-800 text-white text-sm font-medium rounded-lg transition-colors"
>
Try again
</button>
</div>
</div>
);
}
function RootLayout() {
const navigate = useNavigate();
const { data: auth } = useAuth();

View File

@@ -1,23 +1,10 @@
import { createFileRoute } from "@tanstack/react-router";
import { createFileRoute, Link } from "@tanstack/react-router";
import { AnimatePresence, motion } from "framer-motion";
import { useMemo, useRef, useState } from "react";
import { useRef } from "react";
import { z } from "zod";
import { CategoryFilterDropdown } from "../../components/CategoryFilterDropdown";
import { CategoryHeader } from "../../components/CategoryHeader";
import { CreateThreadModal } from "../../components/CreateThreadModal";
import { ItemCard } from "../../components/ItemCard";
import { SetupCard } from "../../components/SetupCard";
import { ThreadCard } from "../../components/ThreadCard";
import { useCategories } from "../../hooks/useCategories";
import { useCurrency } from "../../hooks/useCurrency";
import { useItems } from "../../hooks/useItems";
import { useCreateSetup, useSetups } from "../../hooks/useSetups";
import { useThreads } from "../../hooks/useThreads";
import { useTotals } from "../../hooks/useTotals";
import { useWeightUnit } from "../../hooks/useWeightUnit";
import { formatPrice, formatWeight } from "../../lib/formatters";
import { LucideIcon } from "../../lib/iconData";
import { useUIStore } from "../../stores/uiStore";
import { CollectionView } from "../../components/CollectionView";
import { PlanningView } from "../../components/PlanningView";
import { SetupsView } from "../../components/SetupsView";
const searchSchema = z.object({
tab: z.enum(["gear", "planning", "setups"]).catch("gear"),
@@ -29,6 +16,11 @@ export const Route = createFileRoute("/collection/")({
});
const TAB_ORDER = ["gear", "planning", "setups"] as const;
const TAB_LABELS: Record<(typeof TAB_ORDER)[number], string> = {
gear: "Gear",
planning: "Planning",
setups: "Setups",
};
const slideVariants = {
enter: (dir: number) => ({ x: `${dir * 15}%`, opacity: 0 }),
@@ -46,6 +38,26 @@ function CollectionPage() {
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 overflow-x-hidden">
{/* Tab navigation */}
<div className="flex justify-center mb-6">
<div className="flex bg-gray-100 rounded-full p-0.5 gap-0.5">
{TAB_ORDER.map((t) => (
<Link
key={t}
to="/collection"
search={{ tab: t }}
className={`px-4 py-1.5 text-sm font-medium rounded-full transition-colors ${
tab === t
? "bg-gray-700 text-white"
: "text-gray-600 hover:bg-gray-200"
}`}
>
{TAB_LABELS[t]}
</Link>
))}
</div>
</div>
<AnimatePresence mode="wait" initial={false} custom={direction}>
<motion.div
key={tab}
@@ -68,566 +80,3 @@ function CollectionPage() {
</div>
);
}
function CollectionView() {
const { data: items, isLoading: itemsLoading } = useItems();
const { data: totals } = useTotals();
const { data: categories } = useCategories();
const unit = useWeightUnit();
const currency = useCurrency();
const openAddPanel = useUIStore((s) => s.openAddPanel);
const [searchText, setSearchText] = useState("");
const [categoryFilter, setCategoryFilter] = useState<number | null>(null);
const filteredItems = useMemo(() => {
if (!items) return [];
return items.filter((item) => {
const matchesSearch =
searchText === "" ||
item.name.toLowerCase().includes(searchText.toLowerCase());
const matchesCategory =
categoryFilter === null || item.categoryId === categoryFilter;
return matchesSearch && matchesCategory;
});
}, [items, searchText, categoryFilter]);
const hasActiveFilters = searchText !== "" || categoryFilter !== null;
if (itemsLoading) {
return (
<div className="animate-pulse space-y-6">
<div className="h-6 bg-gray-200 rounded w-48" />
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{[1, 2, 3].map((i) => (
<div key={i} className="h-40 bg-gray-200 rounded-xl" />
))}
</div>
</div>
);
}
if (!items || items.length === 0) {
return (
<div className="py-16 text-center">
<div className="max-w-md mx-auto">
<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">
Your collection is empty
</h2>
<p className="text-sm text-gray-500 mb-6">
Start cataloging your gear by adding your first item. Track weight,
price, and organize by category.
</p>
<button
type="button"
onClick={openAddPanel}
className="inline-flex items-center gap-2 px-5 py-2.5 bg-gray-700 hover:bg-gray-800 text-white text-sm font-medium rounded-lg transition-colors"
>
<svg
aria-hidden="true"
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 4v16m8-8H4"
/>
</svg>
Add your first item
</button>
</div>
</div>
);
}
// Build category totals lookup
const categoryTotalsMap = new Map<
number,
{ totalWeight: number; totalCost: number; itemCount: number }
>();
if (totals?.categories) {
for (const ct of totals.categories) {
categoryTotalsMap.set(ct.categoryId, {
totalWeight: ct.totalWeight,
totalCost: ct.totalCost,
itemCount: ct.itemCount,
});
}
}
// Group filtered items by categoryId (used when no active filters)
const groupedItems = new Map<
number,
{
items: typeof filteredItems;
categoryName: string;
categoryIcon: string;
}
>();
for (const item of filteredItems) {
const group = groupedItems.get(item.categoryId);
if (group) {
group.items.push(item);
} else {
groupedItems.set(item.categoryId, {
items: [item],
categoryName: item.categoryName,
categoryIcon: item.categoryIcon,
});
}
}
return (
<>
{/* Collection stats card */}
{totals?.global && (
<div className="bg-white rounded-xl border border-gray-100 p-5 mb-6">
<div className="flex items-center gap-8">
<div className="flex flex-col items-center gap-1">
<LucideIcon name="layers" size={14} className="text-gray-400" />
<span className="text-xs text-gray-500">Items</span>
<span className="text-sm font-semibold text-gray-900">
{totals.global.itemCount}
</span>
</div>
<div className="flex flex-col items-center gap-1">
<LucideIcon name="weight" size={14} className="text-gray-400" />
<span className="text-xs text-gray-500">Total Weight</span>
<span className="text-sm font-semibold text-gray-900">
{formatWeight(totals.global.totalWeight, unit)}
</span>
</div>
<div className="flex flex-col items-center gap-1">
<LucideIcon
name="credit-card"
size={14}
className="text-gray-400"
/>
<span className="text-xs text-gray-500">Total Spent</span>
<span className="text-sm font-semibold text-gray-900">
{formatPrice(totals.global.totalCost, currency)}
</span>
</div>
</div>
</div>
)}
{/* Search/filter toolbar */}
<div className="sticky top-0 z-10 bg-gray-50/95 backdrop-blur-sm border-b border-gray-100 -mx-4 px-4 py-3 sm:-mx-6 sm:px-6 lg:-mx-8 lg:px-8 mb-6">
<div className="flex gap-3 items-center">
<div className="relative flex-1">
<input
type="text"
placeholder="Search items..."
value={searchText}
onChange={(e) => setSearchText(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-gray-400 focus:border-transparent"
/>
{searchText && (
<button
type="button"
onClick={() => setSearchText("")}
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
)}
</div>
<CategoryFilterDropdown
value={categoryFilter}
onChange={setCategoryFilter}
categories={categories ?? []}
/>
</div>
{hasActiveFilters && (
<p className="text-xs text-gray-500 mt-2">
Showing {filteredItems.length} of {items.length} items
</p>
)}
</div>
{/* Filtered results */}
{hasActiveFilters ? (
filteredItems.length === 0 ? (
<div className="py-12 text-center">
<p className="text-sm text-gray-500">No items match your search</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filteredItems.map((item) => (
<ItemCard
key={item.id}
id={item.id}
name={item.name}
weightGrams={item.weightGrams}
priceCents={item.priceCents}
categoryName={item.categoryName}
categoryIcon={item.categoryIcon}
imageFilename={item.imageFilename}
productUrl={item.productUrl}
/>
))}
</div>
)
) : (
Array.from(groupedItems.entries()).map(
([
categoryId,
{ items: categoryItems, categoryName, categoryIcon },
]) => {
const catTotals = categoryTotalsMap.get(categoryId);
return (
<div key={categoryId} className="mb-8">
<CategoryHeader
categoryId={categoryId}
name={categoryName}
icon={categoryIcon}
totalWeight={catTotals?.totalWeight ?? 0}
totalCost={catTotals?.totalCost ?? 0}
itemCount={catTotals?.itemCount ?? categoryItems.length}
/>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{categoryItems.map((item) => (
<ItemCard
key={item.id}
id={item.id}
name={item.name}
weightGrams={item.weightGrams}
priceCents={item.priceCents}
categoryName={categoryName}
categoryIcon={categoryIcon}
imageFilename={item.imageFilename}
productUrl={item.productUrl}
/>
))}
</div>
</div>
);
},
)
)}
</>
);
}
function PlanningView() {
const [activeTab, setActiveTab] = useState<"active" | "resolved">("active");
const [categoryFilter, setCategoryFilter] = useState<number | null>(null);
const openCreateThreadModal = useUIStore((s) => s.openCreateThreadModal);
const { data: categories } = useCategories();
const { data: threads, isLoading } = useThreads(activeTab === "resolved");
if (isLoading) {
return (
<div className="animate-pulse space-y-4">
{[1, 2].map((i) => (
<div key={i} className="h-24 bg-gray-200 rounded-xl" />
))}
</div>
);
}
// Filter threads by active tab and category
const filteredThreads = (threads ?? [])
.filter((t) => t.status === activeTab)
.filter((t) => (categoryFilter ? t.categoryId === categoryFilter : true));
// Determine if we should show the educational empty state
const isEmptyNoFilters =
filteredThreads.length === 0 &&
activeTab === "active" &&
categoryFilter === null &&
(!threads || threads.length === 0);
return (
<div>
{/* Header row */}
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-gray-900">
Planning Threads
</h2>
<button
type="button"
onClick={openCreateThreadModal}
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
aria-hidden="true"
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 4v16m8-8H4"
/>
</svg>
New Thread
</button>
</div>
{/* Filter row */}
<div className="flex items-center justify-between mb-6">
{/* Pill tabs */}
<div className="flex bg-gray-100 rounded-full p-0.5 gap-0.5">
<button
type="button"
onClick={() => setActiveTab("active")}
className={`px-4 py-1.5 text-sm font-medium rounded-full transition-colors ${
activeTab === "active"
? "bg-gray-700 text-white"
: "text-gray-600 hover:bg-gray-200"
}`}
>
Active
</button>
<button
type="button"
onClick={() => setActiveTab("resolved")}
className={`px-4 py-1.5 text-sm font-medium rounded-full transition-colors ${
activeTab === "resolved"
? "bg-gray-700 text-white"
: "text-gray-600 hover:bg-gray-200"
}`}
>
Resolved
</button>
</div>
{/* Category filter */}
<CategoryFilterDropdown
value={categoryFilter}
onChange={setCategoryFilter}
categories={categories ?? []}
/>
</div>
{/* Content: empty state or thread grid */}
{isEmptyNoFilters ? (
<div className="py-16">
<div className="max-w-lg mx-auto text-center">
<h2 className="text-xl font-semibold text-gray-900 mb-8">
Plan your next purchase
</h2>
<div className="space-y-6 text-left mb-10">
<div className="flex items-start gap-4">
<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
</div>
<div>
<p className="font-medium text-gray-900">Create a thread</p>
<p className="text-sm text-gray-500">
Start a research thread for gear you're considering
</p>
</div>
</div>
<div className="flex items-start gap-4">
<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
</div>
<div>
<p className="font-medium text-gray-900">Add candidates</p>
<p className="text-sm text-gray-500">
Add products you're comparing with prices and weights
</p>
</div>
</div>
<div className="flex items-start gap-4">
<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
</div>
<div>
<p className="font-medium text-gray-900">Pick a winner</p>
<p className="text-sm text-gray-500">
Resolve the thread and the winner joins your collection
</p>
</div>
</div>
</div>
<button
type="button"
onClick={openCreateThreadModal}
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
aria-hidden="true"
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 4v16m8-8H4"
/>
</svg>
Create your first thread
</button>
</div>
</div>
) : filteredThreads.length === 0 ? (
<div className="py-12 text-center">
<p className="text-sm text-gray-500">No threads found</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filteredThreads.map((thread) => (
<ThreadCard
key={thread.id}
id={thread.id}
name={thread.name}
candidateCount={thread.candidateCount}
minPriceCents={thread.minPriceCents}
maxPriceCents={thread.maxPriceCents}
createdAt={thread.createdAt}
status={thread.status}
categoryName={thread.categoryName}
categoryIcon={thread.categoryIcon}
/>
))}
</div>
)}
<CreateThreadModal />
</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">
<div className="max-w-lg mx-auto text-center">
<h2 className="text-xl font-semibold text-gray-900 mb-8">
Build your perfect loadout
</h2>
<div className="space-y-6 text-left mb-10">
<div className="flex items-start gap-4">
<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
</div>
<div>
<p className="font-medium text-gray-900">Create a setup</p>
<p className="text-sm text-gray-500">
Name your loadout for a specific trip or activity
</p>
</div>
</div>
<div className="flex items-start gap-4">
<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
</div>
<div>
<p className="font-medium text-gray-900">Add items</p>
<p className="text-sm text-gray-500">
Pick gear from your collection to include in the setup
</p>
</div>
</div>
<div className="flex items-start gap-4">
<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
</div>
<div>
<p className="font-medium text-gray-900">Track weight</p>
<p className="text-sm text-gray-500">
See weight breakdown and optimize your pack
</p>
</div>
</div>
</div>
</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,11 +1,9 @@
import { createFileRoute } from "@tanstack/react-router";
import { DashboardCard } from "../components/DashboardCard";
import { useCurrency } from "../hooks/useCurrency";
import { useFormatters } from "../hooks/useFormatters";
import { useSetups } from "../hooks/useSetups";
import { useThreads } from "../hooks/useThreads";
import { useTotals } from "../hooks/useTotals";
import { useWeightUnit } from "../hooks/useWeightUnit";
import { formatPrice, formatWeight } from "../lib/formatters";
export const Route = createFileRoute("/")({
component: DashboardPage,
@@ -15,8 +13,7 @@ function DashboardPage() {
const { data: totals } = useTotals();
const { data: threads } = useThreads(false);
const { data: setups } = useSetups();
const unit = useWeightUnit();
const currency = useCurrency();
const { weight, price } = useFormatters();
const global = totals?.global;
const activeThreadCount = threads?.length ?? 0;
@@ -33,11 +30,11 @@ function DashboardPage() {
{ label: "Items", value: String(global?.itemCount ?? 0) },
{
label: "Weight",
value: formatWeight(global?.totalWeight ?? null, unit),
value: weight(global?.totalWeight ?? null),
},
{
label: "Cost",
value: formatPrice(global?.totalCost ?? null, currency),
value: price(global?.totalCost ?? null),
},
]}
emptyText="Get started"

View File

@@ -1,5 +1,5 @@
import { createFileRoute, Link } from "@tanstack/react-router";
import { useState } from "react";
import { useRef, useState } from "react";
import {
useApiKeys,
useAuth,
@@ -8,6 +8,7 @@ import {
useDeleteApiKey,
} from "../hooks/useAuth";
import { useCurrency } from "../hooks/useCurrency";
import { useExportItems, useImportItems } from "../hooks/useItems";
import { useUpdateSetting } from "../hooks/useSettings";
import { useWeightUnit } from "../hooks/useWeightUnit";
import type { Currency, WeightUnit } from "../lib/formatters";
@@ -172,6 +173,95 @@ function ApiKeySection() {
);
}
function ImportExportSection() {
const exportItems = useExportItems();
const importItems = useImportItems();
const fileInputRef = useRef<HTMLInputElement>(null);
const [importResult, setImportResult] = useState<{
imported: number;
createdCategories: string[];
errors: string[];
} | null>(null);
async function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
setImportResult(null);
try {
const result = await importItems.mutateAsync(file);
setImportResult(result);
} catch (err) {
setImportResult({
imported: 0,
createdCategories: [],
errors: [(err as Error).message],
});
}
// Reset so the same file can be imported again if needed
if (fileInputRef.current) fileInputRef.current.value = "";
}
return (
<div className="space-y-3">
<h3 className="text-sm font-medium text-gray-900">Import / Export</h3>
<p className="text-xs text-gray-500">
Export your gear collection as a CSV file, or import items from a CSV.
</p>
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={exportItems}
className="px-4 py-2 text-sm font-medium text-white bg-gray-700 hover:bg-gray-800 rounded-lg transition-colors"
>
Export CSV
</button>
<label className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-200 hover:bg-gray-50 rounded-lg transition-colors cursor-pointer">
{importItems.isPending ? "Importing..." : "Import CSV"}
<input
ref={fileInputRef}
type="file"
accept=".csv"
className="hidden"
disabled={importItems.isPending}
onChange={handleFileChange}
/>
</label>
</div>
{importResult && (
<div
className={`rounded-lg p-3 text-xs space-y-1 ${
importResult.errors.length > 0 && importResult.imported === 0
? "bg-red-50 border border-red-200 text-red-700"
: "bg-green-50 border border-green-200 text-green-700"
}`}
>
{importResult.imported > 0 && (
<p className="font-medium">
{importResult.imported} item
{importResult.imported !== 1 ? "s" : ""} imported.
</p>
)}
{importResult.createdCategories.length > 0 && (
<p>New categories: {importResult.createdCategories.join(", ")}</p>
)}
{importResult.errors.map((err, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: static error list
<p key={i} className="text-red-600">
{err}
</p>
))}
{importResult.imported === 0 && importResult.errors.length === 0 && (
<p>No items found in the CSV.</p>
)}
</div>
)}
</div>
);
}
function SettingsPage() {
const unit = useWeightUnit();
const currency = useCurrency();
@@ -255,6 +345,10 @@ function SettingsPage() {
</div>
</div>
<div className="bg-white rounded-xl border border-gray-100 p-5 space-y-6 mt-4">
<ImportExportSection />
</div>
{auth?.user && (
<div className="bg-white rounded-xl border border-gray-100 p-5 space-y-6 mt-4">
<ChangePasswordSection />

View File

@@ -4,15 +4,13 @@ import { CategoryHeader } from "../../components/CategoryHeader";
import { ItemCard } from "../../components/ItemCard";
import { ItemPicker } from "../../components/ItemPicker";
import { WeightSummaryCard } from "../../components/WeightSummaryCard";
import { useCurrency } from "../../hooks/useCurrency";
import { useFormatters } from "../../hooks/useFormatters";
import {
useDeleteSetup,
useRemoveSetupItem,
useSetup,
useUpdateItemClassification,
} from "../../hooks/useSetups";
import { useWeightUnit } from "../../hooks/useWeightUnit";
import { formatPrice, formatWeight } from "../../lib/formatters";
import { LucideIcon } from "../../lib/iconData";
export const Route = createFileRoute("/setups/$setupId")({
@@ -21,8 +19,7 @@ export const Route = createFileRoute("/setups/$setupId")({
function SetupDetailPage() {
const { setupId } = Route.useParams();
const unit = useWeightUnit();
const currency = useCurrency();
const { weight, price } = useFormatters();
const navigate = useNavigate();
const numericId = Number(setupId);
const { data: setup, isLoading } = useSetup(numericId);
@@ -56,13 +53,13 @@ function SetupDetailPage() {
);
}
// Compute totals from items
// Compute totals from items (multiply by quantity)
const totalWeight = setup.items.reduce(
(sum, item) => sum + (item.weightGrams ?? 0),
(sum, item) => sum + (item.weightGrams ?? 0) * (item.quantity ?? 1),
0,
);
const totalCost = setup.items.reduce(
(sum, item) => sum + (item.priceCents ?? 0),
(sum, item) => sum + (item.priceCents ?? 0) * (item.quantity ?? 1),
0,
);
const itemCount = setup.items.length;
@@ -127,13 +124,13 @@ function SetupDetailPage() {
</span>
<span>
<span className="font-medium text-gray-700">
{formatWeight(totalWeight, unit)}
{weight(totalWeight)}
</span>{" "}
total
</span>
<span>
<span className="font-medium text-gray-700">
{formatPrice(totalCost, currency)}
{price(totalCost)}
</span>{" "}
cost
</span>
@@ -210,11 +207,13 @@ function SetupDetailPage() {
{ items: categoryItems, categoryName, categoryIcon },
]) => {
const catWeight = categoryItems.reduce(
(sum, item) => sum + (item.weightGrams ?? 0),
(sum, item) =>
sum + (item.weightGrams ?? 0) * (item.quantity ?? 1),
0,
);
const catCost = categoryItems.reduce(
(sum, item) => sum + (item.priceCents ?? 0),
(sum, item) =>
sum + (item.priceCents ?? 0) * (item.quantity ?? 1),
0,
);
return (
@@ -235,6 +234,7 @@ function SetupDetailPage() {
name={item.name}
weightGrams={item.weightGrams}
priceCents={item.priceCents}
quantity={item.quantity}
categoryName={categoryName}
categoryIcon={categoryIcon}
imageFilename={item.imageFilename}

View File

@@ -4,10 +4,13 @@ import { useEffect, useState } from "react";
import { CandidateCard } from "../../components/CandidateCard";
import { CandidateListItem } from "../../components/CandidateListItem";
import { ComparisonTable } from "../../components/ComparisonTable";
import { SetupImpactSelector } from "../../components/SetupImpactSelector";
import {
useReorderCandidates,
useUpdateCandidate,
} from "../../hooks/useCandidates";
import { useImpactDeltas } from "../../hooks/useImpactDeltas";
import { useSetup } from "../../hooks/useSetups";
import { useThread } from "../../hooks/useThreads";
import { LucideIcon } from "../../lib/iconData";
import { useUIStore } from "../../stores/uiStore";
@@ -23,15 +26,21 @@ function ThreadDetailPage() {
const openCandidateAddPanel = useUIStore((s) => s.openCandidateAddPanel);
const candidateViewMode = useUIStore((s) => s.candidateViewMode);
const setCandidateViewMode = useUIStore((s) => s.setCandidateViewMode);
const selectedSetupId = useUIStore((s) => s.selectedSetupId);
const updateCandidate = useUpdateCandidate(threadId);
const reorderMutation = useReorderCandidates(threadId);
const [tempItems, setTempItems] =
useState<typeof thread extends { candidates: infer C } ? C : never | null>(
null,
const { data: setupData } = useSetup(selectedSetupId);
const { deltas } = useImpactDeltas(
thread?.candidates ?? [],
setupData?.items,
thread?.categoryId ?? 0,
);
// Clear tempItems when server data changes (biome-ignore: thread?.candidates is intentional dep)
const [tempItems, setTempItems] = useState<
NonNullable<typeof thread>["candidates"] | null
>(null);
// Clear tempItems when server data changes
// biome-ignore lint/correctness/useExhaustiveDependencies: thread?.candidates is the intended trigger
useEffect(() => {
setTempItems(null);
@@ -78,10 +87,9 @@ function ThreadDetailPage() {
function handleDragEnd() {
if (!tempItems) return;
reorderMutation.mutate(
{ orderedIds: tempItems.map((c) => c.id) },
{ onSettled: () => setTempItems(null) },
);
reorderMutation.mutate({
orderedIds: tempItems.map((c) => c.id),
});
}
return (
@@ -120,8 +128,8 @@ function ThreadDetailPage() {
)}
{/* Toolbar: Add candidate + view toggle */}
<div className="mb-6 flex items-center gap-3">
{isActive && candidateViewMode !== "compare" && (
<div className="mb-6 flex items-center gap-3 flex-wrap">
{isActive && (
<button
type="button"
onClick={openCandidateAddPanel}
@@ -185,6 +193,7 @@ function ThreadDetailPage() {
)}
</div>
)}
<SetupImpactSelector threadStatus={thread.status} />
</div>
{/* Candidates */}
@@ -208,6 +217,7 @@ function ThreadDetailPage() {
<ComparisonTable
candidates={displayItems}
resolvedCandidateId={thread.resolvedCandidateId}
deltas={deltas}
/>
) : candidateViewMode === "list" ? (
isActive ? (
@@ -215,8 +225,7 @@ function ThreadDetailPage() {
axis="y"
values={displayItems}
onReorder={setTempItems}
onPointerUp={handleDragEnd}
className="flex flex-col gap-2"
className="flex flex-col"
>
{displayItems.map((candidate, index) => (
<CandidateListItem
@@ -230,6 +239,8 @@ function ThreadDetailPage() {
status: newStatus,
})
}
delta={deltas[candidate.id]}
onDragEnd={handleDragEnd}
/>
))}
</Reorder.Group>
@@ -247,6 +258,7 @@ function ThreadDetailPage() {
status: newStatus,
})
}
delta={deltas[candidate.id]}
/>
))}
</div>
@@ -276,6 +288,7 @@ function ThreadDetailPage() {
pros={candidate.pros}
cons={candidate.cons}
rank={index + 1}
delta={deltas[candidate.id]}
/>
))}
</div>

View File

@@ -52,6 +52,10 @@ interface UIState {
// Candidate view mode
candidateViewMode: "list" | "grid" | "compare";
setCandidateViewMode: (mode: "list" | "grid" | "compare") => void;
// Setup impact preview
selectedSetupId: number | null;
setSelectedSetupId: (id: number | null) => void;
}
export const useUIStore = create<UIState>((set) => ({
@@ -111,4 +115,8 @@ export const useUIStore = create<UIState>((set) => ({
// Candidate view mode
candidateViewMode: "list",
setCandidateViewMode: (mode) => set({ candidateViewMode: mode }),
// Setup impact preview
selectedSetupId: null,
setSelectedSetupId: (id) => set({ selectedSetupId: id }),
}));

View File

@@ -21,6 +21,7 @@ export const items = sqliteTable("items", {
productUrl: text("product_url"),
imageFilename: text("image_filename"),
imageSourceUrl: text("image_source_url"),
quantity: integer("quantity").notNull().default(1),
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.$defaultFn(() => new Date()),

View File

@@ -1,5 +1,6 @@
import { Hono } from "hono";
import { serveStatic } from "hono/bun";
import { db as prodDb } from "../db/index.ts";
import { seedDefaults } from "../db/seed.ts";
import { mcpRoutes } from "./mcp/index.ts";
import { requireAuth } from "./middleware/auth.ts";
@@ -17,12 +18,28 @@ seedDefaults();
const app = new Hono();
// Centralized error handler
app.onError((err, c) => {
console.error(`[${c.req.method}] ${c.req.path}:`, err);
const message =
process.env.NODE_ENV === "production"
? "Internal server error"
: err.message || "Internal server error";
return c.json({ error: message }, 500);
});
// Health check
app.get("/api/health", (c) => {
return c.json({ status: "ok" });
});
// Auth middleware for write operations (POST/PUT/DELETE) on non-auth routes
// Inject production database into request context
app.use("/api/*", async (c, next) => {
c.set("db", prodDb);
return next();
});
// Auth middleware for write operations (POST/PUT/PATCH/DELETE) on non-auth routes
app.use("/api/*", async (c, next) => {
// Skip auth routes — they handle their own auth
if (c.req.path.startsWith("/api/auth")) return next();

9
src/server/lib/params.ts Normal file
View File

@@ -0,0 +1,9 @@
/**
* Parse a route parameter as a positive integer ID.
* Returns the number if valid, or null if the string is not a positive integer.
*/
export function parseId(raw: string): number | null {
const id = Number(raw);
if (!Number.isInteger(id) || id <= 0) return null;
return id;
}

View File

@@ -0,0 +1,53 @@
import type { Context, Next } from "hono";
interface RateLimitEntry {
count: number;
resetAt: number;
}
const store = new Map<string, RateLimitEntry>();
const MAX_ATTEMPTS = 5;
const WINDOW_MS = 15 * 60 * 1000; // 15 minutes
function getClientIp(c: Context): string {
return c.req.header("x-forwarded-for")?.split(",")[0]?.trim() || "unknown";
}
function cleanup() {
const now = Date.now();
for (const [key, entry] of store) {
if (now >= entry.resetAt) {
store.delete(key);
}
}
}
export async function rateLimit(c: Context, next: Next) {
cleanup();
const ip = getClientIp(c);
const key = `${ip}:${c.req.path}`;
const now = Date.now();
const entry = store.get(key);
if (!entry || now >= entry.resetAt) {
store.set(key, { count: 1, resetAt: now + WINDOW_MS });
return next();
}
if (entry.count >= MAX_ATTEMPTS) {
const retryAfter = Math.ceil((entry.resetAt - now) / 1000);
c.header("Retry-After", String(retryAfter));
return c.json({ error: "Too many attempts. Try again later." }, 429);
}
entry.count++;
return next();
}
/** @internal — only for testing */
export function _resetForTesting() {
store.clear();
}

View File

@@ -4,7 +4,9 @@ import { Hono } from "hono";
import { deleteCookie, getCookie, setCookie } from "hono/cookie";
import { z } from "zod";
import { users } from "../../db/schema.ts";
import { parseId } from "../lib/params.ts";
import { requireAuth } from "../middleware/auth.ts";
import { rateLimit } from "../middleware/rateLimit.ts";
import {
changePassword,
createApiKey,
@@ -59,7 +61,7 @@ app.get("/me", (c) => {
return c.json({ user: null, setupRequired });
});
app.post("/setup", zValidator("json", setupSchema), async (c) => {
app.post("/setup", rateLimit, zValidator("json", setupSchema), async (c) => {
const db = c.get("db");
if (getUserCount(db) > 0) {
@@ -80,7 +82,7 @@ app.post("/setup", zValidator("json", setupSchema), async (c) => {
return c.json({ username: user.username }, 201);
});
app.post("/login", zValidator("json", loginSchema), async (c) => {
app.post("/login", rateLimit, zValidator("json", loginSchema), async (c) => {
const db = c.get("db");
const { username, password } = c.req.valid("json");
@@ -186,7 +188,8 @@ app.post(
app.delete("/keys/:id", requireAuth, (c) => {
const db = c.get("db");
const id = Number(c.req.param("id"));
const id = parseId(c.req.param("id"));
if (!id) return c.json({ error: "Invalid key ID" }, 400);
deleteApiKey(db, id);
return c.json({ ok: true });
});

View File

@@ -4,6 +4,7 @@ import {
createCategorySchema,
updateCategorySchema,
} from "../../shared/schemas.ts";
import { parseId } from "../lib/params.ts";
import {
createCategory,
deleteCategory,
@@ -33,7 +34,8 @@ app.put(
zValidator("json", updateCategorySchema.omit({ id: true })),
(c) => {
const db = c.get("db");
const id = Number(c.req.param("id"));
const id = parseId(c.req.param("id"));
if (!id) return c.json({ error: "Invalid category ID" }, 400);
const data = c.req.valid("json");
const cat = updateCategory(db, id, data);
if (!cat) return c.json({ error: "Category not found" }, 404);
@@ -43,7 +45,8 @@ app.put(
app.delete("/:id", (c) => {
const db = c.get("db");
const id = Number(c.req.param("id"));
const id = parseId(c.req.param("id"));
if (!id) return c.json({ error: "Invalid category ID" }, 400);
const result = deleteCategory(db, id);
if (!result.success) {

View File

@@ -3,9 +3,12 @@ import { join } from "node:path";
import { zValidator } from "@hono/zod-validator";
import { Hono } from "hono";
import { createItemSchema, updateItemSchema } from "../../shared/schemas.ts";
import { parseId } from "../lib/params.ts";
import { exportItemsCsv, importItemsCsv } from "../services/csv.service.ts";
import {
createItem,
deleteItem,
duplicateItem,
getAllItems,
getItemById,
updateItem,
@@ -15,6 +18,27 @@ type Env = { Variables: { db?: any } };
const app = new Hono<Env>();
app.get("/export", (c) => {
const db = c.get("db");
const csv = exportItemsCsv(db);
c.header("Content-Type", "text/csv");
c.header("Content-Disposition", 'attachment; filename="gearbox-export.csv"');
return c.body(csv);
});
app.post("/import", async (c) => {
const db = c.get("db");
const body = await c.req.parseBody();
// Accept either "file" (direct) or "image" (via apiUpload helper)
const file = body.file ?? body.image;
if (!file || typeof file === "string") {
return c.json({ error: "No CSV file provided" }, 400);
}
const content = await file.text();
const result = importItemsCsv(db, content);
return c.json(result);
});
app.get("/", (c) => {
const db = c.get("db");
const items = getAllItems(db);
@@ -23,7 +47,8 @@ app.get("/", (c) => {
app.get("/:id", (c) => {
const db = c.get("db");
const id = Number(c.req.param("id"));
const id = parseId(c.req.param("id"));
if (!id) return c.json({ error: "Invalid item ID" }, 400);
const item = getItemById(db, id);
if (!item) return c.json({ error: "Item not found" }, 404);
return c.json(item);
@@ -41,7 +66,8 @@ app.put(
zValidator("json", updateItemSchema.omit({ id: true })),
(c) => {
const db = c.get("db");
const id = Number(c.req.param("id"));
const id = parseId(c.req.param("id"));
if (!id) return c.json({ error: "Invalid item ID" }, 400);
const data = c.req.valid("json");
const item = updateItem(db, id, data);
if (!item) return c.json({ error: "Item not found" }, 404);
@@ -49,9 +75,19 @@ app.put(
},
);
app.post("/:id/duplicate", (c) => {
const db = c.get("db");
const id = parseId(c.req.param("id"));
if (!id) return c.json({ error: "Invalid item ID" }, 400);
const newItem = duplicateItem(db, id);
if (!newItem) return c.json({ error: "Item not found" }, 404);
return c.json(newItem, 201);
});
app.delete("/:id", async (c) => {
const db = c.get("db");
const id = Number(c.req.param("id"));
const id = parseId(c.req.param("id"));
if (!id) return c.json({ error: "Invalid item ID" }, 400);
const deleted = deleteItem(db, id);
if (!deleted) return c.json({ error: "Item not found" }, 404);

View File

@@ -1,6 +1,5 @@
import { eq } from "drizzle-orm";
import { Hono } from "hono";
import { db as prodDb } from "../../db/index.ts";
import { settings } from "../../db/schema.ts";
type Env = { Variables: { db?: any } };
@@ -8,7 +7,7 @@ type Env = { Variables: { db?: any } };
const app = new Hono<Env>();
app.get("/:key", (c) => {
const database = c.get("db") ?? prodDb;
const database = c.get("db");
const key = c.req.param("key");
const row = database
.select()
@@ -20,7 +19,7 @@ app.get("/:key", (c) => {
});
app.put("/:key", async (c) => {
const database = c.get("db") ?? prodDb;
const database = c.get("db");
const key = c.req.param("key");
const body = await c.req.json<{ value: string }>();

View File

@@ -6,6 +6,7 @@ import {
updateClassificationSchema,
updateSetupSchema,
} from "../../shared/schemas.ts";
import { parseId } from "../lib/params.ts";
import {
createSetup,
deleteSetup,
@@ -38,7 +39,8 @@ app.post("/", zValidator("json", createSetupSchema), (c) => {
app.get("/:id", (c) => {
const db = c.get("db");
const id = Number(c.req.param("id"));
const id = parseId(c.req.param("id"));
if (!id) return c.json({ error: "Invalid setup ID" }, 400);
const setup = getSetupWithItems(db, id);
if (!setup) return c.json({ error: "Setup not found" }, 404);
return c.json(setup);
@@ -46,7 +48,8 @@ app.get("/:id", (c) => {
app.put("/:id", zValidator("json", updateSetupSchema), (c) => {
const db = c.get("db");
const id = Number(c.req.param("id"));
const id = parseId(c.req.param("id"));
if (!id) return c.json({ error: "Invalid setup ID" }, 400);
const data = c.req.valid("json");
const setup = updateSetup(db, id, data);
if (!setup) return c.json({ error: "Setup not found" }, 404);
@@ -55,7 +58,8 @@ app.put("/:id", zValidator("json", updateSetupSchema), (c) => {
app.delete("/:id", (c) => {
const db = c.get("db");
const id = Number(c.req.param("id"));
const id = parseId(c.req.param("id"));
if (!id) return c.json({ error: "Invalid setup ID" }, 400);
const deleted = deleteSetup(db, id);
if (!deleted) return c.json({ error: "Setup not found" }, 404);
return c.json({ success: true });
@@ -65,7 +69,8 @@ app.delete("/:id", (c) => {
app.put("/:id/items", zValidator("json", syncSetupItemsSchema), (c) => {
const db = c.get("db");
const id = Number(c.req.param("id"));
const id = parseId(c.req.param("id"));
if (!id) return c.json({ error: "Invalid setup ID" }, 400);
const { itemIds } = c.req.valid("json");
const setup = getSetupWithItems(db, id);
@@ -80,8 +85,9 @@ app.patch(
zValidator("json", updateClassificationSchema),
(c) => {
const db = c.get("db");
const setupId = Number(c.req.param("id"));
const itemId = Number(c.req.param("itemId"));
const setupId = parseId(c.req.param("id"));
const itemId = parseId(c.req.param("itemId"));
if (!setupId || !itemId) return c.json({ error: "Invalid ID" }, 400);
const { classification } = c.req.valid("json");
updateItemClassification(db, setupId, itemId, classification);
return c.json({ success: true });
@@ -90,8 +96,9 @@ app.patch(
app.delete("/:id/items/:itemId", (c) => {
const db = c.get("db");
const setupId = Number(c.req.param("id"));
const itemId = Number(c.req.param("itemId"));
const setupId = parseId(c.req.param("id"));
const itemId = parseId(c.req.param("itemId"));
if (!setupId || !itemId) return c.json({ error: "Invalid ID" }, 400);
removeSetupItem(db, setupId, itemId);
return c.json({ success: true });
});

View File

@@ -10,6 +10,7 @@ import {
updateCandidateSchema,
updateThreadSchema,
} from "../../shared/schemas.ts";
import { parseId } from "../lib/params.ts";
import {
createCandidate,
createThread,
@@ -45,7 +46,8 @@ app.post("/", zValidator("json", createThreadSchema), (c) => {
app.get("/:id", (c) => {
const db = c.get("db");
const id = Number(c.req.param("id"));
const id = parseId(c.req.param("id"));
if (!id) return c.json({ error: "Invalid thread ID" }, 400);
const thread = getThreadWithCandidates(db, id);
if (!thread) return c.json({ error: "Thread not found" }, 404);
return c.json(thread);
@@ -53,7 +55,8 @@ app.get("/:id", (c) => {
app.put("/:id", zValidator("json", updateThreadSchema), (c) => {
const db = c.get("db");
const id = Number(c.req.param("id"));
const id = parseId(c.req.param("id"));
if (!id) return c.json({ error: "Invalid thread ID" }, 400);
const data = c.req.valid("json");
const thread = updateThread(db, id, data);
if (!thread) return c.json({ error: "Thread not found" }, 404);
@@ -62,7 +65,8 @@ app.put("/:id", zValidator("json", updateThreadSchema), (c) => {
app.delete("/:id", async (c) => {
const db = c.get("db");
const id = Number(c.req.param("id"));
const id = parseId(c.req.param("id"));
if (!id) return c.json({ error: "Invalid thread ID" }, 400);
const deleted = deleteThread(db, id);
if (!deleted) return c.json({ error: "Thread not found" }, 404);
@@ -82,7 +86,8 @@ app.delete("/:id", async (c) => {
app.post("/:id/candidates", zValidator("json", createCandidateSchema), (c) => {
const db = c.get("db");
const threadId = Number(c.req.param("id"));
const threadId = parseId(c.req.param("id"));
if (!threadId) return c.json({ error: "Invalid thread ID" }, 400);
// Verify thread exists
const thread = getThreadWithCandidates(db, threadId);
@@ -98,7 +103,8 @@ app.put(
zValidator("json", updateCandidateSchema),
(c) => {
const db = c.get("db");
const candidateId = Number(c.req.param("candidateId"));
const candidateId = parseId(c.req.param("candidateId"));
if (!candidateId) return c.json({ error: "Invalid candidate ID" }, 400);
const data = c.req.valid("json");
const candidate = updateCandidate(db, candidateId, data);
if (!candidate) return c.json({ error: "Candidate not found" }, 404);
@@ -108,7 +114,8 @@ app.put(
app.delete("/:threadId/candidates/:candidateId", async (c) => {
const db = c.get("db");
const candidateId = Number(c.req.param("candidateId"));
const candidateId = parseId(c.req.param("candidateId"));
if (!candidateId) return c.json({ error: "Invalid candidate ID" }, 400);
const deleted = deleteCandidate(db, candidateId);
if (!deleted) return c.json({ error: "Candidate not found" }, 404);
@@ -131,7 +138,8 @@ app.patch(
zValidator("json", reorderCandidatesSchema),
(c) => {
const db = c.get("db");
const threadId = Number(c.req.param("id"));
const threadId = parseId(c.req.param("id"));
if (!threadId) return c.json({ error: "Invalid thread ID" }, 400);
const { orderedIds } = c.req.valid("json");
const result = reorderCandidates(db, threadId, orderedIds);
if (!result.success) return c.json({ error: result.error }, 400);
@@ -143,7 +151,8 @@ app.patch(
app.post("/:id/resolve", zValidator("json", resolveThreadSchema), (c) => {
const db = c.get("db");
const threadId = Number(c.req.param("id"));
const threadId = parseId(c.req.param("id"));
if (!threadId) return c.json({ error: "Invalid thread ID" }, 400);
const { candidateId } = c.req.valid("json");
const result = resolveThread(db, threadId, candidateId);

View File

@@ -0,0 +1,246 @@
import { eq } from "drizzle-orm";
import { db as prodDb } from "../../db/index.ts";
import { categories, items } from "../../db/schema.ts";
type Db = typeof prodDb;
// ─── CSV serialisation helpers ────────────────────────────────────────────────
function escapeField(value: string | number | null | undefined): string {
if (value === null || value === undefined) return "";
const str = String(value);
// Wrap in quotes if the field contains a comma, double-quote, or newline
if (
str.includes(",") ||
str.includes('"') ||
str.includes("\n") ||
str.includes("\r")
) {
return `"${str.replace(/"/g, '""')}"`;
}
return str;
}
function buildCsvRow(fields: (string | number | null | undefined)[]): string {
return fields.map(escapeField).join(",");
}
// ─── CSV parsing helpers ───────────────────────────────────────────────────────
function parseCsvLine(line: string): string[] {
const fields: string[] = [];
let i = 0;
while (i <= line.length) {
if (i === line.length) {
// End of line — push empty trailing field only if we were expecting one
// (handled by the loop condition above + break below)
break;
}
if (line[i] === '"') {
// Quoted field
let field = "";
i++; // skip opening quote
while (i < line.length) {
if (line[i] === '"') {
if (i + 1 < line.length && line[i + 1] === '"') {
// Escaped quote
field += '"';
i += 2;
} else {
// Closing quote
i++;
break;
}
} else {
field += line[i];
i++;
}
}
fields.push(field);
// Skip comma separator
if (i < line.length && line[i] === ",") i++;
} else {
// Unquoted field — read until comma or end of line
const start = i;
while (i < line.length && line[i] !== ",") i++;
fields.push(line.slice(start, i));
if (i < line.length) i++; // skip comma
}
}
return fields;
}
function parseCsv(content: string): { headers: string[]; rows: string[][] } {
const lines = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
const nonEmpty = lines.filter((l) => l.trim() !== "");
if (nonEmpty.length === 0) return { headers: [], rows: [] };
const headers = parseCsvLine(nonEmpty[0]);
const rows = nonEmpty.slice(1).map(parseCsvLine);
return { headers, rows };
}
// ─── Export ───────────────────────────────────────────────────────────────────
export function exportItemsCsv(db: Db = prodDb): string {
const rows = db
.select({
name: items.name,
quantity: items.quantity,
weightGrams: items.weightGrams,
priceCents: items.priceCents,
categoryName: categories.name,
notes: items.notes,
productUrl: items.productUrl,
})
.from(items)
.innerJoin(categories, eq(items.categoryId, categories.id))
.all();
const header =
"name,quantity,weightGrams,priceCents,category,notes,productUrl";
const dataLines = rows.map((row) =>
buildCsvRow([
row.name,
row.quantity,
row.weightGrams,
row.priceCents,
row.categoryName,
row.notes,
row.productUrl,
]),
);
return [header, ...dataLines].join("\n");
}
// ─── Import ───────────────────────────────────────────────────────────────────
export interface ImportResult {
imported: number;
createdCategories: string[];
errors: string[];
}
export function importItemsCsv(
db: Db = prodDb,
csvContent: string,
): ImportResult {
const { headers, rows } = parseCsv(csvContent);
const result: ImportResult = {
imported: 0,
createdCategories: [],
errors: [],
};
if (headers.length === 0) return result;
// Normalise header names for lookup (case-insensitive)
const headerIndex = (name: string): number =>
headers.findIndex((h) => h.trim().toLowerCase() === name.toLowerCase());
const nameIdx = headerIndex("name");
const quantityIdx = headerIndex("quantity");
const weightIdx = headerIndex("weightGrams");
const priceIdx = headerIndex("priceCents");
const categoryIdx = headerIndex("category");
const notesIdx = headerIndex("notes");
const urlIdx = headerIndex("productUrl");
// Build a local category cache (name → id) seeded from the DB
const categoryCache = new Map<string, number>();
const existingCategories = db
.select({ id: categories.id, name: categories.name })
.from(categories)
.all();
for (const cat of existingCategories) {
categoryCache.set(cat.name.toLowerCase(), cat.id);
}
for (let rowNum = 0; rowNum < rows.length; rowNum++) {
const row = rows[rowNum];
const lineNum = rowNum + 2; // 1-based, +1 for header
try {
const name = nameIdx >= 0 ? row[nameIdx]?.trim() : undefined;
if (!name) {
result.errors.push(`Row ${lineNum}: missing required field "name"`);
continue;
}
// Category resolution
let categoryId: number;
const rawCategory = categoryIdx >= 0 ? row[categoryIdx]?.trim() : "";
const categoryName = rawCategory || "Uncategorized";
const cacheKey = categoryName.toLowerCase();
if (categoryCache.has(cacheKey)) {
categoryId = categoryCache.get(cacheKey)!;
} else {
// Create a new category
const inserted = db
.insert(categories)
.values({ name: categoryName, icon: "package" })
.returning()
.get();
categoryId = inserted.id;
categoryCache.set(cacheKey, categoryId);
result.createdCategories.push(categoryName);
}
// Parse optional numeric fields
const rawQuantity = quantityIdx >= 0 ? row[quantityIdx]?.trim() : "";
const quantity = rawQuantity ? Number.parseInt(rawQuantity, 10) : 1;
if (rawQuantity && Number.isNaN(quantity)) {
result.errors.push(
`Row ${lineNum}: invalid quantity "${rawQuantity}", skipping`,
);
continue;
}
const rawWeight = weightIdx >= 0 ? row[weightIdx]?.trim() : "";
const weightGrams = rawWeight ? Number.parseFloat(rawWeight) : null;
if (rawWeight && Number.isNaN(weightGrams as number)) {
result.errors.push(
`Row ${lineNum}: invalid weightGrams "${rawWeight}", skipping`,
);
continue;
}
const rawPrice = priceIdx >= 0 ? row[priceIdx]?.trim() : "";
const priceCents = rawPrice ? Number.parseInt(rawPrice, 10) : null;
if (rawPrice && Number.isNaN(priceCents as number)) {
result.errors.push(
`Row ${lineNum}: invalid priceCents "${rawPrice}", skipping`,
);
continue;
}
const notes = notesIdx >= 0 ? row[notesIdx]?.trim() || null : null;
const productUrl = urlIdx >= 0 ? row[urlIdx]?.trim() || null : null;
db.insert(items)
.values({
name,
quantity,
weightGrams,
priceCents,
categoryId,
notes,
productUrl,
imageFilename: null,
imageSourceUrl: null,
})
.run();
result.imported++;
} catch (err) {
result.errors.push(`Row ${lineNum}: ${(err as Error).message}`);
}
}
return result;
}

View File

@@ -12,6 +12,7 @@ export function getAllItems(db: Db = prodDb) {
name: items.name,
weightGrams: items.weightGrams,
priceCents: items.priceCents,
quantity: items.quantity,
categoryId: items.categoryId,
notes: items.notes,
productUrl: items.productUrl,
@@ -63,6 +64,7 @@ export function createItem(
name: data.name,
weightGrams: data.weightGrams ?? null,
priceCents: data.priceCents ?? null,
quantity: data.quantity ?? 1,
categoryId: data.categoryId,
notes: data.notes ?? null,
productUrl: data.productUrl ?? null,
@@ -80,6 +82,7 @@ export function updateItem(
name: string;
weightGrams: number;
priceCents: number;
quantity: number;
categoryId: number;
notes: string;
productUrl: string;
@@ -104,6 +107,28 @@ export function updateItem(
.get();
}
export function duplicateItem(db: Db = prodDb, id: number) {
const source = db.select().from(items).where(eq(items.id, id)).get();
if (!source) return null;
return db
.insert(items)
.values({
name: `${source.name} (copy)`,
weightGrams: source.weightGrams,
priceCents: source.priceCents,
categoryId: source.categoryId,
notes: source.notes,
productUrl: source.productUrl,
imageFilename: source.imageFilename,
imageSourceUrl: source.imageSourceUrl,
quantity: source.quantity,
})
.returning()
.get();
}
export function deleteItem(db: Db = prodDb, id: number) {
// Get item first (for image cleanup info)
const item = db.select().from(items).where(eq(items.id, id)).get();

View File

@@ -21,12 +21,12 @@ export function getAllSetups(db: Db = prodDb) {
WHERE setup_items.setup_id = setups.id
), 0)`.as("item_count"),
totalWeight: sql<number>`COALESCE((
SELECT SUM(items.weight_grams) FROM setup_items
SELECT SUM(items.weight_grams * items.quantity) FROM setup_items
JOIN items ON items.id = setup_items.item_id
WHERE setup_items.setup_id = setups.id
), 0)`.as("total_weight"),
totalCost: sql<number>`COALESCE((
SELECT SUM(items.price_cents) FROM setup_items
SELECT SUM(items.price_cents * items.quantity) FROM setup_items
JOIN items ON items.id = setup_items.item_id
WHERE setup_items.setup_id = setups.id
), 0)`.as("total_cost"),
@@ -45,6 +45,7 @@ export function getSetupWithItems(db: Db = prodDb, setupId: number) {
name: items.name,
weightGrams: items.weightGrams,
priceCents: items.priceCents,
quantity: items.quantity,
categoryId: items.categoryId,
notes: items.notes,
productUrl: items.productUrl,

View File

@@ -299,6 +299,7 @@ export function resolveThread(
productUrl: candidate.productUrl,
imageFilename: candidate.imageFilename,
imageSourceUrl: candidate.imageSourceUrl,
quantity: 1,
})
.returning()
.get();

View File

@@ -10,8 +10,8 @@ export function getCategoryTotals(db: Db = prodDb) {
categoryId: items.categoryId,
categoryName: categories.name,
categoryIcon: categories.icon,
totalWeight: sql<number>`COALESCE(SUM(${items.weightGrams}), 0)`,
totalCost: sql<number>`COALESCE(SUM(${items.priceCents}), 0)`,
totalWeight: sql<number>`COALESCE(SUM(${items.weightGrams} * ${items.quantity}), 0)`,
totalCost: sql<number>`COALESCE(SUM(${items.priceCents} * ${items.quantity}), 0)`,
itemCount: sql<number>`COUNT(*)`,
})
.from(items)
@@ -23,8 +23,8 @@ export function getCategoryTotals(db: Db = prodDb) {
export function getGlobalTotals(db: Db = prodDb) {
return db
.select({
totalWeight: sql<number>`COALESCE(SUM(${items.weightGrams}), 0)`,
totalCost: sql<number>`COALESCE(SUM(${items.priceCents}), 0)`,
totalWeight: sql<number>`COALESCE(SUM(${items.weightGrams} * ${items.quantity}), 0)`,
totalCost: sql<number>`COALESCE(SUM(${items.priceCents} * ${items.quantity}), 0)`,
itemCount: sql<number>`COUNT(*)`,
})
.from(items)

View File

@@ -9,6 +9,7 @@ export const createItemSchema = z.object({
productUrl: z.string().url().optional().or(z.literal("")),
imageFilename: z.string().optional(),
imageSourceUrl: z.string().url().optional().or(z.literal("")),
quantity: z.number().int().positive().optional(),
});
export const updateItemSchema = createItemSchema.partial().extend({

View File

@@ -1,124 +1,17 @@
import { Database } from "bun:sqlite";
import { drizzle } from "drizzle-orm/bun-sqlite";
import { migrate } from "drizzle-orm/bun-sqlite/migrator";
import * as schema from "../../src/db/schema.ts";
export function createTestDb() {
const sqlite = new Database(":memory:");
sqlite.run("PRAGMA foreign_keys = ON");
// Create tables matching the Drizzle schema
sqlite.run(`
CREATE TABLE categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
icon TEXT NOT NULL DEFAULT 'package',
created_at INTEGER NOT NULL DEFAULT (unixepoch())
)
`);
sqlite.run(`
CREATE TABLE items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
weight_grams REAL,
price_cents INTEGER,
category_id INTEGER NOT NULL REFERENCES categories(id),
notes TEXT,
product_url TEXT,
image_filename TEXT,
image_source_url TEXT,
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
)
`);
sqlite.run(`
CREATE TABLE threads (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
resolved_candidate_id INTEGER,
category_id INTEGER NOT NULL REFERENCES categories(id),
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
)
`);
sqlite.run(`
CREATE TABLE thread_candidates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
thread_id INTEGER NOT NULL REFERENCES threads(id) ON DELETE CASCADE,
name TEXT NOT NULL,
weight_grams REAL,
price_cents INTEGER,
category_id INTEGER NOT NULL REFERENCES categories(id),
notes TEXT,
product_url TEXT,
image_filename TEXT,
image_source_url TEXT,
status TEXT NOT NULL DEFAULT 'researching',
pros TEXT,
cons TEXT,
sort_order REAL NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
)
`);
sqlite.run(`
CREATE TABLE setups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
)
`);
sqlite.run(`
CREATE TABLE setup_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
setup_id INTEGER NOT NULL REFERENCES setups(id) ON DELETE CASCADE,
item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE,
classification TEXT NOT NULL DEFAULT 'base'
)
`);
sqlite.run(`
CREATE TABLE settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
)
`);
sqlite.run(`
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
created_at INTEGER NOT NULL DEFAULT (unixepoch())
)
`);
sqlite.run(`
CREATE TABLE sessions (
id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires_at INTEGER NOT NULL
)
`);
sqlite.run(`
CREATE TABLE api_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
key_hash TEXT NOT NULL,
key_prefix TEXT NOT NULL,
created_at INTEGER NOT NULL DEFAULT (unixepoch())
)
`);
const db = drizzle(sqlite, { schema });
// Apply all migrations to create tables
migrate(db, { migrationsFolder: "./drizzle" });
// Seed default Uncategorized category
db.insert(schema.categories)
.values({ name: "Uncategorized", icon: "package" })

View File

@@ -0,0 +1,87 @@
import { describe, expect, it } from "bun:test";
import { computeImpactDeltas } from "../../src/client/lib/impactDeltas";
describe("computeImpactDeltas", () => {
const candidate = { id: 1, weightGrams: 500, priceCents: 20000 };
const candidate2 = { id: 2, weightGrams: 300, priceCents: 15000 };
it("returns mode 'none' when setupItems is undefined", () => {
const result = computeImpactDeltas([candidate], undefined, 1);
expect(result.mode).toBe("none");
expect(Object.keys(result.deltas)).toHaveLength(0);
});
it("returns replace mode when setup item matches thread category", () => {
const setupItems = [
{ categoryId: 5, weightGrams: 800, priceCents: 30000, name: "Old Tent" },
];
const result = computeImpactDeltas([candidate], setupItems, 5);
expect(result.mode).toBe("replace");
expect(result.deltas[1].weightDelta).toBe(-300); // 500 - 800
expect(result.deltas[1].priceDelta).toBe(-10000); // 20000 - 30000
expect(result.deltas[1].replacedItemName).toBe("Old Tent");
});
it("returns add mode when no setup item matches thread category", () => {
const setupItems = [
{ categoryId: 99, weightGrams: 200, priceCents: 5000, name: "Unrelated" },
];
const result = computeImpactDeltas([candidate], setupItems, 5);
expect(result.mode).toBe("add");
expect(result.deltas[1].weightDelta).toBe(500);
expect(result.deltas[1].priceDelta).toBe(20000);
expect(result.deltas[1].replacedItemName).toBeNull();
});
it("returns null weightDelta when candidate weight is null", () => {
const nullWeight = { id: 3, weightGrams: null, priceCents: 10000 };
const setupItems = [
{ categoryId: 5, weightGrams: 200, priceCents: 5000, name: "Item" },
];
const result = computeImpactDeltas([nullWeight], setupItems, 5);
expect(result.deltas[3].weightDelta).toBeNull();
expect(result.deltas[3].priceDelta).toBe(5000); // 10000 - 5000
});
it("returns null priceDelta when candidate price is null", () => {
const nullPrice = { id: 4, weightGrams: 500, priceCents: null };
const setupItems = [
{ categoryId: 5, weightGrams: 200, priceCents: 5000, name: "Item" },
];
const result = computeImpactDeltas([nullPrice], setupItems, 5);
expect(result.deltas[4].weightDelta).toBe(300);
expect(result.deltas[4].priceDelta).toBeNull();
});
it("handles replace mode with null replaced item weight", () => {
const setupItems = [
{
categoryId: 5,
weightGrams: null,
priceCents: 5000,
name: "Unknown Weight",
},
];
const result = computeImpactDeltas([candidate], setupItems, 5);
expect(result.deltas[1].weightDelta).toBe(500); // treat as add for weight
expect(result.deltas[1].priceDelta).toBe(15000); // 20000 - 5000
});
it("shows negative delta when candidate is lighter", () => {
const setupItems = [
{ categoryId: 5, weightGrams: 1000, priceCents: 50000, name: "Heavy" },
];
const result = computeImpactDeltas([candidate], setupItems, 5);
expect(result.deltas[1].weightDelta).toBe(-500);
expect(result.deltas[1].priceDelta).toBe(-30000);
});
it("handles multiple candidates", () => {
const setupItems = [
{ categoryId: 5, weightGrams: 400, priceCents: 18000, name: "Current" },
];
const result = computeImpactDeltas([candidate, candidate2], setupItems, 5);
expect(result.deltas[1].weightDelta).toBe(100); // 500 - 400
expect(result.deltas[2].weightDelta).toBe(-100); // 300 - 400
});
});

37
tests/lib/params.test.ts Normal file
View File

@@ -0,0 +1,37 @@
import { describe, expect, it } from "bun:test";
import { parseId } from "../../src/server/lib/params";
describe("parseId", () => {
it("returns number for valid positive integers", () => {
expect(parseId("1")).toBe(1);
expect(parseId("42")).toBe(42);
expect(parseId("999")).toBe(999);
});
it("returns null for zero", () => {
expect(parseId("0")).toBeNull();
});
it("returns null for negative numbers", () => {
expect(parseId("-1")).toBeNull();
expect(parseId("-100")).toBeNull();
});
it("returns null for decimals", () => {
expect(parseId("1.5")).toBeNull();
expect(parseId("3.14")).toBeNull();
});
it("returns null for non-numeric strings", () => {
expect(parseId("abc")).toBeNull();
expect(parseId("")).toBeNull();
expect(parseId("hello")).toBeNull();
expect(parseId("12abc")).toBeNull();
});
it("returns null for special values", () => {
expect(parseId("NaN")).toBeNull();
expect(parseId("Infinity")).toBeNull();
expect(parseId("-Infinity")).toBeNull();
});
});

View File

@@ -0,0 +1,85 @@
import { beforeEach, describe, expect, it } from "bun:test";
import { Hono } from "hono";
import {
_resetForTesting,
rateLimit,
} from "../../src/server/middleware/rateLimit";
function createApp() {
const app = new Hono();
app.post("/login", rateLimit, (c) => c.json({ ok: true }));
app.post("/setup", rateLimit, (c) => c.json({ ok: true }));
return app;
}
function makeRequest(app: Hono, path: string, ip = "127.0.0.1") {
return app.request(path, {
method: "POST",
headers: { "x-forwarded-for": ip },
});
}
describe("rateLimit middleware", () => {
beforeEach(() => {
_resetForTesting();
});
it("allows first request through", async () => {
const app = createApp();
const res = await makeRequest(app, "/login");
expect(res.status).toBe(200);
});
it("allows up to 5 requests", async () => {
const app = createApp();
for (let i = 0; i < 5; i++) {
const res = await makeRequest(app, "/login");
expect(res.status).toBe(200);
}
});
it("returns 429 after 5 requests", async () => {
const app = createApp();
for (let i = 0; i < 5; i++) {
await makeRequest(app, "/login");
}
const res = await makeRequest(app, "/login");
expect(res.status).toBe(429);
const body = await res.json();
expect(body.error).toBe("Too many attempts. Try again later.");
});
it("includes Retry-After header on 429", async () => {
const app = createApp();
for (let i = 0; i < 5; i++) {
await makeRequest(app, "/login");
}
const res = await makeRequest(app, "/login");
expect(res.status).toBe(429);
const retryAfter = res.headers.get("Retry-After");
expect(retryAfter).toBeTruthy();
expect(Number(retryAfter)).toBeGreaterThan(0);
});
it("tracks different IPs independently", async () => {
const app = createApp();
for (let i = 0; i < 5; i++) {
await makeRequest(app, "/login", "10.0.0.1");
}
const blocked = await makeRequest(app, "/login", "10.0.0.1");
expect(blocked.status).toBe(429);
const allowed = await makeRequest(app, "/login", "10.0.0.2");
expect(allowed.status).toBe(200);
});
it("tracks different paths independently", async () => {
const app = createApp();
for (let i = 0; i < 5; i++) {
await makeRequest(app, "/login");
}
const blockedLogin = await makeRequest(app, "/login");
expect(blockedLogin.status).toBe(429);
const allowedSetup = await makeRequest(app, "/setup");
expect(allowedSetup.status).toBe(200);
});
});

View File

@@ -118,4 +118,90 @@ describe("Item Routes", () => {
const res = await app.request("/api/items/9999");
expect(res.status).toBe(404);
});
it("POST /api/items/:id/duplicate returns 201 with the copy", async () => {
const createRes = await app.request("/api/items", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "Tent", categoryId: 1, weightGrams: 1200 }),
});
const created = await createRes.json();
const res = await app.request(`/api/items/${created.id}/duplicate`, {
method: "POST",
});
expect(res.status).toBe(201);
const body = await res.json();
expect(body.name).toBe("Tent (copy)");
expect(body.weightGrams).toBe(1200);
expect(body.id).not.toBe(created.id);
});
it("POST /api/items/999/duplicate returns 404", async () => {
const res = await app.request("/api/items/999/duplicate", {
method: "POST",
});
expect(res.status).toBe(404);
});
it("GET /api/items/export returns CSV with correct content-type", async () => {
// Create an item first
await app.request("/api/items", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: "Tent",
weightGrams: 1200,
priceCents: 35000,
categoryId: 1,
}),
});
const res = await app.request("/api/items/export");
expect(res.status).toBe(200);
expect(res.headers.get("Content-Type")).toContain("text/csv");
const text = await res.text();
const lines = text.split("\n");
expect(lines[0]).toBe(
"name,quantity,weightGrams,priceCents,category,notes,productUrl",
);
expect(lines.length).toBeGreaterThanOrEqual(2);
expect(lines[1]).toContain("Tent");
});
it("POST /api/items/import with CSV file creates items", async () => {
const csvContent = [
"name,quantity,weightGrams,priceCents,category,notes,productUrl",
"Sleeping Bag,1,800,25000,Camping,,",
].join("\n");
const formData = new FormData();
formData.append(
"file",
new Blob([csvContent], { type: "text/csv" }),
"import.csv",
);
const res = await app.request("/api/items/import", {
method: "POST",
body: formData,
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.imported).toBe(1);
expect(body.errors).toHaveLength(0);
});
it("POST /api/items/import with no file returns 400", async () => {
const res = await app.request("/api/items/import", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
expect(res.status).toBe(400);
});
});

View File

@@ -0,0 +1,81 @@
import { beforeEach, describe, expect, it } from "bun:test";
import { Hono } from "hono";
import { categoryRoutes } from "../../src/server/routes/categories";
import { itemRoutes } from "../../src/server/routes/items";
import { setupRoutes } from "../../src/server/routes/setups";
import { threadRoutes } from "../../src/server/routes/threads";
import { createTestDb } from "../helpers/db";
function createTestApp() {
const db = createTestDb();
const app = new Hono();
app.use("*", async (c, next) => {
c.set("db", db);
await next();
});
app.route("/api/items", itemRoutes);
app.route("/api/categories", categoryRoutes);
app.route("/api/threads", threadRoutes);
app.route("/api/setups", setupRoutes);
return app;
}
describe("Invalid ID parameter handling", () => {
let app: Hono;
beforeEach(() => {
app = createTestApp();
});
describe("items", () => {
it("GET /api/items/abc returns 400", async () => {
const res = await app.request("/api/items/abc");
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toContain("Invalid");
});
it("GET /api/items/0 returns 400", async () => {
const res = await app.request("/api/items/0");
expect(res.status).toBe(400);
});
it("GET /api/items/-1 returns 400", async () => {
const res = await app.request("/api/items/-1");
expect(res.status).toBe(400);
});
});
describe("categories", () => {
it("DELETE /api/categories/abc returns 400", async () => {
const res = await app.request("/api/categories/abc", {
method: "DELETE",
});
expect(res.status).toBe(400);
});
});
describe("threads", () => {
it("GET /api/threads/abc returns 400", async () => {
const res = await app.request("/api/threads/abc");
expect(res.status).toBe(400);
});
it("GET /api/threads/1.5 returns 400", async () => {
const res = await app.request("/api/threads/1.5");
expect(res.status).toBe(400);
});
});
describe("setups", () => {
it("GET /api/setups/abc returns 400", async () => {
const res = await app.request("/api/setups/abc");
expect(res.status).toBe(400);
});
it("GET /api/setups/0 returns 400", async () => {
const res = await app.request("/api/setups/0");
expect(res.status).toBe(400);
});
});
});

View File

@@ -0,0 +1,197 @@
import { beforeEach, describe, expect, it } from "bun:test";
import { items } from "../../src/db/schema.ts";
import {
exportItemsCsv,
importItemsCsv,
} from "../../src/server/services/csv.service.ts";
import { createItem } from "../../src/server/services/item.service.ts";
import { createTestDb } from "../helpers/db.ts";
describe("CSV Service", () => {
let db: ReturnType<typeof createTestDb>;
beforeEach(() => {
db = createTestDb();
});
// ── Export ────────────────────────────────────────────────────────────────
describe("exportItemsCsv", () => {
it("returns correct headers on empty collection", () => {
const csv = exportItemsCsv(db);
const lines = csv.split("\n");
expect(lines[0]).toBe(
"name,quantity,weightGrams,priceCents,category,notes,productUrl",
);
expect(lines).toHaveLength(1);
});
it("exports items with correct values", () => {
createItem(db, {
name: "Tent",
weightGrams: 1200,
priceCents: 35000,
categoryId: 1,
notes: "Ultralight",
productUrl: "https://example.com/tent",
});
const csv = exportItemsCsv(db);
const lines = csv.split("\n");
expect(lines).toHaveLength(2);
expect(lines[1]).toContain("Tent");
expect(lines[1]).toContain("1200");
expect(lines[1]).toContain("35000");
expect(lines[1]).toContain("Uncategorized");
expect(lines[1]).toContain("Ultralight");
expect(lines[1]).toContain("https://example.com/tent");
});
it("properly escapes fields with commas", () => {
createItem(db, {
name: "Tent, Ultralight",
categoryId: 1,
});
const csv = exportItemsCsv(db);
const lines = csv.split("\n");
expect(lines[1]).toContain('"Tent, Ultralight"');
});
it("properly escapes fields with double quotes", () => {
createItem(db, {
name: 'He said "great tent"',
categoryId: 1,
});
const csv = exportItemsCsv(db);
const lines = csv.split("\n");
expect(lines[1]).toContain('"He said ""great tent"""');
});
it("exports multiple items", () => {
createItem(db, { name: "Tent", categoryId: 1 });
createItem(db, { name: "Sleeping Bag", categoryId: 1 });
const csv = exportItemsCsv(db);
const lines = csv.split("\n");
expect(lines).toHaveLength(3); // header + 2 items
});
it("exports quantity correctly", () => {
// Insert directly to set quantity > 1 (createItem service defaults to 1)
db.insert(items)
.values({ name: "Bolt", categoryId: 1, quantity: 4 })
.run();
const csv = exportItemsCsv(db);
const lines = csv.split("\n");
const fields = lines[1].split(",");
// quantity is second field
expect(fields[1]).toBe("4");
});
});
// ── Import ────────────────────────────────────────────────────────────────
describe("importItemsCsv", () => {
it("parses a valid CSV and creates items", () => {
const csv = [
"name,quantity,weightGrams,priceCents,category,notes,productUrl",
"Tent,1,1200,35000,Camping,Ultralight,https://example.com/tent",
"Sleeping Bag,1,800,25000,Camping,,",
].join("\n");
const result = importItemsCsv(db, csv);
expect(result.imported).toBe(2);
expect(result.errors).toHaveLength(0);
});
it("creates missing category and reports it", () => {
const csv = [
"name,quantity,weightGrams,priceCents,category,notes,productUrl",
"Helmet,1,350,12000,Cycling,,",
].join("\n");
const result = importItemsCsv(db, csv);
expect(result.imported).toBe(1);
expect(result.createdCategories).toContain("Cycling");
expect(result.errors).toHaveLength(0);
});
it("uses existing category (case-insensitive) without creating a duplicate", () => {
const csv = [
"name,quantity,weightGrams,priceCents,category,notes,productUrl",
// "uncategorized" should match the seeded "Uncategorized"
"Spork,1,,,uncategorized,,",
].join("\n");
const result = importItemsCsv(db, csv);
expect(result.imported).toBe(1);
expect(result.createdCategories).toHaveLength(0);
});
it("skips rows with no name and records an error", () => {
const csv = [
"name,quantity,weightGrams,priceCents,category,notes,productUrl",
",1,200,,,",
"Tent,1,1200,,,",
].join("\n");
const result = importItemsCsv(db, csv);
expect(result.imported).toBe(1);
expect(result.errors).toHaveLength(1);
expect(result.errors[0]).toMatch(/missing required field "name"/);
});
it("defaults quantity to 1 when not provided", () => {
const csv = [
"name,weightGrams,priceCents,category,notes,productUrl",
"Tent,1200,35000,Camping,,",
].join("\n");
const result = importItemsCsv(db, csv);
expect(result.imported).toBe(1);
expect(result.errors).toHaveLength(0);
});
it("handles optional fields being empty", () => {
const csv = [
"name,quantity,weightGrams,priceCents,category,notes,productUrl",
"Tent,,,,,",
].join("\n");
const result = importItemsCsv(db, csv);
expect(result.imported).toBe(1);
expect(result.errors).toHaveLength(0);
});
it("handles quoted fields containing commas", () => {
const csv = [
"name,quantity,weightGrams,priceCents,category,notes,productUrl",
'"Tent, Ultralight",1,1200,,,',
].join("\n");
const result = importItemsCsv(db, csv);
expect(result.imported).toBe(1);
expect(result.errors).toHaveLength(0);
});
it("returns zero imported on empty CSV", () => {
const result = importItemsCsv(db, "");
expect(result.imported).toBe(0);
expect(result.errors).toHaveLength(0);
});
it("uses Uncategorized when category column is empty", () => {
const csv = [
"name,quantity,weightGrams,priceCents,category,notes,productUrl",
"Tent,1,,,,",
].join("\n");
const result = importItemsCsv(db, csv);
expect(result.imported).toBe(1);
expect(result.createdCategories).toHaveLength(0);
});
});
});

View File

@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it } from "bun:test";
import {
createItem,
deleteItem,
duplicateItem,
getAllItems,
getItemById,
updateItem,
@@ -98,6 +99,41 @@ describe("Item Service", () => {
});
});
describe("duplicateItem", () => {
it("creates a copy with '(copy)' suffix in name", () => {
const original = createItem(db, {
name: "Tent",
weightGrams: 1200,
priceCents: 35000,
categoryId: 1,
notes: "Ultralight",
productUrl: "https://example.com/tent",
});
const copy = duplicateItem(db, original?.id);
expect(copy).toBeDefined();
expect(copy?.name).toBe("Tent (copy)");
expect(copy?.weightGrams).toBe(1200);
expect(copy?.priceCents).toBe(35000);
expect(copy?.categoryId).toBe(1);
expect(copy?.notes).toBe("Ultralight");
expect(copy?.productUrl).toBe("https://example.com/tent");
});
it("copy has a different ID from the original", () => {
const original = createItem(db, { name: "Helmet", categoryId: 1 });
const copy = duplicateItem(db, original?.id);
expect(copy?.id).not.toBe(original?.id);
});
it("returns null for non-existent item", () => {
const result = duplicateItem(db, 9999);
expect(result).toBeNull();
});
});
describe("deleteItem", () => {
it("removes item from DB, returns deleted item", () => {
const created = createItem(db, {