Merge pull request 'v1.4 Collection Tools' (#9) from feature/v1.4-collection-tools into Develop
This commit was merged in pull request #9.
This commit is contained in:
@@ -2,9 +2,7 @@ name: CI
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [Develop]
|
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [Develop]
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
ci:
|
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,
|
"when": 1775215076284,
|
||||||
"tag": "0007_icy_prodigy",
|
"tag": "0007_icy_prodigy",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 8,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1775232090363,
|
||||||
|
"tag": "0008_loving_colossus",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
import { useFormatters } from "../hooks/useFormatters";
|
import { useFormatters } from "../hooks/useFormatters";
|
||||||
|
import type { CandidateDelta } from "../hooks/useImpactDeltas";
|
||||||
import { LucideIcon } from "../lib/iconData";
|
import { LucideIcon } from "../lib/iconData";
|
||||||
import { useUIStore } from "../stores/uiStore";
|
import { useUIStore } from "../stores/uiStore";
|
||||||
import { RankBadge } from "./CandidateListItem";
|
import { RankBadge } from "./CandidateListItem";
|
||||||
|
import { ImpactDeltaBadge } from "./ImpactDeltaBadge";
|
||||||
import { StatusBadge } from "./StatusBadge";
|
import { StatusBadge } from "./StatusBadge";
|
||||||
|
|
||||||
interface CandidateCardProps {
|
interface CandidateCardProps {
|
||||||
@@ -20,6 +22,7 @@ interface CandidateCardProps {
|
|||||||
pros?: string | null;
|
pros?: string | null;
|
||||||
cons?: string | null;
|
cons?: string | null;
|
||||||
rank?: number;
|
rank?: number;
|
||||||
|
delta?: CandidateDelta;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CandidateCard({
|
export function CandidateCard({
|
||||||
@@ -38,6 +41,7 @@ export function CandidateCard({
|
|||||||
pros,
|
pros,
|
||||||
cons,
|
cons,
|
||||||
rank,
|
rank,
|
||||||
|
delta,
|
||||||
}: CandidateCardProps) {
|
}: CandidateCardProps) {
|
||||||
const { weight, price } = useFormatters();
|
const { weight, price } = useFormatters();
|
||||||
const openCandidateEditPanel = useUIStore((s) => s.openCandidateEditPanel);
|
const openCandidateEditPanel = useUIStore((s) => s.openCandidateEditPanel);
|
||||||
@@ -165,11 +169,13 @@ export function CandidateCard({
|
|||||||
{weight(weightGrams)}
|
{weight(weightGrams)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
<ImpactDeltaBadge delta={delta} type="weight" formatFn={weight} />
|
||||||
{priceCents != null && (
|
{priceCents != null && (
|
||||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-50 text-green-500">
|
<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)}
|
{price(priceCents)}
|
||||||
</span>
|
</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">
|
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-50 text-gray-600">
|
||||||
<LucideIcon
|
<LucideIcon
|
||||||
name={categoryIcon}
|
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 { useFormatters } from "../hooks/useFormatters";
|
||||||
|
import type { CandidateDelta } from "../hooks/useImpactDeltas";
|
||||||
import { LucideIcon } from "../lib/iconData";
|
import { LucideIcon } from "../lib/iconData";
|
||||||
import { useUIStore } from "../stores/uiStore";
|
import { useUIStore } from "../stores/uiStore";
|
||||||
|
import { ImpactDeltaBadge } from "./ImpactDeltaBadge";
|
||||||
import { StatusBadge } from "./StatusBadge";
|
import { StatusBadge } from "./StatusBadge";
|
||||||
|
|
||||||
interface CandidateWithCategory {
|
interface CandidateWithCategory {
|
||||||
@@ -28,6 +31,8 @@ interface CandidateListItemProps {
|
|||||||
rank: number;
|
rank: number;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
onStatusChange: (status: "researching" | "ordered" | "arrived") => void;
|
onStatusChange: (status: "researching" | "ordered" | "arrived") => void;
|
||||||
|
delta?: CandidateDelta;
|
||||||
|
onDragEnd?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const RANK_COLORS = ["#D4AF37", "#C0C0C0", "#CD7F32"]; // gold, silver, bronze
|
const RANK_COLORS = ["#D4AF37", "#C0C0C0", "#CD7F32"]; // gold, silver, bronze
|
||||||
@@ -49,8 +54,10 @@ export function CandidateListItem({
|
|||||||
rank,
|
rank,
|
||||||
isActive,
|
isActive,
|
||||||
onStatusChange,
|
onStatusChange,
|
||||||
|
delta,
|
||||||
|
onDragEnd,
|
||||||
}: CandidateListItemProps) {
|
}: CandidateListItemProps) {
|
||||||
const controls = useDragControls();
|
const isDragging = useRef(false);
|
||||||
const { weight, price } = useFormatters();
|
const { weight, price } = useFormatters();
|
||||||
const openCandidateEditPanel = useUIStore((s) => s.openCandidateEditPanel);
|
const openCandidateEditPanel = useUIStore((s) => s.openCandidateEditPanel);
|
||||||
const openConfirmDeleteCandidate = useUIStore(
|
const openConfirmDeleteCandidate = useUIStore(
|
||||||
@@ -60,20 +67,15 @@ export function CandidateListItem({
|
|||||||
const openExternalLink = useUIStore((s) => s.openExternalLink);
|
const openExternalLink = useUIStore((s) => s.openExternalLink);
|
||||||
|
|
||||||
const sharedClassName =
|
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 = (
|
const innerContent = (
|
||||||
<>
|
<>
|
||||||
{/* Drag handle */}
|
{/* Drag handle indicator */}
|
||||||
{isActive && (
|
{isActive && (
|
||||||
<button
|
<span className="text-gray-300 shrink-0">
|
||||||
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"
|
|
||||||
>
|
|
||||||
<LucideIcon name="grip-vertical" size={16} />
|
<LucideIcon name="grip-vertical" size={16} />
|
||||||
</button>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Rank badge */}
|
{/* Rank badge */}
|
||||||
@@ -99,7 +101,10 @@ export function CandidateListItem({
|
|||||||
{/* Name + badges */}
|
{/* Name + badges */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => openCandidateEditPanel(candidate.id)}
|
onClick={() => {
|
||||||
|
if (isDragging.current) return;
|
||||||
|
openCandidateEditPanel(candidate.id);
|
||||||
|
}}
|
||||||
className="flex-1 min-w-0 text-left"
|
className="flex-1 min-w-0 text-left"
|
||||||
>
|
>
|
||||||
<p className="text-sm font-semibold text-gray-900 truncate">
|
<p className="text-sm font-semibold text-gray-900 truncate">
|
||||||
@@ -111,11 +116,13 @@ export function CandidateListItem({
|
|||||||
{weight(candidate.weightGrams)}
|
{weight(candidate.weightGrams)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
<ImpactDeltaBadge delta={delta} type="weight" formatFn={weight} />
|
||||||
{candidate.priceCents != null && (
|
{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">
|
<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)}
|
{price(candidate.priceCents)}
|
||||||
</span>
|
</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">
|
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-50 text-gray-600">
|
||||||
<LucideIcon
|
<LucideIcon
|
||||||
name={candidate.categoryIcon}
|
name={candidate.categoryIcon}
|
||||||
@@ -209,8 +216,17 @@ export function CandidateListItem({
|
|||||||
return (
|
return (
|
||||||
<Reorder.Item
|
<Reorder.Item
|
||||||
value={candidate}
|
value={candidate}
|
||||||
dragControls={controls}
|
onDragStart={() => {
|
||||||
dragListener={false}
|
isDragging.current = true;
|
||||||
|
}}
|
||||||
|
onDragEnd={() => {
|
||||||
|
setTimeout(() => {
|
||||||
|
isDragging.current = false;
|
||||||
|
}, 0);
|
||||||
|
onDragEnd?.();
|
||||||
|
}}
|
||||||
|
whileDrag={{ cursor: "grabbing" }}
|
||||||
|
style={{ marginBottom: 8, cursor: "grab" }}
|
||||||
className={sharedClassName}
|
className={sharedClassName}
|
||||||
>
|
>
|
||||||
{innerContent}
|
{innerContent}
|
||||||
|
|||||||
@@ -224,6 +224,7 @@ export function CollectionView() {
|
|||||||
name={item.name}
|
name={item.name}
|
||||||
weightGrams={item.weightGrams}
|
weightGrams={item.weightGrams}
|
||||||
priceCents={item.priceCents}
|
priceCents={item.priceCents}
|
||||||
|
quantity={item.quantity}
|
||||||
categoryName={item.categoryName}
|
categoryName={item.categoryName}
|
||||||
categoryIcon={item.categoryIcon}
|
categoryIcon={item.categoryIcon}
|
||||||
imageFilename={item.imageFilename}
|
imageFilename={item.imageFilename}
|
||||||
@@ -257,6 +258,7 @@ export function CollectionView() {
|
|||||||
name={item.name}
|
name={item.name}
|
||||||
weightGrams={item.weightGrams}
|
weightGrams={item.weightGrams}
|
||||||
priceCents={item.priceCents}
|
priceCents={item.priceCents}
|
||||||
|
quantity={item.quantity}
|
||||||
categoryName={categoryName}
|
categoryName={categoryName}
|
||||||
categoryIcon={categoryIcon}
|
categoryIcon={categoryIcon}
|
||||||
imageFilename={item.imageFilename}
|
imageFilename={item.imageFilename}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useFormatters } from "../hooks/useFormatters";
|
import { useFormatters } from "../hooks/useFormatters";
|
||||||
|
import type { CandidateDelta } from "../hooks/useImpactDeltas";
|
||||||
import { LucideIcon } from "../lib/iconData";
|
import { LucideIcon } from "../lib/iconData";
|
||||||
import { useUIStore } from "../stores/uiStore";
|
import { useUIStore } from "../stores/uiStore";
|
||||||
import { RankBadge } from "./CandidateListItem";
|
import { RankBadge } from "./CandidateListItem";
|
||||||
|
import { ImpactDeltaBadge } from "./ImpactDeltaBadge";
|
||||||
|
|
||||||
interface CandidateWithCategory {
|
interface CandidateWithCategory {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -26,6 +28,7 @@ interface CandidateWithCategory {
|
|||||||
interface ComparisonTableProps {
|
interface ComparisonTableProps {
|
||||||
candidates: CandidateWithCategory[];
|
candidates: CandidateWithCategory[];
|
||||||
resolvedCandidateId: number | null;
|
resolvedCandidateId: number | null;
|
||||||
|
deltas?: Record<number, CandidateDelta>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_LABELS: Record<"researching" | "ordered" | "arrived", string> = {
|
const STATUS_LABELS: Record<"researching" | "ordered" | "arrived", string> = {
|
||||||
@@ -37,6 +40,7 @@ const STATUS_LABELS: Record<"researching" | "ordered" | "arrived", string> = {
|
|||||||
export function ComparisonTable({
|
export function ComparisonTable({
|
||||||
candidates,
|
candidates,
|
||||||
resolvedCandidateId,
|
resolvedCandidateId,
|
||||||
|
deltas,
|
||||||
}: ComparisonTableProps) {
|
}: ComparisonTableProps) {
|
||||||
const { weight, price } = useFormatters();
|
const { weight, price } = useFormatters();
|
||||||
const openExternalLink = useUIStore((s) => s.openExternalLink);
|
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);
|
const tableMinWidth = Math.max(400, candidates.length * 180);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -324,6 +332,50 @@ export function ComparisonTable({
|
|||||||
})}
|
})}
|
||||||
</tr>
|
</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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</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 { useFormatters } from "../hooks/useFormatters";
|
||||||
|
import { useDuplicateItem } from "../hooks/useItems";
|
||||||
import { LucideIcon } from "../lib/iconData";
|
import { LucideIcon } from "../lib/iconData";
|
||||||
import { useUIStore } from "../stores/uiStore";
|
import { useUIStore } from "../stores/uiStore";
|
||||||
import { ClassificationBadge } from "./ClassificationBadge";
|
import { ClassificationBadge } from "./ClassificationBadge";
|
||||||
@@ -8,6 +9,7 @@ interface ItemCardProps {
|
|||||||
name: string;
|
name: string;
|
||||||
weightGrams: number | null;
|
weightGrams: number | null;
|
||||||
priceCents: number | null;
|
priceCents: number | null;
|
||||||
|
quantity?: number;
|
||||||
categoryName: string;
|
categoryName: string;
|
||||||
categoryIcon: string;
|
categoryIcon: string;
|
||||||
imageFilename: string | null;
|
imageFilename: string | null;
|
||||||
@@ -22,6 +24,7 @@ export function ItemCard({
|
|||||||
name,
|
name,
|
||||||
weightGrams,
|
weightGrams,
|
||||||
priceCents,
|
priceCents,
|
||||||
|
quantity,
|
||||||
categoryName,
|
categoryName,
|
||||||
categoryIcon,
|
categoryIcon,
|
||||||
imageFilename,
|
imageFilename,
|
||||||
@@ -33,6 +36,7 @@ export function ItemCard({
|
|||||||
const { weight, price } = useFormatters();
|
const { weight, price } = useFormatters();
|
||||||
const openEditPanel = useUIStore((s) => s.openEditPanel);
|
const openEditPanel = useUIStore((s) => s.openEditPanel);
|
||||||
const openExternalLink = useUIStore((s) => s.openExternalLink);
|
const openExternalLink = useUIStore((s) => s.openExternalLink);
|
||||||
|
const duplicateItem = useDuplicateItem();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -40,6 +44,46 @@ export function ItemCard({
|
|||||||
onClick={() => openEditPanel(id)}
|
onClick={() => openEditPanel(id)}
|
||||||
className="relative w-full text-left bg-white rounded-xl border border-gray-100 hover:border-gray-200 hover:shadow-sm transition-all overflow-hidden group"
|
className="relative w-full text-left bg-white rounded-xl border border-gray-100 hover:border-gray-200 hover:shadow-sm transition-all overflow-hidden group"
|
||||||
>
|
>
|
||||||
|
{!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 && (
|
{productUrl && (
|
||||||
<span
|
<span
|
||||||
role="button"
|
role="button"
|
||||||
@@ -122,9 +166,16 @@ export function ItemCard({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4">
|
<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}
|
{name}
|
||||||
</h3>
|
</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">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{weightGrams != null && (
|
{weightGrams != null && (
|
||||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-50 text-blue-400">
|
<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;
|
name: string;
|
||||||
weightGrams: string;
|
weightGrams: string;
|
||||||
priceDollars: string;
|
priceDollars: string;
|
||||||
|
quantity: number;
|
||||||
categoryId: number;
|
categoryId: number;
|
||||||
notes: string;
|
notes: string;
|
||||||
productUrl: string;
|
productUrl: string;
|
||||||
@@ -23,6 +24,7 @@ const INITIAL_FORM: FormData = {
|
|||||||
name: "",
|
name: "",
|
||||||
weightGrams: "",
|
weightGrams: "",
|
||||||
priceDollars: "",
|
priceDollars: "",
|
||||||
|
quantity: 1,
|
||||||
categoryId: 1,
|
categoryId: 1,
|
||||||
notes: "",
|
notes: "",
|
||||||
productUrl: "",
|
productUrl: "",
|
||||||
@@ -49,6 +51,7 @@ export function ItemForm({ mode, itemId }: ItemFormProps) {
|
|||||||
weightGrams: item.weightGrams != null ? String(item.weightGrams) : "",
|
weightGrams: item.weightGrams != null ? String(item.weightGrams) : "",
|
||||||
priceDollars:
|
priceDollars:
|
||||||
item.priceCents != null ? (item.priceCents / 100).toFixed(2) : "",
|
item.priceCents != null ? (item.priceCents / 100).toFixed(2) : "",
|
||||||
|
quantity: item.quantity ?? 1,
|
||||||
categoryId: item.categoryId,
|
categoryId: item.categoryId,
|
||||||
notes: item.notes ?? "",
|
notes: item.notes ?? "",
|
||||||
productUrl: item.productUrl ?? "",
|
productUrl: item.productUrl ?? "",
|
||||||
@@ -98,6 +101,7 @@ export function ItemForm({ mode, itemId }: ItemFormProps) {
|
|||||||
priceCents: form.priceDollars
|
priceCents: form.priceDollars
|
||||||
? Math.round(Number(form.priceDollars) * 100)
|
? Math.round(Number(form.priceDollars) * 100)
|
||||||
: undefined,
|
: undefined,
|
||||||
|
quantity: form.quantity,
|
||||||
categoryId: form.categoryId,
|
categoryId: form.categoryId,
|
||||||
notes: form.notes.trim() || undefined,
|
notes: form.notes.trim() || undefined,
|
||||||
productUrl: form.productUrl.trim() || undefined,
|
productUrl: form.productUrl.trim() || undefined,
|
||||||
@@ -202,6 +206,30 @@ export function ItemForm({ mode, itemId }: ItemFormProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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 */}
|
{/* Category */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
|||||||
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>();
|
const groups = new Map<string, number>();
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
const current = groups.get(item.categoryName) ?? 0;
|
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())
|
return Array.from(groups.entries())
|
||||||
.filter(([, weight]) => weight > 0)
|
.filter(([, weight]) => weight > 0)
|
||||||
.map(([name, weight]) => ({
|
.map(([name, weight]) => ({
|
||||||
@@ -76,7 +82,8 @@ function buildClassificationChartData(
|
|||||||
consumable: 0,
|
consumable: 0,
|
||||||
};
|
};
|
||||||
for (const item of items) {
|
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);
|
const total = Object.values(groups).reduce((a, b) => a + b, 0);
|
||||||
return Object.entries(groups)
|
return Object.entries(groups)
|
||||||
@@ -148,17 +155,23 @@ export function WeightSummaryCard({ items }: WeightSummaryCardProps) {
|
|||||||
|
|
||||||
const baseWeight = items.reduce(
|
const baseWeight = items.reduce(
|
||||||
(sum, i) =>
|
(sum, i) =>
|
||||||
i.classification === "base" ? sum + (i.weightGrams ?? 0) : sum,
|
i.classification === "base"
|
||||||
|
? sum + (i.weightGrams ?? 0) * (i.quantity ?? 1)
|
||||||
|
: sum,
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
const wornWeight = items.reduce(
|
const wornWeight = items.reduce(
|
||||||
(sum, i) =>
|
(sum, i) =>
|
||||||
i.classification === "worn" ? sum + (i.weightGrams ?? 0) : sum,
|
i.classification === "worn"
|
||||||
|
? sum + (i.weightGrams ?? 0) * (i.quantity ?? 1)
|
||||||
|
: sum,
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
const consumableWeight = items.reduce(
|
const consumableWeight = items.reduce(
|
||||||
(sum, i) =>
|
(sum, i) =>
|
||||||
i.classification === "consumable" ? sum + (i.weightGrams ?? 0) : sum,
|
i.classification === "consumable"
|
||||||
|
? sum + (i.weightGrams ?? 0) * (i.quantity ?? 1)
|
||||||
|
: sum,
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
const totalWeight = baseWeight + wornWeight + consumableWeight;
|
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 { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import type { CreateItem } from "../../shared/types";
|
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 {
|
interface ItemWithCategory {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
weightGrams: number | null;
|
weightGrams: number | null;
|
||||||
priceCents: number | null;
|
priceCents: number | null;
|
||||||
|
quantity: number;
|
||||||
categoryId: number;
|
categoryId: number;
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
productUrl: string | null;
|
productUrl: string | null;
|
||||||
@@ -29,6 +52,8 @@ export function useItem(id: number | null) {
|
|||||||
queryKey: ["items", id],
|
queryKey: ["items", id],
|
||||||
queryFn: () => apiGet<ItemWithCategory>(`/api/items/${id}`),
|
queryFn: () => apiGet<ItemWithCategory>(`/api/items/${id}`),
|
||||||
enabled: id != null,
|
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 { 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 {
|
interface SetupListItem {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -16,6 +23,7 @@ interface SetupItemWithCategory {
|
|||||||
name: string;
|
name: string;
|
||||||
weightGrams: number | null;
|
weightGrams: number | null;
|
||||||
priceCents: number | null;
|
priceCents: number | null;
|
||||||
|
quantity: number;
|
||||||
categoryId: number;
|
categoryId: number;
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
productUrl: string | null;
|
productUrl: string | null;
|
||||||
@@ -49,6 +57,8 @@ export function useSetup(setupId: number | null) {
|
|||||||
queryKey: ["setups", setupId],
|
queryKey: ["setups", setupId],
|
||||||
queryFn: () => apiGet<SetupWithItems>(`/api/setups/${setupId}`),
|
queryFn: () => apiGet<SetupWithItems>(`/api/setups/${setupId}`),
|
||||||
enabled: setupId != null,
|
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 { 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 {
|
interface ThreadListItem {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -40,6 +40,7 @@ interface ThreadWithCandidates {
|
|||||||
name: string;
|
name: string;
|
||||||
status: "active" | "resolved";
|
status: "active" | "resolved";
|
||||||
resolvedCandidateId: number | null;
|
resolvedCandidateId: number | null;
|
||||||
|
categoryId: number;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
candidates: CandidateWithCategory[];
|
candidates: CandidateWithCategory[];
|
||||||
@@ -60,6 +61,8 @@ export function useThread(threadId: number | null) {
|
|||||||
queryKey: ["threads", threadId],
|
queryKey: ["threads", threadId],
|
||||||
queryFn: () => apiGet<ThreadWithCandidates>(`/api/threads/${threadId}`),
|
queryFn: () => apiGet<ThreadWithCandidates>(`/api/threads/${threadId}`),
|
||||||
enabled: threadId != null,
|
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(
|
constructor(
|
||||||
message: string,
|
message: string,
|
||||||
public status: number,
|
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 { AnimatePresence, motion } from "framer-motion";
|
||||||
import { useRef } from "react";
|
import { useRef } from "react";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
@@ -16,6 +16,11 @@ export const Route = createFileRoute("/collection/")({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const TAB_ORDER = ["gear", "planning", "setups"] as const;
|
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 = {
|
const slideVariants = {
|
||||||
enter: (dir: number) => ({ x: `${dir * 15}%`, opacity: 0 }),
|
enter: (dir: number) => ({ x: `${dir * 15}%`, opacity: 0 }),
|
||||||
@@ -33,6 +38,26 @@ function CollectionPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 overflow-x-hidden">
|
<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}>
|
<AnimatePresence mode="wait" initial={false} custom={direction}>
|
||||||
<motion.div
|
<motion.div
|
||||||
key={tab}
|
key={tab}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||||
import { useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
useApiKeys,
|
useApiKeys,
|
||||||
useAuth,
|
useAuth,
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
useDeleteApiKey,
|
useDeleteApiKey,
|
||||||
} from "../hooks/useAuth";
|
} from "../hooks/useAuth";
|
||||||
import { useCurrency } from "../hooks/useCurrency";
|
import { useCurrency } from "../hooks/useCurrency";
|
||||||
|
import { useExportItems, useImportItems } from "../hooks/useItems";
|
||||||
import { useUpdateSetting } from "../hooks/useSettings";
|
import { useUpdateSetting } from "../hooks/useSettings";
|
||||||
import { useWeightUnit } from "../hooks/useWeightUnit";
|
import { useWeightUnit } from "../hooks/useWeightUnit";
|
||||||
import type { Currency, WeightUnit } from "../lib/formatters";
|
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() {
|
function SettingsPage() {
|
||||||
const unit = useWeightUnit();
|
const unit = useWeightUnit();
|
||||||
const currency = useCurrency();
|
const currency = useCurrency();
|
||||||
@@ -255,6 +345,10 @@ function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-xl border border-gray-100 p-5 space-y-6 mt-4">
|
||||||
|
<ImportExportSection />
|
||||||
|
</div>
|
||||||
|
|
||||||
{auth?.user && (
|
{auth?.user && (
|
||||||
<div className="bg-white rounded-xl border border-gray-100 p-5 space-y-6 mt-4">
|
<div className="bg-white rounded-xl border border-gray-100 p-5 space-y-6 mt-4">
|
||||||
<ChangePasswordSection />
|
<ChangePasswordSection />
|
||||||
|
|||||||
@@ -53,13 +53,13 @@ function SetupDetailPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compute totals from items
|
// Compute totals from items (multiply by quantity)
|
||||||
const totalWeight = setup.items.reduce(
|
const totalWeight = setup.items.reduce(
|
||||||
(sum, item) => sum + (item.weightGrams ?? 0),
|
(sum, item) => sum + (item.weightGrams ?? 0) * (item.quantity ?? 1),
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
const totalCost = setup.items.reduce(
|
const totalCost = setup.items.reduce(
|
||||||
(sum, item) => sum + (item.priceCents ?? 0),
|
(sum, item) => sum + (item.priceCents ?? 0) * (item.quantity ?? 1),
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
const itemCount = setup.items.length;
|
const itemCount = setup.items.length;
|
||||||
@@ -207,11 +207,13 @@ function SetupDetailPage() {
|
|||||||
{ items: categoryItems, categoryName, categoryIcon },
|
{ items: categoryItems, categoryName, categoryIcon },
|
||||||
]) => {
|
]) => {
|
||||||
const catWeight = categoryItems.reduce(
|
const catWeight = categoryItems.reduce(
|
||||||
(sum, item) => sum + (item.weightGrams ?? 0),
|
(sum, item) =>
|
||||||
|
sum + (item.weightGrams ?? 0) * (item.quantity ?? 1),
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
const catCost = categoryItems.reduce(
|
const catCost = categoryItems.reduce(
|
||||||
(sum, item) => sum + (item.priceCents ?? 0),
|
(sum, item) =>
|
||||||
|
sum + (item.priceCents ?? 0) * (item.quantity ?? 1),
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
@@ -232,6 +234,7 @@ function SetupDetailPage() {
|
|||||||
name={item.name}
|
name={item.name}
|
||||||
weightGrams={item.weightGrams}
|
weightGrams={item.weightGrams}
|
||||||
priceCents={item.priceCents}
|
priceCents={item.priceCents}
|
||||||
|
quantity={item.quantity}
|
||||||
categoryName={categoryName}
|
categoryName={categoryName}
|
||||||
categoryIcon={categoryIcon}
|
categoryIcon={categoryIcon}
|
||||||
imageFilename={item.imageFilename}
|
imageFilename={item.imageFilename}
|
||||||
|
|||||||
@@ -4,10 +4,13 @@ import { useEffect, useState } from "react";
|
|||||||
import { CandidateCard } from "../../components/CandidateCard";
|
import { CandidateCard } from "../../components/CandidateCard";
|
||||||
import { CandidateListItem } from "../../components/CandidateListItem";
|
import { CandidateListItem } from "../../components/CandidateListItem";
|
||||||
import { ComparisonTable } from "../../components/ComparisonTable";
|
import { ComparisonTable } from "../../components/ComparisonTable";
|
||||||
|
import { SetupImpactSelector } from "../../components/SetupImpactSelector";
|
||||||
import {
|
import {
|
||||||
useReorderCandidates,
|
useReorderCandidates,
|
||||||
useUpdateCandidate,
|
useUpdateCandidate,
|
||||||
} from "../../hooks/useCandidates";
|
} from "../../hooks/useCandidates";
|
||||||
|
import { useImpactDeltas } from "../../hooks/useImpactDeltas";
|
||||||
|
import { useSetup } from "../../hooks/useSetups";
|
||||||
import { useThread } from "../../hooks/useThreads";
|
import { useThread } from "../../hooks/useThreads";
|
||||||
import { LucideIcon } from "../../lib/iconData";
|
import { LucideIcon } from "../../lib/iconData";
|
||||||
import { useUIStore } from "../../stores/uiStore";
|
import { useUIStore } from "../../stores/uiStore";
|
||||||
@@ -23,15 +26,21 @@ function ThreadDetailPage() {
|
|||||||
const openCandidateAddPanel = useUIStore((s) => s.openCandidateAddPanel);
|
const openCandidateAddPanel = useUIStore((s) => s.openCandidateAddPanel);
|
||||||
const candidateViewMode = useUIStore((s) => s.candidateViewMode);
|
const candidateViewMode = useUIStore((s) => s.candidateViewMode);
|
||||||
const setCandidateViewMode = useUIStore((s) => s.setCandidateViewMode);
|
const setCandidateViewMode = useUIStore((s) => s.setCandidateViewMode);
|
||||||
|
const selectedSetupId = useUIStore((s) => s.selectedSetupId);
|
||||||
const updateCandidate = useUpdateCandidate(threadId);
|
const updateCandidate = useUpdateCandidate(threadId);
|
||||||
const reorderMutation = useReorderCandidates(threadId);
|
const reorderMutation = useReorderCandidates(threadId);
|
||||||
|
const { data: setupData } = useSetup(selectedSetupId);
|
||||||
const [tempItems, setTempItems] =
|
const { deltas } = useImpactDeltas(
|
||||||
useState<typeof thread extends { candidates: infer C } ? C : never | null>(
|
thread?.candidates ?? [],
|
||||||
null,
|
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
|
// biome-ignore lint/correctness/useExhaustiveDependencies: thread?.candidates is the intended trigger
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setTempItems(null);
|
setTempItems(null);
|
||||||
@@ -78,10 +87,9 @@ function ThreadDetailPage() {
|
|||||||
|
|
||||||
function handleDragEnd() {
|
function handleDragEnd() {
|
||||||
if (!tempItems) return;
|
if (!tempItems) return;
|
||||||
reorderMutation.mutate(
|
reorderMutation.mutate({
|
||||||
{ orderedIds: tempItems.map((c) => c.id) },
|
orderedIds: tempItems.map((c) => c.id),
|
||||||
{ onSettled: () => setTempItems(null) },
|
});
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -120,8 +128,8 @@ function ThreadDetailPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Toolbar: Add candidate + view toggle */}
|
{/* Toolbar: Add candidate + view toggle */}
|
||||||
<div className="mb-6 flex items-center gap-3">
|
<div className="mb-6 flex items-center gap-3 flex-wrap">
|
||||||
{isActive && candidateViewMode !== "compare" && (
|
{isActive && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={openCandidateAddPanel}
|
onClick={openCandidateAddPanel}
|
||||||
@@ -185,6 +193,7 @@ function ThreadDetailPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<SetupImpactSelector threadStatus={thread.status} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Candidates */}
|
{/* Candidates */}
|
||||||
@@ -208,6 +217,7 @@ function ThreadDetailPage() {
|
|||||||
<ComparisonTable
|
<ComparisonTable
|
||||||
candidates={displayItems}
|
candidates={displayItems}
|
||||||
resolvedCandidateId={thread.resolvedCandidateId}
|
resolvedCandidateId={thread.resolvedCandidateId}
|
||||||
|
deltas={deltas}
|
||||||
/>
|
/>
|
||||||
) : candidateViewMode === "list" ? (
|
) : candidateViewMode === "list" ? (
|
||||||
isActive ? (
|
isActive ? (
|
||||||
@@ -215,8 +225,7 @@ function ThreadDetailPage() {
|
|||||||
axis="y"
|
axis="y"
|
||||||
values={displayItems}
|
values={displayItems}
|
||||||
onReorder={setTempItems}
|
onReorder={setTempItems}
|
||||||
onPointerUp={handleDragEnd}
|
className="flex flex-col"
|
||||||
className="flex flex-col gap-2"
|
|
||||||
>
|
>
|
||||||
{displayItems.map((candidate, index) => (
|
{displayItems.map((candidate, index) => (
|
||||||
<CandidateListItem
|
<CandidateListItem
|
||||||
@@ -230,6 +239,8 @@ function ThreadDetailPage() {
|
|||||||
status: newStatus,
|
status: newStatus,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
delta={deltas[candidate.id]}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Reorder.Group>
|
</Reorder.Group>
|
||||||
@@ -247,6 +258,7 @@ function ThreadDetailPage() {
|
|||||||
status: newStatus,
|
status: newStatus,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
delta={deltas[candidate.id]}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -276,6 +288,7 @@ function ThreadDetailPage() {
|
|||||||
pros={candidate.pros}
|
pros={candidate.pros}
|
||||||
cons={candidate.cons}
|
cons={candidate.cons}
|
||||||
rank={index + 1}
|
rank={index + 1}
|
||||||
|
delta={deltas[candidate.id]}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -52,6 +52,10 @@ interface UIState {
|
|||||||
// Candidate view mode
|
// Candidate view mode
|
||||||
candidateViewMode: "list" | "grid" | "compare";
|
candidateViewMode: "list" | "grid" | "compare";
|
||||||
setCandidateViewMode: (mode: "list" | "grid" | "compare") => void;
|
setCandidateViewMode: (mode: "list" | "grid" | "compare") => void;
|
||||||
|
|
||||||
|
// Setup impact preview
|
||||||
|
selectedSetupId: number | null;
|
||||||
|
setSelectedSetupId: (id: number | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useUIStore = create<UIState>((set) => ({
|
export const useUIStore = create<UIState>((set) => ({
|
||||||
@@ -111,4 +115,8 @@ export const useUIStore = create<UIState>((set) => ({
|
|||||||
// Candidate view mode
|
// Candidate view mode
|
||||||
candidateViewMode: "list",
|
candidateViewMode: "list",
|
||||||
setCandidateViewMode: (mode) => set({ candidateViewMode: mode }),
|
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"),
|
productUrl: text("product_url"),
|
||||||
imageFilename: text("image_filename"),
|
imageFilename: text("image_filename"),
|
||||||
imageSourceUrl: text("image_source_url"),
|
imageSourceUrl: text("image_source_url"),
|
||||||
|
quantity: integer("quantity").notNull().default(1),
|
||||||
createdAt: integer("created_at", { mode: "timestamp" })
|
createdAt: integer("created_at", { mode: "timestamp" })
|
||||||
.notNull()
|
.notNull()
|
||||||
.$defaultFn(() => new Date()),
|
.$defaultFn(() => new Date()),
|
||||||
|
|||||||
@@ -4,9 +4,11 @@ import { zValidator } from "@hono/zod-validator";
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { createItemSchema, updateItemSchema } from "../../shared/schemas.ts";
|
import { createItemSchema, updateItemSchema } from "../../shared/schemas.ts";
|
||||||
import { parseId } from "../lib/params.ts";
|
import { parseId } from "../lib/params.ts";
|
||||||
|
import { exportItemsCsv, importItemsCsv } from "../services/csv.service.ts";
|
||||||
import {
|
import {
|
||||||
createItem,
|
createItem,
|
||||||
deleteItem,
|
deleteItem,
|
||||||
|
duplicateItem,
|
||||||
getAllItems,
|
getAllItems,
|
||||||
getItemById,
|
getItemById,
|
||||||
updateItem,
|
updateItem,
|
||||||
@@ -16,6 +18,27 @@ type Env = { Variables: { db?: any } };
|
|||||||
|
|
||||||
const app = new Hono<Env>();
|
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) => {
|
app.get("/", (c) => {
|
||||||
const db = c.get("db");
|
const db = c.get("db");
|
||||||
const items = getAllItems(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) => {
|
app.delete("/:id", async (c) => {
|
||||||
const db = c.get("db");
|
const db = c.get("db");
|
||||||
const id = parseId(c.req.param("id"));
|
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,
|
name: items.name,
|
||||||
weightGrams: items.weightGrams,
|
weightGrams: items.weightGrams,
|
||||||
priceCents: items.priceCents,
|
priceCents: items.priceCents,
|
||||||
|
quantity: items.quantity,
|
||||||
categoryId: items.categoryId,
|
categoryId: items.categoryId,
|
||||||
notes: items.notes,
|
notes: items.notes,
|
||||||
productUrl: items.productUrl,
|
productUrl: items.productUrl,
|
||||||
@@ -63,6 +64,7 @@ export function createItem(
|
|||||||
name: data.name,
|
name: data.name,
|
||||||
weightGrams: data.weightGrams ?? null,
|
weightGrams: data.weightGrams ?? null,
|
||||||
priceCents: data.priceCents ?? null,
|
priceCents: data.priceCents ?? null,
|
||||||
|
quantity: data.quantity ?? 1,
|
||||||
categoryId: data.categoryId,
|
categoryId: data.categoryId,
|
||||||
notes: data.notes ?? null,
|
notes: data.notes ?? null,
|
||||||
productUrl: data.productUrl ?? null,
|
productUrl: data.productUrl ?? null,
|
||||||
@@ -80,6 +82,7 @@ export function updateItem(
|
|||||||
name: string;
|
name: string;
|
||||||
weightGrams: number;
|
weightGrams: number;
|
||||||
priceCents: number;
|
priceCents: number;
|
||||||
|
quantity: number;
|
||||||
categoryId: number;
|
categoryId: number;
|
||||||
notes: string;
|
notes: string;
|
||||||
productUrl: string;
|
productUrl: string;
|
||||||
@@ -104,6 +107,28 @@ export function updateItem(
|
|||||||
.get();
|
.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) {
|
export function deleteItem(db: Db = prodDb, id: number) {
|
||||||
// Get item first (for image cleanup info)
|
// Get item first (for image cleanup info)
|
||||||
const item = db.select().from(items).where(eq(items.id, id)).get();
|
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
|
WHERE setup_items.setup_id = setups.id
|
||||||
), 0)`.as("item_count"),
|
), 0)`.as("item_count"),
|
||||||
totalWeight: sql<number>`COALESCE((
|
totalWeight: sql<number>`COALESCE((
|
||||||
SELECT SUM(items.weight_grams) FROM setup_items
|
SELECT SUM(items.weight_grams * items.quantity) FROM setup_items
|
||||||
JOIN items ON items.id = setup_items.item_id
|
JOIN items ON items.id = setup_items.item_id
|
||||||
WHERE setup_items.setup_id = setups.id
|
WHERE setup_items.setup_id = setups.id
|
||||||
), 0)`.as("total_weight"),
|
), 0)`.as("total_weight"),
|
||||||
totalCost: sql<number>`COALESCE((
|
totalCost: sql<number>`COALESCE((
|
||||||
SELECT SUM(items.price_cents) FROM setup_items
|
SELECT SUM(items.price_cents * items.quantity) FROM setup_items
|
||||||
JOIN items ON items.id = setup_items.item_id
|
JOIN items ON items.id = setup_items.item_id
|
||||||
WHERE setup_items.setup_id = setups.id
|
WHERE setup_items.setup_id = setups.id
|
||||||
), 0)`.as("total_cost"),
|
), 0)`.as("total_cost"),
|
||||||
@@ -45,6 +45,7 @@ export function getSetupWithItems(db: Db = prodDb, setupId: number) {
|
|||||||
name: items.name,
|
name: items.name,
|
||||||
weightGrams: items.weightGrams,
|
weightGrams: items.weightGrams,
|
||||||
priceCents: items.priceCents,
|
priceCents: items.priceCents,
|
||||||
|
quantity: items.quantity,
|
||||||
categoryId: items.categoryId,
|
categoryId: items.categoryId,
|
||||||
notes: items.notes,
|
notes: items.notes,
|
||||||
productUrl: items.productUrl,
|
productUrl: items.productUrl,
|
||||||
|
|||||||
@@ -299,6 +299,7 @@ export function resolveThread(
|
|||||||
productUrl: candidate.productUrl,
|
productUrl: candidate.productUrl,
|
||||||
imageFilename: candidate.imageFilename,
|
imageFilename: candidate.imageFilename,
|
||||||
imageSourceUrl: candidate.imageSourceUrl,
|
imageSourceUrl: candidate.imageSourceUrl,
|
||||||
|
quantity: 1,
|
||||||
})
|
})
|
||||||
.returning()
|
.returning()
|
||||||
.get();
|
.get();
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ export function getCategoryTotals(db: Db = prodDb) {
|
|||||||
categoryId: items.categoryId,
|
categoryId: items.categoryId,
|
||||||
categoryName: categories.name,
|
categoryName: categories.name,
|
||||||
categoryIcon: categories.icon,
|
categoryIcon: categories.icon,
|
||||||
totalWeight: sql<number>`COALESCE(SUM(${items.weightGrams}), 0)`,
|
totalWeight: sql<number>`COALESCE(SUM(${items.weightGrams} * ${items.quantity}), 0)`,
|
||||||
totalCost: sql<number>`COALESCE(SUM(${items.priceCents}), 0)`,
|
totalCost: sql<number>`COALESCE(SUM(${items.priceCents} * ${items.quantity}), 0)`,
|
||||||
itemCount: sql<number>`COUNT(*)`,
|
itemCount: sql<number>`COUNT(*)`,
|
||||||
})
|
})
|
||||||
.from(items)
|
.from(items)
|
||||||
@@ -23,8 +23,8 @@ export function getCategoryTotals(db: Db = prodDb) {
|
|||||||
export function getGlobalTotals(db: Db = prodDb) {
|
export function getGlobalTotals(db: Db = prodDb) {
|
||||||
return db
|
return db
|
||||||
.select({
|
.select({
|
||||||
totalWeight: sql<number>`COALESCE(SUM(${items.weightGrams}), 0)`,
|
totalWeight: sql<number>`COALESCE(SUM(${items.weightGrams} * ${items.quantity}), 0)`,
|
||||||
totalCost: sql<number>`COALESCE(SUM(${items.priceCents}), 0)`,
|
totalCost: sql<number>`COALESCE(SUM(${items.priceCents} * ${items.quantity}), 0)`,
|
||||||
itemCount: sql<number>`COUNT(*)`,
|
itemCount: sql<number>`COUNT(*)`,
|
||||||
})
|
})
|
||||||
.from(items)
|
.from(items)
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export const createItemSchema = z.object({
|
|||||||
productUrl: z.string().url().optional().or(z.literal("")),
|
productUrl: z.string().url().optional().or(z.literal("")),
|
||||||
imageFilename: z.string().optional(),
|
imageFilename: z.string().optional(),
|
||||||
imageSourceUrl: z.string().url().optional().or(z.literal("")),
|
imageSourceUrl: z.string().url().optional().or(z.literal("")),
|
||||||
|
quantity: z.number().int().positive().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const updateItemSchema = createItemSchema.partial().extend({
|
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");
|
const res = await app.request("/api/items/9999");
|
||||||
expect(res.status).toBe(404);
|
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 {
|
import {
|
||||||
createItem,
|
createItem,
|
||||||
deleteItem,
|
deleteItem,
|
||||||
|
duplicateItem,
|
||||||
getAllItems,
|
getAllItems,
|
||||||
getItemById,
|
getItemById,
|
||||||
updateItem,
|
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", () => {
|
describe("deleteItem", () => {
|
||||||
it("removes item from DB, returns deleted item", () => {
|
it("removes item from DB, returns deleted item", () => {
|
||||||
const created = createItem(db, {
|
const created = createItem(db, {
|
||||||
|
|||||||
Reference in New Issue
Block a user