diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 514bb30..add0de0 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -2,9 +2,7 @@ name: CI on: push: - branches: [Develop] pull_request: - branches: [Develop] jobs: ci: diff --git a/docs/superpowers/specs/2026-04-03-v1.4-collection-tools-design.md b/docs/superpowers/specs/2026-04-03-v1.4-collection-tools-design.md new file mode 100644 index 0000000..7ec4a4a --- /dev/null +++ b/docs/superpowers/specs/2026-04-03-v1.4-collection-tools-design.md @@ -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 diff --git a/drizzle/0008_loving_colossus.sql b/drizzle/0008_loving_colossus.sql new file mode 100644 index 0000000..0860c27 --- /dev/null +++ b/drizzle/0008_loving_colossus.sql @@ -0,0 +1 @@ +ALTER TABLE `items` ADD `quantity` integer DEFAULT 1 NOT NULL; \ No newline at end of file diff --git a/drizzle/meta/0008_snapshot.json b/drizzle/meta/0008_snapshot.json new file mode 100644 index 0000000..7cd4652 --- /dev/null +++ b/drizzle/meta/0008_snapshot.json @@ -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": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 36bb07b..80c7e0b 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -57,6 +57,13 @@ "when": 1775215076284, "tag": "0007_icy_prodigy", "breakpoints": true + }, + { + "idx": 8, + "version": "6", + "when": 1775232090363, + "tag": "0008_loving_colossus", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/client/components/CandidateCard.tsx b/src/client/components/CandidateCard.tsx index 7821431..34da898 100644 --- a/src/client/components/CandidateCard.tsx +++ b/src/client/components/CandidateCard.tsx @@ -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)} )} + {priceCents != null && ( {price(priceCents)} )} + 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 && ( - controls.start(e)} - className="cursor-grab active:cursor-grabbing text-gray-300 hover:text-gray-500 touch-none shrink-0" - title="Drag to reorder" - > + - + )} {/* Rank badge */} @@ -99,7 +101,10 @@ export function CandidateListItem({ {/* Name + badges */} openCandidateEditPanel(candidate.id)} + onClick={() => { + if (isDragging.current) return; + openCandidateEditPanel(candidate.id); + }} className="flex-1 min-w-0 text-left" > @@ -111,11 +116,13 @@ export function CandidateListItem({ {weight(candidate.weightGrams)} )} + {candidate.priceCents != null && ( {price(candidate.priceCents)} )} + { + isDragging.current = true; + }} + onDragEnd={() => { + setTimeout(() => { + isDragging.current = false; + }, 0); + onDragEnd?.(); + }} + whileDrag={{ cursor: "grabbing" }} + style={{ marginBottom: 8, cursor: "grab" }} className={sharedClassName} > {innerContent} diff --git a/src/client/components/CollectionView.tsx b/src/client/components/CollectionView.tsx index b337d23..f3f4d51 100644 --- a/src/client/components/CollectionView.tsx +++ b/src/client/components/CollectionView.tsx @@ -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} diff --git a/src/client/components/ComparisonTable.tsx b/src/client/components/ComparisonTable.tsx index 71e61fa..3dd9178 100644 --- a/src/client/components/ComparisonTable.tsx +++ b/src/client/components/ComparisonTable.tsx @@ -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; } 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({ })} ))} + {showImpact && ( + <> + + + Weight Impact + + {candidates.map((candidate) => { + const isWinner = candidate.id === resolvedCandidateId; + return ( + + + + ); + })} + + + + Price Impact + + {candidates.map((candidate) => { + const isWinner = candidate.id === resolvedCandidateId; + return ( + + + + ); + })} + + > + )} diff --git a/src/client/components/ImpactDeltaBadge.tsx b/src/client/components/ImpactDeltaBadge.tsx new file mode 100644 index 0000000..5fec0a7 --- /dev/null +++ b/src/client/components/ImpactDeltaBadge.tsx @@ -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 —; + } + + if (value === 0) { + return ±0; + } + + if (value > 0) { + return ( + + +{formatFn(value)} + {delta.mode === "add" && ( + (add) + )} + + ); + } + + // value < 0 + return {formatFn(value)}; +} diff --git a/src/client/components/ItemCard.tsx b/src/client/components/ItemCard.tsx index 03f4b49..d3c2475 100644 --- a/src/client/components/ItemCard.tsx +++ b/src/client/components/ItemCard.tsx @@ -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 ( 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 && ( + { + 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" + > + + + + + )} {productUrl && ( - - {name} - + + + {name} + + {quantity != null && quantity > 1 && ( + + ×{quantity} + + )} + {weightGrams != null && ( diff --git a/src/client/components/ItemForm.tsx b/src/client/components/ItemForm.tsx index fbb2ec2..dc2d127 100644 --- a/src/client/components/ItemForm.tsx +++ b/src/client/components/ItemForm.tsx @@ -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) { )} + {/* Quantity */} + + + Quantity + + + 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" + /> + + {/* Category */} diff --git a/src/client/components/SetupImpactSelector.tsx b/src/client/components/SetupImpactSelector.tsx new file mode 100644 index 0000000..b282ea2 --- /dev/null +++ b/src/client/components/SetupImpactSelector.tsx @@ -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 ( + + 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" + > + Compare with setup... + {setups.map((setup) => ( + + {setup.name} + + ))} + + ); +} diff --git a/src/client/components/WeightSummaryCard.tsx b/src/client/components/WeightSummaryCard.tsx index e4b035f..ed4fa73 100644 --- a/src/client/components/WeightSummaryCard.tsx +++ b/src/client/components/WeightSummaryCard.tsx @@ -55,9 +55,15 @@ function buildCategoryChartData(items: SetupItemWithCategory[]): ChartDatum[] { const groups = new Map(); 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; diff --git a/src/client/hooks/useImpactDeltas.ts b/src/client/hooks/useImpactDeltas.ts new file mode 100644 index 0000000..8bb9e8e --- /dev/null +++ b/src/client/hooks/useImpactDeltas.ts @@ -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], + ); +} diff --git a/src/client/hooks/useItems.ts b/src/client/hooks/useItems.ts index cd665cf..51c5bf4 100644 --- a/src/client/hooks/useItems.ts +++ b/src/client/hooks/useItems.ts @@ -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(`/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(`/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("/api/items/import", file), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["items"] }); + queryClient.invalidateQueries({ queryKey: ["totals"] }); + }, + }); +} diff --git a/src/client/hooks/useSetups.ts b/src/client/hooks/useSetups.ts index 70eb776..b0f6385 100644 --- a/src/client/hooks/useSetups.ts +++ b/src/client/hooks/useSetups.ts @@ -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(`/api/setups/${setupId}`), enabled: setupId != null, + retry: (count, error) => + error instanceof ApiError && error.status === 404 ? false : count < 3, }); } diff --git a/src/client/hooks/useThreads.ts b/src/client/hooks/useThreads.ts index befe6f8..4f22c7c 100644 --- a/src/client/hooks/useThreads.ts +++ b/src/client/hooks/useThreads.ts @@ -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(`/api/threads/${threadId}`), enabled: threadId != null, + retry: (count, error) => + error instanceof ApiError && error.status === 404 ? false : count < 3, }); } diff --git a/src/client/lib/api.ts b/src/client/lib/api.ts index 71dbea2..c7fdc0d 100644 --- a/src/client/lib/api.ts +++ b/src/client/lib/api.ts @@ -1,4 +1,4 @@ -class ApiError extends Error { +export class ApiError extends Error { constructor( message: string, public status: number, diff --git a/src/client/lib/impactDeltas.ts b/src/client/lib/impactDeltas.ts new file mode 100644 index 0000000..c8b392f --- /dev/null +++ b/src/client/lib/impactDeltas.ts @@ -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; +} + +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 = {}; + + 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 }; +} diff --git a/src/client/routes/collection/index.tsx b/src/client/routes/collection/index.tsx index a12cf15..5d3b431 100644 --- a/src/client/routes/collection/index.tsx +++ b/src/client/routes/collection/index.tsx @@ -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 ( + {/* Tab navigation */} + + + {TAB_ORDER.map((t) => ( + + {TAB_LABELS[t]} + + ))} + + + (null); + const [importResult, setImportResult] = useState<{ + imported: number; + createdCategories: string[]; + errors: string[]; + } | null>(null); + + async function handleFileChange(e: React.ChangeEvent) { + 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 ( + + Import / Export + + Export your gear collection as a CSV file, or import items from a CSV. + + + + + Export CSV + + + + {importItems.isPending ? "Importing..." : "Import CSV"} + + + + + {importResult && ( + 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 && ( + + {importResult.imported} item + {importResult.imported !== 1 ? "s" : ""} imported. + + )} + {importResult.createdCategories.length > 0 && ( + New categories: {importResult.createdCategories.join(", ")} + )} + {importResult.errors.map((err, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: static error list + + {err} + + ))} + {importResult.imported === 0 && importResult.errors.length === 0 && ( + No items found in the CSV. + )} + + )} + + ); +} + function SettingsPage() { const unit = useWeightUnit(); const currency = useCurrency(); @@ -255,6 +345,10 @@ function SettingsPage() { + + + + {auth?.user && ( diff --git a/src/client/routes/setups/$setupId.tsx b/src/client/routes/setups/$setupId.tsx index a985c90..70f3f23 100644 --- a/src/client/routes/setups/$setupId.tsx +++ b/src/client/routes/setups/$setupId.tsx @@ -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} diff --git a/src/client/routes/threads/$threadId.tsx b/src/client/routes/threads/$threadId.tsx index 4ba63b2..9652eab 100644 --- a/src/client/routes/threads/$threadId.tsx +++ b/src/client/routes/threads/$threadId.tsx @@ -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( - null, - ); + const [tempItems, setTempItems] = useState< + NonNullable["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 */} - - {isActive && candidateViewMode !== "compare" && ( + + {isActive && ( )} + {/* Candidates */} @@ -208,6 +217,7 @@ function ThreadDetailPage() { ) : 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) => ( ))} @@ -247,6 +258,7 @@ function ThreadDetailPage() { status: newStatus, }) } + delta={deltas[candidate.id]} /> ))} @@ -276,6 +288,7 @@ function ThreadDetailPage() { pros={candidate.pros} cons={candidate.cons} rank={index + 1} + delta={deltas[candidate.id]} /> ))} diff --git a/src/client/stores/uiStore.ts b/src/client/stores/uiStore.ts index 8bd5c19..6d2ff42 100644 --- a/src/client/stores/uiStore.ts +++ b/src/client/stores/uiStore.ts @@ -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((set) => ({ @@ -111,4 +115,8 @@ export const useUIStore = create((set) => ({ // Candidate view mode candidateViewMode: "list", setCandidateViewMode: (mode) => set({ candidateViewMode: mode }), + + // Setup impact preview + selectedSetupId: null, + setSelectedSetupId: (id) => set({ selectedSetupId: id }), })); diff --git a/src/db/schema.ts b/src/db/schema.ts index e6e88f3..c73d483 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -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()), diff --git a/src/server/routes/items.ts b/src/server/routes/items.ts index 1ed6dc2..2ba4325 100644 --- a/src/server/routes/items.ts +++ b/src/server/routes/items.ts @@ -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(); +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")); diff --git a/src/server/services/csv.service.ts b/src/server/services/csv.service.ts new file mode 100644 index 0000000..78c4b4a --- /dev/null +++ b/src/server/services/csv.service.ts @@ -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(); + 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; +} diff --git a/src/server/services/item.service.ts b/src/server/services/item.service.ts index 2320943..7732adc 100644 --- a/src/server/services/item.service.ts +++ b/src/server/services/item.service.ts @@ -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(); diff --git a/src/server/services/setup.service.ts b/src/server/services/setup.service.ts index a3f2004..1128ab7 100644 --- a/src/server/services/setup.service.ts +++ b/src/server/services/setup.service.ts @@ -21,12 +21,12 @@ export function getAllSetups(db: Db = prodDb) { WHERE setup_items.setup_id = setups.id ), 0)`.as("item_count"), totalWeight: sql`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`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, diff --git a/src/server/services/thread.service.ts b/src/server/services/thread.service.ts index a43d04a..3b68b07 100644 --- a/src/server/services/thread.service.ts +++ b/src/server/services/thread.service.ts @@ -299,6 +299,7 @@ export function resolveThread( productUrl: candidate.productUrl, imageFilename: candidate.imageFilename, imageSourceUrl: candidate.imageSourceUrl, + quantity: 1, }) .returning() .get(); diff --git a/src/server/services/totals.service.ts b/src/server/services/totals.service.ts index c226b0b..53678e7 100644 --- a/src/server/services/totals.service.ts +++ b/src/server/services/totals.service.ts @@ -10,8 +10,8 @@ export function getCategoryTotals(db: Db = prodDb) { categoryId: items.categoryId, categoryName: categories.name, categoryIcon: categories.icon, - totalWeight: sql`COALESCE(SUM(${items.weightGrams}), 0)`, - totalCost: sql`COALESCE(SUM(${items.priceCents}), 0)`, + totalWeight: sql`COALESCE(SUM(${items.weightGrams} * ${items.quantity}), 0)`, + totalCost: sql`COALESCE(SUM(${items.priceCents} * ${items.quantity}), 0)`, itemCount: sql`COUNT(*)`, }) .from(items) @@ -23,8 +23,8 @@ export function getCategoryTotals(db: Db = prodDb) { export function getGlobalTotals(db: Db = prodDb) { return db .select({ - totalWeight: sql`COALESCE(SUM(${items.weightGrams}), 0)`, - totalCost: sql`COALESCE(SUM(${items.priceCents}), 0)`, + totalWeight: sql`COALESCE(SUM(${items.weightGrams} * ${items.quantity}), 0)`, + totalCost: sql`COALESCE(SUM(${items.priceCents} * ${items.quantity}), 0)`, itemCount: sql`COUNT(*)`, }) .from(items) diff --git a/src/shared/schemas.ts b/src/shared/schemas.ts index 2a69f33..ed34157 100644 --- a/src/shared/schemas.ts +++ b/src/shared/schemas.ts @@ -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({ diff --git a/tests/lib/impactDeltas.test.ts b/tests/lib/impactDeltas.test.ts new file mode 100644 index 0000000..caa51b5 --- /dev/null +++ b/tests/lib/impactDeltas.test.ts @@ -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 + }); +}); diff --git a/tests/routes/items.test.ts b/tests/routes/items.test.ts index 0b5ab6a..c0f5b8b 100644 --- a/tests/routes/items.test.ts +++ b/tests/routes/items.test.ts @@ -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); + }); }); diff --git a/tests/services/csv.service.test.ts b/tests/services/csv.service.test.ts new file mode 100644 index 0000000..63e1389 --- /dev/null +++ b/tests/services/csv.service.test.ts @@ -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; + + 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); + }); + }); +}); diff --git a/tests/services/item.service.test.ts b/tests/services/item.service.test.ts index 2a78edf..03cb679 100644 --- a/tests/services/item.service.test.ts +++ b/tests/services/item.service.test.ts @@ -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, {
@@ -111,11 +116,13 @@ export function CandidateListItem({ {weight(candidate.weightGrams)} )} + {candidate.priceCents != null && ( {price(candidate.priceCents)} )} + { + isDragging.current = true; + }} + onDragEnd={() => { + setTimeout(() => { + isDragging.current = false; + }, 0); + onDragEnd?.(); + }} + whileDrag={{ cursor: "grabbing" }} + style={{ marginBottom: 8, cursor: "grab" }} className={sharedClassName} > {innerContent} diff --git a/src/client/components/CollectionView.tsx b/src/client/components/CollectionView.tsx index b337d23..f3f4d51 100644 --- a/src/client/components/CollectionView.tsx +++ b/src/client/components/CollectionView.tsx @@ -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} diff --git a/src/client/components/ComparisonTable.tsx b/src/client/components/ComparisonTable.tsx index 71e61fa..3dd9178 100644 --- a/src/client/components/ComparisonTable.tsx +++ b/src/client/components/ComparisonTable.tsx @@ -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; } 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({ })} ))} + {showImpact && ( + <> + + + Weight Impact + + {candidates.map((candidate) => { + const isWinner = candidate.id === resolvedCandidateId; + return ( + + + + ); + })} + + + + Price Impact + + {candidates.map((candidate) => { + const isWinner = candidate.id === resolvedCandidateId; + return ( + + + + ); + })} + + > + )} diff --git a/src/client/components/ImpactDeltaBadge.tsx b/src/client/components/ImpactDeltaBadge.tsx new file mode 100644 index 0000000..5fec0a7 --- /dev/null +++ b/src/client/components/ImpactDeltaBadge.tsx @@ -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 —; + } + + if (value === 0) { + return ±0; + } + + if (value > 0) { + return ( + + +{formatFn(value)} + {delta.mode === "add" && ( + (add) + )} + + ); + } + + // value < 0 + return {formatFn(value)}; +} diff --git a/src/client/components/ItemCard.tsx b/src/client/components/ItemCard.tsx index 03f4b49..d3c2475 100644 --- a/src/client/components/ItemCard.tsx +++ b/src/client/components/ItemCard.tsx @@ -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 ( 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 && ( + { + 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" + > + + + + + )} {productUrl && ( - - {name} - + + + {name} + + {quantity != null && quantity > 1 && ( + + ×{quantity} + + )} + {weightGrams != null && ( diff --git a/src/client/components/ItemForm.tsx b/src/client/components/ItemForm.tsx index fbb2ec2..dc2d127 100644 --- a/src/client/components/ItemForm.tsx +++ b/src/client/components/ItemForm.tsx @@ -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) { )} + {/* Quantity */} + + + Quantity + + + 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" + /> + + {/* Category */} diff --git a/src/client/components/SetupImpactSelector.tsx b/src/client/components/SetupImpactSelector.tsx new file mode 100644 index 0000000..b282ea2 --- /dev/null +++ b/src/client/components/SetupImpactSelector.tsx @@ -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 ( + + 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" + > + Compare with setup... + {setups.map((setup) => ( + + {setup.name} + + ))} + + ); +} diff --git a/src/client/components/WeightSummaryCard.tsx b/src/client/components/WeightSummaryCard.tsx index e4b035f..ed4fa73 100644 --- a/src/client/components/WeightSummaryCard.tsx +++ b/src/client/components/WeightSummaryCard.tsx @@ -55,9 +55,15 @@ function buildCategoryChartData(items: SetupItemWithCategory[]): ChartDatum[] { const groups = new Map(); 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; diff --git a/src/client/hooks/useImpactDeltas.ts b/src/client/hooks/useImpactDeltas.ts new file mode 100644 index 0000000..8bb9e8e --- /dev/null +++ b/src/client/hooks/useImpactDeltas.ts @@ -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], + ); +} diff --git a/src/client/hooks/useItems.ts b/src/client/hooks/useItems.ts index cd665cf..51c5bf4 100644 --- a/src/client/hooks/useItems.ts +++ b/src/client/hooks/useItems.ts @@ -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(`/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(`/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("/api/items/import", file), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["items"] }); + queryClient.invalidateQueries({ queryKey: ["totals"] }); + }, + }); +} diff --git a/src/client/hooks/useSetups.ts b/src/client/hooks/useSetups.ts index 70eb776..b0f6385 100644 --- a/src/client/hooks/useSetups.ts +++ b/src/client/hooks/useSetups.ts @@ -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(`/api/setups/${setupId}`), enabled: setupId != null, + retry: (count, error) => + error instanceof ApiError && error.status === 404 ? false : count < 3, }); } diff --git a/src/client/hooks/useThreads.ts b/src/client/hooks/useThreads.ts index befe6f8..4f22c7c 100644 --- a/src/client/hooks/useThreads.ts +++ b/src/client/hooks/useThreads.ts @@ -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(`/api/threads/${threadId}`), enabled: threadId != null, + retry: (count, error) => + error instanceof ApiError && error.status === 404 ? false : count < 3, }); } diff --git a/src/client/lib/api.ts b/src/client/lib/api.ts index 71dbea2..c7fdc0d 100644 --- a/src/client/lib/api.ts +++ b/src/client/lib/api.ts @@ -1,4 +1,4 @@ -class ApiError extends Error { +export class ApiError extends Error { constructor( message: string, public status: number, diff --git a/src/client/lib/impactDeltas.ts b/src/client/lib/impactDeltas.ts new file mode 100644 index 0000000..c8b392f --- /dev/null +++ b/src/client/lib/impactDeltas.ts @@ -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; +} + +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 = {}; + + 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 }; +} diff --git a/src/client/routes/collection/index.tsx b/src/client/routes/collection/index.tsx index a12cf15..5d3b431 100644 --- a/src/client/routes/collection/index.tsx +++ b/src/client/routes/collection/index.tsx @@ -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 ( + {/* Tab navigation */} + + + {TAB_ORDER.map((t) => ( + + {TAB_LABELS[t]} + + ))} + + + (null); + const [importResult, setImportResult] = useState<{ + imported: number; + createdCategories: string[]; + errors: string[]; + } | null>(null); + + async function handleFileChange(e: React.ChangeEvent) { + 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 ( + + Import / Export + + Export your gear collection as a CSV file, or import items from a CSV. + + + + + Export CSV + + + + {importItems.isPending ? "Importing..." : "Import CSV"} + + + + + {importResult && ( + 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 && ( + + {importResult.imported} item + {importResult.imported !== 1 ? "s" : ""} imported. + + )} + {importResult.createdCategories.length > 0 && ( + New categories: {importResult.createdCategories.join(", ")} + )} + {importResult.errors.map((err, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: static error list + + {err} + + ))} + {importResult.imported === 0 && importResult.errors.length === 0 && ( + No items found in the CSV. + )} + + )} + + ); +} + function SettingsPage() { const unit = useWeightUnit(); const currency = useCurrency(); @@ -255,6 +345,10 @@ function SettingsPage() { + + + + {auth?.user && ( diff --git a/src/client/routes/setups/$setupId.tsx b/src/client/routes/setups/$setupId.tsx index a985c90..70f3f23 100644 --- a/src/client/routes/setups/$setupId.tsx +++ b/src/client/routes/setups/$setupId.tsx @@ -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} diff --git a/src/client/routes/threads/$threadId.tsx b/src/client/routes/threads/$threadId.tsx index 4ba63b2..9652eab 100644 --- a/src/client/routes/threads/$threadId.tsx +++ b/src/client/routes/threads/$threadId.tsx @@ -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( - null, - ); + const [tempItems, setTempItems] = useState< + NonNullable["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 */} - - {isActive && candidateViewMode !== "compare" && ( + + {isActive && ( )} + {/* Candidates */} @@ -208,6 +217,7 @@ function ThreadDetailPage() { ) : 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) => ( ))} @@ -247,6 +258,7 @@ function ThreadDetailPage() { status: newStatus, }) } + delta={deltas[candidate.id]} /> ))} @@ -276,6 +288,7 @@ function ThreadDetailPage() { pros={candidate.pros} cons={candidate.cons} rank={index + 1} + delta={deltas[candidate.id]} /> ))} diff --git a/src/client/stores/uiStore.ts b/src/client/stores/uiStore.ts index 8bd5c19..6d2ff42 100644 --- a/src/client/stores/uiStore.ts +++ b/src/client/stores/uiStore.ts @@ -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((set) => ({ @@ -111,4 +115,8 @@ export const useUIStore = create((set) => ({ // Candidate view mode candidateViewMode: "list", setCandidateViewMode: (mode) => set({ candidateViewMode: mode }), + + // Setup impact preview + selectedSetupId: null, + setSelectedSetupId: (id) => set({ selectedSetupId: id }), })); diff --git a/src/db/schema.ts b/src/db/schema.ts index e6e88f3..c73d483 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -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()), diff --git a/src/server/routes/items.ts b/src/server/routes/items.ts index 1ed6dc2..2ba4325 100644 --- a/src/server/routes/items.ts +++ b/src/server/routes/items.ts @@ -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(); +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")); diff --git a/src/server/services/csv.service.ts b/src/server/services/csv.service.ts new file mode 100644 index 0000000..78c4b4a --- /dev/null +++ b/src/server/services/csv.service.ts @@ -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(); + 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; +} diff --git a/src/server/services/item.service.ts b/src/server/services/item.service.ts index 2320943..7732adc 100644 --- a/src/server/services/item.service.ts +++ b/src/server/services/item.service.ts @@ -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(); diff --git a/src/server/services/setup.service.ts b/src/server/services/setup.service.ts index a3f2004..1128ab7 100644 --- a/src/server/services/setup.service.ts +++ b/src/server/services/setup.service.ts @@ -21,12 +21,12 @@ export function getAllSetups(db: Db = prodDb) { WHERE setup_items.setup_id = setups.id ), 0)`.as("item_count"), totalWeight: sql`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`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, diff --git a/src/server/services/thread.service.ts b/src/server/services/thread.service.ts index a43d04a..3b68b07 100644 --- a/src/server/services/thread.service.ts +++ b/src/server/services/thread.service.ts @@ -299,6 +299,7 @@ export function resolveThread( productUrl: candidate.productUrl, imageFilename: candidate.imageFilename, imageSourceUrl: candidate.imageSourceUrl, + quantity: 1, }) .returning() .get(); diff --git a/src/server/services/totals.service.ts b/src/server/services/totals.service.ts index c226b0b..53678e7 100644 --- a/src/server/services/totals.service.ts +++ b/src/server/services/totals.service.ts @@ -10,8 +10,8 @@ export function getCategoryTotals(db: Db = prodDb) { categoryId: items.categoryId, categoryName: categories.name, categoryIcon: categories.icon, - totalWeight: sql`COALESCE(SUM(${items.weightGrams}), 0)`, - totalCost: sql`COALESCE(SUM(${items.priceCents}), 0)`, + totalWeight: sql`COALESCE(SUM(${items.weightGrams} * ${items.quantity}), 0)`, + totalCost: sql`COALESCE(SUM(${items.priceCents} * ${items.quantity}), 0)`, itemCount: sql`COUNT(*)`, }) .from(items) @@ -23,8 +23,8 @@ export function getCategoryTotals(db: Db = prodDb) { export function getGlobalTotals(db: Db = prodDb) { return db .select({ - totalWeight: sql`COALESCE(SUM(${items.weightGrams}), 0)`, - totalCost: sql`COALESCE(SUM(${items.priceCents}), 0)`, + totalWeight: sql`COALESCE(SUM(${items.weightGrams} * ${items.quantity}), 0)`, + totalCost: sql`COALESCE(SUM(${items.priceCents} * ${items.quantity}), 0)`, itemCount: sql`COUNT(*)`, }) .from(items) diff --git a/src/shared/schemas.ts b/src/shared/schemas.ts index 2a69f33..ed34157 100644 --- a/src/shared/schemas.ts +++ b/src/shared/schemas.ts @@ -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({ diff --git a/tests/lib/impactDeltas.test.ts b/tests/lib/impactDeltas.test.ts new file mode 100644 index 0000000..caa51b5 --- /dev/null +++ b/tests/lib/impactDeltas.test.ts @@ -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 + }); +}); diff --git a/tests/routes/items.test.ts b/tests/routes/items.test.ts index 0b5ab6a..c0f5b8b 100644 --- a/tests/routes/items.test.ts +++ b/tests/routes/items.test.ts @@ -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); + }); }); diff --git a/tests/services/csv.service.test.ts b/tests/services/csv.service.test.ts new file mode 100644 index 0000000..63e1389 --- /dev/null +++ b/tests/services/csv.service.test.ts @@ -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; + + 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); + }); + }); +}); diff --git a/tests/services/item.service.test.ts b/tests/services/item.service.test.ts index 2a78edf..03cb679 100644 --- a/tests/services/item.service.test.ts +++ b/tests/services/item.service.test.ts @@ -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, {
+ Export your gear collection as a CSV file, or import items from a CSV. +
+ {importResult.imported} item + {importResult.imported !== 1 ? "s" : ""} imported. +
New categories: {importResult.createdCategories.join(", ")}
+ {err} +
No items found in the CSV.