24 Commits

Author SHA1 Message Date
bb60168ffb Merge pull request 'feat: user menu dropdown + fix MCP tool schemas' (#10) from feature/user-menu-dropdown into Develop
All checks were successful
CI / ci (push) Successful in 25s
CI / e2e (push) Successful in 1m2s
2026-04-03 18:59:59 +00:00
68f6647f76 fix: convert MCP tool schemas from JSON Schema to Zod for SDK v1.29.0
All checks were successful
CI / ci (push) Successful in 28s
CI / ci (pull_request) Successful in 25s
CI / e2e (push) Successful in 1m2s
CI / e2e (pull_request) Successful in 1m3s
The MCP SDK v1.29.0 changed server.tool() to require Zod schemas
(raw shapes) instead of plain JSON Schema objects. The old format
triggered "expected a Zod schema or ToolAnnotations" errors.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 20:54:20 +02:00
0a40d7627f feat: add user menu dropdown with settings link and sign out
Replace the plain "Sign out" button in the header with a user icon
that opens a dropdown menu containing Settings and Sign out options.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 20:19:53 +02:00
3eccbb12fd Merge pull request 'v1.4 Collection Tools' (#9) from feature/v1.4-collection-tools into Develop
All checks were successful
CI / ci (push) Successful in 24s
CI / e2e (push) Successful in 1m0s
2026-04-03 18:05:23 +00:00
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
46 changed files with 2324 additions and 244 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

2
bunfig.toml Normal file
View File

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

View File

@@ -0,0 +1,35 @@
# User Menu Design
## Summary
Replace the plain "Sign out" button in the header with a user icon that opens a dropdown menu containing Settings and Sign out options. This provides a way to navigate to the Settings page from the header.
## Components
### UserMenu (`src/client/components/UserMenu.tsx`)
New component rendered by `TotalsBar` when authenticated.
**Trigger:** Circular `CircleUser` icon button (Lucide). Styled consistently with surrounding header elements.
**Dropdown:** Absolutely-positioned popover anchored to the right edge, appearing below the icon:
1. **Settings**`Settings` (gear) icon + "Settings" label, `<Link to="/settings">`
2. **Divider** — thin horizontal line
3. **Sign out**`LogOut` icon + "Sign out" label, calls `logout.mutate()` from `useLogout()`
**Behavior:**
- Click icon toggles open/close
- Click outside closes (via `useEffect` with document click listener)
- Clicking a menu item closes the dropdown
- Dropdown anchored right so it doesn't overflow viewport
### TotalsBar Changes (`src/client/components/TotalsBar.tsx`)
- When `isAuthenticated`: render `<UserMenu />` in place of the current "Sign out" button
- When not authenticated: keep the existing "Sign in" link unchanged
- Remove the `useLogout` hook usage from TotalsBar (moved into UserMenu)
## No Backend Changes
The existing `/api/auth/me` endpoint and `useAuth` hook are sufficient. No username display needed — using a generic user icon.

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

View File

@@ -1,7 +1,9 @@
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 {
@@ -20,6 +22,7 @@ interface CandidateCardProps {
pros?: string | null;
cons?: string | null;
rank?: number;
delta?: CandidateDelta;
}
export function CandidateCard({
@@ -38,6 +41,7 @@ export function CandidateCard({
pros,
cons,
rank,
delta,
}: CandidateCardProps) {
const { weight, price } = useFormatters();
const openCandidateEditPanel = useUIStore((s) => s.openCandidateEditPanel);
@@ -165,11 +169,13 @@ export function CandidateCard({
{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">
{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,7 +1,10 @@
import { Reorder, useDragControls } from "framer-motion";
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 {
@@ -28,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
@@ -49,8 +54,10 @@ export function CandidateListItem({
rank,
isActive,
onStatusChange,
delta,
onDragEnd,
}: CandidateListItemProps) {
const controls = useDragControls();
const isDragging = useRef(false);
const { weight, price } = useFormatters();
const openCandidateEditPanel = useUIStore((s) => s.openCandidateEditPanel);
const openConfirmDeleteCandidate = useUIStore(
@@ -60,20 +67,15 @@ export function CandidateListItem({
const openExternalLink = useUIStore((s) => s.openExternalLink);
const sharedClassName =
"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";
"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 */}
{/* 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 */}
@@ -99,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">
@@ -111,11 +116,13 @@ export function CandidateListItem({
{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">
{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}
@@ -209,8 +216,17 @@ export function CandidateListItem({
return (
<Reorder.Item
value={candidate}
dragControls={controls}
dragListener={false}
onDragStart={() => {
isDragging.current = true;
}}
onDragEnd={() => {
setTimeout(() => {
isDragging.current = false;
}, 0);
onDragEnd?.();
}}
whileDrag={{ cursor: "grabbing" }}
style={{ marginBottom: 8, cursor: "grab" }}
className={sharedClassName}
>
{innerContent}

View File

@@ -224,6 +224,7 @@ export function CollectionView() {
name={item.name}
weightGrams={item.weightGrams}
priceCents={item.priceCents}
quantity={item.quantity}
categoryName={item.categoryName}
categoryIcon={item.categoryIcon}
imageFilename={item.imageFilename}
@@ -257,6 +258,7 @@ export function CollectionView() {
name={item.name}
weightGrams={item.weightGrams}
priceCents={item.priceCents}
quantity={item.quantity}
categoryName={categoryName}
categoryIcon={categoryIcon}
imageFilename={item.imageFilename}

View File

@@ -1,8 +1,10 @@
import { useMemo } from "react";
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;
@@ -26,6 +28,7 @@ interface CandidateWithCategory {
interface ComparisonTableProps {
candidates: CandidateWithCategory[];
resolvedCandidateId: number | null;
deltas?: Record<number, CandidateDelta>;
}
const STATUS_LABELS: Record<"researching" | "ordered" | "arrived", string> = {
@@ -37,6 +40,7 @@ const STATUS_LABELS: Record<"researching" | "ordered" | "arrived", string> = {
export function ComparisonTable({
candidates,
resolvedCandidateId,
deltas,
}: ComparisonTableProps) {
const { weight, price } = useFormatters();
const openExternalLink = useUIStore((s) => s.openExternalLink);
@@ -263,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 (
@@ -324,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,4 +1,5 @@
import { useFormatters } from "../hooks/useFormatters";
import { useDuplicateItem } from "../hooks/useItems";
import { LucideIcon } from "../lib/iconData";
import { useUIStore } from "../stores/uiStore";
import { ClassificationBadge } from "./ClassificationBadge";
@@ -8,6 +9,7 @@ interface ItemCardProps {
name: string;
weightGrams: number | null;
priceCents: number | null;
quantity?: number;
categoryName: string;
categoryIcon: string;
imageFilename: string | null;
@@ -22,6 +24,7 @@ export function ItemCard({
name,
weightGrams,
priceCents,
quantity,
categoryName,
categoryIcon,
imageFilename,
@@ -33,6 +36,7 @@ export function ItemCard({
const { weight, price } = useFormatters();
const openEditPanel = useUIStore((s) => s.openEditPanel);
const openExternalLink = useUIStore((s) => s.openExternalLink);
const duplicateItem = useDuplicateItem();
return (
<button
@@ -40,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"
@@ -122,9 +166,16 @@ 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">

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

@@ -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

@@ -1,10 +1,11 @@
import { Link } from "@tanstack/react-router";
import { useAuth, useLogout } from "../hooks/useAuth";
import { useAuth } from "../hooks/useAuth";
import { useFormatters } from "../hooks/useFormatters";
import { useUpdateSetting } from "../hooks/useSettings";
import { useTotals } from "../hooks/useTotals";
import type { WeightUnit } from "../lib/formatters";
import { LucideIcon } from "../lib/iconData";
import { UserMenu } from "./UserMenu";
const UNITS: WeightUnit[] = ["g", "oz", "lb", "kg"];
@@ -21,7 +22,6 @@ export function TotalsBar({
}: TotalsBarProps) {
const { data } = useTotals();
const { data: auth } = useAuth();
const logout = useLogout();
const isAuthenticated = !!auth?.user;
const { weight, price, unit } = useFormatters();
const updateSetting = useUpdateSetting();
@@ -104,15 +104,8 @@ export function TotalsBar({
))}
</div>
)}
<div className="flex items-center gap-2 ml-auto">
{isAuthenticated ? (
<button
type="button"
onClick={() => logout.mutate()}
className="text-xs text-gray-500 hover:text-gray-700 transition-colors"
>
Sign out
</button>
<UserMenu />
) : (
<Link
to="/login"
@@ -125,6 +118,5 @@ export function TotalsBar({
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,57 @@
import { Link } from "@tanstack/react-router";
import { useEffect, useRef, useState } from "react";
import { useLogout } from "../hooks/useAuth";
import { LucideIcon } from "../lib/iconData";
export function UserMenu() {
const [open, setOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
const logout = useLogout();
useEffect(() => {
if (!open) return;
function handleClick(e: MouseEvent) {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setOpen(false);
}
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, [open]);
return (
<div ref={menuRef} className="relative">
<button
type="button"
onClick={() => setOpen((prev) => !prev)}
className="flex items-center justify-center w-8 h-8 rounded-full text-gray-500 hover:text-gray-700 hover:bg-gray-100 transition-colors"
>
<LucideIcon name="circle-user" size={22} />
</button>
{open && (
<div className="absolute right-0 mt-1 w-40 bg-white rounded-lg shadow-lg border border-gray-200 py-1 z-50">
<Link
to="/settings"
onClick={() => setOpen(false)}
className="flex items-center gap-2 px-3 py-2 text-sm text-gray-700 hover:bg-gray-50 transition-colors"
>
<LucideIcon name="settings" size={16} className="text-gray-400" />
Settings
</Link>
<div className="border-t border-gray-100 my-1" />
<button
type="button"
onClick={() => {
setOpen(false);
logout.mutate();
}}
className="flex items-center gap-2 w-full px-3 py-2 text-sm text-gray-700 hover:bg-gray-50 transition-colors"
>
<LucideIcon name="log-out" size={16} className="text-gray-400" />
Sign out
</button>
</div>
)}
</div>
);
}

View File

@@ -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)
@@ -148,17 +155,23 @@ export function WeightSummaryCard({ items }: WeightSummaryCardProps) {
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,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,4 +1,4 @@
import { createFileRoute } from "@tanstack/react-router";
import { createFileRoute, Link } from "@tanstack/react-router";
import { AnimatePresence, motion } from "framer-motion";
import { useRef } from "react";
import { z } from "zod";
@@ -16,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 }),
@@ -33,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}

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

@@ -53,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;
@@ -207,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 (
@@ -232,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,3 +1,4 @@
import { z } from "zod";
import type { db as prodDb } from "../../../db/index.ts";
import {
createCategory,
@@ -24,24 +25,14 @@ export const categoryToolDefinitions = [
{
name: "list_categories",
description: "List all gear categories.",
inputSchema: {
type: "object" as const,
properties: {},
},
inputSchema: {},
},
{
name: "create_category",
description: "Create a new gear category.",
inputSchema: {
type: "object" as const,
properties: {
name: { type: "string", description: "Category name" },
icon: {
type: "string",
description: "Icon name (defaults to 'package')",
},
},
required: ["name"],
name: z.string().describe("Category name"),
icon: z.string().optional().describe("Icon name (defaults to 'package')"),
},
},
];

View File

@@ -1,3 +1,4 @@
import { z } from "zod";
import { fetchImageFromUrl } from "../../services/image.service.ts";
interface ToolResult {
@@ -20,14 +21,9 @@ export const imageToolDefinitions = [
description:
"Fetch an image from a URL and save it locally. Returns the filename to use with create_item or add_candidate.",
inputSchema: {
type: "object" as const,
properties: {
url: {
type: "string",
description: "URL of the image to fetch (jpeg, png, or webp)",
},
},
required: ["url"],
url: z
.string()
.describe("URL of the image to fetch (jpeg, png, or webp)"),
},
},
];

View File

@@ -1,3 +1,4 @@
import { z } from "zod";
import type { db as prodDb } from "../../../db/index.ts";
import {
createItem,
@@ -29,24 +30,14 @@ export const itemToolDefinitions = [
description:
"List all items in the gear collection, optionally filtered by category.",
inputSchema: {
type: "object" as const,
properties: {
categoryId: {
type: "number",
description: "Filter items by category ID",
},
},
categoryId: z.number().optional().describe("Filter items by category ID"),
},
},
{
name: "get_item",
description: "Get a single item by its ID, including all details.",
inputSchema: {
type: "object" as const,
properties: {
id: { type: "number", description: "The item ID" },
},
required: ["id"],
id: z.number().describe("The item ID"),
},
},
{
@@ -54,60 +45,48 @@ export const itemToolDefinitions = [
description:
"Add a new item to the gear collection. Use this for items you've already decided on. For items you're still researching, use create_thread instead.",
inputSchema: {
type: "object" as const,
properties: {
name: { type: "string", description: "Item name" },
categoryId: { type: "number", description: "Category ID" },
weightGrams: { type: "number", description: "Weight in grams" },
priceCents: { type: "number", description: "Price in cents" },
notes: { type: "string", description: "Notes about the item" },
productUrl: { type: "string", description: "URL to the product page" },
imageFilename: {
type: "string",
description: "Filename of an uploaded image",
},
imageSourceUrl: {
type: "string",
description: "Original URL the image was fetched from",
},
},
required: ["name", "categoryId"],
name: z.string().describe("Item name"),
categoryId: z.number().describe("Category ID"),
weightGrams: z.number().optional().describe("Weight in grams"),
priceCents: z.number().optional().describe("Price in cents"),
notes: z.string().optional().describe("Notes about the item"),
productUrl: z.string().optional().describe("URL to the product page"),
imageFilename: z
.string()
.optional()
.describe("Filename of an uploaded image"),
imageSourceUrl: z
.string()
.optional()
.describe("Original URL the image was fetched from"),
},
},
{
name: "update_item",
description: "Update an existing item's fields.",
inputSchema: {
type: "object" as const,
properties: {
id: { type: "number", description: "The item ID to update" },
name: { type: "string", description: "Item name" },
categoryId: { type: "number", description: "Category ID" },
weightGrams: { type: "number", description: "Weight in grams" },
priceCents: { type: "number", description: "Price in cents" },
notes: { type: "string", description: "Notes about the item" },
productUrl: { type: "string", description: "URL to the product page" },
imageFilename: {
type: "string",
description: "Filename of an uploaded image",
},
imageSourceUrl: {
type: "string",
description: "Original URL the image was fetched from",
},
},
required: ["id"],
id: z.number().describe("The item ID to update"),
name: z.string().optional().describe("Item name"),
categoryId: z.number().optional().describe("Category ID"),
weightGrams: z.number().optional().describe("Weight in grams"),
priceCents: z.number().optional().describe("Price in cents"),
notes: z.string().optional().describe("Notes about the item"),
productUrl: z.string().optional().describe("URL to the product page"),
imageFilename: z
.string()
.optional()
.describe("Filename of an uploaded image"),
imageSourceUrl: z
.string()
.optional()
.describe("Original URL the image was fetched from"),
},
},
{
name: "delete_item",
description: "Delete an item from the gear collection by ID.",
inputSchema: {
type: "object" as const,
properties: {
id: { type: "number", description: "The item ID to delete" },
},
required: ["id"],
id: z.number().describe("The item ID to delete"),
},
},
];

View File

@@ -1,3 +1,4 @@
import { z } from "zod";
import type { db as prodDb } from "../../../db/index.ts";
import {
createSetup,
@@ -28,31 +29,20 @@ export const setupToolDefinitions = [
name: "list_setups",
description:
"List all gear setups with item counts and weight/cost totals.",
inputSchema: {
type: "object" as const,
properties: {},
},
inputSchema: {},
},
{
name: "get_setup",
description: "Get a setup with all its items and details.",
inputSchema: {
type: "object" as const,
properties: {
id: { type: "number", description: "Setup ID" },
},
required: ["id"],
id: z.number().describe("Setup ID"),
},
},
{
name: "create_setup",
description: "Create a new gear setup (e.g. 'Bikepacking weekend').",
inputSchema: {
type: "object" as const,
properties: {
name: { type: "string", description: "Setup name" },
},
required: ["name"],
name: z.string().describe("Setup name"),
},
},
{
@@ -60,17 +50,12 @@ export const setupToolDefinitions = [
description:
"Update a setup's name and/or replace its item list. Pass itemIds to set exactly which items belong to this setup.",
inputSchema: {
type: "object" as const,
properties: {
id: { type: "number", description: "Setup ID" },
name: { type: "string", description: "New setup name" },
itemIds: {
type: "array",
items: { type: "number" },
description: "Array of item IDs to include in the setup",
},
},
required: ["id"],
id: z.number().describe("Setup ID"),
name: z.string().optional().describe("New setup name"),
itemIds: z
.array(z.number())
.optional()
.describe("Array of item IDs to include in the setup"),
},
},
];

View File

@@ -1,3 +1,4 @@
import { z } from "zod";
import type { db as prodDb } from "../../../db/index.ts";
import {
createCandidate,
@@ -31,14 +32,12 @@ export const threadToolDefinitions = [
description:
"List research threads. Threads are the recommended way to evaluate gear purchases — each thread tracks multiple candidates for a single gear slot, making it easy to compare options before committing.",
inputSchema: {
type: "object" as const,
properties: {
includeResolved: {
type: "boolean",
description:
includeResolved: z
.boolean()
.optional()
.describe(
"Include resolved threads (default: false, only active threads)",
},
},
),
},
},
{
@@ -46,11 +45,7 @@ export const threadToolDefinitions = [
description:
"Get a thread with all its candidates for detailed comparison.",
inputSchema: {
type: "object" as const,
properties: {
id: { type: "number", description: "Thread ID" },
},
required: ["id"],
id: z.number().describe("Thread ID"),
},
},
{
@@ -58,15 +53,8 @@ export const threadToolDefinitions = [
description:
"Start a new research thread for a gear slot. This is the preferred workflow: create a thread, add candidates with pros/cons/prices, compare them, then resolve the thread to add the winner to your collection.",
inputSchema: {
type: "object" as const,
properties: {
name: {
type: "string",
description: "Thread name (e.g. 'Handlebar bag')",
},
categoryId: { type: "number", description: "Category ID" },
},
required: ["name", "categoryId"],
name: z.string().describe("Thread name (e.g. 'Handlebar bag')"),
categoryId: z.number().describe("Category ID"),
},
},
{
@@ -74,35 +62,24 @@ export const threadToolDefinitions = [
description:
"Resolve a research thread by picking the winning candidate. The winner is automatically added to the gear collection as a new item, and the thread is marked as resolved.",
inputSchema: {
type: "object" as const,
properties: {
threadId: { type: "number", description: "Thread ID" },
candidateId: {
type: "number",
description: "ID of the winning candidate",
},
},
required: ["threadId", "candidateId"],
threadId: z.number().describe("Thread ID"),
candidateId: z.number().describe("ID of the winning candidate"),
},
},
{
name: "add_candidate",
description: "Add a candidate option to a research thread for comparison.",
inputSchema: {
type: "object" as const,
properties: {
threadId: { type: "number", description: "Thread ID" },
name: { type: "string", description: "Candidate name" },
categoryId: { type: "number", description: "Category ID" },
weightGrams: { type: "number", description: "Weight in grams" },
priceCents: { type: "number", description: "Price in cents" },
notes: { type: "string", description: "Notes" },
productUrl: { type: "string", description: "Product URL" },
imageFilename: { type: "string", description: "Image filename" },
pros: { type: "string", description: "Pros of this candidate" },
cons: { type: "string", description: "Cons of this candidate" },
},
required: ["threadId", "name", "categoryId"],
threadId: z.number().describe("Thread ID"),
name: z.string().describe("Candidate name"),
categoryId: z.number().describe("Category ID"),
weightGrams: z.number().optional().describe("Weight in grams"),
priceCents: z.number().optional().describe("Price in cents"),
notes: z.string().optional().describe("Notes"),
productUrl: z.string().optional().describe("Product URL"),
imageFilename: z.string().optional().describe("Image filename"),
pros: z.string().optional().describe("Pros of this candidate"),
cons: z.string().optional().describe("Cons of this candidate"),
},
},
{
@@ -110,36 +87,28 @@ export const threadToolDefinitions = [
description:
"Update a candidate's details (name, price, pros, cons, etc.).",
inputSchema: {
type: "object" as const,
properties: {
id: { type: "number", description: "Candidate ID" },
name: { type: "string", description: "Candidate name" },
weightGrams: { type: "number", description: "Weight in grams" },
priceCents: { type: "number", description: "Price in cents" },
categoryId: { type: "number", description: "Category ID" },
notes: { type: "string", description: "Notes" },
productUrl: { type: "string", description: "Product URL" },
imageFilename: { type: "string", description: "Image filename" },
imageSourceUrl: { type: "string", description: "Image source URL" },
status: {
type: "string",
description: "Status: researching, ordered, or arrived",
},
pros: { type: "string", description: "Pros" },
cons: { type: "string", description: "Cons" },
},
required: ["id"],
id: z.number().describe("Candidate ID"),
name: z.string().optional().describe("Candidate name"),
weightGrams: z.number().optional().describe("Weight in grams"),
priceCents: z.number().optional().describe("Price in cents"),
categoryId: z.number().optional().describe("Category ID"),
notes: z.string().optional().describe("Notes"),
productUrl: z.string().optional().describe("Product URL"),
imageFilename: z.string().optional().describe("Image filename"),
imageSourceUrl: z.string().optional().describe("Image source URL"),
status: z
.string()
.optional()
.describe("Status: researching, ordered, or arrived"),
pros: z.string().optional().describe("Pros"),
cons: z.string().optional().describe("Cons"),
},
},
{
name: "remove_candidate",
description: "Remove a candidate from a research thread.",
inputSchema: {
type: "object" as const,
properties: {
id: { type: "number", description: "Candidate ID to remove" },
},
required: ["id"],
id: z.number().describe("Candidate ID to remove"),
},
},
];

View File

@@ -4,9 +4,11 @@ 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,
@@ -16,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);
@@ -52,6 +75,15 @@ 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 = parseId(c.req.param("id"));

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

@@ -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
});
});

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,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, {