From 1b492f2ac2fdd7ca934ab8a14d739fd137f23fb8 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Fri, 3 Apr 2026 18:00:02 +0200 Subject: [PATCH 01/17] docs: add v1.4 Collection Tools design spec Co-Authored-By: Claude Opus 4.6 (1M context) --- ...2026-04-03-v1.4-collection-tools-design.md | 114 ++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-03-v1.4-collection-tools-design.md 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 -- 2.49.1 From 923a0f66b0f793e84d7609e6e7689d7f17e9821e Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Fri, 3 Apr 2026 18:04:15 +0200 Subject: [PATCH 02/17] feat: add quantity field to items schema Add integer quantity column (default 1) to the items table, generate the corresponding Drizzle migration, and extend createItemSchema / updateItemSchema with an optional positive-integer quantity field. Co-Authored-By: Claude Sonnet 4.6 --- drizzle/0008_loving_colossus.sql | 1 + src/db/schema.ts | 1 + src/shared/schemas.ts | 1 + 3 files changed, 3 insertions(+) create mode 100644 drizzle/0008_loving_colossus.sql 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/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/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({ -- 2.49.1 From 1a5e6a303ef6ccdf4a6fdfb2f2ffdfea853c065b Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Fri, 3 Apr 2026 18:04:27 +0200 Subject: [PATCH 03/17] feat: add quantity support to totals, UI, and thread resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - totals.service: multiply weight/cost sums by quantity in category and global totals - setup.service: multiply by quantity in getAllSetups SQL subqueries; expose quantity in getSetupWithItems item list - thread.service: explicitly pass quantity: 1 when inserting resolved item - ItemForm: add Quantity number input (min=1, default=1) after price field - ItemCard: show ×N badge next to item name when quantity > 1 - CollectionView: pass quantity prop to ItemCard in both filtered and grouped views - $setupId.tsx: pass quantity to ItemCard; multiply by quantity in client-side per-setup totals - WeightSummaryCard: multiply by quantity in all chart and legend weight calculations - useItems / useSetups: add quantity to ItemWithCategory / SetupItemWithCategory interfaces Co-Authored-By: Claude Sonnet 4.6 --- src/client/components/CollectionView.tsx | 2 ++ src/client/components/ItemCard.tsx | 15 ++++++++--- src/client/components/ItemForm.tsx | 28 +++++++++++++++++++++ src/client/components/WeightSummaryCard.tsx | 25 +++++++++++++----- src/client/hooks/useItems.ts | 1 + src/client/hooks/useSetups.ts | 1 + src/client/routes/setups/$setupId.tsx | 13 ++++++---- src/server/services/setup.service.ts | 5 ++-- src/server/services/thread.service.ts | 1 + src/server/services/totals.service.ts | 8 +++--- 10 files changed, 79 insertions(+), 20 deletions(-) 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/ItemCard.tsx b/src/client/components/ItemCard.tsx index 03f4b49..e6383ee 100644 --- a/src/client/components/ItemCard.tsx +++ b/src/client/components/ItemCard.tsx @@ -8,6 +8,7 @@ interface ItemCardProps { name: string; weightGrams: number | null; priceCents: number | null; + quantity?: number; categoryName: string; categoryIcon: string; imageFilename: string | null; @@ -22,6 +23,7 @@ export function ItemCard({ name, weightGrams, priceCents, + quantity, categoryName, categoryIcon, imageFilename, @@ -122,9 +124,16 @@ export function ItemCard({ )}
-

- {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 */} +
+ + + 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/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/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 ( + + ); +} diff --git a/src/client/routes/threads/$threadId.tsx b/src/client/routes/threads/$threadId.tsx index 4ba63b2..52b5ebf 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,8 +26,15 @@ 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( @@ -120,7 +130,7 @@ function ThreadDetailPage() { )} {/* Toolbar: Add candidate + view toggle */} -
+
{isActive && candidateViewMode !== "compare" && (
{/* Candidates */} @@ -208,6 +219,7 @@ function ThreadDetailPage() { ) : candidateViewMode === "list" ? ( isActive ? ( @@ -230,6 +242,7 @@ function ThreadDetailPage() { status: newStatus, }) } + delta={deltas[candidate.id]} /> ))} @@ -247,6 +260,7 @@ function ThreadDetailPage() { status: newStatus, }) } + delta={deltas[candidate.id]} /> ))}
@@ -276,6 +290,7 @@ function ThreadDetailPage() { pros={candidate.pros} cons={candidate.cons} rank={index + 1} + delta={deltas[candidate.id]} /> ))}
-- 2.49.1 From 15f146ee893a2ac04af271abf289e8719eef4c05 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Fri, 3 Apr 2026 18:12:07 +0200 Subject: [PATCH 07/17] feat: add CSV import/export for gear collection Adds export (GET /api/items/export) and import (POST /api/items/import) routes backed by a pure csv.service with no external deps, plus useExportItems/useImportItems hooks and an Import/Export section in the Settings page. Co-Authored-By: Claude Sonnet 4.6 --- src/client/hooks/useItems.ts | 26 ++- src/client/routes/settings.tsx | 96 ++++++++++- src/server/routes/items.ts | 22 +++ src/server/services/csv.service.ts | 246 +++++++++++++++++++++++++++++ tests/routes/items.test.ts | 60 +++++++ tests/services/csv.service.test.ts | 197 +++++++++++++++++++++++ 6 files changed, 645 insertions(+), 2 deletions(-) create mode 100644 src/server/services/csv.service.ts create mode 100644 tests/services/csv.service.test.ts diff --git a/src/client/hooks/useItems.ts b/src/client/hooks/useItems.ts index 20f169e..30eccd2 100644 --- a/src/client/hooks/useItems.ts +++ b/src/client/hooks/useItems.ts @@ -1,6 +1,6 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import type { CreateItem } from "../../shared/types"; -import { apiDelete, apiGet, apiPost, apiPut } from "../lib/api"; +import { apiDelete, apiGet, apiPost, apiPut, apiUpload } from "../lib/api"; interface Item { id: number; @@ -96,3 +96,27 @@ export function useDuplicateItem() { }, }); } + +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/routes/settings.tsx b/src/client/routes/settings.tsx index d3e9014..9d9f79a 100644 --- a/src/client/routes/settings.tsx +++ b/src/client/routes/settings.tsx @@ -1,5 +1,5 @@ import { createFileRoute, Link } from "@tanstack/react-router"; -import { useState } from "react"; +import { useRef, useState } from "react"; import { useApiKeys, useAuth, @@ -8,6 +8,7 @@ import { useDeleteApiKey, } from "../hooks/useAuth"; import { useCurrency } from "../hooks/useCurrency"; +import { useExportItems, useImportItems } from "../hooks/useItems"; import { useUpdateSetting } from "../hooks/useSettings"; import { useWeightUnit } from "../hooks/useWeightUnit"; import type { Currency, WeightUnit } from "../lib/formatters"; @@ -172,6 +173,95 @@ function ApiKeySection() { ); } +function ImportExportSection() { + const exportItems = useExportItems(); + const importItems = useImportItems(); + const fileInputRef = useRef(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. +

+ +
+ + + +
+ + {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/server/routes/items.ts b/src/server/routes/items.ts index 3482d8c..2ba4325 100644 --- a/src/server/routes/items.ts +++ b/src/server/routes/items.ts @@ -4,6 +4,7 @@ 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, @@ -17,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); 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/tests/routes/items.test.ts b/tests/routes/items.test.ts index 620462b..c0f5b8b 100644 --- a/tests/routes/items.test.ts +++ b/tests/routes/items.test.ts @@ -144,4 +144,64 @@ describe("Item Routes", () => { }); 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); + }); + }); +}); -- 2.49.1 From a8696c2a8583a551c0ba9bbfe4082c53ed7cf479 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Fri, 3 Apr 2026 18:18:12 +0200 Subject: [PATCH 08/17] fix: commit missing migration metadata and run CI on all branches The Drizzle migration journal and snapshot for 0008 (quantity column) were not committed, causing test failures in CI. Also updates CI to trigger on all branches, not just Develop. Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitea/workflows/ci.yml | 2 - drizzle/meta/0008_snapshot.json | 663 ++++++++++++++++++++++++++++++++ drizzle/meta/_journal.json | 7 + 3 files changed, 670 insertions(+), 2 deletions(-) create mode 100644 drizzle/meta/0008_snapshot.json 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/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 -- 2.49.1 From b993a0a8315b06626baac8d56033ccace4c99def Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Fri, 3 Apr 2026 18:28:04 +0200 Subject: [PATCH 09/17] fix: skip retries on 404 for single-resource queries Prevents 10-second loading skeleton when navigating to non-existent threads, setups, or items. Shows error/not-found state immediately. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/client/hooks/useItems.ts | 11 ++++++++++- src/client/hooks/useSetups.ts | 11 ++++++++++- src/client/hooks/useThreads.ts | 4 +++- src/client/lib/api.ts | 2 +- 4 files changed, 24 insertions(+), 4 deletions(-) diff --git a/src/client/hooks/useItems.ts b/src/client/hooks/useItems.ts index 30eccd2..51c5bf4 100644 --- a/src/client/hooks/useItems.ts +++ b/src/client/hooks/useItems.ts @@ -1,6 +1,13 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import type { CreateItem } from "../../shared/types"; -import { apiDelete, apiGet, apiPost, apiPut, apiUpload } from "../lib/api"; +import { + ApiError, + apiDelete, + apiGet, + apiPost, + apiPut, + apiUpload, +} from "../lib/api"; interface Item { id: number; @@ -45,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, }); } diff --git a/src/client/hooks/useSetups.ts b/src/client/hooks/useSetups.ts index 4149bc4..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; @@ -50,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 9cf57c3..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; @@ -61,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, -- 2.49.1 From 3fc737c87219344eb32c1110f26aa9c856789da8 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Fri, 3 Apr 2026 18:31:57 +0200 Subject: [PATCH 10/17] fix: add tab navigation to collection page and skip 404 retries Adds Gear/Planning/Setups pill tabs to the collection page so users can switch tabs without going back to the dashboard. Also skips React Query retries on 404 responses for immediate error display. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/client/routes/collection/index.tsx | 27 +++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) 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]} + + ))} +
+
+ Date: Fri, 3 Apr 2026 18:42:16 +0200 Subject: [PATCH 11/17] fix: use onDragEnd on Reorder.Item instead of onPointerUp on Group MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous approach used onPointerUp on the Reorder.Group which fired unreliably — triggering on non-drag clicks and sometimes not at all after a drag. Moving to onDragEnd on each Reorder.Item gives clean, predictable drag-to-reorder behavior. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/client/components/CandidateListItem.tsx | 3 +++ src/client/routes/threads/$threadId.tsx | 11 +++++------ 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/client/components/CandidateListItem.tsx b/src/client/components/CandidateListItem.tsx index 282153f..d69e479 100644 --- a/src/client/components/CandidateListItem.tsx +++ b/src/client/components/CandidateListItem.tsx @@ -31,6 +31,7 @@ interface CandidateListItemProps { isActive: boolean; onStatusChange: (status: "researching" | "ordered" | "arrived") => void; delta?: CandidateDelta; + onDragEnd?: () => void; } const RANK_COLORS = ["#D4AF37", "#C0C0C0", "#CD7F32"]; // gold, silver, bronze @@ -53,6 +54,7 @@ export function CandidateListItem({ isActive, onStatusChange, delta, + onDragEnd, }: CandidateListItemProps) { const controls = useDragControls(); const { weight, price } = useFormatters(); @@ -217,6 +219,7 @@ export function CandidateListItem({ value={candidate} dragControls={controls} dragListener={false} + onDragEnd={onDragEnd} className={sharedClassName} > {innerContent} diff --git a/src/client/routes/threads/$threadId.tsx b/src/client/routes/threads/$threadId.tsx index 52b5ebf..a9a0799 100644 --- a/src/client/routes/threads/$threadId.tsx +++ b/src/client/routes/threads/$threadId.tsx @@ -36,12 +36,11 @@ function ThreadDetailPage() { 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); @@ -227,7 +226,6 @@ function ThreadDetailPage() { axis="y" values={displayItems} onReorder={setTempItems} - onPointerUp={handleDragEnd} className="flex flex-col gap-2" > {displayItems.map((candidate, index) => ( @@ -243,6 +241,7 @@ function ThreadDetailPage() { }) } delta={deltas[candidate.id]} + onDragEnd={handleDragEnd} /> ))} -- 2.49.1 From 684cfd3789cb2e32aa701b4da5da509a886eac79 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Fri, 3 Apr 2026 18:44:39 +0200 Subject: [PATCH 12/17] fix: stabilize drag-to-reorder with layout animation fixes - Remove transition-all from list items (fights framer-motion layout) - Add layout="position" to Reorder.Item for proper sibling tracking - Replace CSS gap with marginBottom (gap confuses layout engine) - Add explicit short transition duration for snappy reorder Co-Authored-By: Claude Opus 4.6 (1M context) --- src/client/components/CandidateListItem.tsx | 5 ++++- src/client/routes/threads/$threadId.tsx | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/client/components/CandidateListItem.tsx b/src/client/components/CandidateListItem.tsx index d69e479..f76c530 100644 --- a/src/client/components/CandidateListItem.tsx +++ b/src/client/components/CandidateListItem.tsx @@ -66,7 +66,7 @@ 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 = ( <> @@ -220,6 +220,9 @@ export function CandidateListItem({ dragControls={controls} dragListener={false} onDragEnd={onDragEnd} + layout="position" + transition={{ layout: { duration: 0.15, ease: "easeOut" } }} + style={{ marginBottom: 8 }} className={sharedClassName} > {innerContent} diff --git a/src/client/routes/threads/$threadId.tsx b/src/client/routes/threads/$threadId.tsx index a9a0799..bcb9cd9 100644 --- a/src/client/routes/threads/$threadId.tsx +++ b/src/client/routes/threads/$threadId.tsx @@ -226,7 +226,7 @@ function ThreadDetailPage() { axis="y" values={displayItems} onReorder={setTempItems} - className="flex flex-col gap-2" + className="flex flex-col" > {displayItems.map((candidate, index) => ( Date: Fri, 3 Apr 2026 18:46:52 +0200 Subject: [PATCH 13/17] fix: make entire candidate row draggable instead of handle-only Remove dragControls/dragListener pattern which prevented onReorder from firing. The whole row is now the drag target with visual feedback (scale + shadow). Grip icon remains as a visual indicator. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/client/components/CandidateListItem.tsx | 23 +++++++++------------ 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/src/client/components/CandidateListItem.tsx b/src/client/components/CandidateListItem.tsx index f76c530..11a244a 100644 --- a/src/client/components/CandidateListItem.tsx +++ b/src/client/components/CandidateListItem.tsx @@ -1,4 +1,4 @@ -import { Reorder, useDragControls } from "framer-motion"; +import { Reorder } from "framer-motion"; import { useFormatters } from "../hooks/useFormatters"; import type { CandidateDelta } from "../hooks/useImpactDeltas"; import { LucideIcon } from "../lib/iconData"; @@ -56,7 +56,6 @@ export function CandidateListItem({ delta, onDragEnd, }: CandidateListItemProps) { - const controls = useDragControls(); const { weight, price } = useFormatters(); const openCandidateEditPanel = useUIStore((s) => s.openCandidateEditPanel); const openConfirmDeleteCandidate = useUIStore( @@ -70,16 +69,11 @@ export function CandidateListItem({ const innerContent = ( <> - {/* Drag handle */} + {/* Drag handle indicator */} {isActive && ( - + )} {/* Rank badge */} @@ -217,12 +211,15 @@ export function CandidateListItem({ return ( {innerContent} -- 2.49.1 From f8a1a00e0a767f796a56293ea14593465db6cff4 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Fri, 3 Apr 2026 18:49:39 +0200 Subject: [PATCH 14/17] fix: prevent snap-back after drag and click-opens-edit during drag Two fixes: - Remove onSettled clearing tempItems before refetch completes, let useEffect clear it when fresh server data arrives - Track isDragging ref to suppress edit panel click after drag - Remove layout="position" which interfered with reorder detection Co-Authored-By: Claude Opus 4.6 (1M context) --- src/client/components/CandidateListItem.tsx | 20 ++++++++++++++++---- src/client/routes/threads/$threadId.tsx | 7 +++---- 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/src/client/components/CandidateListItem.tsx b/src/client/components/CandidateListItem.tsx index 11a244a..8b6ae44 100644 --- a/src/client/components/CandidateListItem.tsx +++ b/src/client/components/CandidateListItem.tsx @@ -1,4 +1,5 @@ import { Reorder } from "framer-motion"; +import { useRef } from "react"; import { useFormatters } from "../hooks/useFormatters"; import type { CandidateDelta } from "../hooks/useImpactDeltas"; import { LucideIcon } from "../lib/iconData"; @@ -56,6 +57,7 @@ export function CandidateListItem({ delta, onDragEnd, }: CandidateListItemProps) { + const isDragging = useRef(false); const { weight, price } = useFormatters(); const openCandidateEditPanel = useUIStore((s) => s.openCandidateEditPanel); const openConfirmDeleteCandidate = useUIStore( @@ -99,7 +101,10 @@ export function CandidateListItem({ {/* Name + badges */}