v1.4 Collection Tools #9

Merged
makiolaj merged 17 commits from feature/v1.4-collection-tools into Develop 2026-04-03 18:05:24 +00:00
37 changed files with 2099 additions and 55 deletions

View File

@@ -2,9 +2,7 @@ name: CI
on:
push:
branches: [Develop]
pull_request:
branches: [Develop]
jobs:
ci:

View File

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

View File

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

View File

@@ -0,0 +1,663 @@
{
"version": "6",
"dialect": "sqlite",
"id": "ede9f482-7af0-42bc-9672-43f5fba289d0",
"prevId": "738e67c5-ebad-46c1-9261-6ab60ec4bdb1",
"tables": {
"api_keys": {
"name": "api_keys",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"key_hash": {
"name": "key_hash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"key_prefix": {
"name": "key_prefix",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"categories": {
"name": "categories",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"icon": {
"name": "icon",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'package'"
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"categories_name_unique": {
"name": "categories_name_unique",
"columns": [
"name"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"items": {
"name": "items",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"weight_grams": {
"name": "weight_grams",
"type": "real",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"price_cents": {
"name": "price_cents",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"category_id": {
"name": "category_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"notes": {
"name": "notes",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"product_url": {
"name": "product_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"image_filename": {
"name": "image_filename",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"image_source_url": {
"name": "image_source_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"quantity": {
"name": "quantity",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 1
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"items_category_id_categories_id_fk": {
"name": "items_category_id_categories_id_fk",
"tableFrom": "items",
"tableTo": "categories",
"columnsFrom": [
"category_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"sessions": {
"name": "sessions",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"sessions_user_id_users_id_fk": {
"name": "sessions_user_id_users_id_fk",
"tableFrom": "sessions",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"settings": {
"name": "settings",
"columns": {
"key": {
"name": "key",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"setup_items": {
"name": "setup_items",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"setup_id": {
"name": "setup_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"item_id": {
"name": "item_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"classification": {
"name": "classification",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'base'"
}
},
"indexes": {},
"foreignKeys": {
"setup_items_setup_id_setups_id_fk": {
"name": "setup_items_setup_id_setups_id_fk",
"tableFrom": "setup_items",
"tableTo": "setups",
"columnsFrom": [
"setup_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"setup_items_item_id_items_id_fk": {
"name": "setup_items_item_id_items_id_fk",
"tableFrom": "setup_items",
"tableTo": "items",
"columnsFrom": [
"item_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"setups": {
"name": "setups",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"thread_candidates": {
"name": "thread_candidates",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"thread_id": {
"name": "thread_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"weight_grams": {
"name": "weight_grams",
"type": "real",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"price_cents": {
"name": "price_cents",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"category_id": {
"name": "category_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"notes": {
"name": "notes",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"product_url": {
"name": "product_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"image_filename": {
"name": "image_filename",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"image_source_url": {
"name": "image_source_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'researching'"
},
"pros": {
"name": "pros",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"cons": {
"name": "cons",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"sort_order": {
"name": "sort_order",
"type": "real",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"thread_candidates_thread_id_threads_id_fk": {
"name": "thread_candidates_thread_id_threads_id_fk",
"tableFrom": "thread_candidates",
"tableTo": "threads",
"columnsFrom": [
"thread_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"thread_candidates_category_id_categories_id_fk": {
"name": "thread_candidates_category_id_categories_id_fk",
"tableFrom": "thread_candidates",
"tableTo": "categories",
"columnsFrom": [
"category_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"threads": {
"name": "threads",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'active'"
},
"resolved_candidate_id": {
"name": "resolved_candidate_id",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"category_id": {
"name": "category_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"threads_category_id_categories_id_fk": {
"name": "threads_category_id_categories_id_fk",
"tableFrom": "threads",
"tableTo": "categories",
"columnsFrom": [
"category_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"password_hash": {
"name": "password_hash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"users_username_unique": {
"name": "users_username_unique",
"columns": [
"username"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

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

View File

@@ -1,7 +1,9 @@
import { useFormatters } from "../hooks/useFormatters";
import type { CandidateDelta } from "../hooks/useImpactDeltas";
import { LucideIcon } from "../lib/iconData";
import { useUIStore } from "../stores/uiStore";
import { RankBadge } from "./CandidateListItem";
import { ImpactDeltaBadge } from "./ImpactDeltaBadge";
import { StatusBadge } from "./StatusBadge";
interface CandidateCardProps {
@@ -20,6 +22,7 @@ interface CandidateCardProps {
pros?: string | null;
cons?: string | null;
rank?: number;
delta?: CandidateDelta;
}
export function CandidateCard({
@@ -38,6 +41,7 @@ export function CandidateCard({
pros,
cons,
rank,
delta,
}: CandidateCardProps) {
const { weight, price } = useFormatters();
const openCandidateEditPanel = useUIStore((s) => s.openCandidateEditPanel);
@@ -165,11 +169,13 @@ export function CandidateCard({
{weight(weightGrams)}
</span>
)}
<ImpactDeltaBadge delta={delta} type="weight" formatFn={weight} />
{priceCents != null && (
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-50 text-green-500">
{price(priceCents)}
</span>
)}
<ImpactDeltaBadge delta={delta} type="price" formatFn={price} />
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-50 text-gray-600">
<LucideIcon
name={categoryIcon}

View File

@@ -1,7 +1,10 @@
import { Reorder, useDragControls } from "framer-motion";
import { Reorder } from "framer-motion";
import { useRef } from "react";
import { useFormatters } from "../hooks/useFormatters";
import type { CandidateDelta } from "../hooks/useImpactDeltas";
import { LucideIcon } from "../lib/iconData";
import { useUIStore } from "../stores/uiStore";
import { ImpactDeltaBadge } from "./ImpactDeltaBadge";
import { StatusBadge } from "./StatusBadge";
interface CandidateWithCategory {
@@ -28,6 +31,8 @@ interface CandidateListItemProps {
rank: number;
isActive: boolean;
onStatusChange: (status: "researching" | "ordered" | "arrived") => void;
delta?: CandidateDelta;
onDragEnd?: () => void;
}
const RANK_COLORS = ["#D4AF37", "#C0C0C0", "#CD7F32"]; // gold, silver, bronze
@@ -49,8 +54,10 @@ export function CandidateListItem({
rank,
isActive,
onStatusChange,
delta,
onDragEnd,
}: CandidateListItemProps) {
const controls = useDragControls();
const isDragging = useRef(false);
const { weight, price } = useFormatters();
const openCandidateEditPanel = useUIStore((s) => s.openCandidateEditPanel);
const openConfirmDeleteCandidate = useUIStore(
@@ -60,20 +67,15 @@ export function CandidateListItem({
const openExternalLink = useUIStore((s) => s.openExternalLink);
const sharedClassName =
"flex items-center gap-3 bg-white rounded-xl border border-gray-100 p-3 hover:border-gray-200 hover:shadow-sm transition-all group cursor-default";
"flex items-center gap-3 bg-white rounded-xl border border-gray-100 p-3 hover:border-gray-200 hover:shadow-sm group cursor-default";
const innerContent = (
<>
{/* Drag handle */}
{/* Drag handle indicator */}
{isActive && (
<button
type="button"
onPointerDown={(e) => controls.start(e)}
className="cursor-grab active:cursor-grabbing text-gray-300 hover:text-gray-500 touch-none shrink-0"
title="Drag to reorder"
>
<span className="text-gray-300 shrink-0">
<LucideIcon name="grip-vertical" size={16} />
</button>
</span>
)}
{/* Rank badge */}
@@ -99,7 +101,10 @@ export function CandidateListItem({
{/* Name + badges */}
<button
type="button"
onClick={() => openCandidateEditPanel(candidate.id)}
onClick={() => {
if (isDragging.current) return;
openCandidateEditPanel(candidate.id);
}}
className="flex-1 min-w-0 text-left"
>
<p className="text-sm font-semibold text-gray-900 truncate">
@@ -111,11 +116,13 @@ export function CandidateListItem({
{weight(candidate.weightGrams)}
</span>
)}
<ImpactDeltaBadge delta={delta} type="weight" formatFn={weight} />
{candidate.priceCents != null && (
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-50 text-green-500">
{price(candidate.priceCents)}
</span>
)}
<ImpactDeltaBadge delta={delta} type="price" formatFn={price} />
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-50 text-gray-600">
<LucideIcon
name={candidate.categoryIcon}
@@ -209,8 +216,17 @@ export function CandidateListItem({
return (
<Reorder.Item
value={candidate}
dragControls={controls}
dragListener={false}
onDragStart={() => {
isDragging.current = true;
}}
onDragEnd={() => {
setTimeout(() => {
isDragging.current = false;
}, 0);
onDragEnd?.();
}}
whileDrag={{ cursor: "grabbing" }}
style={{ marginBottom: 8, cursor: "grab" }}
className={sharedClassName}
>
{innerContent}

View File

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

View File

@@ -1,8 +1,10 @@
import { useMemo } from "react";
import { useFormatters } from "../hooks/useFormatters";
import type { CandidateDelta } from "../hooks/useImpactDeltas";
import { LucideIcon } from "../lib/iconData";
import { useUIStore } from "../stores/uiStore";
import { RankBadge } from "./CandidateListItem";
import { ImpactDeltaBadge } from "./ImpactDeltaBadge";
interface CandidateWithCategory {
id: number;
@@ -26,6 +28,7 @@ interface CandidateWithCategory {
interface ComparisonTableProps {
candidates: CandidateWithCategory[];
resolvedCandidateId: number | null;
deltas?: Record<number, CandidateDelta>;
}
const STATUS_LABELS: Record<"researching" | "ordered" | "arrived", string> = {
@@ -37,6 +40,7 @@ const STATUS_LABELS: Record<"researching" | "ordered" | "arrived", string> = {
export function ComparisonTable({
candidates,
resolvedCandidateId,
deltas,
}: ComparisonTableProps) {
const { weight, price } = useFormatters();
const openExternalLink = useUIStore((s) => s.openExternalLink);
@@ -263,6 +267,10 @@ export function ComparisonTable({
},
];
// Determine if impact rows should be shown
const firstDelta = deltas ? Object.values(deltas)[0] : undefined;
const showImpact = !!deltas && !!firstDelta && firstDelta.mode !== "none";
const tableMinWidth = Math.max(400, candidates.length * 180);
return (
@@ -324,6 +332,50 @@ export function ComparisonTable({
})}
</tr>
))}
{showImpact && (
<>
<tr className="border-b border-gray-50">
<td className="sticky left-0 z-10 bg-white px-4 py-3 text-xs font-medium text-gray-500 uppercase tracking-wide w-28">
Weight Impact
</td>
{candidates.map((candidate) => {
const isWinner = candidate.id === resolvedCandidateId;
return (
<td
key={candidate.id}
className={`px-4 py-3 min-w-[160px] ${isWinner ? "bg-amber-50/50" : ""}`}
>
<ImpactDeltaBadge
delta={deltas?.[candidate.id]}
type="weight"
formatFn={weight}
/>
</td>
);
})}
</tr>
<tr className="border-b border-gray-50">
<td className="sticky left-0 z-10 bg-white px-4 py-3 text-xs font-medium text-gray-500 uppercase tracking-wide w-28">
Price Impact
</td>
{candidates.map((candidate) => {
const isWinner = candidate.id === resolvedCandidateId;
return (
<td
key={candidate.id}
className={`px-4 py-3 min-w-[160px] ${isWinner ? "bg-amber-50/50" : ""}`}
>
<ImpactDeltaBadge
delta={deltas?.[candidate.id]}
type="price"
formatFn={price}
/>
</td>
);
})}
</tr>
</>
)}
</tbody>
</table>
</div>

View File

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

View File

@@ -1,4 +1,5 @@
import { useFormatters } from "../hooks/useFormatters";
import { useDuplicateItem } from "../hooks/useItems";
import { LucideIcon } from "../lib/iconData";
import { useUIStore } from "../stores/uiStore";
import { ClassificationBadge } from "./ClassificationBadge";
@@ -8,6 +9,7 @@ interface ItemCardProps {
name: string;
weightGrams: number | null;
priceCents: number | null;
quantity?: number;
categoryName: string;
categoryIcon: string;
imageFilename: string | null;
@@ -22,6 +24,7 @@ export function ItemCard({
name,
weightGrams,
priceCents,
quantity,
categoryName,
categoryIcon,
imageFilename,
@@ -33,6 +36,7 @@ export function ItemCard({
const { weight, price } = useFormatters();
const openEditPanel = useUIStore((s) => s.openEditPanel);
const openExternalLink = useUIStore((s) => s.openExternalLink);
const duplicateItem = useDuplicateItem();
return (
<button
@@ -40,6 +44,46 @@ export function ItemCard({
onClick={() => openEditPanel(id)}
className="relative w-full text-left bg-white rounded-xl border border-gray-100 hover:border-gray-200 hover:shadow-sm transition-all overflow-hidden group"
>
{!onRemove && (
<span
role="button"
tabIndex={0}
onClick={(e) => {
e.stopPropagation();
duplicateItem.mutate(id, {
onSuccess: (newItem) => {
openEditPanel(newItem.id);
},
});
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.stopPropagation();
duplicateItem.mutate(id, {
onSuccess: (newItem) => {
openEditPanel(newItem.id);
},
});
}
}}
className={`absolute top-2 ${productUrl ? "right-10" : "right-2"} z-10 w-6 h-6 flex items-center justify-center rounded-full bg-gray-100/80 text-gray-400 hover:bg-blue-100 hover:text-blue-500 opacity-0 group-hover:opacity-100 transition-all cursor-pointer`}
title="Duplicate item"
>
<svg
className="w-3.5 h-3.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
/>
</svg>
</span>
)}
{productUrl && (
<span
role="button"
@@ -122,9 +166,16 @@ export function ItemCard({
)}
</div>
<div className="p-4">
<h3 className="text-sm font-semibold text-gray-900 mb-2 truncate">
{name}
</h3>
<div className="flex items-center gap-1.5 mb-2">
<h3 className="text-sm font-semibold text-gray-900 truncate min-w-0">
{name}
</h3>
{quantity != null && quantity > 1 && (
<span className="shrink-0 inline-flex items-center px-1.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-600">
&times;{quantity}
</span>
)}
</div>
<div className="flex flex-wrap gap-1.5">
{weightGrams != null && (
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-50 text-blue-400">

View File

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

View File

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

View File

@@ -55,9 +55,15 @@ function buildCategoryChartData(items: SetupItemWithCategory[]): ChartDatum[] {
const groups = new Map<string, number>();
for (const item of items) {
const current = groups.get(item.categoryName) ?? 0;
groups.set(item.categoryName, current + (item.weightGrams ?? 0));
groups.set(
item.categoryName,
current + (item.weightGrams ?? 0) * (item.quantity ?? 1),
);
}
const total = items.reduce((sum, i) => sum + (i.weightGrams ?? 0), 0);
const total = items.reduce(
(sum, i) => sum + (i.weightGrams ?? 0) * (i.quantity ?? 1),
0,
);
return Array.from(groups.entries())
.filter(([, weight]) => weight > 0)
.map(([name, weight]) => ({
@@ -76,7 +82,8 @@ function buildClassificationChartData(
consumable: 0,
};
for (const item of items) {
groups[item.classification] += item.weightGrams ?? 0;
groups[item.classification] +=
(item.weightGrams ?? 0) * (item.quantity ?? 1);
}
const total = Object.values(groups).reduce((a, b) => a + b, 0);
return Object.entries(groups)
@@ -148,17 +155,23 @@ export function WeightSummaryCard({ items }: WeightSummaryCardProps) {
const baseWeight = items.reduce(
(sum, i) =>
i.classification === "base" ? sum + (i.weightGrams ?? 0) : sum,
i.classification === "base"
? sum + (i.weightGrams ?? 0) * (i.quantity ?? 1)
: sum,
0,
);
const wornWeight = items.reduce(
(sum, i) =>
i.classification === "worn" ? sum + (i.weightGrams ?? 0) : sum,
i.classification === "worn"
? sum + (i.weightGrams ?? 0) * (i.quantity ?? 1)
: sum,
0,
);
const consumableWeight = items.reduce(
(sum, i) =>
i.classification === "consumable" ? sum + (i.weightGrams ?? 0) : sum,
i.classification === "consumable"
? sum + (i.weightGrams ?? 0) * (i.quantity ?? 1)
: sum,
0,
);
const totalWeight = baseWeight + wornWeight + consumableWeight;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
import { createFileRoute } from "@tanstack/react-router";
import { createFileRoute, Link } from "@tanstack/react-router";
import { AnimatePresence, motion } from "framer-motion";
import { useRef } from "react";
import { z } from "zod";
@@ -16,6 +16,11 @@ export const Route = createFileRoute("/collection/")({
});
const TAB_ORDER = ["gear", "planning", "setups"] as const;
const TAB_LABELS: Record<(typeof TAB_ORDER)[number], string> = {
gear: "Gear",
planning: "Planning",
setups: "Setups",
};
const slideVariants = {
enter: (dir: number) => ({ x: `${dir * 15}%`, opacity: 0 }),
@@ -33,6 +38,26 @@ function CollectionPage() {
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 overflow-x-hidden">
{/* Tab navigation */}
<div className="flex justify-center mb-6">
<div className="flex bg-gray-100 rounded-full p-0.5 gap-0.5">
{TAB_ORDER.map((t) => (
<Link
key={t}
to="/collection"
search={{ tab: t }}
className={`px-4 py-1.5 text-sm font-medium rounded-full transition-colors ${
tab === t
? "bg-gray-700 text-white"
: "text-gray-600 hover:bg-gray-200"
}`}
>
{TAB_LABELS[t]}
</Link>
))}
</div>
</div>
<AnimatePresence mode="wait" initial={false} custom={direction}>
<motion.div
key={tab}

View File

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

View File

@@ -53,13 +53,13 @@ function SetupDetailPage() {
);
}
// Compute totals from items
// Compute totals from items (multiply by quantity)
const totalWeight = setup.items.reduce(
(sum, item) => sum + (item.weightGrams ?? 0),
(sum, item) => sum + (item.weightGrams ?? 0) * (item.quantity ?? 1),
0,
);
const totalCost = setup.items.reduce(
(sum, item) => sum + (item.priceCents ?? 0),
(sum, item) => sum + (item.priceCents ?? 0) * (item.quantity ?? 1),
0,
);
const itemCount = setup.items.length;
@@ -207,11 +207,13 @@ function SetupDetailPage() {
{ items: categoryItems, categoryName, categoryIcon },
]) => {
const catWeight = categoryItems.reduce(
(sum, item) => sum + (item.weightGrams ?? 0),
(sum, item) =>
sum + (item.weightGrams ?? 0) * (item.quantity ?? 1),
0,
);
const catCost = categoryItems.reduce(
(sum, item) => sum + (item.priceCents ?? 0),
(sum, item) =>
sum + (item.priceCents ?? 0) * (item.quantity ?? 1),
0,
);
return (
@@ -232,6 +234,7 @@ function SetupDetailPage() {
name={item.name}
weightGrams={item.weightGrams}
priceCents={item.priceCents}
quantity={item.quantity}
categoryName={categoryName}
categoryIcon={categoryIcon}
imageFilename={item.imageFilename}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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