v1.4 Collection Tools #9
@@ -2,9 +2,7 @@ name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [Develop]
|
||||
pull_request:
|
||||
branches: [Develop]
|
||||
|
||||
jobs:
|
||||
ci:
|
||||
|
||||
@@ -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
|
||||
1
drizzle/0008_loving_colossus.sql
Normal file
1
drizzle/0008_loving_colossus.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE `items` ADD `quantity` integer DEFAULT 1 NOT NULL;
|
||||
663
drizzle/meta/0008_snapshot.json
Normal file
663
drizzle/meta/0008_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
@@ -57,6 +57,13 @@
|
||||
"when": 1775215076284,
|
||||
"tag": "0007_icy_prodigy",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 8,
|
||||
"version": "6",
|
||||
"when": 1775232090363,
|
||||
"tag": "0008_loving_colossus",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
39
src/client/components/ImpactDeltaBadge.tsx
Normal file
39
src/client/components/ImpactDeltaBadge.tsx
Normal 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>;
|
||||
}
|
||||
@@ -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">
|
||||
{name}
|
||||
</h3>
|
||||
<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">
|
||||
×{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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
34
src/client/components/SetupImpactSelector.tsx
Normal file
34
src/client/components/SetupImpactSelector.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
22
src/client/hooks/useImpactDeltas.ts
Normal file
22
src/client/hooks/useImpactDeltas.ts
Normal 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],
|
||||
);
|
||||
}
|
||||
@@ -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"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
class ApiError extends Error {
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public status: number,
|
||||
|
||||
69
src/client/lib/impactDeltas.ts
Normal file
69
src/client/lib/impactDeltas.ts
Normal 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 };
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 { data: setupData } = useSetup(selectedSetupId);
|
||||
const { deltas } = useImpactDeltas(
|
||||
thread?.candidates ?? [],
|
||||
setupData?.items,
|
||||
thread?.categoryId ?? 0,
|
||||
);
|
||||
|
||||
const [tempItems, setTempItems] =
|
||||
useState<typeof thread extends { candidates: infer C } ? C : never | null>(
|
||||
null,
|
||||
);
|
||||
const [tempItems, setTempItems] = useState<
|
||||
NonNullable<typeof thread>["candidates"] | null
|
||||
>(null);
|
||||
|
||||
// Clear tempItems when server data changes (biome-ignore: thread?.candidates is intentional dep)
|
||||
// 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>
|
||||
|
||||
@@ -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 }),
|
||||
}));
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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"));
|
||||
|
||||
246
src/server/services/csv.service.ts
Normal file
246
src/server/services/csv.service.ts
Normal 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;
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -299,6 +299,7 @@ export function resolveThread(
|
||||
productUrl: candidate.productUrl,
|
||||
imageFilename: candidate.imageFilename,
|
||||
imageSourceUrl: candidate.imageSourceUrl,
|
||||
quantity: 1,
|
||||
})
|
||||
.returning()
|
||||
.get();
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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({
|
||||
|
||||
87
tests/lib/impactDeltas.test.ts
Normal file
87
tests/lib/impactDeltas.test.ts
Normal 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
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
197
tests/services/csv.service.test.ts
Normal file
197
tests/services/csv.service.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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, {
|
||||
|
||||
Reference in New Issue
Block a user