From bea386e7db8e82847eda9c9d3fe9e5b7ff0fa0d4 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sat, 18 Apr 2026 14:49:10 +0200 Subject: [PATCH] =?UTF-8?q?style(i18n):=20fix=20lint=20=E2=80=94=20formatt?= =?UTF-8?q?ing=20and=20import=20ordering=20across=2021=20files?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Biome auto-fix for formatting (line length, ternary wrapping) and import organization in files touched by phase 34 i18n work. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../2026-04-18-catalog-ingestion-script.md | 647 ++++++++ .../2026-04-18-catalog-schema-migration.md | 1355 +++++++++++++++++ .../components/AddToCollectionModal.tsx | 14 +- src/client/components/AddToThreadModal.tsx | 12 +- .../components/CategoryFilterDropdown.tsx | 4 +- src/client/components/CategoryHeader.tsx | 4 +- src/client/components/CategoryPicker.tsx | 4 +- src/client/components/CollectionView.tsx | 17 +- src/client/components/CreateThreadModal.tsx | 8 +- src/client/components/ItemPicker.tsx | 10 +- src/client/components/LinkToGlobalItem.tsx | 8 +- src/client/components/ManualEntryForm.tsx | 10 +- src/client/components/PlanningView.tsx | 16 +- src/client/components/ProfileSection.tsx | 17 +- src/client/components/SetupsView.tsx | 16 +- src/client/components/StatusBadge.tsx | 6 +- src/client/components/WeightSummaryCard.tsx | 10 +- src/client/routes/global-items/index.tsx | 6 +- src/client/routes/index.tsx | 8 +- src/client/routes/items/$itemId.tsx | 28 +- src/client/routes/profile.tsx | 36 +- src/client/routes/setups/$setupId.tsx | 8 +- src/client/routes/threads/$threadId/index.tsx | 12 +- 23 files changed, 2192 insertions(+), 64 deletions(-) create mode 100644 docs/superpowers/plans/2026-04-18-catalog-ingestion-script.md create mode 100644 docs/superpowers/plans/2026-04-18-catalog-schema-migration.md diff --git a/docs/superpowers/plans/2026-04-18-catalog-ingestion-script.md b/docs/superpowers/plans/2026-04-18-catalog-ingestion-script.md new file mode 100644 index 0000000..0c16e30 --- /dev/null +++ b/docs/superpowers/plans/2026-04-18-catalog-ingestion-script.md @@ -0,0 +1,647 @@ +# Catalog Ingestion Script Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a Bun script that takes a manufacturer slug, launches a Claude Haiku agent with web tools, crawls the manufacturer's site, and bulk-upserts the extracted products into the GearBox catalog via the existing API. + +**Architecture:** `scripts/crawl-manufacturer.ts` is the entry point — it fetches the manufacturer record from the GearBox API, builds a structured prompt with the target schema and taxonomy, runs a Claude Haiku agent in an agentic tool-use loop (giving it a `fetch_page` tool backed by Bun's fetch), receives a JSON array of products, and posts them to `POST /api/global-items/bulk`. A `scripts/crawl-all.ts` batch runner iterates all active tier-1 manufacturers. Taxonomy (categories + tags) is defined in code and injected into the agent prompt. + +**Tech Stack:** Bun, `@anthropic-ai/sdk`, Anthropic Claude (Haiku model for cost), GearBox API (local or deployed). + +**Prerequisite:** Plan A (catalog-schema-migration) must be complete — the API must accept `manufacturerSlug` in bulk upserts. + +--- + +## File Map + +| Action | File | +|--------|------| +| Create | `scripts/taxonomy/categories.ts` — canonical category values | +| Create | `scripts/taxonomy/tags.ts` — canonical tag list | +| Create | `scripts/crawl-manufacturer.ts` — core agent runner | +| Create | `scripts/crawl-all.ts` — batch runner by tier | +| Modify | `package.json` — add `db:crawl` and `db:crawl-all` script entries | + +--- + +## Task 1: Taxonomy files + +**Files:** +- Create: `scripts/taxonomy/categories.ts` +- Create: `scripts/taxonomy/tags.ts` + +- [ ] **Step 1: Create `scripts/taxonomy/categories.ts`** + +```typescript +/** + * Canonical category values for globalItems.category. + * These are the only valid values the ingestion agent should use. + */ +export const CATEGORIES = [ + "bags", // bikepacking bags, dry bags, stuff sacks + "shelters", // tents, bivys, tarps, hammocks + "sleep", // sleeping bags, quilts, pads, pillows + "cooking", // stoves, cookware, mugs, utensils + "lighting", // headlamps, bike lights, lanterns + "water", // filters, bottles, bladders + "electronics", // power banks, solar panels, GPS, bike computers + "tools", // multi-tools, pumps, repair kits, locks + "clothing", // jackets, base layers, gloves, shoes + "navigation", // GPS devices, maps, compasses + "bikes", // complete bikes + "components", // drivetrain, brakes, wheels, handlebars, saddles, stems +] as const; + +export type Category = (typeof CATEGORIES)[number]; +``` + +- [ ] **Step 2: Create `scripts/taxonomy/tags.ts`** + +```typescript +/** + * Canonical tags for globalItems. + * Mirrors the seed tags in src/db/seed-global-items.ts. + * The agent should only use tags from this list. + */ +export const TAGS = [ + // Activity + "bikepacking", "cycling", "hiking", "backpacking", "camping", "climbing", + "mountaineering", "road-cycling", "gravel", "running", "trail-running", + // Bag subtypes + "handlebar-bag", "framebag", "saddlebag", "top-tube-bag", "stem-bag", + "fork-bag", "feed-bag", "dry-bag", "stuff-sack", "bike-bag", + // Shelter subtypes + "tent", "bivy", "tarp", "hammock", + // Sleep subtypes + "sleeping-bag", "sleeping-pad", "quilt", "pillow", + // Cooking subtypes + "stove", "cookware", "mug", "utensils", + // Water subtypes + "water-filter", "water-bottle", + // Lighting subtypes + "headlamp", "bike-light", "lantern", + // Electronics subtypes + "gps", "bike-computer", "power-bank", "solar-panel", + // Tools subtypes + "multi-tool", "pump", "repair-kit", "lock", + // Clothing subtypes + "rain-jacket", "base-layer", "gloves", "shoe", +] as const; + +export type Tag = (typeof TAGS)[number]; +``` + +- [ ] **Step 3: Commit** + +```bash +git add scripts/taxonomy/ +git commit -m "feat: canonical taxonomy — categories and tags for ingestion" +``` + +--- + +## Task 2: Core crawl script + +**Files:** +- Create: `scripts/crawl-manufacturer.ts` + +- [ ] **Step 1: Verify `@anthropic-ai/sdk` is available** + +```bash +bun pm ls | grep anthropic +``` + +If not listed: +```bash +bun add @anthropic-ai/sdk +``` + +- [ ] **Step 2: Create `scripts/crawl-manufacturer.ts`** + +```typescript +#!/usr/bin/env bun +/** + * Crawl a manufacturer's website and upsert their products into the GearBox catalog. + * + * Usage: + * bun run scripts/crawl-manufacturer.ts --manufacturer=apidura + * bun run scripts/crawl-manufacturer.ts --manufacturer=canyon --dry-run + * + * Env vars required: + * ANTHROPIC_API_KEY — Anthropic API key + * GEARBOX_URL — Base URL of the GearBox instance (default: http://localhost:3000) + * GEARBOX_API_KEY — GearBox API key with write access + */ + +import Anthropic from "@anthropic-ai/sdk"; +import { CATEGORIES } from "./taxonomy/categories.ts"; +import { TAGS } from "./taxonomy/tags.ts"; + +const GEARBOX_URL = process.env.GEARBOX_URL ?? "http://localhost:3000"; +const GEARBOX_API_KEY = process.env.GEARBOX_API_KEY ?? ""; +const ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY ?? ""; +const MODEL = "claude-haiku-4-5-20251001"; +const MAX_TOOL_ROUNDS = 30; // safety limit + +// ── Parse CLI args ──────────────────────────────────────────────── + +const args = Object.fromEntries( + process.argv + .slice(2) + .filter((a) => a.startsWith("--")) + .map((a) => { + const [k, v] = a.slice(2).split("="); + return [k, v ?? "true"]; + }), +); + +const manufacturerSlug = args["manufacturer"]; +const dryRun = args["dry-run"] === "true"; + +if (!manufacturerSlug) { + console.error("Usage: bun run scripts/crawl-manufacturer.ts --manufacturer="); + process.exit(1); +} + +if (!GEARBOX_API_KEY) { + console.error("GEARBOX_API_KEY env var is required"); + process.exit(1); +} + +if (!ANTHROPIC_API_KEY) { + console.error("ANTHROPIC_API_KEY env var is required"); + process.exit(1); +} + +// ── Fetch manufacturer from GearBox ────────────────────────────── + +async function fetchManufacturer(slug: string) { + const res = await fetch(`${GEARBOX_URL}/api/manufacturers/${slug}`); + if (!res.ok) { + throw new Error(`Manufacturer not found: ${slug} (HTTP ${res.status})`); + } + return res.json() as Promise<{ + id: number; + name: string; + slug: string; + website: string; + tier: number; + country: string | null; + }>; +} + +// ── Tool: fetch a web page ──────────────────────────────────────── + +async function fetchPage(url: string): Promise { + try { + const res = await fetch(url, { + headers: { + "User-Agent": "Mozilla/5.0 (compatible; GearBox-Catalog-Bot/1.0)", + Accept: "text/html,application/xhtml+xml", + }, + signal: AbortSignal.timeout(15_000), + }); + if (!res.ok) return `HTTP ${res.status} for ${url}`; + const html = await res.text(); + // Strip scripts, styles, and excessive whitespace for token efficiency + return html + .replace(/]*>[\s\S]*?<\/script>/gi, "") + .replace(/]*>[\s\S]*?<\/style>/gi, "") + .replace(//g, "") + .replace(/\s{3,}/g, " ") + .slice(0, 60_000); // cap at 60k chars to stay within context + } catch (err) { + return `Error fetching ${url}: ${(err as Error).message}`; + } +} + +// ── Build system prompt ─────────────────────────────────────────── + +function buildSystemPrompt(manufacturer: Awaited>) { + return `You are a product data extraction agent for GearBox, a gear management app for bikepacking, cycling, and hiking. + +Your task: crawl ${manufacturer.name}'s website (${manufacturer.website}) and extract their complete product catalog. + +For each product, extract: +- model: string (product name WITHOUT the brand prefix) +- category: one of [${CATEGORIES.join(", ")}] +- weightGrams: number | null (weight in grams — convert if shown in oz/lbs/kg) +- priceCents: number | null (MSRP in cents, base currency) +- priceCurrency: string (ISO currency code — "EUR" for DE brands, "USD" for US, "GBP" for GB, etc.) +- description: string | null (1-3 sentence product description) +- sourceUrl: string (direct product page URL) +- tags: string[] (from this list only: [${TAGS.join(", ")}]) + +Rules: +- model must NOT include the brand name (e.g., "Terrapin System" not "Revelate Designs Terrapin System") +- Only include outdoor/adventure/cycling products. Skip accessories under €5, clothing if not relevant to the target categories. +- If weight is not listed on a product page, use null — do not guess. +- Assign 2-5 relevant tags per item. +- Extract every product in their catalog, not just featured ones. Navigate to all relevant subcategories. + +When done, output a JSON array of product objects as your final message. Do not wrap in markdown — raw JSON only. + +Example output: +[ + { + "model": "Expedition Handlebar Pack", + "category": "bags", + "weightGrams": 300, + "priceCents": 16000, + "priceCurrency": "GBP", + "description": "14L waterproof handlebar roll bag with internal dry bag and accessory pocket.", + "sourceUrl": "https://apidura.com/shop/expedition-handlebar-pack/", + "tags": ["bikepacking", "handlebar-bag", "bike-bag"] + } +]`; +} + +// ── Agentic tool-use loop ───────────────────────────────────────── + +type CatalogItem = { + model: string; + category: string; + weightGrams: number | null; + priceCents: number | null; + priceCurrency: string; + description: string | null; + sourceUrl: string; + tags: string[]; +}; + +async function runCrawlAgent(manufacturer: Awaited>): Promise { + const client = new Anthropic({ apiKey: ANTHROPIC_API_KEY }); + + const tools: Anthropic.Tool[] = [ + { + name: "fetch_page", + description: "Fetch the HTML content of a URL. Use this to explore the manufacturer's website and product pages.", + input_schema: { + type: "object" as const, + properties: { + url: { type: "string", description: "The URL to fetch" }, + }, + required: ["url"], + }, + }, + ]; + + const messages: Anthropic.MessageParam[] = [ + { + role: "user", + content: `Crawl ${manufacturer.name}'s website at ${manufacturer.website} and extract their complete product catalog. Start with the homepage or sitemap, navigate to all product categories, and return the full product list as JSON.`, + }, + ]; + + let rounds = 0; + + while (rounds < MAX_TOOL_ROUNDS) { + rounds++; + console.log(` [round ${rounds}] calling model...`); + + const response = await client.messages.create({ + model: MODEL, + max_tokens: 8192, + system: buildSystemPrompt(manufacturer), + tools, + messages, + }); + + // Add assistant response to history + messages.push({ role: "assistant", content: response.content }); + + if (response.stop_reason === "end_turn") { + // Final message — extract JSON from text content + const textBlock = response.content.find((b) => b.type === "text"); + if (!textBlock || textBlock.type !== "text") { + throw new Error("Agent finished without text output"); + } + return parseAgentOutput(textBlock.text); + } + + if (response.stop_reason !== "tool_use") { + throw new Error(`Unexpected stop reason: ${response.stop_reason}`); + } + + // Process tool calls + const toolResults: Anthropic.ToolResultBlockParam[] = []; + for (const block of response.content) { + if (block.type !== "tool_use") continue; + if (block.name === "fetch_page") { + const { url } = block.input as { url: string }; + console.log(` [tool] fetch_page ${url}`); + const content = await fetchPage(url); + toolResults.push({ + type: "tool_result", + tool_use_id: block.id, + content, + }); + } + } + + messages.push({ role: "user", content: toolResults }); + } + + throw new Error(`Agent exceeded ${MAX_TOOL_ROUNDS} tool rounds without finishing`); +} + +function parseAgentOutput(text: string): CatalogItem[] { + // Handle agent wrapping output in markdown code blocks + const cleaned = text.replace(/^```json\s*/i, "").replace(/\s*```$/i, "").trim(); + const parsed = JSON.parse(cleaned); + if (!Array.isArray(parsed)) throw new Error("Agent output is not a JSON array"); + return parsed; +} + +// ── Upsert to GearBox API ───────────────────────────────────────── + +async function upsertItems( + manufacturerSlug: string, + items: CatalogItem[], +): Promise<{ created: number; updated: number }> { + const payload = items.map((item) => ({ + manufacturerSlug, + model: item.model, + category: item.category, + weightGrams: item.weightGrams ?? undefined, + priceCents: item.priceCents ?? undefined, + description: item.description ?? undefined, + sourceUrl: item.sourceUrl, + tags: item.tags, + })); + + // Chunk into batches of 100 (API limit) + let totalCreated = 0; + let totalUpdated = 0; + + for (let i = 0; i < payload.length; i += 100) { + const batch = payload.slice(i, i + 100); + const res = await fetch(`${GEARBOX_URL}/api/global-items/bulk`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-API-Key": GEARBOX_API_KEY, + }, + body: JSON.stringify({ items: batch }), + }); + + if (!res.ok) { + const err = await res.text(); + throw new Error(`Bulk upsert failed (HTTP ${res.status}): ${err}`); + } + + const result = await res.json() as { created: number; updated: number }; + totalCreated += result.created; + totalUpdated += result.updated; + console.log(` batch ${Math.floor(i / 100) + 1}: +${result.created} new, ~${result.updated} updated`); + } + + return { created: totalCreated, updated: totalUpdated }; +} + +// ── Main ────────────────────────────────────────────────────────── + +async function main() { + console.log(`\nCrawling manufacturer: ${manufacturerSlug}`); + if (dryRun) console.log("DRY RUN — products will not be saved\n"); + + const manufacturer = await fetchManufacturer(manufacturerSlug); + console.log(`Found: ${manufacturer.name} (${manufacturer.website})\n`); + + console.log("Starting agent crawl..."); + const items = await runCrawlAgent(manufacturer); + console.log(`\nAgent extracted ${items.length} products`); + + if (dryRun) { + console.log("\nDry run output (first 3 items):"); + console.log(JSON.stringify(items.slice(0, 3), null, 2)); + return; + } + + console.log("\nUpserting to catalog..."); + const { created, updated } = await upsertItems(manufacturerSlug, items); + console.log(`\nDone: ${created} created, ${updated} updated`); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); +``` + +- [ ] **Step 3: Add market prices upsert after the bulk upsert** + +After `upsertItems`, add a call to also upsert `marketPrices` for each item that has a price. This requires knowing the item IDs returned from the bulk upsert and the manufacturer's country/currency. Add this helper after `upsertItems`: + +```typescript +async function upsertMarketPrices( + globalItemIds: number[], + items: CatalogItem[], +): Promise { + for (let i = 0; i < globalItemIds.length; i++) { + const item = items[i]; + const globalItemId = globalItemIds[i]; + if (!item?.priceCents || !globalItemId) continue; + + // Derive market from currency + const market = item.priceCurrency === "EUR" ? "EU" + : item.priceCurrency === "USD" ? "US" + : item.priceCurrency === "GBP" ? "GB" + : item.priceCurrency; + + await fetch(`${GEARBOX_URL}/api/global-items/${globalItemId}/market-prices`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-API-Key": GEARBOX_API_KEY, + }, + body: JSON.stringify({ + market, + currency: item.priceCurrency, + priceCents: item.priceCents, + source: "manufacturer-crawl", + }), + }); + } +} +``` + +Call `upsertMarketPrices` in `main()` after the bulk upsert, passing the item IDs from the API response. + +Note: The bulk upsert response returns `items[]` with IDs. Store those and pass them here. Update the `upsertItems` function return type to also return `itemIds: number[]`. + +- [ ] **Step 4: Commit** + +```bash +git add scripts/crawl-manufacturer.ts +git commit -m "feat: crawl-manufacturer agent script — Haiku tool-use loop + bulk upsert" +``` + +--- + +## Task 3: Batch runner + +**Files:** +- Create: `scripts/crawl-all.ts` + +- [ ] **Step 1: Create `scripts/crawl-all.ts`** + +```typescript +#!/usr/bin/env bun +/** + * Crawl all active manufacturers of a given tier. + * + * Usage: + * bun run scripts/crawl-all.ts --tier=1 + * bun run scripts/crawl-all.ts --tier=1 --dry-run + */ + +const GEARBOX_URL = process.env.GEARBOX_URL ?? "http://localhost:3000"; +const GEARBOX_API_KEY = process.env.GEARBOX_API_KEY ?? ""; + +const args = Object.fromEntries( + process.argv + .slice(2) + .filter((a) => a.startsWith("--")) + .map((a) => { + const [k, v] = a.slice(2).split("="); + return [k, v ?? "true"]; + }), +); + +const tier = args["tier"] ? Number(args["tier"]) : 1; +const dryRun = args["dry-run"] === "true"; + +async function listActiveManufacturers(targetTier: number) { + const res = await fetch(`${GEARBOX_URL}/api/manufacturers`); + if (!res.ok) throw new Error(`Failed to list manufacturers: HTTP ${res.status}`); + const all = await res.json() as Array<{ slug: string; tier: number; active: boolean; name: string }>; + return all.filter((m) => m.active && m.tier === targetTier); +} + +async function main() { + if (!GEARBOX_API_KEY) { + console.error("GEARBOX_API_KEY env var is required"); + process.exit(1); + } + + const manufacturers = await listActiveManufacturers(tier); + console.log(`Found ${manufacturers.length} active tier-${tier} manufacturers\n`); + + const results: Array<{ slug: string; status: "ok" | "error"; error?: string }> = []; + + for (const m of manufacturers) { + console.log(`\n${"─".repeat(50)}`); + console.log(`Crawling: ${m.name} (${m.slug})`); + try { + const extraArgs = dryRun ? ["--dry-run"] : []; + const proc = Bun.spawn( + ["bun", "run", "scripts/crawl-manufacturer.ts", `--manufacturer=${m.slug}`, ...extraArgs], + { stdout: "inherit", stderr: "inherit", env: process.env }, + ); + const exitCode = await proc.exited; + if (exitCode !== 0) throw new Error(`Exited with code ${exitCode}`); + results.push({ slug: m.slug, status: "ok" }); + } catch (err) { + console.error(` ERROR: ${(err as Error).message}`); + results.push({ slug: m.slug, status: "error", error: (err as Error).message }); + } + } + + console.log(`\n${"═".repeat(50)}`); + console.log("Summary:"); + for (const r of results) { + const icon = r.status === "ok" ? "✓" : "✗"; + console.log(` ${icon} ${r.slug}${r.error ? ` — ${r.error}` : ""}`); + } + + const failed = results.filter((r) => r.status === "error"); + if (failed.length > 0) { + console.error(`\n${failed.length} manufacturer(s) failed`); + process.exit(1); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); +``` + +- [ ] **Step 2: Commit** + +```bash +git add scripts/crawl-all.ts +git commit -m "feat: crawl-all batch runner — iterate active manufacturers by tier" +``` + +--- + +## Task 4: Package.json scripts + smoke test + +**Files:** +- Modify: `package.json` + +- [ ] **Step 1: Add scripts to `package.json`** + +In the `"scripts"` section, add: + +```json +"db:crawl": "bun run scripts/crawl-manufacturer.ts", +"db:crawl-all": "bun run scripts/crawl-all.ts" +``` + +- [ ] **Step 2: Commit** + +```bash +git add package.json +git commit -m "chore: add db:crawl and db:crawl-all npm scripts" +``` + +- [ ] **Step 3: Smoke test with dry run** + +Ensure GearBox is running (`bun run dev:server` in another terminal) and the manufacturer exists in the DB. + +```bash +GEARBOX_API_KEY= ANTHROPIC_API_KEY= \ + bun run db:crawl --manufacturer=apidura --dry-run +``` + +Expected output: +``` +Crawling manufacturer: apidura +Found: Apidura (https://apidura.com) + +Starting agent crawl... + [round 1] calling model... + [tool] fetch_page https://apidura.com + ... +Agent extracted N products + +Dry run output (first 3 items): +[ + { + "model": "...", + "category": "bags", + ... + } +] +``` + +- [ ] **Step 4: Live run against one manufacturer** + +```bash +GEARBOX_API_KEY= ANTHROPIC_API_KEY= \ + bun run db:crawl --manufacturer=apidura +``` + +Expected: products appear in the catalog. Verify by opening the app catalog search or calling `GET /api/global-items?q=apidura`. + +- [ ] **Step 5: Commit any adjustments** + +If the agent prompt needed tuning (category mapping issues, extra noise in output), update `buildSystemPrompt` in `crawl-manufacturer.ts` and commit: + +```bash +git add scripts/crawl-manufacturer.ts +git commit -m "fix: tune agent prompt for cleaner category/tag extraction" +``` diff --git a/docs/superpowers/plans/2026-04-18-catalog-schema-migration.md b/docs/superpowers/plans/2026-04-18-catalog-schema-migration.md new file mode 100644 index 0000000..307bb04 --- /dev/null +++ b/docs/superpowers/plans/2026-04-18-catalog-schema-migration.md @@ -0,0 +1,1355 @@ +# Catalog Schema Migration Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace `globalItems.brand` text field with a normalized `manufacturers` table, wiring all services, routes, MCP tools, and seed data to use the new FK. + +**Architecture:** Add `manufacturers` table → migrate `globalItems` to drop `brand` and add `manufacturerId` FK → update every service, route, MCP tool, and seed that references `globalItems.brand`. API responses keep returning a `brand` string (populated via join) so client components need no changes. API inputs replace `brand: string` with `manufacturerSlug: string` for ergonomic upserts. + +**Tech Stack:** Drizzle ORM + PostgreSQL (PGlite in tests), Hono, Zod, Bun test runner. + +--- + +## File Map + +| Action | File | +|--------|------| +| Modify | `src/db/schema.ts` — add manufacturers table, update globalItems | +| Create | `src/server/services/manufacturer.service.ts` | +| Create | `src/server/routes/manufacturers.ts` | +| Modify | `src/server/index.ts` — register manufacturers route | +| Modify | `src/server/services/global-item.service.ts` — upsert + search use manufacturerId | +| Modify | `src/shared/schemas.ts` — replace brand with manufacturerSlug in upsert schemas | +| Modify | `src/shared/types.ts` — re-derive UpsertGlobalItemInput | +| Modify | `src/server/services/item.service.ts` — join manufacturers, replace brand ref | +| Modify | `src/server/services/setup.service.ts` — join manufacturers, replace brand ref | +| Modify | `src/server/services/discovery.service.ts` — join manufacturers, replace brand ref | +| Modify | `src/server/services/profile.service.ts` — join manufacturers, replace brand ref | +| Modify | `src/server/services/csv.service.ts` — join manufacturers, replace brand ref | +| Modify | `src/server/services/thread.service.ts` — join manufacturers, replace brand ref | +| Modify | `src/server/mcp/tools/catalog.ts` — replace brand with manufacturerSlug | +| Modify | `src/db/seed-global-items.ts` — add seedManufacturers, update seedGlobalItems | +| Modify | `src/db/global-items-seed.json` — replace brand with manufacturerSlug | +| Modify | `src/db/dev-seed-data.ts` — add DEV_MANUFACTURERS, update DEV_GLOBAL_ITEMS | +| Modify | `src/db/dev-seed.ts` — insert manufacturers before globalItems | +| Modify | `tests/helpers/db.ts` — add manufacturers to TRUNCATE_TABLES | +| Create | `tests/services/manufacturer.service.test.ts` | +| Modify | `tests/services/global-item.service.test.ts` — update insertGlobalItem helper + tests | + +--- + +## Task 1: Add `manufacturers` table to schema + +**Files:** +- Modify: `src/db/schema.ts` + +- [ ] **Step 1: Add manufacturers table to schema.ts** + +Open `src/db/schema.ts`. After the `users` table and before `categories`, insert: + +```typescript +// ── Manufacturers ──────────────────────────────────────────────────── + +export const manufacturers = pgTable("manufacturers", { + id: serial("id").primaryKey(), + name: text("name").notNull().unique(), + slug: text("slug").notNull().unique(), + website: text("website").notNull(), + tier: integer("tier").notNull().default(1), + active: boolean("active").notNull().default(true), + country: text("country"), + createdAt: timestamp("created_at").defaultNow().notNull(), +}); +``` + +Add `boolean` to the import at the top: +```typescript +import { + boolean, + doublePrecision, + integer, + pgTable, + primaryKey, + serial, + text, + timestamp, + unique, +} from "drizzle-orm/pg-core"; +``` + +- [ ] **Step 2: Generate and push the migration** + +```bash +bun run db:generate +bun run db:push +``` + +Expected: new `manufacturers` table created with no errors. + +- [ ] **Step 3: Commit** + +```bash +git add src/db/schema.ts drizzle-pg/ +git commit -m "feat: add manufacturers table to schema" +``` + +--- + +## Task 2: Manufacturer service + +**Files:** +- Create: `src/server/services/manufacturer.service.ts` +- Create: `tests/services/manufacturer.service.test.ts` + +- [ ] **Step 1: Write the failing tests** + +Create `tests/services/manufacturer.service.test.ts`: + +```typescript +import { beforeEach, describe, expect, it } from "bun:test"; +import { manufacturers } from "../../src/db/schema.ts"; +import { + createManufacturer, + getManufacturerBySlug, + listManufacturers, +} from "../../src/server/services/manufacturer.service.ts"; +import { createTestDb } from "../helpers/db.ts"; + +let db: Awaited>["db"]; + +beforeEach(async () => { + ({ db } = await createTestDb()); +}); + +describe("createManufacturer", () => { + it("inserts a manufacturer and returns it", async () => { + const result = await createManufacturer(db, { + name: "Apidura", + slug: "apidura", + website: "https://apidura.com", + tier: 1, + country: "GB", + }); + expect(result.id).toBeGreaterThan(0); + expect(result.name).toBe("Apidura"); + expect(result.slug).toBe("apidura"); + expect(result.active).toBe(true); + }); + + it("throws on duplicate slug", async () => { + await createManufacturer(db, { + name: "Apidura", + slug: "apidura", + website: "https://apidura.com", + }); + await expect( + createManufacturer(db, { + name: "Apidura Copy", + slug: "apidura", + website: "https://other.com", + }), + ).rejects.toThrow(); + }); +}); + +describe("getManufacturerBySlug", () => { + it("returns manufacturer when found", async () => { + await createManufacturer(db, { + name: "Revelate Designs", + slug: "revelate-designs", + website: "https://revelatedesigns.com", + }); + const result = await getManufacturerBySlug(db, "revelate-designs"); + expect(result?.name).toBe("Revelate Designs"); + }); + + it("returns null when not found", async () => { + const result = await getManufacturerBySlug(db, "nope"); + expect(result).toBeNull(); + }); +}); + +describe("listManufacturers", () => { + it("returns all manufacturers ordered by name", async () => { + await createManufacturer(db, { name: "Ortlieb", slug: "ortlieb", website: "https://ortlieb.com" }); + await createManufacturer(db, { name: "Apidura", slug: "apidura", website: "https://apidura.com" }); + const result = await listManufacturers(db); + expect(result[0]?.name).toBe("Apidura"); + expect(result[1]?.name).toBe("Ortlieb"); + }); +}); +``` + +- [ ] **Step 2: Run tests to confirm they fail** + +```bash +bun test tests/services/manufacturer.service.test.ts +``` + +Expected: FAIL — module not found. + +- [ ] **Step 3: Also add `manufacturers` to the TRUNCATE_TABLES list in `tests/helpers/db.ts`** + +In `tests/helpers/db.ts`, add `"manufacturers"` before `"users"`: + +```typescript +const TRUNCATE_TABLES = [ + "shares", + "setup_items", + "setups", + "thread_candidates", + "threads", + "community_prices", + "market_prices", + "items", + "global_item_tags", + "global_items", + "tags", + "oauth_tokens", + "oauth_codes", + "oauth_clients", + "api_keys", + "settings", + "categories", + "manufacturers", + "users", +]; +``` + +- [ ] **Step 4: Create `src/server/services/manufacturer.service.ts`** + +```typescript +import { asc, eq } from "drizzle-orm"; +import { db as prodDb } from "../../db/index.ts"; +import { manufacturers } from "../../db/schema.ts"; + +type Db = typeof prodDb; + +export type CreateManufacturerInput = { + name: string; + slug: string; + website: string; + tier?: number; + country?: string; +}; + +export async function listManufacturers(db: Db = prodDb) { + return db.select().from(manufacturers).orderBy(asc(manufacturers.name)); +} + +export async function getManufacturerBySlug(db: Db = prodDb, slug: string) { + const [row] = await db + .select() + .from(manufacturers) + .where(eq(manufacturers.slug, slug)); + return row ?? null; +} + +export async function createManufacturer( + db: Db = prodDb, + data: CreateManufacturerInput, +) { + const [row] = await db + .insert(manufacturers) + .values({ + name: data.name, + slug: data.slug, + website: data.website, + tier: data.tier ?? 1, + country: data.country ?? null, + }) + .returning(); + return row!; +} +``` + +- [ ] **Step 5: Run tests to confirm they pass** + +```bash +bun test tests/services/manufacturer.service.test.ts +``` + +Expected: PASS (3 test suites, all green). + +- [ ] **Step 6: Commit** + +```bash +git add src/server/services/manufacturer.service.ts tests/services/manufacturer.service.test.ts tests/helpers/db.ts +git commit -m "feat: manufacturer service with list, get, create" +``` + +--- + +## Task 3: Manufacturers API route + +**Files:** +- Create: `src/server/routes/manufacturers.ts` +- Modify: `src/server/index.ts` +- Modify: `src/shared/schemas.ts` + +- [ ] **Step 1: Add Zod schema for manufacturer creation to `src/shared/schemas.ts`** + +Append after the existing global item schemas (around line 147): + +```typescript +export const createManufacturerSchema = z.object({ + name: z.string().min(1).max(200), + slug: z + .string() + .min(1) + .max(100) + .regex(/^[a-z0-9-]+$/, "Slug must be lowercase alphanumeric with hyphens"), + website: z.string().url(), + tier: z.number().int().min(1).max(3).optional(), + country: z.string().length(2).optional(), +}); +``` + +- [ ] **Step 2: Create `src/server/routes/manufacturers.ts`** + +```typescript +import { zValidator } from "@hono/zod-validator"; +import { Hono } from "hono"; +import { createManufacturerSchema } from "../../shared/schemas.ts"; +import { + createManufacturer, + getManufacturerBySlug, + listManufacturers, +} from "../services/manufacturer.service.ts"; + +type Env = { Variables: { db?: any } }; + +const app = new Hono(); + +app.get("/", async (c) => { + const db = c.get("db"); + return c.json(await listManufacturers(db)); +}); + +app.get("/:slug", async (c) => { + const db = c.get("db"); + const slug = c.req.param("slug"); + const manufacturer = await getManufacturerBySlug(db, slug); + if (!manufacturer) return c.json({ error: "Manufacturer not found" }, 404); + return c.json(manufacturer); +}); + +app.post("/", zValidator("json", createManufacturerSchema), async (c) => { + const db = c.get("db"); + const data = c.req.valid("json"); + try { + const manufacturer = await createManufacturer(db, data); + return c.json(manufacturer, 201); + } catch { + return c.json({ error: "Manufacturer with this name or slug already exists" }, 409); + } +}); + +export { app as manufacturerRoutes }; +``` + +- [ ] **Step 3: Register the route in `src/server/index.ts`** + +Find the existing import of `globalItemRoutes` and add alongside it: + +```typescript +import { manufacturerRoutes } from "./routes/manufacturers.ts"; +``` + +Find where `globalItemRoutes` is registered (around line 292) and add below it: + +```typescript +app.route("/api/manufacturers", manufacturerRoutes); +``` + +Note: GET routes are public (no auth middleware needed — manufacturers are read-only public data). POST is protected by the existing auth middleware that covers all POST/PUT/DELETE on `/api/*`. + +- [ ] **Step 4: Commit** + +```bash +git add src/server/routes/manufacturers.ts src/server/index.ts src/shared/schemas.ts +git commit -m "feat: manufacturers route — list, get, create" +``` + +--- + +## Task 4: Seed manufacturers + +**Files:** +- Modify: `src/db/seed-global-items.ts` + +- [ ] **Step 1: Add `seedManufacturers` to `src/db/seed-global-items.ts`** + +Replace the full contents of `src/db/seed-global-items.ts` with: + +```typescript +import seedData from "./global-items-seed.json"; +import { db as prodDb } from "./index.ts"; +import { globalItems, manufacturers, tags } from "./schema.ts"; + +type Db = typeof prodDb; + +export const SEED_MANUFACTURERS = [ + { name: "Revelate Designs", slug: "revelate-designs", website: "https://revelatedesigns.com", country: "US", tier: 1 }, + { name: "Apidura", slug: "apidura", website: "https://apidura.com", country: "GB", tier: 1 }, + { name: "Ortlieb", slug: "ortlieb", website: "https://ortlieb.com", country: "DE", tier: 1 }, + { name: "Big Agnes", slug: "big-agnes", website: "https://bigagnes.com", country: "US", tier: 1 }, + { name: "Tarptent", slug: "tarptent", website: "https://tarptent.com", country: "US", tier: 1 }, + { name: "Zpacks", slug: "zpacks", website: "https://zpacks.com", country: "US", tier: 1 }, + { name: "Sea to Summit", slug: "sea-to-summit", website: "https://seatosummit.com", country: "AU", tier: 1 }, + { name: "Western Mountaineering", slug: "western-mountaineering", website: "https://westernmountaineering.com", country: "US", tier: 1 }, + { name: "MSR", slug: "msr", website: "https://msrgear.com", country: "US", tier: 1 }, + { name: "BioLite", slug: "biolite", website: "https://bioliteenergy.com", country: "US", tier: 1 }, + { name: "Petzl", slug: "petzl", website: "https://petzl.com", country: "FR", tier: 1 }, + { name: "Black Diamond", slug: "black-diamond", website: "https://blackdiamondequipment.com", country: "US", tier: 1 }, + { name: "Garmin", slug: "garmin", website: "https://garmin.com", country: "US", tier: 1 }, + { name: "Wahoo", slug: "wahoo", website: "https://wahoofitness.com", country: "US", tier: 1 }, + { name: "Sawyer", slug: "sawyer", website: "https://sawyerproducts.com", country: "US", tier: 1 }, + { name: "Canyon", slug: "canyon", website: "https://canyon.com", country: "DE", tier: 1 }, + { name: "Specialized", slug: "specialized", website: "https://specialized.com", country: "US", tier: 1 }, + { name: "Trek", slug: "trek", website: "https://trekbikes.com", country: "US", tier: 1 }, + { name: "Salsa Cycles", slug: "salsa-cycles", website: "https://salsacycles.com", country: "US", tier: 1 }, + { name: "Surly", slug: "surly", website: "https://surlybikes.com", country: "US", tier: 1 }, +]; + +const SEED_TAGS = [ + "bikepacking", "cycling", "hiking", "backpacking", "camping", "climbing", + "mountaineering", "road-cycling", "gravel", "running", "trail-running", + "handlebar-bag", "framebag", "saddlebag", "top-tube-bag", "stem-bag", + "fork-bag", "feed-bag", "dry-bag", "stuff-sack", "bike-bag", + "tent", "bivy", "tarp", "hammock", + "sleeping-bag", "sleeping-pad", "quilt", "pillow", + "stove", "cookware", "mug", "utensils", + "water-filter", "water-bottle", + "headlamp", "bike-light", "lantern", + "gps", "bike-computer", "power-bank", "solar-panel", + "multi-tool", "pump", "repair-kit", "lock", + "rain-jacket", "base-layer", "gloves", "shoe", +]; + +export async function seedManufacturers(db: Db = prodDb) { + for (const m of SEED_MANUFACTURERS) { + await db + .insert(manufacturers) + .values(m) + .onConflictDoNothing(); + } +} + +export async function seedTags(db: Db = prodDb) { + const existing = await db.select().from(tags); + const existingNames = new Set(existing.map((t) => t.name)); + for (const name of SEED_TAGS) { + if (!existingNames.has(name)) { + await db.insert(tags).values({ name }); + } + } +} + +export async function seedGlobalItems(db: Db = prodDb) { + await seedManufacturers(db); + + const existing = await db.select().from(globalItems).limit(1); + if (existing.length > 0) return; + + const allManufacturers = await db.select().from(manufacturers); + const mfByName = new Map(allManufacturers.map((m) => [m.name, m.id])); + + for (const item of seedData) { + const manufacturerId = mfByName.get(item.brand); + if (!manufacturerId) continue; // skip items with no matching manufacturer + + await db.insert(globalItems).values({ + manufacturerId, + model: item.model, + category: item.category ?? null, + weightGrams: item.weightGrams ?? null, + priceCents: item.priceCents ?? null, + description: item.description ?? null, + }); + } + + await seedTags(db); +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/db/seed-global-items.ts +git commit -m "feat: seed manufacturers list, update seedGlobalItems to resolve by name" +``` + +--- + +## Task 5: Migrate `globalItems` — drop brand, add manufacturerId + +**Files:** +- Modify: `src/db/schema.ts` + +- [ ] **Step 1: Update `globalItems` in `src/db/schema.ts`** + +Find the `globalItems` table. Remove the `brand` column and add `manufacturerId`. Change the unique constraint. The updated table definition: + +```typescript +export const globalItems = pgTable( + "global_items", + { + id: serial("id").primaryKey(), + manufacturerId: integer("manufacturer_id") + .notNull() + .references(() => manufacturers.id), + model: text("model").notNull(), + category: text("category"), + weightGrams: doublePrecision("weight_grams"), + priceCents: integer("price_cents"), + imageUrl: text("image_url"), + description: text("description"), + sourceUrl: text("source_url"), + imageCredit: text("image_credit"), + imageSourceUrl: text("image_source_url"), + dominantColor: text("dominant_color"), + cropZoom: doublePrecision("crop_zoom"), + cropX: doublePrecision("crop_x"), + cropY: doublePrecision("crop_y"), + createdAt: timestamp("created_at").defaultNow().notNull(), + }, + (table) => [unique().on(table.manufacturerId, table.model)], +); +``` + +- [ ] **Step 2: Generate and push migration** + +```bash +bun run db:generate +bun run db:push +``` + +If the push fails due to existing data violating `NOT NULL`, that's expected in dev — clear the table first: + +```bash +bun run db:push --force-reset +# or connect to the DB and: TRUNCATE global_items CASCADE; +``` + +Then re-push. + +- [ ] **Step 3: Commit** + +```bash +git add src/db/schema.ts drizzle-pg/ +git commit -m "feat: migrate globalItems — drop brand text, add manufacturerId FK" +``` + +--- + +## Task 6: Update `global-item.service.ts` + +**Files:** +- Modify: `src/server/services/global-item.service.ts` +- Modify: `tests/services/global-item.service.test.ts` + +- [ ] **Step 1: Update the test helper and tests in `tests/services/global-item.service.test.ts`** + +Replace the `insertGlobalItem` helper at the top of the file: + +```typescript +async function insertManufacturer(db: TestDb["db"], name = "Apidura", slug = "apidura") { + const [row] = await db + .insert(schema.manufacturers) + .values({ name, slug, website: `https://${slug}.com` }) + .returning(); + return row!; +} + +async function insertGlobalItem( + db: TestDb["db"], + data: { + manufacturerId: number; + model: string; + category?: string; + weightGrams?: number; + priceCents?: number; + }, +) { + const [row] = await db + .insert(globalItems) + .values({ + manufacturerId: data.manufacturerId, + model: data.model, + category: data.category ?? null, + weightGrams: data.weightGrams ?? null, + priceCents: data.priceCents ?? null, + }) + .returning(); + return row!; +} +``` + +Also update all test cases that pass `brand:` to pass `manufacturerSlug:` and set up a manufacturer before inserting global items. + +- [ ] **Step 2: Run tests to confirm they fail** + +```bash +bun test tests/services/global-item.service.test.ts +``` + +Expected: FAIL — type errors or runtime errors on `brand` field. + +- [ ] **Step 3: Rewrite `src/server/services/global-item.service.ts`** + +```typescript +import type { SQL } from "drizzle-orm"; +import { and, count, eq, ilike, or, sql } from "drizzle-orm"; +import { db as prodDb } from "../../db/index.ts"; +import { globalItems, globalItemTags, items, manufacturers, tags } from "../../db/schema.ts"; + +type Db = typeof prodDb; +type TxDb = Parameters[0]>[0]; + +async function resolveManufacturerId(db: Db | TxDb, slug: string): Promise { + const [m] = await (db as Db) + .select({ id: manufacturers.id }) + .from(manufacturers) + .where(eq(manufacturers.slug, slug)); + if (!m) throw new Error(`Manufacturer not found: ${slug}`); + return m.id; +} + +export async function searchGlobalItems( + db: Db = prodDb, + query?: string, + tagNames?: string[], +) { + const conditions: SQL[] = []; + + if (query) { + const escaped = query.replace(/%/g, "\\%").replace(/_/g, "\\_"); + const pattern = `%${escaped}%`; + conditions.push( + or(ilike(manufacturers.name, pattern), ilike(globalItems.model, pattern))!, + ); + } + + if (tagNames && tagNames.length > 0) { + conditions.push( + sql`${globalItems.id} IN ( + SELECT ${globalItemTags.globalItemId} + FROM ${globalItemTags} + JOIN ${tags} ON ${tags.id} = ${globalItemTags.tagId} + WHERE ${tags.name} IN (${sql.join( + tagNames.map((t) => sql`${t}`), + sql`, `, + )}) + GROUP BY ${globalItemTags.globalItemId} + HAVING COUNT(DISTINCT ${tags.name}) = ${tagNames.length} + )`, + ); + } + + const baseQuery = db + .select({ + id: globalItems.id, + manufacturerId: globalItems.manufacturerId, + brand: manufacturers.name, + model: globalItems.model, + category: globalItems.category, + weightGrams: globalItems.weightGrams, + priceCents: globalItems.priceCents, + imageUrl: globalItems.imageUrl, + description: globalItems.description, + sourceUrl: globalItems.sourceUrl, + imageCredit: globalItems.imageCredit, + imageSourceUrl: globalItems.imageSourceUrl, + dominantColor: globalItems.dominantColor, + cropZoom: globalItems.cropZoom, + cropX: globalItems.cropX, + cropY: globalItems.cropY, + createdAt: globalItems.createdAt, + }) + .from(globalItems) + .innerJoin(manufacturers, eq(globalItems.manufacturerId, manufacturers.id)); + + if (conditions.length === 0) { + return baseQuery; + } + + return baseQuery.where(and(...conditions)); +} + +export async function getGlobalItemWithOwnerCount(db: Db = prodDb, id: number) { + const [item] = await db + .select({ + id: globalItems.id, + manufacturerId: globalItems.manufacturerId, + brand: manufacturers.name, + model: globalItems.model, + category: globalItems.category, + weightGrams: globalItems.weightGrams, + priceCents: globalItems.priceCents, + imageUrl: globalItems.imageUrl, + description: globalItems.description, + sourceUrl: globalItems.sourceUrl, + imageCredit: globalItems.imageCredit, + imageSourceUrl: globalItems.imageSourceUrl, + dominantColor: globalItems.dominantColor, + cropZoom: globalItems.cropZoom, + cropX: globalItems.cropX, + cropY: globalItems.cropY, + createdAt: globalItems.createdAt, + }) + .from(globalItems) + .innerJoin(manufacturers, eq(globalItems.manufacturerId, manufacturers.id)) + .where(eq(globalItems.id, id)); + + if (!item) return null; + + const [result] = await db + .select({ ownerCount: count() }) + .from(items) + .where(eq(items.globalItemId, id)); + + return { ...item, ownerCount: result?.ownerCount ?? 0 }; +} + +async function syncGlobalItemTags( + tx: TxDb, + globalItemId: number, + tagNames: string[], +) { + await tx + .delete(globalItemTags) + .where(eq(globalItemTags.globalItemId, globalItemId)); + + for (const name of tagNames) { + const [tag] = await tx + .insert(tags) + .values({ name }) + .onConflictDoUpdate({ target: tags.name, set: { name } }) + .returning({ id: tags.id }); + + await tx.insert(globalItemTags).values({ globalItemId, tagId: tag.id }); + } +} + +export async function upsertGlobalItem( + db: Db, + data: { + manufacturerSlug: string; + model: string; + category?: string; + weightGrams?: number; + priceCents?: number; + imageUrl?: string; + description?: string; + sourceUrl?: string; + imageCredit?: string; + imageSourceUrl?: string; + tags?: string[]; + }, +) { + const manufacturerId = await resolveManufacturerId(db, data.manufacturerSlug); + + return await db.transaction(async (tx) => { + const [existing] = await tx + .select({ id: globalItems.id }) + .from(globalItems) + .where( + and( + eq(globalItems.manufacturerId, manufacturerId), + eq(globalItems.model, data.model), + ), + ); + + const { tags: tagNames, manufacturerSlug: _slug, ...itemData } = data; + + const [item] = await tx + .insert(globalItems) + .values({ + manufacturerId, + model: itemData.model, + category: itemData.category ?? null, + weightGrams: itemData.weightGrams ?? null, + priceCents: itemData.priceCents ?? null, + imageUrl: itemData.imageUrl ?? null, + description: itemData.description ?? null, + sourceUrl: itemData.sourceUrl ?? null, + imageCredit: itemData.imageCredit ?? null, + imageSourceUrl: itemData.imageSourceUrl ?? null, + }) + .onConflictDoUpdate({ + target: [globalItems.manufacturerId, globalItems.model], + set: { + category: itemData.category ?? null, + weightGrams: itemData.weightGrams ?? null, + priceCents: itemData.priceCents ?? null, + imageUrl: itemData.imageUrl ?? null, + description: itemData.description ?? null, + sourceUrl: itemData.sourceUrl ?? null, + imageCredit: itemData.imageCredit ?? null, + imageSourceUrl: itemData.imageSourceUrl ?? null, + }, + }) + .returning(); + + if (tagNames !== undefined) { + await syncGlobalItemTags(tx, item!.id, tagNames); + } + + return { item: item!, created: !existing }; + }); +} + +export async function bulkUpsertGlobalItems( + db: Db, + itemsData: Array<{ + manufacturerSlug: string; + model: string; + category?: string; + weightGrams?: number; + priceCents?: number; + imageUrl?: string; + description?: string; + sourceUrl?: string; + imageCredit?: string; + imageSourceUrl?: string; + tags?: string[]; + }>, +) { + return await db.transaction(async (tx) => { + let created = 0; + let updated = 0; + const resultItems = []; + + for (const data of itemsData) { + const manufacturerId = await resolveManufacturerId(db, data.manufacturerSlug); + + const [existing] = await tx + .select({ id: globalItems.id }) + .from(globalItems) + .where( + and( + eq(globalItems.manufacturerId, manufacturerId), + eq(globalItems.model, data.model), + ), + ); + + const { tags: tagNames, manufacturerSlug: _slug, ...itemData } = data; + + const [item] = await tx + .insert(globalItems) + .values({ + manufacturerId, + model: itemData.model, + category: itemData.category ?? null, + weightGrams: itemData.weightGrams ?? null, + priceCents: itemData.priceCents ?? null, + imageUrl: itemData.imageUrl ?? null, + description: itemData.description ?? null, + sourceUrl: itemData.sourceUrl ?? null, + imageCredit: itemData.imageCredit ?? null, + imageSourceUrl: itemData.imageSourceUrl ?? null, + }) + .onConflictDoUpdate({ + target: [globalItems.manufacturerId, globalItems.model], + set: { + category: itemData.category ?? null, + weightGrams: itemData.weightGrams ?? null, + priceCents: itemData.priceCents ?? null, + imageUrl: itemData.imageUrl ?? null, + description: itemData.description ?? null, + sourceUrl: itemData.sourceUrl ?? null, + imageCredit: itemData.imageCredit ?? null, + imageSourceUrl: itemData.imageSourceUrl ?? null, + }, + }) + .returning(); + + if (tagNames !== undefined) { + await syncGlobalItemTags(tx, item!.id, tagNames); + } + + if (existing) { + updated++; + } else { + created++; + } + resultItems.push(item!); + } + + return { created, updated, items: resultItems }; + }); +} +``` + +- [ ] **Step 4: Run tests** + +```bash +bun test tests/services/global-item.service.test.ts +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/server/services/global-item.service.ts tests/services/global-item.service.test.ts +git commit -m "feat: global-item service uses manufacturerSlug, joins manufacturers for brand" +``` + +--- + +## Task 7: Update Zod schemas and types + +**Files:** +- Modify: `src/shared/schemas.ts` +- Modify: `src/shared/types.ts` + +- [ ] **Step 1: Update `upsertGlobalItemSchema` in `src/shared/schemas.ts`** + +Replace the `brand` field with `manufacturerSlug`: + +```typescript +export const upsertGlobalItemSchema = z.object({ + manufacturerSlug: z.string().min(1, "Manufacturer slug is required"), + model: z.string().min(1, "Model is required"), + category: z.string().optional(), + weightGrams: z.number().nonnegative().optional(), + priceCents: z.number().int().nonnegative().optional(), + imageUrl: z.string().url().optional().or(z.literal("")), + description: z.string().optional(), + sourceUrl: z.string().url().optional().or(z.literal("")), + imageCredit: z.string().optional(), + imageSourceUrl: z.string().url().optional().or(z.literal("")), + tags: z.array(z.string().min(1).max(100)).max(20).optional(), + dominantColor: z.string().nullable().optional(), + cropZoom: z.number().nullable().optional(), + cropX: z.number().nullable().optional(), + cropY: z.number().nullable().optional(), +}); +``` + +`bulkUpsertGlobalItemsSchema` references `upsertGlobalItemSchema` and needs no direct change. + +- [ ] **Step 2: Check `src/shared/types.ts` for any hardcoded `brand` references in global item types** + +Open `src/shared/types.ts`. If `UpsertGlobalItemInput` or `GlobalItem` is manually typed (not inferred), update to use `manufacturerSlug` / `manufacturerId`. If these types are inferred via `z.infer<>` from the Zod schemas and Drizzle, they will update automatically. + +- [ ] **Step 3: Commit** + +```bash +git add src/shared/schemas.ts src/shared/types.ts +git commit -m "feat: upsertGlobalItemSchema — brand → manufacturerSlug" +``` + +--- + +## Task 8: Update `item.service.ts` + +**Files:** +- Modify: `src/server/services/item.service.ts` + +- [ ] **Step 1: Add manufacturers to imports and joins** + +In `src/server/services/item.service.ts`, update the import: + +```typescript +import { categories, globalItems, items, manufacturers } from "../../db/schema.ts"; +``` + +In `getAllItems`, add a left join to manufacturers after the globalItems join: + +```typescript +.leftJoin(globalItems, eq(items.globalItemId, globalItems.id)) +.leftJoin(manufacturers, eq(globalItems.manufacturerId, manufacturers.id)) +``` + +Replace the two `globalItems.brand` references: + +```typescript +// name computation: was globalItems.brand || ' ' || globalItems.model +name: sql`COALESCE( + CASE WHEN ${items.globalItemId} IS NOT NULL + THEN ${manufacturers.name} || ' ' || ${globalItems.model} + ELSE ${items.name} + END, + ${items.name} +)`.as("name"), + +// brand field: was COALESCE(globalItems.brand, items.brand) +brand: sql`COALESCE(${manufacturers.name}, ${items.brand})`.as("brand"), +``` + +Apply the same two replacements in `getItemById` (same patterns, same file). + +- [ ] **Step 2: Update `createItem` function** + +The `createItem` function fetches brand+model from globalItems to build the item name. Update the select: + +```typescript +const [gi] = await db + .select({ name: manufacturers.name, model: globalItems.model }) + .from(globalItems) + .innerJoin(manufacturers, eq(globalItems.manufacturerId, manufacturers.id)) + .where(eq(globalItems.id, data.globalItemId)); +if (gi) { + name = `${gi.name} ${gi.model}`; +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add src/server/services/item.service.ts +git commit -m "feat: item service joins manufacturers for brand display" +``` + +--- + +## Task 9: Update remaining services + +**Files:** +- Modify: `src/server/services/setup.service.ts` +- Modify: `src/server/services/discovery.service.ts` +- Modify: `src/server/services/profile.service.ts` +- Modify: `src/server/services/csv.service.ts` +- Modify: `src/server/services/thread.service.ts` + +The pattern is the same in all five files. For each: +1. Add `manufacturers` to the schema import +2. Add `.leftJoin(manufacturers, eq(globalItems.manufacturerId, manufacturers.id))` after every `.leftJoin(globalItems, ...)` +3. Replace every `${globalItems.brand}` with `${manufacturers.name}` + +- [ ] **Step 1: `src/server/services/setup.service.ts`** + +Add to import: +```typescript +import { ..., manufacturers } from "../../db/schema.ts"; +``` + +The file has three query functions (around lines 82, 134, 188). In each, add manufacturers join after the globalItems join: +```typescript +.leftJoin(globalItems, eq(items.globalItemId, globalItems.id)) +.leftJoin(manufacturers, eq(globalItems.manufacturerId, manufacturers.id)) +``` + +Replace all four `${globalItems.brand}` occurrences: +- Lines ~82, 134, 188: `THEN ${globalItems.brand} || ' ' || ${globalItems.model}` → `THEN ${manufacturers.name} || ' ' || ${globalItems.model}` +- Line ~195: `THEN ${globalItems.brand} ELSE ${items.brand} END` → `THEN ${manufacturers.name} ELSE ${items.brand} END` + +- [ ] **Step 2: `src/server/services/discovery.service.ts`** + +Add to import: +```typescript +import { ..., manufacturers } from "../../db/schema.ts"; +``` + +In `getPopularItemsByTags` (around line 160), add manufacturers join. This query starts FROM globalItems, so use innerJoin (globalItems always has a manufacturer): +```typescript +.from(globalItems) +.innerJoin(manufacturers, eq(globalItems.manufacturerId, manufacturers.id)) +.innerJoin(globalItemTags, ...) +``` + +Replace `brand: globalItems.brand` in the select: +```typescript +brand: manufacturers.name, +``` + +- [ ] **Step 3: `src/server/services/profile.service.ts`** + +Add to import: +```typescript +import { ..., manufacturers } from "../../db/schema.ts"; +``` + +Add manufacturers join after the globalItems join and replace `${globalItems.brand}` → `${manufacturers.name}` (same pattern as setup.service.ts). + +- [ ] **Step 4: `src/server/services/csv.service.ts`** + +Add to import and add manufacturers join after globalItems join. Replace `${globalItems.brand}` → `${manufacturers.name}`. + +- [ ] **Step 5: `src/server/services/thread.service.ts`** + +This service joins from `threadCandidates` and uses `leftJoin(globalItems, eq(threadCandidates.globalItemId, globalItems.id))`. Add: +```typescript +.leftJoin(globalItems, eq(threadCandidates.globalItemId, globalItems.id)) +.leftJoin(manufacturers, eq(globalItems.manufacturerId, manufacturers.id)) +``` + +Replace `${globalItems.brand}` → `${manufacturers.name}`. + +- [ ] **Step 6: Commit** + +```bash +git add src/server/services/setup.service.ts src/server/services/discovery.service.ts src/server/services/profile.service.ts src/server/services/csv.service.ts src/server/services/thread.service.ts +git commit -m "feat: all services join manufacturers for global item brand display" +``` + +--- + +## Task 10: Update MCP catalog tools + +**Files:** +- Modify: `src/server/mcp/tools/catalog.ts` + +- [ ] **Step 1: Replace `brand` with `manufacturerSlug` in `catalogItemInputSchema`** + +In `src/server/mcp/tools/catalog.ts`, update `catalogItemInputSchema`: + +```typescript +const catalogItemInputSchema = { + manufacturerSlug: z + .string() + .describe("Manufacturer slug (e.g. 'apidura', 'revelate-designs') — must exist in the manufacturers table"), + model: z + .string() + .describe("Model name — combined with manufacturerSlug forms the unique identifier"), + category: z + .string() + .optional() + .describe("Category name (e.g., 'bags', 'shelters', 'sleep')"), + weightGrams: z.number().optional().describe("Weight in grams"), + priceCents: z + .number() + .optional() + .describe("MSRP price in cents (e.g., 9999 = €99.99)"), + imageUrl: z.string().optional().describe("URL to the product image"), + description: z.string().optional().describe("Product description"), + sourceUrl: z + .string() + .optional() + .describe("URL to the product page on manufacturer/retailer site"), + imageCredit: z + .string() + .optional() + .describe("Image credit — photographer or source name"), + imageSourceUrl: z + .string() + .optional() + .describe("Original URL where the image was sourced from"), + tags: z + .array(z.string()) + .optional() + .describe("Tags for categorization (created automatically if new)"), +}; +``` + +Update the handler type annotations: replace `brand: string` with `manufacturerSlug: string` in both `upsert_catalog_item` and `bulk_upsert_catalog` handler args types. + +Update the tool descriptions to mention slugs: +- `upsert_catalog_item`: "...identified by (manufacturerSlug, model)..." +- `bulk_upsert_catalog`: "...upserted on (manufacturerSlug, model) uniqueness..." + +- [ ] **Step 2: Commit** + +```bash +git add src/server/mcp/tools/catalog.ts +git commit -m "feat: MCP catalog tools use manufacturerSlug instead of brand" +``` + +--- + +## Task 11: Update dev seed data + +**Files:** +- Modify: `src/db/dev-seed-data.ts` +- Modify: `src/db/dev-seed.ts` +- Modify: `src/db/global-items-seed.json` + +- [ ] **Step 1: Add `DEV_MANUFACTURERS` to `src/db/dev-seed-data.ts`** + +At the top of the file, add: + +```typescript +export const DEV_MANUFACTURERS = [ + { name: "Revelate Designs", slug: "revelate-designs", website: "https://revelatedesigns.com", country: "US", tier: 1 as const }, + { name: "Apidura", slug: "apidura", website: "https://apidura.com", country: "GB", tier: 1 as const }, + { name: "Ortlieb", slug: "ortlieb", website: "https://ortlieb.com", country: "DE", tier: 1 as const }, + { name: "Big Agnes", slug: "big-agnes", website: "https://bigagnes.com", country: "US", tier: 1 as const }, + { name: "Tarptent", slug: "tarptent", website: "https://tarptent.com", country: "US", tier: 1 as const }, + { name: "Zpacks", slug: "zpacks", website: "https://zpacks.com", country: "US", tier: 1 as const }, + { name: "Sea to Summit", slug: "sea-to-summit", website: "https://seatosummit.com", country: "AU", tier: 1 as const }, + { name: "Western Mountaineering", slug: "western-mountaineering", website: "https://westernmountaineering.com", country: "US", tier: 1 as const }, + { name: "MSR", slug: "msr", website: "https://msrgear.com", country: "US", tier: 1 as const }, + { name: "Petzl", slug: "petzl", website: "https://petzl.com", country: "FR", tier: 1 as const }, + { name: "Black Diamond", slug: "black-diamond", website: "https://blackdiamondequipment.com", country: "US", tier: 1 as const }, + { name: "Garmin", slug: "garmin", website: "https://garmin.com", country: "US", tier: 1 as const }, + { name: "Wahoo", slug: "wahoo", website: "https://wahoofitness.com", country: "US", tier: 1 as const }, + { name: "Sawyer", slug: "sawyer", website: "https://sawyerproducts.com", country: "US", tier: 1 as const }, + { name: "BioLite", slug: "biolite", website: "https://bioliteenergy.com", country: "US", tier: 1 as const }, +] as const; +``` + +- [ ] **Step 2: Update `DEV_GLOBAL_ITEMS` in `src/db/dev-seed-data.ts`** + +Replace the `brand` field with `manufacturerSlug` on every entry. Example for the first few entries: + +```typescript +export const DEV_GLOBAL_ITEMS = [ + // Bags (indices 0-5) + { + manufacturerSlug: "revelate-designs", + model: "Terrapin System", + category: "bags", + weightGrams: 529, + priceCents: 18500, + description: "Waterproof saddle bag with 14L capacity, roll-top closure, and integrated seat bag mount.", + }, + { + manufacturerSlug: "apidura", + model: "Expedition Handlebar Pack", + category: "bags", + weightGrams: 300, + priceCents: 16000, + description: "14L waterproof handlebar roll bag with internal dry bag and accessory pocket.", + }, + { + manufacturerSlug: "ortlieb", + model: "Frame-Pack RC", + category: "bags", + weightGrams: 250, + priceCents: 12000, + description: "6L waterproof roll-closure frame bag with TIZIP zipper for full-frame bikes.", + }, + // ... continue for all entries, replacing brand with manufacturerSlug +]; +``` + +Apply `brand → manufacturerSlug` for every entry in the array. + +- [ ] **Step 3: Update `src/db/dev-seed.ts`** to insert manufacturers first + +In `seedDevData`, before step 1 (seed global items), add: + +```typescript +// ── 0. Insert dev manufacturers ──────────────────────────────── +for (const m of DEV_MANUFACTURERS) { + await database + .insert(schema.manufacturers) + .values(m) + .onConflictDoNothing(); +} +console.log(` ${DEV_MANUFACTURERS.length} manufacturers seeded.`); +``` + +Also add `DEV_MANUFACTURERS` to the import from `./dev-seed-data.ts`. + +In step 5 (insert global items), update the insertion block to use `manufacturerSlug`: + +```typescript +for (const item of DEV_GLOBAL_ITEMS) { + const key = `${item.manufacturerSlug}::${item.model}`; + const existingId = existingGlobalItemMap.get(key); + // ... resolve manufacturerId from slug before inserting + const [mfRow] = await database + .select({ id: schema.manufacturers.id }) + .from(schema.manufacturers) + .where(eq(schema.manufacturers.slug, item.manufacturerSlug)); + if (!mfRow) continue; + + if (existingId) { + globalItemIds.push(existingId); + } else { + const [inserted] = await database + .insert(schema.globalItems) + .values({ + manufacturerId: mfRow.id, + model: item.model, + category: item.category, + weightGrams: item.weightGrams, + priceCents: item.priceCents, + description: item.description, + }) + .returning(); + if (!inserted) throw new Error(`Failed to insert: ${item.manufacturerSlug} ${item.model}`); + globalItemIds.push(inserted.id); + newGlobalCount++; + } +} +``` + +Also update the `existingGlobalItemMap` to key by `manufacturerId::model`. After loading `existingGlobalItems`, build the manufacturer slug → id map first, then key the map: + +```typescript +// Build manufacturer slug → id map +const allMfrs = await database.select().from(schema.manufacturers); +const mfrSlugToId = new Map(allMfrs.map((m) => [m.slug, m.id])); + +// Build existing global item map keyed by manufacturerId::model +const existingGlobalItems = await database.select().from(schema.globalItems); +const existingGlobalItemMap = new Map(); +for (const gi of existingGlobalItems) { + existingGlobalItemMap.set(`${gi.manufacturerId}::${gi.model}`, gi.id); +} + +// When checking/inserting each item: +const mfrId = mfrSlugToId.get(item.manufacturerSlug); +if (!mfrId) continue; +const key = `${mfrId}::${item.model}`; +const existingId = existingGlobalItemMap.get(key); +``` + +- [ ] **Step 4: Update `src/db/global-items-seed.json`** + +Replace `"brand"` with `"manufacturerSlug"` using the manufacturer slugs. Example: + +```json +[ + { + "manufacturerSlug": "revelate-designs", + "model": "Terrapin System", + "category": "bags", + "weightGrams": 529, + "priceCents": 18500, + "description": "Waterproof saddle bag with 14L capacity..." + }, + ... +] +``` + +Apply `brand → manufacturerSlug` for all ~20 entries, converting brand names to slugs (lowercase, hyphens). + +- [ ] **Step 5: Commit** + +```bash +git add src/db/dev-seed-data.ts src/db/dev-seed.ts src/db/global-items-seed.json +git commit -m "feat: dev seed and json seed use manufacturerSlug" +``` + +--- + +## Task 12: Run full test suite + +- [ ] **Step 1: Run all tests** + +```bash +bun test +``` + +Expected: all tests pass. Common failure patterns: +- `brand` referenced in a test helper → update to `manufacturerSlug` +- Missing manufacturer in a test that inserts globalItems directly → add `insertManufacturer` call first +- TypeScript type errors on service function signatures → check all call sites + +- [ ] **Step 2: Fix any failures, commit** + +```bash +git add -p +git commit -m "fix: update remaining test references after brand → manufacturerSlug migration" +``` + +- [ ] **Step 3: Verify dev seed runs cleanly** + +```bash +bun run db:seed:dev +``` + +Expected output includes "X manufacturers seeded" and all subsequent counts without errors. + +- [ ] **Step 4: Commit if any fixes were needed** + +```bash +git add . +git commit -m "fix: dev seed after manufacturers migration" +``` diff --git a/src/client/components/AddToCollectionModal.tsx b/src/client/components/AddToCollectionModal.tsx index da43c00..ef84c9f 100644 --- a/src/client/components/AddToCollectionModal.tsx +++ b/src/client/components/AddToCollectionModal.tsx @@ -72,7 +72,11 @@ export function AddToCollectionModal() { closeAddToCollection(); }, onError: (err) => { - setError(err instanceof Error ? err.message : t("collection:addToCollection.failedToAdd")); + setError( + err instanceof Error + ? err.message + : t("collection:addToCollection.failedToAdd"), + ); }, }, ); @@ -138,7 +142,9 @@ export function AddToCollectionModal() { type="number" value={purchasePrice} onChange={(e) => setPurchasePrice(e.target.value)} - placeholder={t("collection:addToCollection.purchasePricePlaceholder")} + placeholder={t( + "collection:addToCollection.purchasePricePlaceholder", + )} min="0" step="0.01" 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" @@ -160,7 +166,9 @@ export function AddToCollectionModal() { disabled={createItem.isPending} className="px-4 py-2 text-sm font-medium text-white bg-gray-700 hover:bg-gray-800 disabled:opacity-50 rounded-lg transition-colors" > - {createItem.isPending ? t("collection:addToCollection.addingButton") : t("collection:addToCollection.addButton")} + {createItem.isPending + ? t("collection:addToCollection.addingButton") + : t("collection:addToCollection.addButton")} diff --git a/src/client/components/AddToThreadModal.tsx b/src/client/components/AddToThreadModal.tsx index c107b2a..b4e687e 100644 --- a/src/client/components/AddToThreadModal.tsx +++ b/src/client/components/AddToThreadModal.tsx @@ -116,7 +116,9 @@ export function AddToThreadModal() { toast.success(`Added to "${thread?.name ?? "thread"}"`); closeAddToThread(); } catch (err) { - setError(err instanceof Error ? err.message : t("addToThread.failedToAdd")); + setError( + err instanceof Error ? err.message : t("addToThread.failedToAdd"), + ); } finally { setIsSubmitting(false); } @@ -144,7 +146,9 @@ export function AddToThreadModal() { toast.success(`Created "${trimmedName}" with first candidate`); closeAddToThread(); } catch (err) { - setError(err instanceof Error ? err.message : t("addToThread.failedToCreate")); + setError( + err instanceof Error ? err.message : t("addToThread.failedToCreate"), + ); } finally { setIsSubmitting(false); } @@ -175,7 +179,9 @@ export function AddToThreadModal() { onKeyDown={() => {}} >

- {mode === "pick" ? t("addToThread.title") : t("addToThread.newThreadTitle")} + {mode === "pick" + ? t("addToThread.title") + : t("addToThread.newThreadTitle")}

{globalItemName}

diff --git a/src/client/components/CategoryFilterDropdown.tsx b/src/client/components/CategoryFilterDropdown.tsx index ffacdf4..513c8c4 100644 --- a/src/client/components/CategoryFilterDropdown.tsx +++ b/src/client/components/CategoryFilterDropdown.tsx @@ -83,7 +83,9 @@ export function CategoryFilterDropdown({ {selectedCategory.name} ) : ( - {t("categoryFilter.allCategories")} + + {t("categoryFilter.allCategories")} + )} {selectedCategory ? ( diff --git a/src/client/components/CollectionView.tsx b/src/client/components/CollectionView.tsx index ec7f0de..6cf7cba 100644 --- a/src/client/components/CollectionView.tsx +++ b/src/client/components/CollectionView.tsx @@ -137,14 +137,18 @@ export function CollectionView() {
- {t("common:stats.items")} + + {t("common:stats.items")} + {totals.global.itemCount}
- {t("common:stats.totalWeight")} + + {t("common:stats.totalWeight")} + {weight(totals.global.totalWeight)} @@ -155,7 +159,9 @@ export function CollectionView() { size={14} className="text-gray-400" /> - {t("common:stats.totalSpent")} + + {t("common:stats.totalSpent")} + {price(totals.global.totalCost)} @@ -205,7 +211,10 @@ export function CollectionView() {
{hasActiveFilters && (

- {t("common:filter.showing", { filtered: filteredItems.length, total: items.length })} + {t("common:filter.showing", { + filtered: filteredItems.length, + total: items.length, + })}

)}
diff --git a/src/client/components/CreateThreadModal.tsx b/src/client/components/CreateThreadModal.tsx index d53172a..82f7c8d 100644 --- a/src/client/components/CreateThreadModal.tsx +++ b/src/client/components/CreateThreadModal.tsx @@ -79,7 +79,9 @@ export function CreateThreadModal() { onClick={(e) => e.stopPropagation()} onKeyDown={() => {}} > -

{t("create.title")}

+

+ {t("create.title")} +

@@ -135,7 +137,9 @@ export function CreateThreadModal() { disabled={createThread.isPending} className="px-4 py-2 text-sm font-medium text-white bg-gray-700 hover:bg-gray-800 disabled:opacity-50 rounded-lg transition-colors" > - {createThread.isPending ? t("common:actions.creating") : t("create.createThread")} + {createThread.isPending + ? t("common:actions.creating") + : t("create.createThread")}
diff --git a/src/client/components/ItemPicker.tsx b/src/client/components/ItemPicker.tsx index 8892512..a26f344 100644 --- a/src/client/components/ItemPicker.tsx +++ b/src/client/components/ItemPicker.tsx @@ -76,7 +76,11 @@ export function ItemPicker({ } return ( - +
{!items || items.length === 0 ? ( @@ -146,7 +150,9 @@ export function ItemPicker({ disabled={syncItems.isPending} className="flex-1 px-4 py-2 text-sm font-medium text-white bg-gray-700 hover:bg-gray-800 disabled:opacity-50 rounded-lg transition-colors" > - {syncItems.isPending ? t("common:actions.saving") : t("collection:itemPicker.done")} + {syncItems.isPending + ? t("common:actions.saving") + : t("collection:itemPicker.done")}
diff --git a/src/client/components/LinkToGlobalItem.tsx b/src/client/components/LinkToGlobalItem.tsx index df80fa0..e5f6291 100644 --- a/src/client/components/LinkToGlobalItem.tsx +++ b/src/client/components/LinkToGlobalItem.tsx @@ -167,7 +167,9 @@ export function LinkToGlobalItem({
{isSearching ? (
- {t("linkToGlobal.searching")} + + {t("linkToGlobal.searching")} +
) : searchResults && searchResults.length > 0 ? (
@@ -197,7 +199,9 @@ export function LinkToGlobalItem({
) : (
- {t("linkToGlobal.noItemsFound")} + + {t("linkToGlobal.noItemsFound")} +
)}
diff --git a/src/client/components/ManualEntryForm.tsx b/src/client/components/ManualEntryForm.tsx index 8179136..95ea358 100644 --- a/src/client/components/ManualEntryForm.tsx +++ b/src/client/components/ManualEntryForm.tsx @@ -71,7 +71,11 @@ export function ManualEntryForm({ onSuccess(item.name); }, onError: (err) => { - setError(err instanceof Error ? err.message : t("collection:manualEntry.failedToSave")); + setError( + err instanceof Error + ? err.message + : t("collection:manualEntry.failedToSave"), + ); }, }, ); @@ -223,7 +227,9 @@ export function ManualEntryForm({ disabled={createItem.isPending || !name.trim() || categoryId === null} className="w-full px-4 py-2.5 text-sm font-medium text-white bg-gray-900 rounded-lg hover:bg-gray-800 disabled:opacity-50 transition-colors" > - {createItem.isPending ? t("common:actions.saving") : t("collection:manualEntry.addToCollection")} + {createItem.isPending + ? t("common:actions.saving") + : t("collection:manualEntry.addToCollection")} diff --git a/src/client/components/PlanningView.tsx b/src/client/components/PlanningView.tsx index 30c277d..f5641ee 100644 --- a/src/client/components/PlanningView.tsx +++ b/src/client/components/PlanningView.tsx @@ -117,7 +117,9 @@ export function PlanningView() { 1
-

{t("threads:planning.step1Title")}

+

+ {t("threads:planning.step1Title")} +

{t("threads:planning.step1Description")}

@@ -128,7 +130,9 @@ export function PlanningView() { 2
-

{t("threads:planning.step2Title")}

+

+ {t("threads:planning.step2Title")} +

{t("threads:planning.step2Description")}

@@ -139,7 +143,9 @@ export function PlanningView() { 3
-

{t("threads:planning.step3Title")}

+

+ {t("threads:planning.step3Title")} +

{t("threads:planning.step3Description")}

@@ -171,7 +177,9 @@ export function PlanningView() {
) : filteredThreads.length === 0 ? (
-

{t("threads:empty.noThreads")}

+

+ {t("threads:empty.noThreads")} +

) : (
diff --git a/src/client/components/ProfileSection.tsx b/src/client/components/ProfileSection.tsx index 903b3c6..ab11aa2 100644 --- a/src/client/components/ProfileSection.tsx +++ b/src/client/components/ProfileSection.tsx @@ -77,7 +77,10 @@ export function ProfileSection() { setDirty(true); } catch { setAvatarDisplayUrl(null); - setMessage({ type: "error", text: t("collection:profileSection.avatarUploadFailed") }); + setMessage({ + type: "error", + text: t("collection:profileSection.avatarUploadFailed"), + }); } finally { setUploading(false); if (fileInputRef.current) fileInputRef.current.value = ""; @@ -87,7 +90,9 @@ export function ProfileSection() { return (
-

{t("collection:profileSection.title")}

+

+ {t("collection:profileSection.title")} +

{t("collection:profileSection.subtitle")}

@@ -150,7 +155,9 @@ export function ProfileSection() { disabled={uploading} className="text-sm text-gray-600 hover:text-gray-800 transition-colors" > - {uploading ? t("collection:profileSection.uploadingAvatar") : t("collection:profileSection.changeAvatar")} + {uploading + ? t("collection:profileSection.uploadingAvatar") + : t("collection:profileSection.changeAvatar")} {avatarFilename && ( ); diff --git a/src/client/components/SetupsView.tsx b/src/client/components/SetupsView.tsx index 531836e..77fca19 100644 --- a/src/client/components/SetupsView.tsx +++ b/src/client/components/SetupsView.tsx @@ -32,7 +32,9 @@ export function SetupsView() { disabled={!newSetupName.trim() || createSetup.isPending} className="px-4 py-2 bg-gray-700 hover:bg-gray-800 disabled:opacity-50 text-white text-sm font-medium rounded-lg transition-colors" > - {createSetup.isPending ? t("setups:creating") : t("common:actions.create")} + {createSetup.isPending + ? t("setups:creating") + : t("common:actions.create")} @@ -61,7 +63,9 @@ export function SetupsView() { 1
-

{t("setups:emptyState.step1Title")}

+

+ {t("setups:emptyState.step1Title")} +

{t("setups:emptyState.step1Description")}

@@ -72,7 +76,9 @@ export function SetupsView() { 2
-

{t("setups:emptyState.step2Title")}

+

+ {t("setups:emptyState.step2Title")} +

{t("setups:emptyState.step2Description")}

@@ -83,7 +89,9 @@ export function SetupsView() { 3
-

{t("setups:emptyState.step3Title")}

+

+ {t("setups:emptyState.step3Title")} +

{t("setups:emptyState.step3Description")}

diff --git a/src/client/components/StatusBadge.tsx b/src/client/components/StatusBadge.tsx index e887730..d9227f0 100644 --- a/src/client/components/StatusBadge.tsx +++ b/src/client/components/StatusBadge.tsx @@ -62,7 +62,11 @@ export function StatusBadge({ status, onStatusChange }: StatusBadgeProps) { }} className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-600 hover:bg-gray-200 transition-colors cursor-pointer" > - + {STATUS_LABELS[status]} diff --git a/src/client/components/WeightSummaryCard.tsx b/src/client/components/WeightSummaryCard.tsx index 98e4b0c..377545f 100644 --- a/src/client/components/WeightSummaryCard.tsx +++ b/src/client/components/WeightSummaryCard.tsx @@ -1,4 +1,5 @@ import { useState } from "react"; +import { useTranslation } from "react-i18next"; import { Cell, Label, @@ -7,7 +8,6 @@ import { ResponsiveContainer, Tooltip, } from "recharts"; -import { useTranslation } from "react-i18next"; import { useFormatters } from "../hooks/useFormatters"; import type { SetupItemWithCategory } from "../hooks/useSetups"; import { formatWeight, type WeightUnit } from "../lib/formatters"; @@ -205,7 +205,9 @@ export function WeightSummaryCard({ items }: WeightSummaryCardProps) {
{/* Header with pill toggle */}
-

{t("weightSummary.title")}

+

+ {t("weightSummary.title")} +

{VIEW_MODES.map((mode) => ( ))}
diff --git a/src/client/routes/global-items/index.tsx b/src/client/routes/global-items/index.tsx index aec41e6..457fce6 100644 --- a/src/client/routes/global-items/index.tsx +++ b/src/client/routes/global-items/index.tsx @@ -1,7 +1,7 @@ import { createFileRoute, Link } from "@tanstack/react-router"; import { ArrowLeft, LayoutGrid, LayoutList, X } from "lucide-react"; -import { useTranslation } from "react-i18next"; import { useEffect, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; import { z } from "zod"; import { GearImage } from "../../components/GearImage"; import { GlobalItemCard } from "../../components/GlobalItemCard"; @@ -663,9 +663,7 @@ function EmptyState({ hasQuery }: { hasQuery: boolean }) { />

- {hasQuery - ? t("empty.noResults") - : t("empty.noCatalogItems")} + {hasQuery ? t("empty.noResults") : t("empty.noCatalogItems")}

); diff --git a/src/client/routes/index.tsx b/src/client/routes/index.tsx index 14ea309..5c420c7 100644 --- a/src/client/routes/index.tsx +++ b/src/client/routes/index.tsx @@ -32,7 +32,9 @@ function PopularSetupsSection() { return (
-

{t("home.popularSetups")}

+

+ {t("home.popularSetups")} +

{isLoading ? ( @@ -57,7 +59,9 @@ function RecentItemsSection() { return (
-

{t("home.recentlyAdded")}

+

+ {t("home.recentlyAdded")} +

{isLoading ? ( diff --git a/src/client/routes/items/$itemId.tsx b/src/client/routes/items/$itemId.tsx index b5b72e3..f412bde 100644 --- a/src/client/routes/items/$itemId.tsx +++ b/src/client/routes/items/$itemId.tsx @@ -207,7 +207,9 @@ function ItemDetail() { search: shareToken ? { share: shareToken } : {}, } : { to: "/collection" as const, params: {}, search: {} }; - const backLabel = setupId ? t("collection:item.backToSetup") : t("collection:item.backToCollection"); + const backLabel = setupId + ? t("collection:item.backToSetup") + : t("collection:item.backToCollection"); if (error || !item) { return ( @@ -221,7 +223,9 @@ function ItemDetail() { ← {backLabel}
-

{t("collection:item.notFound")}

+

+ {t("collection:item.notFound")} +

); @@ -270,15 +274,25 @@ function ItemDetail() { onClick={handleDelete} className="hidden md:inline-flex items-center gap-1.5 px-3 py-1.5 text-sm text-red-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors" > - {isReference ? t("collection:item.removeFromCollection") : t("common:actions.delete")} + {isReference + ? t("collection:item.removeFromCollection") + : t("common:actions.delete")} {/* Delete — mobile */} @@ -317,7 +331,9 @@ function ItemDetail() { disabled={updateItem.isPending || !form.name.trim()} className="px-4 py-1.5 text-sm font-medium text-white bg-gray-700 hover:bg-gray-800 rounded-lg transition-colors disabled:opacity-50" > - {updateItem.isPending ? t("common:actions.saving") : t("common:actions.save")} + {updateItem.isPending + ? t("common:actions.saving") + : t("common:actions.save")}
)} diff --git a/src/client/routes/profile.tsx b/src/client/routes/profile.tsx index 4e92015..700bcc5 100644 --- a/src/client/routes/profile.tsx +++ b/src/client/routes/profile.tsx @@ -38,7 +38,9 @@ function ProfilePage() { > ← {t("actions.back")} -

{t("profile.title")}

+

+ {t("profile.title")} +

{/* Section 1: Profile Info (D-02) */} @@ -108,14 +110,20 @@ function AccountInfoSection({ return (
-

{t("profile.account")}

-

{t("profile.accountInfo")}

+

+ {t("profile.account")} +

+

+ {t("profile.accountInfo")} +

{/* Email row */}
- {t("profile.email")} + + {t("profile.email")} + {email || t("profile.noEmail")} @@ -147,7 +155,9 @@ function AccountInfoSection({ disabled={changeEmail.isPending} className="px-4 py-2 text-sm font-medium text-white bg-gray-700 hover:bg-gray-800 disabled:opacity-50 rounded-lg transition-colors" > - {changeEmail.isPending ? t("profile.updating") : t("profile.updateEmail")} + {changeEmail.isPending + ? t("profile.updating") + : t("profile.updateEmail")}
diff --git a/src/client/routes/setups/$setupId.tsx b/src/client/routes/setups/$setupId.tsx index 67506b0..a81c258 100644 --- a/src/client/routes/setups/$setupId.tsx +++ b/src/client/routes/setups/$setupId.tsx @@ -158,7 +158,9 @@ function SetupDetailPage() { {isSharedView && setup && (
- {t("setups:detail.sharedSetup")} + + {t("setups:detail.sharedSetup")} +
)} @@ -448,7 +450,9 @@ function SetupDetailPage() { disabled={deleteSetup.isPending} className="px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 disabled:opacity-50 rounded-lg transition-colors" > - {deleteSetup.isPending ? t("common:actions.deleting") : t("common:actions.delete")} + {deleteSetup.isPending + ? t("common:actions.deleting") + : t("common:actions.delete")}
diff --git a/src/client/routes/threads/$threadId/index.tsx b/src/client/routes/threads/$threadId/index.tsx index 19c151a..d036497 100644 --- a/src/client/routes/threads/$threadId/index.tsx +++ b/src/client/routes/threads/$threadId/index.tsx @@ -119,7 +119,9 @@ function ThreadDetailPage() { : "bg-gray-100 text-gray-500" }`} > - {isActive ? t("threads:detail.statusActive") : t("threads:detail.statusResolved")} + {isActive + ? t("threads:detail.statusActive") + : t("threads:detail.statusResolved")} @@ -419,7 +421,9 @@ function AddCandidateModal({ threadId, onClose }: AddCandidateModalProps) { onKeyDown={(e) => e.stopPropagation()} >
-

{t("threads:detail.addCandidateModal.title")}

+

+ {t("threads:detail.addCandidateModal.title")} +