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 && ( - + )} {/* Rank badge */} @@ -99,7 +101,10 @@ export function CandidateListItem({ {/* Name + badges */} + + + + + {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, {