9 Commits

Author SHA1 Message Date
94ebd84cc7 refactor: move setups list into collection page as third tab
All checks were successful
CI / ci (push) Successful in 13s
Setups now lives alongside My Gear and Planning under /collection?tab=setups
instead of its own /setups route. Dashboard card updated to link to the new
tab. Setup detail pages (/setups/:id) remain unchanged.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 00:07:48 +01:00
5938a686c7 feat: add package icon as favicon and in top bar title
All checks were successful
CI / ci (push) Successful in 12s
Add Lucide package icon as SVG favicon (white stroke) and display it
next to the GearBox title in the TotalsBar.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 22:57:43 +01:00
9bcdcc7168 style: replace blue accent with gray and mute card badge colors
Switch all interactive UI elements (buttons, focus rings, active tabs,
FAB, links, spinners) from blue to gray to match icon colors for a
more cohesive look. Mute card badge text colors to pastels (blue-400,
green-500, purple-500) to keep the focus on card content.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 22:42:38 +01:00
628907bb20 docs: add user-facing README and update compose for production
All checks were successful
CI / ci (push) Successful in 20s
Add README with Docker setup instructions for self-hosting. Update
docker-compose.yml to use the pre-built registry image instead of
local build, and add a healthcheck against /api/health.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 21:53:29 +01:00
891bb248c8 fix: use bun-sqlite migrator instead of drizzle-kit push in Docker
All checks were successful
CI / ci (push) Successful in 21s
drizzle-kit push depends on better-sqlite3 which isn't supported in
Bun, causing migrations to fail and the server to crash-loop in prod.
Replace with drizzle-orm/bun-sqlite/migrator that applies the existing
SQL migration files using the native bun:sqlite driver.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 21:30:39 +01:00
81f89fd14e fix: install docker-cli on dind runner for image build
All checks were successful
CI / ci (push) Successful in 12s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 19:53:41 +01:00
b496462df5 chore: auto-fix Biome formatting and configure lint rules
All checks were successful
CI / ci (push) Successful in 15s
Run biome check --write --unsafe to fix tabs, import ordering, and
non-null assertions across entire codebase. Disable a11y rules not
applicable to this single-user app. Exclude auto-generated routeTree.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 19:51:34 +01:00
4d0452b7b3 fix: handle better-sqlite3 native build in Docker and skip in CI
Some checks failed
CI / ci (push) Failing after 8s
Install python3/make/g++ in Dockerfile deps stage for drizzle-kit's
better-sqlite3 dependency. Use --ignore-scripts in CI workflows since
lint, test, and build don't need the native module.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 19:41:55 +01:00
8ec96b9a6c fix: use correct branch name "Develop" in CI workflow triggers
Some checks failed
CI / ci (push) Failing after 29s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 19:39:54 +01:00
80 changed files with 5023 additions and 4857 deletions

View File

@@ -2,9 +2,9 @@ name: CI
on: on:
push: push:
branches: [develop] branches: [Develop]
pull_request: pull_request:
branches: [develop] branches: [Develop]
jobs: jobs:
ci: ci:
@@ -16,7 +16,7 @@ jobs:
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Install dependencies - name: Install dependencies
run: bun install --frozen-lockfile run: bun install --frozen-lockfile --ignore-scripts
- name: Lint - name: Lint
run: bun run lint run: bun run lint

View File

@@ -23,7 +23,7 @@ jobs:
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Install dependencies - name: Install dependencies
run: bun install --frozen-lockfile run: bun install --frozen-lockfile --ignore-scripts
- name: Lint - name: Lint
run: bun run lint run: bun run lint
@@ -40,7 +40,7 @@ jobs:
steps: steps:
- name: Clone repository - name: Clone repository
run: | run: |
apk add --no-cache git curl jq apk add --no-cache git curl jq docker-cli
git clone https://${{ secrets.GITEA_TOKEN }}@gitea.jeanlucmakiola.de/${{ gitea.repository }}.git repo git clone https://${{ secrets.GITEA_TOKEN }}@gitea.jeanlucmakiola.de/${{ gitea.repository }}.git repo
cd repo cd repo
git checkout ${{ gitea.ref_name }} git checkout ${{ gitea.ref_name }}

View File

@@ -1,5 +1,6 @@
FROM oven/bun:1 AS deps FROM oven/bun:1 AS deps
WORKDIR /app WORKDIR /app
RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
COPY package.json bun.lock ./ COPY package.json bun.lock ./
RUN bun install --frozen-lockfile RUN bun install --frozen-lockfile

View File

@@ -1,2 +1,83 @@
# GearBox # GearBox
A single-user web app for managing gear collections (bikepacking, sim racing, etc.), tracking weight and price, and planning purchases through research threads.
## Features
- Organize gear into categories with custom icons
- Track weight and price for every item
- Create setups (packing lists) from your collection with automatic weight/cost totals
- Research threads for comparing candidates before buying
- Image uploads for items and candidates
## Quick Start
### Docker Compose (recommended)
Create a `docker-compose.yml`:
```yaml
services:
gearbox:
image: gitea.jeanlucmakiola.de/makiolaj/gearbox:latest
container_name: gearbox
ports:
- "3000:3000"
environment:
- NODE_ENV=production
- DATABASE_PATH=./data/gearbox.db
volumes:
- gearbox-data:/app/data
- gearbox-uploads:/app/uploads
healthcheck:
test: ["CMD", "bun", "-e", "fetch('http://localhost:3000/api/health').then(r=>r.ok?process.exit(0):process.exit(1)).catch(()=>process.exit(1))"]
interval: 30s
timeout: 5s
start_period: 10s
retries: 3
restart: unless-stopped
volumes:
gearbox-data:
gearbox-uploads:
```
Then run:
```bash
docker compose up -d
```
GearBox will be available at `http://localhost:3000`.
### Docker
```bash
docker run -d \
--name gearbox \
-p 3000:3000 \
-e NODE_ENV=production \
-e DATABASE_PATH=./data/gearbox.db \
-v gearbox-data:/app/data \
-v gearbox-uploads:/app/uploads \
--restart unless-stopped \
gitea.jeanlucmakiola.de/makiolaj/gearbox:latest
```
## Data
All data is stored in two Docker volumes:
- **gearbox-data** -- SQLite database
- **gearbox-uploads** -- uploaded images
Back up these volumes to preserve your data.
## Updating
```bash
docker compose pull
docker compose up -d
```
Database migrations run automatically on startup.

View File

@@ -6,7 +6,8 @@
"useIgnoreFile": true "useIgnoreFile": true
}, },
"files": { "files": {
"ignoreUnknown": false "ignoreUnknown": false,
"includes": ["**", "!src/client/routeTree.gen.ts"]
}, },
"formatter": { "formatter": {
"enabled": true, "enabled": true,
@@ -15,7 +16,22 @@
"linter": { "linter": {
"enabled": true, "enabled": true,
"rules": { "rules": {
"recommended": true "recommended": true,
"a11y": {
"noSvgWithoutTitle": "off",
"noStaticElementInteractions": "off",
"useKeyWithClickEvents": "off",
"useSemanticElements": "off",
"noAutofocus": "off",
"useAriaPropsSupportedByRole": "off",
"noLabelWithoutControl": "off"
},
"suspicious": {
"noExplicitAny": "off"
},
"style": {
"noNonNullAssertion": "off"
}
} }
}, },
"javascript": { "javascript": {

View File

@@ -1,6 +1,6 @@
services: services:
gearbox: gearbox:
build: . image: gitea.jeanlucmakiola.de/makiolaj/gearbox:latest
container_name: gearbox container_name: gearbox
ports: ports:
- "3000:3000" - "3000:3000"
@@ -10,6 +10,12 @@ services:
volumes: volumes:
- gearbox-data:/app/data - gearbox-data:/app/data
- gearbox-uploads:/app/uploads - gearbox-uploads:/app/uploads
healthcheck:
test: ["CMD", "bun", "-e", "fetch('http://localhost:3000/api/health').then(r=>r.ok?process.exit(0):process.exit(1)).catch(()=>process.exit(1))"]
interval: 30s
timeout: 5s
start_period: 10s
retries: 3
restart: unless-stopped restart: unless-stopped
volumes: volumes:

View File

@@ -40,9 +40,7 @@
"indexes": { "indexes": {
"categories_name_unique": { "categories_name_unique": {
"name": "categories_name_unique", "name": "categories_name_unique",
"columns": [ "columns": ["name"],
"name"
],
"isUnique": true "isUnique": true
} }
}, },
@@ -131,12 +129,8 @@
"name": "items_category_id_categories_id_fk", "name": "items_category_id_categories_id_fk",
"tableFrom": "items", "tableFrom": "items",
"tableTo": "categories", "tableTo": "categories",
"columnsFrom": [ "columnsFrom": ["category_id"],
"category_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "no action", "onDelete": "no action",
"onUpdate": "no action" "onUpdate": "no action"
} }
@@ -200,12 +194,8 @@
"name": "setup_items_setup_id_setups_id_fk", "name": "setup_items_setup_id_setups_id_fk",
"tableFrom": "setup_items", "tableFrom": "setup_items",
"tableTo": "setups", "tableTo": "setups",
"columnsFrom": [ "columnsFrom": ["setup_id"],
"setup_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
}, },
@@ -213,12 +203,8 @@
"name": "setup_items_item_id_items_id_fk", "name": "setup_items_item_id_items_id_fk",
"tableFrom": "setup_items", "tableFrom": "setup_items",
"tableTo": "items", "tableTo": "items",
"columnsFrom": [ "columnsFrom": ["item_id"],
"item_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
} }
@@ -352,12 +338,8 @@
"name": "thread_candidates_thread_id_threads_id_fk", "name": "thread_candidates_thread_id_threads_id_fk",
"tableFrom": "thread_candidates", "tableFrom": "thread_candidates",
"tableTo": "threads", "tableTo": "threads",
"columnsFrom": [ "columnsFrom": ["thread_id"],
"thread_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
}, },
@@ -365,12 +347,8 @@
"name": "thread_candidates_category_id_categories_id_fk", "name": "thread_candidates_category_id_categories_id_fk",
"tableFrom": "thread_candidates", "tableFrom": "thread_candidates",
"tableTo": "categories", "tableTo": "categories",
"columnsFrom": [ "columnsFrom": ["category_id"],
"category_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "no action", "onDelete": "no action",
"onUpdate": "no action" "onUpdate": "no action"
} }
@@ -439,12 +417,8 @@
"name": "threads_category_id_categories_id_fk", "name": "threads_category_id_categories_id_fk",
"tableFrom": "threads", "tableFrom": "threads",
"tableTo": "categories", "tableTo": "categories",
"columnsFrom": [ "columnsFrom": ["category_id"],
"category_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "no action", "onDelete": "no action",
"onUpdate": "no action" "onUpdate": "no action"
} }

View File

@@ -40,9 +40,7 @@
"indexes": { "indexes": {
"categories_name_unique": { "categories_name_unique": {
"name": "categories_name_unique", "name": "categories_name_unique",
"columns": [ "columns": ["name"],
"name"
],
"isUnique": true "isUnique": true
} }
}, },
@@ -131,12 +129,8 @@
"name": "items_category_id_categories_id_fk", "name": "items_category_id_categories_id_fk",
"tableFrom": "items", "tableFrom": "items",
"tableTo": "categories", "tableTo": "categories",
"columnsFrom": [ "columnsFrom": ["category_id"],
"category_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "no action", "onDelete": "no action",
"onUpdate": "no action" "onUpdate": "no action"
} }
@@ -200,12 +194,8 @@
"name": "setup_items_setup_id_setups_id_fk", "name": "setup_items_setup_id_setups_id_fk",
"tableFrom": "setup_items", "tableFrom": "setup_items",
"tableTo": "setups", "tableTo": "setups",
"columnsFrom": [ "columnsFrom": ["setup_id"],
"setup_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
}, },
@@ -213,12 +203,8 @@
"name": "setup_items_item_id_items_id_fk", "name": "setup_items_item_id_items_id_fk",
"tableFrom": "setup_items", "tableFrom": "setup_items",
"tableTo": "items", "tableTo": "items",
"columnsFrom": [ "columnsFrom": ["item_id"],
"item_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
} }
@@ -352,12 +338,8 @@
"name": "thread_candidates_thread_id_threads_id_fk", "name": "thread_candidates_thread_id_threads_id_fk",
"tableFrom": "thread_candidates", "tableFrom": "thread_candidates",
"tableTo": "threads", "tableTo": "threads",
"columnsFrom": [ "columnsFrom": ["thread_id"],
"thread_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
}, },
@@ -365,12 +347,8 @@
"name": "thread_candidates_category_id_categories_id_fk", "name": "thread_candidates_category_id_categories_id_fk",
"tableFrom": "thread_candidates", "tableFrom": "thread_candidates",
"tableTo": "categories", "tableTo": "categories",
"columnsFrom": [ "columnsFrom": ["category_id"],
"category_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "no action", "onDelete": "no action",
"onUpdate": "no action" "onUpdate": "no action"
} }
@@ -439,12 +417,8 @@
"name": "threads_category_id_categories_id_fk", "name": "threads_category_id_categories_id_fk",
"tableFrom": "threads", "tableFrom": "threads",
"tableTo": "categories", "tableTo": "categories",
"columnsFrom": [ "columnsFrom": ["category_id"],
"category_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "no action", "onDelete": "no action",
"onUpdate": "no action" "onUpdate": "no action"
} }

View File

@@ -1,4 +1,4 @@
#!/bin/sh #!/bin/sh
set -e set -e
bun run db:push bun run src/db/migrate.ts
exec bun run src/server/index.ts exec bun run src/server/index.ts

View File

@@ -3,6 +3,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>GearBox</title> <title>GearBox</title>
</head> </head>
<body> <body>

1
public/favicon.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m7.5 4.27 9 5.15"/><path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/></svg>

After

Width:  |  Height:  |  Size: 392 B

View File

@@ -46,7 +46,7 @@ export function CandidateCard({
openExternalLink(productUrl); openExternalLink(productUrl);
} }
}} }}
className="absolute top-2 right-2 z-10 w-6 h-6 flex items-center justify-center rounded-full bg-gray-100/80 text-gray-400 hover:bg-blue-100 hover:text-blue-500 opacity-0 group-hover:opacity-100 transition-all cursor-pointer" className="absolute top-2 right-2 z-10 w-6 h-6 flex items-center justify-center rounded-full bg-gray-100/80 text-gray-400 hover:bg-gray-200 hover:text-gray-600 opacity-0 group-hover:opacity-100 transition-all cursor-pointer"
title="Open product link" title="Open product link"
> >
<svg <svg
@@ -73,7 +73,11 @@ export function CandidateCard({
/> />
) : ( ) : (
<div className="w-full h-full flex flex-col items-center justify-center"> <div className="w-full h-full flex flex-col items-center justify-center">
<LucideIcon name={categoryIcon} size={36} className="text-gray-400" /> <LucideIcon
name={categoryIcon}
size={36}
className="text-gray-400"
/>
</div> </div>
)} )}
</div> </div>
@@ -83,24 +87,29 @@ export function CandidateCard({
</h3> </h3>
<div className="flex flex-wrap gap-1.5 mb-3"> <div className="flex flex-wrap gap-1.5 mb-3">
{weightGrams != null && ( {weightGrams != null && (
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-50 text-blue-700"> <span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-50 text-blue-400">
{formatWeight(weightGrams)} {formatWeight(weightGrams)}
</span> </span>
)} )}
{priceCents != null && ( {priceCents != null && (
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-50 text-green-700"> <span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-50 text-green-500">
{formatPrice(priceCents)} {formatPrice(priceCents)}
</span> </span>
)} )}
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-50 text-gray-600"> <span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-50 text-gray-600">
<LucideIcon name={categoryIcon} size={14} className="inline-block mr-1 text-gray-500" /> {categoryName} <LucideIcon
name={categoryIcon}
size={14}
className="inline-block mr-1 text-gray-500"
/>{" "}
{categoryName}
</span> </span>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<button <button
type="button" type="button"
onClick={() => openCandidateEditPanel(id)} onClick={() => openCandidateEditPanel(id)}
className="text-xs text-gray-500 hover:text-blue-600 transition-colors" className="text-xs text-gray-500 hover:text-gray-700 transition-colors"
> >
Edit Edit
</button> </button>

View File

@@ -1,8 +1,5 @@
import { useState, useEffect } from "react"; import { useEffect, useState } from "react";
import { import { useCreateCandidate, useUpdateCandidate } from "../hooks/useCandidates";
useCreateCandidate,
useUpdateCandidate,
} from "../hooks/useCandidates";
import { useThread } from "../hooks/useThreads"; import { useThread } from "../hooks/useThreads";
import { useUIStore } from "../stores/uiStore"; import { useUIStore } from "../stores/uiStore";
import { CategoryPicker } from "./CategoryPicker"; import { CategoryPicker } from "./CategoryPicker";
@@ -78,13 +75,13 @@ export function CandidateForm({
} }
if ( if (
form.weightGrams && form.weightGrams &&
(isNaN(Number(form.weightGrams)) || Number(form.weightGrams) < 0) (Number.isNaN(Number(form.weightGrams)) || Number(form.weightGrams) < 0)
) { ) {
newErrors.weightGrams = "Must be a positive number"; newErrors.weightGrams = "Must be a positive number";
} }
if ( if (
form.priceDollars && form.priceDollars &&
(isNaN(Number(form.priceDollars)) || Number(form.priceDollars) < 0) (Number.isNaN(Number(form.priceDollars)) || Number(form.priceDollars) < 0)
) { ) {
newErrors.priceDollars = "Must be a positive number"; newErrors.priceDollars = "Must be a positive number";
} }
@@ -155,9 +152,8 @@ export function CandidateForm({
type="text" type="text"
value={form.name} value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" 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"
placeholder="e.g. Osprey Talon 22" placeholder="e.g. Osprey Talon 22"
autoFocus
/> />
{errors.name && ( {errors.name && (
<p className="mt-1 text-xs text-red-500">{errors.name}</p> <p className="mt-1 text-xs text-red-500">{errors.name}</p>
@@ -181,7 +177,7 @@ export function CandidateForm({
onChange={(e) => onChange={(e) =>
setForm((f) => ({ ...f, weightGrams: e.target.value })) setForm((f) => ({ ...f, weightGrams: e.target.value }))
} }
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" 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"
placeholder="e.g. 680" placeholder="e.g. 680"
/> />
{errors.weightGrams && ( {errors.weightGrams && (
@@ -206,7 +202,7 @@ export function CandidateForm({
onChange={(e) => onChange={(e) =>
setForm((f) => ({ ...f, priceDollars: e.target.value })) setForm((f) => ({ ...f, priceDollars: e.target.value }))
} }
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" 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"
placeholder="e.g. 129.99" placeholder="e.g. 129.99"
/> />
{errors.priceDollars && ( {errors.priceDollars && (
@@ -238,7 +234,7 @@ export function CandidateForm({
value={form.notes} value={form.notes}
onChange={(e) => setForm((f) => ({ ...f, notes: e.target.value }))} onChange={(e) => setForm((f) => ({ ...f, notes: e.target.value }))}
rows={3} rows={3}
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none" 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 resize-none"
placeholder="Any additional notes..." placeholder="Any additional notes..."
/> />
</div> </div>
@@ -258,7 +254,7 @@ export function CandidateForm({
onChange={(e) => onChange={(e) =>
setForm((f) => ({ ...f, productUrl: e.target.value })) setForm((f) => ({ ...f, productUrl: e.target.value }))
} }
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" 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"
placeholder="https://..." placeholder="https://..."
/> />
{errors.productUrl && ( {errors.productUrl && (
@@ -271,7 +267,7 @@ export function CandidateForm({
<button <button
type="submit" type="submit"
disabled={isPending} disabled={isPending}
className="flex-1 py-2.5 px-4 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white text-sm font-medium rounded-lg transition-colors" className="flex-1 py-2.5 px-4 bg-gray-700 hover:bg-gray-800 disabled:opacity-50 text-white text-sm font-medium rounded-lg transition-colors"
> >
{isPending {isPending
? "Saving..." ? "Saving..."

View File

@@ -1,6 +1,6 @@
import { useState } from "react"; import { useState } from "react";
import { formatWeight, formatPrice } from "../lib/formatters"; import { useDeleteCategory, useUpdateCategory } from "../hooks/useCategories";
import { useUpdateCategory, useDeleteCategory } from "../hooks/useCategories"; import { formatPrice, formatWeight } from "../lib/formatters";
import { LucideIcon } from "../lib/iconData"; import { LucideIcon } from "../lib/iconData";
import { IconPicker } from "./IconPicker"; import { IconPicker } from "./IconPicker";
@@ -39,7 +39,9 @@ export function CategoryHeader({
function handleDelete() { function handleDelete() {
if ( if (
confirm(`Delete category "${name}"? Items will be moved to Uncategorized.`) confirm(
`Delete category "${name}"? Items will be moved to Uncategorized.`,
)
) { ) {
deleteCategory.mutate(categoryId); deleteCategory.mutate(categoryId);
} }
@@ -58,12 +60,11 @@ export function CategoryHeader({
if (e.key === "Enter") handleSave(); if (e.key === "Enter") handleSave();
if (e.key === "Escape") setIsEditing(false); if (e.key === "Escape") setIsEditing(false);
}} }}
autoFocus
/> />
<button <button
type="button" type="button"
onClick={handleSave} onClick={handleSave}
className="text-sm text-blue-600 hover:text-blue-800 font-medium" className="text-sm text-gray-600 hover:text-gray-800 font-medium"
> >
Save Save
</button> </button>

View File

@@ -1,8 +1,5 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { import { useCategories, useCreateCategory } from "../hooks/useCategories";
useCategories,
useCreateCategory,
} from "../hooks/useCategories";
import { LucideIcon } from "../lib/iconData"; import { LucideIcon } from "../lib/iconData";
import { IconPicker } from "./IconPicker"; import { IconPicker } from "./IconPicker";
@@ -109,10 +106,7 @@ export function CategoryPicker({ value, onChange }: CategoryPickerProps) {
handleConfirmCreate(); handleConfirmCreate();
} else if (highlightIndex >= 0 && highlightIndex < filtered.length) { } else if (highlightIndex >= 0 && highlightIndex < filtered.length) {
handleSelect(filtered[highlightIndex].id); handleSelect(filtered[highlightIndex].id);
} else if ( } else if (showCreateOption && highlightIndex === filtered.length) {
showCreateOption &&
highlightIndex === filtered.length
) {
handleStartCreate(); handleStartCreate();
} }
break; break;
@@ -162,11 +156,7 @@ export function CategoryPicker({ value, onChange }: CategoryPickerProps) {
: undefined : undefined
} }
value={ value={
isOpen isOpen ? inputValue : selectedCategory ? selectedCategory.name : ""
? inputValue
: selectedCategory
? selectedCategory.name
: ""
} }
placeholder="Search or create category..." placeholder="Search or create category..."
onChange={(e) => { onChange={(e) => {
@@ -179,7 +169,7 @@ export function CategoryPicker({ value, onChange }: CategoryPickerProps) {
setInputValue(""); setInputValue("");
}} }}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
className={`w-full py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent ${ className={`w-full py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent ${
!isOpen && selectedCategory ? "pl-8 pr-3" : "px-3" !isOpen && selectedCategory ? "pl-8 pr-3" : "px-3"
}`} }`}
/> />
@@ -188,18 +178,16 @@ export function CategoryPicker({ value, onChange }: CategoryPickerProps) {
<ul <ul
ref={listRef} ref={listRef}
id="category-listbox" id="category-listbox"
role="listbox"
className="absolute z-20 mt-1 w-full max-h-48 overflow-auto bg-white border border-gray-200 rounded-lg shadow-lg" className="absolute z-20 mt-1 w-full max-h-48 overflow-auto bg-white border border-gray-200 rounded-lg shadow-lg"
> >
{filtered.map((cat, i) => ( {filtered.map((cat, i) => (
<li <li
key={cat.id} key={cat.id}
id={`category-option-${i}`} id={`category-option-${i}`}
role="option"
aria-selected={cat.id === value} aria-selected={cat.id === value}
className={`px-3 py-2 text-sm cursor-pointer flex items-center gap-1.5 ${ className={`px-3 py-2 text-sm cursor-pointer flex items-center gap-1.5 ${
i === highlightIndex i === highlightIndex
? "bg-blue-50 text-blue-900" ? "bg-gray-100 text-gray-900"
: "hover:bg-gray-50" : "hover:bg-gray-50"
} ${cat.id === value ? "font-medium" : ""}`} } ${cat.id === value ? "font-medium" : ""}`}
onClick={() => handleSelect(cat.id)} onClick={() => handleSelect(cat.id)}
@@ -216,11 +204,10 @@ export function CategoryPicker({ value, onChange }: CategoryPickerProps) {
{showCreateOption && !isCreating && ( {showCreateOption && !isCreating && (
<li <li
id={`category-option-${filtered.length}`} id={`category-option-${filtered.length}`}
role="option"
aria-selected={false} aria-selected={false}
className={`px-3 py-2 text-sm cursor-pointer border-t border-gray-100 ${ className={`px-3 py-2 text-sm cursor-pointer border-t border-gray-100 ${
highlightIndex === filtered.length highlightIndex === filtered.length
? "bg-blue-50 text-blue-900" ? "bg-gray-100 text-gray-900"
: "hover:bg-gray-50 text-gray-600" : "hover:bg-gray-50 text-gray-600"
}`} }`}
onClick={handleStartCreate} onClick={handleStartCreate}
@@ -244,7 +231,7 @@ export function CategoryPicker({ value, onChange }: CategoryPickerProps) {
type="button" type="button"
onClick={handleConfirmCreate} onClick={handleConfirmCreate}
disabled={createCategory.isPending} disabled={createCategory.isPending}
className="text-xs font-medium text-blue-600 hover:text-blue-800 disabled:opacity-50" className="text-xs font-medium text-gray-600 hover:text-gray-800 disabled:opacity-50"
> >
{createCategory.isPending ? "..." : "Create"} {createCategory.isPending ? "..." : "Create"}
</button> </button>

View File

@@ -1,6 +1,5 @@
import { useDeleteItem, useItems } from "../hooks/useItems";
import { useUIStore } from "../stores/uiStore"; import { useUIStore } from "../stores/uiStore";
import { useDeleteItem } from "../hooks/useItems";
import { useItems } from "../hooks/useItems";
export function ConfirmDialog() { export function ConfirmDialog() {
const confirmDeleteItemId = useUIStore((s) => s.confirmDeleteItemId); const confirmDeleteItemId = useUIStore((s) => s.confirmDeleteItemId);

View File

@@ -93,7 +93,7 @@ export function CreateThreadModal() {
value={name} value={name}
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
placeholder="e.g. Lightweight sleeping bag" placeholder="e.g. Lightweight sleeping bag"
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent"
/> />
</div> </div>
@@ -108,7 +108,7 @@ export function CreateThreadModal() {
id="thread-category" id="thread-category"
value={categoryId ?? ""} value={categoryId ?? ""}
onChange={(e) => setCategoryId(Number(e.target.value))} onChange={(e) => setCategoryId(Number(e.target.value))}
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white" 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 bg-white"
> >
{categories?.map((cat) => ( {categories?.map((cat) => (
<option key={cat.id} value={cat.id}> <option key={cat.id} value={cat.id}>
@@ -131,7 +131,7 @@ export function CreateThreadModal() {
<button <button
type="submit" type="submit"
disabled={createThread.isPending} disabled={createThread.isPending}
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 disabled:opacity-50 rounded-lg transition-colors" 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 ? "Creating..." : "Create Thread"} {createThread.isPending ? "Creating..." : "Create Thread"}
</button> </button>

View File

@@ -43,7 +43,7 @@ export function DashboardCard({
))} ))}
</div> </div>
{allZero && emptyText && ( {allZero && emptyText && (
<p className="mt-4 text-sm text-blue-600 font-medium">{emptyText}</p> <p className="mt-4 text-sm text-gray-500 font-medium">{emptyText}</p>
)} )}
</Link> </Link>
); );

View File

@@ -37,10 +37,8 @@ export function ExternalLinkDialog() {
<h3 className="text-lg font-semibold text-gray-900 mb-2"> <h3 className="text-lg font-semibold text-gray-900 mb-2">
You are about to leave GearBox You are about to leave GearBox
</h3> </h3>
<p className="text-sm text-gray-600 mb-1"> <p className="text-sm text-gray-600 mb-1">You will be redirected to:</p>
You will be redirected to: <p className="text-sm text-gray-600 break-all mb-6">
</p>
<p className="text-sm text-blue-600 break-all mb-6">
{externalLinkUrl} {externalLinkUrl}
</p> </p>
<div className="flex justify-end gap-3"> <div className="flex justify-end gap-3">
@@ -54,7 +52,7 @@ export function ExternalLinkDialog() {
<button <button
type="button" type="button"
onClick={handleContinue} onClick={handleContinue}
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors" className="px-4 py-2 text-sm font-medium text-white bg-gray-700 hover:bg-gray-800 rounded-lg transition-colors"
> >
Continue Continue
</button> </button>

View File

@@ -8,11 +8,7 @@ interface IconPickerProps {
size?: "sm" | "md"; size?: "sm" | "md";
} }
export function IconPicker({ export function IconPicker({ value, onChange, size = "md" }: IconPickerProps) {
value,
onChange,
size = "md",
}: IconPickerProps) {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [activeGroup, setActiveGroup] = useState(0); const [activeGroup, setActiveGroup] = useState(0);
@@ -99,8 +95,7 @@ export function IconPicker({
const results = iconGroups.flatMap((group) => const results = iconGroups.flatMap((group) =>
group.icons.filter( group.icons.filter(
(icon) => (icon) =>
icon.name.includes(q) || icon.name.includes(q) || icon.keywords.some((kw) => kw.includes(q)),
icon.keywords.some((kw) => kw.includes(q)),
), ),
); );
// Deduplicate by name (some icons appear in multiple groups) // Deduplicate by name (some icons appear in multiple groups)
@@ -118,8 +113,7 @@ export function IconPicker({
setSearch(""); setSearch("");
} }
const buttonSize = const buttonSize = size === "sm" ? "w-10 h-10" : "w-12 h-12";
size === "sm" ? "w-10 h-10" : "w-12 h-12";
const iconSize = size === "sm" ? 20 : 24; const iconSize = size === "sm" ? 20 : 24;
return ( return (
@@ -156,7 +150,7 @@ export function IconPicker({
setActiveGroup(0); setActiveGroup(0);
}} }}
placeholder="Search icons..." placeholder="Search icons..."
className="w-full px-2 py-1.5 text-sm border border-gray-200 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" className="w-full px-2 py-1.5 text-sm border border-gray-200 rounded-md focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent"
/> />
</div> </div>
@@ -170,7 +164,7 @@ export function IconPicker({
onClick={() => setActiveGroup(i)} onClick={() => setActiveGroup(i)}
className={`flex-1 flex items-center justify-center py-1 rounded transition-colors ${ className={`flex-1 flex items-center justify-center py-1 rounded transition-colors ${
i === activeGroup i === activeGroup
? "bg-blue-50 text-blue-700" ? "bg-gray-200 text-gray-700"
: "hover:bg-gray-50 text-gray-500" : "hover:bg-gray-50 text-gray-500"
}`} }`}
title={group.name} title={group.name}
@@ -179,9 +173,7 @@ export function IconPicker({
name={group.icon} name={group.icon}
size={16} size={16}
className={ className={
i === activeGroup i === activeGroup ? "text-gray-700" : "text-gray-400"
? "text-blue-700"
: "text-gray-400"
} }
/> />
</button> </button>

View File

@@ -1,4 +1,4 @@
import { useState, useRef } from "react"; import { useRef, useState } from "react";
import { apiUpload } from "../lib/api"; import { apiUpload } from "../lib/api";
interface ImageUploadProps { interface ImageUploadProps {
@@ -32,10 +32,7 @@ export function ImageUpload({ value, onChange }: ImageUploadProps) {
setUploading(true); setUploading(true);
try { try {
const result = await apiUpload<{ filename: string }>( const result = await apiUpload<{ filename: string }>("/api/images", file);
"/api/images",
file,
);
onChange(result.filename); onChange(result.filename);
} catch { } catch {
setError("Upload failed. Please try again."); setError("Upload failed. Please try again.");

View File

@@ -48,7 +48,7 @@ export function ItemCard({
openExternalLink(productUrl); openExternalLink(productUrl);
} }
}} }}
className={`absolute top-2 ${onRemove ? "right-10" : "right-2"} z-10 w-6 h-6 flex items-center justify-center rounded-full bg-gray-100/80 text-gray-400 hover:bg-blue-100 hover:text-blue-500 opacity-0 group-hover:opacity-100 transition-all cursor-pointer`} className={`absolute top-2 ${onRemove ? "right-10" : "right-2"} z-10 w-6 h-6 flex items-center justify-center rounded-full bg-gray-100/80 text-gray-400 hover:bg-gray-200 hover:text-gray-600 opacity-0 group-hover:opacity-100 transition-all cursor-pointer`}
title="Open product link" title="Open product link"
> >
<svg <svg
@@ -107,7 +107,11 @@ export function ItemCard({
/> />
) : ( ) : (
<div className="w-full h-full flex flex-col items-center justify-center"> <div className="w-full h-full flex flex-col items-center justify-center">
<LucideIcon name={categoryIcon} size={36} className="text-gray-400" /> <LucideIcon
name={categoryIcon}
size={36}
className="text-gray-400"
/>
</div> </div>
)} )}
</div> </div>
@@ -117,17 +121,22 @@ export function ItemCard({
</h3> </h3>
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
{weightGrams != null && ( {weightGrams != null && (
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-50 text-blue-700"> <span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-50 text-blue-400">
{formatWeight(weightGrams)} {formatWeight(weightGrams)}
</span> </span>
)} )}
{priceCents != null && ( {priceCents != null && (
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-50 text-green-700"> <span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-50 text-green-500">
{formatPrice(priceCents)} {formatPrice(priceCents)}
</span> </span>
)} )}
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-50 text-gray-600"> <span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-50 text-gray-600">
<LucideIcon name={categoryIcon} size={14} className="inline-block mr-1 text-gray-500" /> {categoryName} <LucideIcon
name={categoryIcon}
size={14}
className="inline-block mr-1 text-gray-500"
/>{" "}
{categoryName}
</span> </span>
</div> </div>
</div> </div>

View File

@@ -1,5 +1,5 @@
import { useState, useEffect } from "react"; import { useEffect, useState } from "react";
import { useCreateItem, useUpdateItem, useItems } from "../hooks/useItems"; import { useCreateItem, useItems, useUpdateItem } from "../hooks/useItems";
import { useUIStore } from "../stores/uiStore"; import { useUIStore } from "../stores/uiStore";
import { CategoryPicker } from "./CategoryPicker"; import { CategoryPicker } from "./CategoryPicker";
import { ImageUpload } from "./ImageUpload"; import { ImageUpload } from "./ImageUpload";
@@ -46,8 +46,7 @@ export function ItemForm({ mode, itemId }: ItemFormProps) {
if (item) { if (item) {
setForm({ setForm({
name: item.name, name: item.name,
weightGrams: weightGrams: item.weightGrams != null ? String(item.weightGrams) : "",
item.weightGrams != null ? String(item.weightGrams) : "",
priceDollars: priceDollars:
item.priceCents != null ? (item.priceCents / 100).toFixed(2) : "", item.priceCents != null ? (item.priceCents / 100).toFixed(2) : "",
categoryId: item.categoryId, categoryId: item.categoryId,
@@ -66,10 +65,16 @@ export function ItemForm({ mode, itemId }: ItemFormProps) {
if (!form.name.trim()) { if (!form.name.trim()) {
newErrors.name = "Name is required"; newErrors.name = "Name is required";
} }
if (form.weightGrams && (isNaN(Number(form.weightGrams)) || Number(form.weightGrams) < 0)) { if (
form.weightGrams &&
(Number.isNaN(Number(form.weightGrams)) || Number(form.weightGrams) < 0)
) {
newErrors.weightGrams = "Must be a positive number"; newErrors.weightGrams = "Must be a positive number";
} }
if (form.priceDollars && (isNaN(Number(form.priceDollars)) || Number(form.priceDollars) < 0)) { if (
form.priceDollars &&
(Number.isNaN(Number(form.priceDollars)) || Number(form.priceDollars) < 0)
) {
newErrors.priceDollars = "Must be a positive number"; newErrors.priceDollars = "Must be a positive number";
} }
if ( if (
@@ -139,9 +144,8 @@ export function ItemForm({ mode, itemId }: ItemFormProps) {
type="text" type="text"
value={form.name} value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" 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"
placeholder="e.g. Osprey Talon 22" placeholder="e.g. Osprey Talon 22"
autoFocus
/> />
{errors.name && ( {errors.name && (
<p className="mt-1 text-xs text-red-500">{errors.name}</p> <p className="mt-1 text-xs text-red-500">{errors.name}</p>
@@ -165,7 +169,7 @@ export function ItemForm({ mode, itemId }: ItemFormProps) {
onChange={(e) => onChange={(e) =>
setForm((f) => ({ ...f, weightGrams: e.target.value })) setForm((f) => ({ ...f, weightGrams: e.target.value }))
} }
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" 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"
placeholder="e.g. 680" placeholder="e.g. 680"
/> />
{errors.weightGrams && ( {errors.weightGrams && (
@@ -190,7 +194,7 @@ export function ItemForm({ mode, itemId }: ItemFormProps) {
onChange={(e) => onChange={(e) =>
setForm((f) => ({ ...f, priceDollars: e.target.value })) setForm((f) => ({ ...f, priceDollars: e.target.value }))
} }
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" 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"
placeholder="e.g. 129.99" placeholder="e.g. 129.99"
/> />
{errors.priceDollars && ( {errors.priceDollars && (
@@ -222,7 +226,7 @@ export function ItemForm({ mode, itemId }: ItemFormProps) {
value={form.notes} value={form.notes}
onChange={(e) => setForm((f) => ({ ...f, notes: e.target.value }))} onChange={(e) => setForm((f) => ({ ...f, notes: e.target.value }))}
rows={3} rows={3}
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none" 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 resize-none"
placeholder="Any additional notes..." placeholder="Any additional notes..."
/> />
</div> </div>
@@ -242,7 +246,7 @@ export function ItemForm({ mode, itemId }: ItemFormProps) {
onChange={(e) => onChange={(e) =>
setForm((f) => ({ ...f, productUrl: e.target.value })) setForm((f) => ({ ...f, productUrl: e.target.value }))
} }
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" 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"
placeholder="https://..." placeholder="https://..."
/> />
{errors.productUrl && ( {errors.productUrl && (
@@ -255,7 +259,7 @@ export function ItemForm({ mode, itemId }: ItemFormProps) {
<button <button
type="submit" type="submit"
disabled={isPending} disabled={isPending}
className="flex-1 py-2.5 px-4 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white text-sm font-medium rounded-lg transition-colors" className="flex-1 py-2.5 px-4 bg-gray-700 hover:bg-gray-800 disabled:opacity-50 text-white text-sm font-medium rounded-lg transition-colors"
> >
{isPending {isPending
? "Saving..." ? "Saving..."

View File

@@ -1,9 +1,9 @@
import { useState, useEffect } from "react"; import { useEffect, useState } from "react";
import { SlideOutPanel } from "./SlideOutPanel";
import { useItems } from "../hooks/useItems"; import { useItems } from "../hooks/useItems";
import { useSyncSetupItems } from "../hooks/useSetups"; import { useSyncSetupItems } from "../hooks/useSetups";
import { formatWeight, formatPrice } from "../lib/formatters"; import { formatPrice, formatWeight } from "../lib/formatters";
import { LucideIcon } from "../lib/iconData"; import { LucideIcon } from "../lib/iconData";
import { SlideOutPanel } from "./SlideOutPanel";
interface ItemPickerProps { interface ItemPickerProps {
setupId: number; setupId: number;
@@ -84,10 +84,18 @@ export function ItemPicker({
</div> </div>
) : ( ) : (
Array.from(grouped.entries()).map( Array.from(grouped.entries()).map(
([categoryId, { categoryName, categoryIcon, items: catItems }]) => ( ([
categoryId,
{ categoryName, categoryIcon, items: catItems },
]) => (
<div key={categoryId} className="mb-4"> <div key={categoryId} className="mb-4">
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2"> <h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2">
<LucideIcon name={categoryIcon} size={16} className="inline-block mr-1 text-gray-500" /> {categoryName} <LucideIcon
name={categoryIcon}
size={16}
className="inline-block mr-1 text-gray-500"
/>{" "}
{categoryName}
</h3> </h3>
<div className="space-y-1"> <div className="space-y-1">
{catItems.map((item) => ( {catItems.map((item) => (
@@ -99,15 +107,19 @@ export function ItemPicker({
type="checkbox" type="checkbox"
checked={selectedIds.has(item.id)} checked={selectedIds.has(item.id)}
onChange={() => handleToggle(item.id)} onChange={() => handleToggle(item.id)}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500" className="rounded border-gray-300 text-gray-600 focus:ring-gray-400"
/> />
<span className="flex-1 text-sm text-gray-900 truncate"> <span className="flex-1 text-sm text-gray-900 truncate">
{item.name} {item.name}
</span> </span>
<span className="text-xs text-gray-400 shrink-0"> <span className="text-xs text-gray-400 shrink-0">
{item.weightGrams != null && formatWeight(item.weightGrams)} {item.weightGrams != null &&
{item.weightGrams != null && item.priceCents != null && " · "} formatWeight(item.weightGrams)}
{item.priceCents != null && formatPrice(item.priceCents)} {item.weightGrams != null &&
item.priceCents != null &&
" · "}
{item.priceCents != null &&
formatPrice(item.priceCents)}
</span> </span>
</label> </label>
))} ))}
@@ -131,7 +143,7 @@ export function ItemPicker({
type="button" type="button"
onClick={handleDone} onClick={handleDone}
disabled={syncItems.isPending} disabled={syncItems.isPending}
className="flex-1 px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 disabled:opacity-50 rounded-lg transition-colors" 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 ? "Saving..." : "Done"} {syncItems.isPending ? "Saving..." : "Done"}
</button> </button>

View File

@@ -102,7 +102,7 @@ export function OnboardingWizard({ onComplete }: OnboardingWizardProps) {
<div <div
key={s} key={s}
className={`h-1.5 rounded-full transition-all ${ className={`h-1.5 rounded-full transition-all ${
s <= Math.min(step, 3) ? "bg-blue-600 w-8" : "bg-gray-200 w-6" s <= Math.min(step, 3) ? "bg-gray-700 w-8" : "bg-gray-200 w-6"
}`} }`}
/> />
))} ))}
@@ -121,7 +121,7 @@ export function OnboardingWizard({ onComplete }: OnboardingWizardProps) {
<button <button
type="button" type="button"
onClick={() => setStep(2)} onClick={() => setStep(2)}
className="w-full py-3 px-4 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg transition-colors" className="w-full py-3 px-4 bg-gray-700 hover:bg-gray-800 text-white font-medium rounded-lg transition-colors"
> >
Get Started Get Started
</button> </button>
@@ -159,9 +159,8 @@ export function OnboardingWizard({ onComplete }: OnboardingWizardProps) {
type="text" type="text"
value={categoryName} value={categoryName}
onChange={(e) => setCategoryName(e.target.value)} onChange={(e) => setCategoryName(e.target.value)}
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" 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"
placeholder="e.g. Shelter" placeholder="e.g. Shelter"
autoFocus
/> />
</div> </div>
@@ -185,7 +184,7 @@ export function OnboardingWizard({ onComplete }: OnboardingWizardProps) {
type="button" type="button"
onClick={handleCreateCategory} onClick={handleCreateCategory}
disabled={createCategory.isPending} disabled={createCategory.isPending}
className="mt-6 w-full py-3 px-4 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white font-medium rounded-lg transition-colors" className="mt-6 w-full py-3 px-4 bg-gray-700 hover:bg-gray-800 disabled:opacity-50 text-white font-medium rounded-lg transition-colors"
> >
{createCategory.isPending ? "Creating..." : "Create Category"} {createCategory.isPending ? "Creating..." : "Create Category"}
</button> </button>
@@ -222,9 +221,8 @@ export function OnboardingWizard({ onComplete }: OnboardingWizardProps) {
type="text" type="text"
value={itemName} value={itemName}
onChange={(e) => setItemName(e.target.value)} onChange={(e) => setItemName(e.target.value)}
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" 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"
placeholder="e.g. Big Agnes Copper Spur" placeholder="e.g. Big Agnes Copper Spur"
autoFocus
/> />
</div> </div>
@@ -243,7 +241,7 @@ export function OnboardingWizard({ onComplete }: OnboardingWizardProps) {
step="any" step="any"
value={itemWeight} value={itemWeight}
onChange={(e) => setItemWeight(e.target.value)} onChange={(e) => setItemWeight(e.target.value)}
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" 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"
placeholder="e.g. 1200" placeholder="e.g. 1200"
/> />
</div> </div>
@@ -261,7 +259,7 @@ export function OnboardingWizard({ onComplete }: OnboardingWizardProps) {
step="0.01" step="0.01"
value={itemPrice} value={itemPrice}
onChange={(e) => setItemPrice(e.target.value)} onChange={(e) => setItemPrice(e.target.value)}
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" 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"
placeholder="e.g. 349.99" placeholder="e.g. 349.99"
/> />
</div> </div>
@@ -274,7 +272,7 @@ export function OnboardingWizard({ onComplete }: OnboardingWizardProps) {
type="button" type="button"
onClick={handleCreateItem} onClick={handleCreateItem}
disabled={createItem.isPending} disabled={createItem.isPending}
className="mt-6 w-full py-3 px-4 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white font-medium rounded-lg transition-colors" className="mt-6 w-full py-3 px-4 bg-gray-700 hover:bg-gray-800 disabled:opacity-50 text-white font-medium rounded-lg transition-colors"
> >
{createItem.isPending ? "Adding..." : "Add Item"} {createItem.isPending ? "Adding..." : "Add Item"}
</button> </button>
@@ -309,7 +307,7 @@ export function OnboardingWizard({ onComplete }: OnboardingWizardProps) {
type="button" type="button"
onClick={handleDone} onClick={handleDone}
disabled={updateSetting.isPending} disabled={updateSetting.isPending}
className="w-full py-3 px-4 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white font-medium rounded-lg transition-colors" className="w-full py-3 px-4 bg-gray-700 hover:bg-gray-800 disabled:opacity-50 text-white font-medium rounded-lg transition-colors"
> >
{updateSetting.isPending ? "Finishing..." : "Done"} {updateSetting.isPending ? "Finishing..." : "Done"}
</button> </button>

View File

@@ -1,5 +1,5 @@
import { Link } from "@tanstack/react-router"; import { Link } from "@tanstack/react-router";
import { formatWeight, formatPrice } from "../lib/formatters"; import { formatPrice, formatWeight } from "../lib/formatters";
interface SetupCardProps { interface SetupCardProps {
id: number; id: number;
@@ -23,18 +23,16 @@ export function SetupCard({
className="block w-full text-left bg-white rounded-xl border border-gray-100 hover:border-gray-200 hover:shadow-md transition-all p-4" className="block w-full text-left bg-white rounded-xl border border-gray-100 hover:border-gray-200 hover:shadow-md transition-all p-4"
> >
<div className="flex items-start justify-between mb-3"> <div className="flex items-start justify-between mb-3">
<h3 className="text-sm font-semibold text-gray-900 truncate"> <h3 className="text-sm font-semibold text-gray-900 truncate">{name}</h3>
{name} <span className="ml-2 inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-50 text-blue-400 shrink-0">
</h3>
<span className="ml-2 inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-50 text-blue-700 shrink-0">
{itemCount} {itemCount === 1 ? "item" : "items"} {itemCount} {itemCount === 1 ? "item" : "items"}
</span> </span>
</div> </div>
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-50 text-blue-700"> <span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-50 text-blue-400">
{formatWeight(totalWeight)} {formatWeight(totalWeight)}
</span> </span>
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-50 text-green-700"> <span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-50 text-green-500">
{formatPrice(totalCost)} {formatPrice(totalCost)}
</span> </span>
</div> </div>

View File

@@ -66,17 +66,22 @@ export function ThreadCard({
)} )}
</div> </div>
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-50 text-blue-700"> <span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-50 text-gray-600">
<LucideIcon name={categoryIcon} size={16} className="inline-block mr-1 text-gray-500" /> {categoryName} <LucideIcon
name={categoryIcon}
size={16}
className="inline-block mr-1 text-gray-500"
/>{" "}
{categoryName}
</span> </span>
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-purple-50 text-purple-700"> <span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-purple-50 text-purple-500">
{candidateCount} {candidateCount === 1 ? "candidate" : "candidates"} {candidateCount} {candidateCount === 1 ? "candidate" : "candidates"}
</span> </span>
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-50 text-gray-600"> <span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-50 text-gray-600">
{formatDate(createdAt)} {formatDate(createdAt)}
</span> </span>
{priceRange && ( {priceRange && (
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-50 text-green-700"> <span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-50 text-green-500">
{priceRange} {priceRange}
</span> </span>
)} )}

View File

@@ -1,14 +1,17 @@
interface ThreadTabsProps { type TabKey = "gear" | "planning" | "setups";
active: "gear" | "planning";
onChange: (tab: "gear" | "planning") => void; interface CollectionTabsProps {
active: TabKey;
onChange: (tab: TabKey) => void;
} }
const tabs = [ const tabs = [
{ key: "gear" as const, label: "My Gear" }, { key: "gear" as const, label: "My Gear" },
{ key: "planning" as const, label: "Planning" }, { key: "planning" as const, label: "Planning" },
{ key: "setups" as const, label: "Setups" },
]; ];
export function ThreadTabs({ active, onChange }: ThreadTabsProps) { export function CollectionTabs({ active, onChange }: CollectionTabsProps) {
return ( return (
<div className="flex border-b border-gray-200"> <div className="flex border-b border-gray-200">
{tabs.map((tab) => ( {tabs.map((tab) => (
@@ -18,13 +21,13 @@ export function ThreadTabs({ active, onChange }: ThreadTabsProps) {
onClick={() => onChange(tab.key)} onClick={() => onChange(tab.key)}
className={`px-4 py-2.5 text-sm font-medium transition-colors relative ${ className={`px-4 py-2.5 text-sm font-medium transition-colors relative ${
active === tab.key active === tab.key
? "text-blue-600" ? "text-gray-700"
: "text-gray-500 hover:text-gray-700" : "text-gray-500 hover:text-gray-700"
}`} }`}
> >
{tab.label} {tab.label}
{active === tab.key && ( {active === tab.key && (
<span className="absolute bottom-0 left-0 right-0 h-0.5 bg-blue-600 rounded-t" /> <span className="absolute bottom-0 left-0 right-0 h-0.5 bg-gray-700 rounded-t" />
)} )}
</button> </button>
))} ))}

View File

@@ -1,6 +1,7 @@
import { Link } from "@tanstack/react-router"; import { Link } from "@tanstack/react-router";
import { useTotals } from "../hooks/useTotals"; import { useTotals } from "../hooks/useTotals";
import { formatWeight, formatPrice } from "../lib/formatters"; import { formatPrice, formatWeight } from "../lib/formatters";
import { LucideIcon } from "../lib/iconData";
interface TotalsBarProps { interface TotalsBarProps {
title?: string; title?: string;
@@ -8,11 +9,17 @@ interface TotalsBarProps {
linkTo?: string; linkTo?: string;
} }
export function TotalsBar({ title = "GearBox", stats, linkTo }: TotalsBarProps) { export function TotalsBar({
title = "GearBox",
stats,
linkTo,
}: TotalsBarProps) {
const { data } = useTotals(); const { data } = useTotals();
// When no stats provided, use global totals (backward compatible) // When no stats provided, use global totals (backward compatible)
const displayStats = stats ?? (data?.global const displayStats =
stats ??
(data?.global
? [ ? [
{ label: "items", value: String(data.global.itemCount) }, { label: "items", value: String(data.global.itemCount) },
{ label: "total", value: formatWeight(data.global.totalWeight) }, { label: "total", value: formatWeight(data.global.totalWeight) },
@@ -24,12 +31,22 @@ export function TotalsBar({ title = "GearBox", stats, linkTo }: TotalsBarProps)
{ label: "spent", value: formatPrice(null) }, { label: "spent", value: formatPrice(null) },
]); ]);
const titleElement = linkTo ? ( const titleContent = (
<Link to={linkTo} className="text-lg font-semibold text-gray-900 hover:text-blue-600 transition-colors"> <span className="flex items-center gap-2">
<LucideIcon name="package" size={20} className="text-gray-500" />
{title} {title}
</span>
);
const titleElement = linkTo ? (
<Link
to={linkTo}
className="text-lg font-semibold text-gray-900 hover:text-gray-600 transition-colors"
>
{titleContent}
</Link> </Link>
) : ( ) : (
<h1 className="text-lg font-semibold text-gray-900">{title}</h1> <h1 className="text-lg font-semibold text-gray-900">{titleContent}</h1>
); );
// If stats prop is explicitly an empty array, show title only (dashboard mode) // If stats prop is explicitly an empty array, show title only (dashboard mode)

View File

@@ -1,6 +1,6 @@
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { apiPost, apiPut, apiDelete } from "../lib/api";
import type { CreateCandidate, UpdateCandidate } from "../../shared/types"; import type { CreateCandidate, UpdateCandidate } from "../../shared/types";
import { apiDelete, apiPost, apiPut } from "../lib/api";
interface CandidateResponse { interface CandidateResponse {
id: number; id: number;

View File

@@ -1,6 +1,6 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiGet, apiPost, apiPut, apiDelete } from "../lib/api";
import type { Category, CreateCategory } from "../../shared/types"; import type { Category, CreateCategory } from "../../shared/types";
import { apiDelete, apiGet, apiPost, apiPut } from "../lib/api";
export function useCategories() { export function useCategories() {
return useQuery({ return useQuery({

View File

@@ -1,6 +1,6 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiGet, apiPost, apiPut, apiDelete } from "../lib/api";
import type { CreateItem } from "../../shared/types"; import type { CreateItem } from "../../shared/types";
import { apiDelete, apiGet, apiPost, apiPut } from "../lib/api";
interface ItemWithCategory { interface ItemWithCategory {
id: number; id: number;

View File

@@ -1,4 +1,4 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiGet, apiPut } from "../lib/api"; import { apiGet, apiPut } from "../lib/api";
interface Setting { interface Setting {

View File

@@ -1,5 +1,5 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiGet, apiPost, apiPut, apiDelete } from "../lib/api"; import { apiDelete, apiGet, apiPost, apiPut } from "../lib/api";
interface SetupListItem { interface SetupListItem {
id: number; id: number;
@@ -34,7 +34,7 @@ interface SetupWithItems {
items: SetupItemWithCategory[]; items: SetupItemWithCategory[];
} }
export type { SetupListItem, SetupWithItems, SetupItemWithCategory }; export type { SetupItemWithCategory, SetupListItem, SetupWithItems };
export function useSetups() { export function useSetups() {
return useQuery({ return useQuery({

View File

@@ -1,5 +1,5 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiGet, apiPost, apiPut, apiDelete } from "../lib/api"; import { apiDelete, apiGet, apiPost, apiPut } from "../lib/api";
interface ThreadListItem { interface ThreadListItem {
id: number; id: number;

View File

@@ -1,7 +1,7 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { createRouter, RouterProvider } from "@tanstack/react-router";
import { StrictMode } from "react"; import { StrictMode } from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import { RouterProvider, createRouter } from "@tanstack/react-router";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { routeTree } from "./routeTree.gen"; import { routeTree } from "./routeTree.gen";
const queryClient = new QueryClient(); const queryClient = new QueryClient();

View File

@@ -10,7 +10,6 @@
import { Route as rootRouteImport } from './routes/__root' import { Route as rootRouteImport } from './routes/__root'
import { Route as IndexRouteImport } from './routes/index' import { Route as IndexRouteImport } from './routes/index'
import { Route as SetupsIndexRouteImport } from './routes/setups/index'
import { Route as CollectionIndexRouteImport } from './routes/collection/index' import { Route as CollectionIndexRouteImport } from './routes/collection/index'
import { Route as ThreadsThreadIdRouteImport } from './routes/threads/$threadId' import { Route as ThreadsThreadIdRouteImport } from './routes/threads/$threadId'
import { Route as SetupsSetupIdRouteImport } from './routes/setups/$setupId' import { Route as SetupsSetupIdRouteImport } from './routes/setups/$setupId'
@@ -20,11 +19,6 @@ const IndexRoute = IndexRouteImport.update({
path: '/', path: '/',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } as any)
const SetupsIndexRoute = SetupsIndexRouteImport.update({
id: '/setups/',
path: '/setups/',
getParentRoute: () => rootRouteImport,
} as any)
const CollectionIndexRoute = CollectionIndexRouteImport.update({ const CollectionIndexRoute = CollectionIndexRouteImport.update({
id: '/collection/', id: '/collection/',
path: '/collection/', path: '/collection/',
@@ -46,14 +40,12 @@ export interface FileRoutesByFullPath {
'/setups/$setupId': typeof SetupsSetupIdRoute '/setups/$setupId': typeof SetupsSetupIdRoute
'/threads/$threadId': typeof ThreadsThreadIdRoute '/threads/$threadId': typeof ThreadsThreadIdRoute
'/collection/': typeof CollectionIndexRoute '/collection/': typeof CollectionIndexRoute
'/setups/': typeof SetupsIndexRoute
} }
export interface FileRoutesByTo { export interface FileRoutesByTo {
'/': typeof IndexRoute '/': typeof IndexRoute
'/setups/$setupId': typeof SetupsSetupIdRoute '/setups/$setupId': typeof SetupsSetupIdRoute
'/threads/$threadId': typeof ThreadsThreadIdRoute '/threads/$threadId': typeof ThreadsThreadIdRoute
'/collection': typeof CollectionIndexRoute '/collection': typeof CollectionIndexRoute
'/setups': typeof SetupsIndexRoute
} }
export interface FileRoutesById { export interface FileRoutesById {
__root__: typeof rootRouteImport __root__: typeof rootRouteImport
@@ -61,30 +53,18 @@ export interface FileRoutesById {
'/setups/$setupId': typeof SetupsSetupIdRoute '/setups/$setupId': typeof SetupsSetupIdRoute
'/threads/$threadId': typeof ThreadsThreadIdRoute '/threads/$threadId': typeof ThreadsThreadIdRoute
'/collection/': typeof CollectionIndexRoute '/collection/': typeof CollectionIndexRoute
'/setups/': typeof SetupsIndexRoute
} }
export interface FileRouteTypes { export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: fullPaths: '/' | '/setups/$setupId' | '/threads/$threadId' | '/collection/'
| '/'
| '/setups/$setupId'
| '/threads/$threadId'
| '/collection/'
| '/setups/'
fileRoutesByTo: FileRoutesByTo fileRoutesByTo: FileRoutesByTo
to: to: '/' | '/setups/$setupId' | '/threads/$threadId' | '/collection'
| '/'
| '/setups/$setupId'
| '/threads/$threadId'
| '/collection'
| '/setups'
id: id:
| '__root__' | '__root__'
| '/' | '/'
| '/setups/$setupId' | '/setups/$setupId'
| '/threads/$threadId' | '/threads/$threadId'
| '/collection/' | '/collection/'
| '/setups/'
fileRoutesById: FileRoutesById fileRoutesById: FileRoutesById
} }
export interface RootRouteChildren { export interface RootRouteChildren {
@@ -92,7 +72,6 @@ export interface RootRouteChildren {
SetupsSetupIdRoute: typeof SetupsSetupIdRoute SetupsSetupIdRoute: typeof SetupsSetupIdRoute
ThreadsThreadIdRoute: typeof ThreadsThreadIdRoute ThreadsThreadIdRoute: typeof ThreadsThreadIdRoute
CollectionIndexRoute: typeof CollectionIndexRoute CollectionIndexRoute: typeof CollectionIndexRoute
SetupsIndexRoute: typeof SetupsIndexRoute
} }
declare module '@tanstack/react-router' { declare module '@tanstack/react-router' {
@@ -104,13 +83,6 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof IndexRouteImport preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport parentRoute: typeof rootRouteImport
} }
'/setups/': {
id: '/setups/'
path: '/setups'
fullPath: '/setups/'
preLoaderRoute: typeof SetupsIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/collection/': { '/collection/': {
id: '/collection/' id: '/collection/'
path: '/collection' path: '/collection'
@@ -140,7 +112,6 @@ const rootRouteChildren: RootRouteChildren = {
SetupsSetupIdRoute: SetupsSetupIdRoute, SetupsSetupIdRoute: SetupsSetupIdRoute,
ThreadsThreadIdRoute: ThreadsThreadIdRoute, ThreadsThreadIdRoute: ThreadsThreadIdRoute,
CollectionIndexRoute: CollectionIndexRoute, CollectionIndexRoute: CollectionIndexRoute,
SetupsIndexRoute: SetupsIndexRoute,
} }
export const routeTree = rootRouteImport export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren) ._addFileChildren(rootRouteChildren)

View File

@@ -1,22 +1,22 @@
import { useState } from "react";
import { import {
createRootRoute, createRootRoute,
Outlet, Outlet,
useMatchRoute, useMatchRoute,
useNavigate, useNavigate,
} from "@tanstack/react-router"; } from "@tanstack/react-router";
import { useState } from "react";
import "../app.css"; import "../app.css";
import { TotalsBar } from "../components/TotalsBar";
import { SlideOutPanel } from "../components/SlideOutPanel";
import { ItemForm } from "../components/ItemForm";
import { CandidateForm } from "../components/CandidateForm"; import { CandidateForm } from "../components/CandidateForm";
import { ConfirmDialog } from "../components/ConfirmDialog"; import { ConfirmDialog } from "../components/ConfirmDialog";
import { ExternalLinkDialog } from "../components/ExternalLinkDialog"; import { ExternalLinkDialog } from "../components/ExternalLinkDialog";
import { ItemForm } from "../components/ItemForm";
import { OnboardingWizard } from "../components/OnboardingWizard"; import { OnboardingWizard } from "../components/OnboardingWizard";
import { useUIStore } from "../stores/uiStore"; import { SlideOutPanel } from "../components/SlideOutPanel";
import { useOnboardingComplete } from "../hooks/useSettings"; import { TotalsBar } from "../components/TotalsBar";
import { useThread, useResolveThread } from "../hooks/useThreads";
import { useDeleteCandidate } from "../hooks/useCandidates"; import { useDeleteCandidate } from "../hooks/useCandidates";
import { useOnboardingComplete } from "../hooks/useSettings";
import { useResolveThread, useThread } from "../hooks/useThreads";
import { useUIStore } from "../stores/uiStore";
export const Route = createRootRoute({ export const Route = createRootRoute({
component: RootLayout, component: RootLayout,
@@ -74,7 +74,7 @@ function RootLayout() {
const isSetupDetail = !!matchRoute({ to: "/setups/$setupId", fuzzy: true }); const isSetupDetail = !!matchRoute({ to: "/setups/$setupId", fuzzy: true });
// Determine TotalsBar props based on current route // Determine TotalsBar props based on current route
const totalsBarProps = isDashboard const _totalsBarProps = isDashboard
? { stats: [] as Array<{ label: string; value: string }> } // Title only, no stats, no link ? { stats: [] as Array<{ label: string; value: string }> } // Title only, no stats, no link
: isSetupDetail : isSetupDetail
? { linkTo: "/" } // Setup detail will render its own local bar; root bar just has link ? { linkTo: "/" } // Setup detail will render its own local bar; root bar just has link
@@ -89,14 +89,20 @@ function RootLayout() {
: { linkTo: "/" }; : { linkTo: "/" };
// FAB visibility: only show on /collection route when gear tab is active // FAB visibility: only show on /collection route when gear tab is active
const collectionSearch = matchRoute({ to: "/collection" }) as { tab?: string } | false; const collectionSearch = matchRoute({ to: "/collection" }) as
const showFab = isCollection && (!collectionSearch || (collectionSearch as Record<string, string>).tab !== "planning"); | { tab?: string }
| false;
const showFab =
isCollection &&
(!collectionSearch ||
!(collectionSearch as Record<string, string>).tab ||
(collectionSearch as Record<string, string>).tab === "gear");
// Show a minimal loading state while checking onboarding status // Show a minimal loading state while checking onboarding status
if (onboardingLoading) { if (onboardingLoading) {
return ( return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center"> <div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="w-6 h-6 border-2 border-blue-600 border-t-transparent rounded-full animate-spin" /> <div className="w-6 h-6 border-2 border-gray-600 border-t-transparent rounded-full animate-spin" />
</div> </div>
); );
} }
@@ -173,7 +179,7 @@ function RootLayout() {
<button <button
type="button" type="button"
onClick={openAddPanel} onClick={openAddPanel}
className="fixed bottom-6 right-6 z-20 w-14 h-14 bg-blue-600 hover:bg-blue-700 text-white rounded-full shadow-lg hover:shadow-xl transition-all flex items-center justify-center" className="fixed bottom-6 right-6 z-20 w-14 h-14 bg-gray-700 hover:bg-gray-800 text-white rounded-full shadow-lg hover:shadow-xl transition-all flex items-center justify-center"
title="Add new item" title="Add new item"
> >
<svg <svg

View File

@@ -4,17 +4,19 @@ import { z } from "zod";
import { CategoryHeader } from "../../components/CategoryHeader"; import { CategoryHeader } from "../../components/CategoryHeader";
import { CreateThreadModal } from "../../components/CreateThreadModal"; import { CreateThreadModal } from "../../components/CreateThreadModal";
import { ItemCard } from "../../components/ItemCard"; import { ItemCard } from "../../components/ItemCard";
import { SetupCard } from "../../components/SetupCard";
import { ThreadCard } from "../../components/ThreadCard"; import { ThreadCard } from "../../components/ThreadCard";
import { ThreadTabs } from "../../components/ThreadTabs"; import { CollectionTabs } from "../../components/ThreadTabs";
import { useCategories } from "../../hooks/useCategories"; import { useCategories } from "../../hooks/useCategories";
import { useItems } from "../../hooks/useItems"; import { useItems } from "../../hooks/useItems";
import { useCreateSetup, useSetups } from "../../hooks/useSetups";
import { useThreads } from "../../hooks/useThreads"; import { useThreads } from "../../hooks/useThreads";
import { useTotals } from "../../hooks/useTotals"; import { useTotals } from "../../hooks/useTotals";
import { LucideIcon } from "../../lib/iconData"; import { LucideIcon } from "../../lib/iconData";
import { useUIStore } from "../../stores/uiStore"; import { useUIStore } from "../../stores/uiStore";
const searchSchema = z.object({ const searchSchema = z.object({
tab: z.enum(["gear", "planning"]).catch("gear"), tab: z.enum(["gear", "planning", "setups"]).catch("gear"),
}); });
export const Route = createFileRoute("/collection/")({ export const Route = createFileRoute("/collection/")({
@@ -26,15 +28,21 @@ function CollectionPage() {
const { tab } = Route.useSearch(); const { tab } = Route.useSearch();
const navigate = useNavigate(); const navigate = useNavigate();
function handleTabChange(newTab: "gear" | "planning") { function handleTabChange(newTab: "gear" | "planning" | "setups") {
navigate({ to: "/collection", search: { tab: newTab } }); navigate({ to: "/collection", search: { tab: newTab } });
} }
return ( return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<ThreadTabs active={tab} onChange={handleTabChange} /> <CollectionTabs active={tab} onChange={handleTabChange} />
<div className="mt-6"> <div className="mt-6">
{tab === "gear" ? <CollectionView /> : <PlanningView />} {tab === "gear" ? (
<CollectionView />
) : tab === "planning" ? (
<PlanningView />
) : (
<SetupsView />
)}
</div> </div>
</div> </div>
); );
@@ -79,7 +87,7 @@ function CollectionView() {
<button <button
type="button" type="button"
onClick={openAddPanel} onClick={openAddPanel}
className="inline-flex items-center gap-2 px-5 py-2.5 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-lg transition-colors" className="inline-flex items-center gap-2 px-5 py-2.5 bg-gray-700 hover:bg-gray-800 text-white text-sm font-medium rounded-lg transition-colors"
> >
<svg <svg
aria-hidden="true" aria-hidden="true"
@@ -217,7 +225,7 @@ function PlanningView() {
<button <button
type="button" type="button"
onClick={openCreateThreadModal} onClick={openCreateThreadModal}
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-lg transition-colors" className="inline-flex items-center gap-2 px-4 py-2 bg-gray-700 hover:bg-gray-800 text-white text-sm font-medium rounded-lg transition-colors"
> >
<svg <svg
aria-hidden="true" aria-hidden="true"
@@ -246,7 +254,7 @@ function PlanningView() {
onClick={() => setActiveTab("active")} onClick={() => setActiveTab("active")}
className={`px-4 py-1.5 text-sm font-medium rounded-full transition-colors ${ className={`px-4 py-1.5 text-sm font-medium rounded-full transition-colors ${
activeTab === "active" activeTab === "active"
? "bg-blue-600 text-white" ? "bg-gray-700 text-white"
: "text-gray-600 hover:bg-gray-200" : "text-gray-600 hover:bg-gray-200"
}`} }`}
> >
@@ -257,7 +265,7 @@ function PlanningView() {
onClick={() => setActiveTab("resolved")} onClick={() => setActiveTab("resolved")}
className={`px-4 py-1.5 text-sm font-medium rounded-full transition-colors ${ className={`px-4 py-1.5 text-sm font-medium rounded-full transition-colors ${
activeTab === "resolved" activeTab === "resolved"
? "bg-blue-600 text-white" ? "bg-gray-700 text-white"
: "text-gray-600 hover:bg-gray-200" : "text-gray-600 hover:bg-gray-200"
}`} }`}
> >
@@ -271,7 +279,7 @@ function PlanningView() {
onChange={(e) => onChange={(e) =>
setCategoryFilter(e.target.value ? Number(e.target.value) : null) setCategoryFilter(e.target.value ? Number(e.target.value) : null)
} }
className="px-3 py-1.5 border border-gray-200 rounded-lg text-sm bg-white focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" className="px-3 py-1.5 border border-gray-200 rounded-lg text-sm bg-white focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent"
> >
<option value="">All categories</option> <option value="">All categories</option>
{categories?.map((cat) => ( {categories?.map((cat) => (
@@ -291,7 +299,7 @@ function PlanningView() {
</h2> </h2>
<div className="space-y-6 text-left mb-10"> <div className="space-y-6 text-left mb-10">
<div className="flex items-start gap-4"> <div className="flex items-start gap-4">
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-blue-100 text-blue-700 font-bold text-sm shrink-0"> <div className="flex items-center justify-center w-8 h-8 rounded-full bg-gray-200 text-gray-700 font-bold text-sm shrink-0">
1 1
</div> </div>
<div> <div>
@@ -302,7 +310,7 @@ function PlanningView() {
</div> </div>
</div> </div>
<div className="flex items-start gap-4"> <div className="flex items-start gap-4">
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-blue-100 text-blue-700 font-bold text-sm shrink-0"> <div className="flex items-center justify-center w-8 h-8 rounded-full bg-gray-200 text-gray-700 font-bold text-sm shrink-0">
2 2
</div> </div>
<div> <div>
@@ -313,7 +321,7 @@ function PlanningView() {
</div> </div>
</div> </div>
<div className="flex items-start gap-4"> <div className="flex items-start gap-4">
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-blue-100 text-blue-700 font-bold text-sm shrink-0"> <div className="flex items-center justify-center w-8 h-8 rounded-full bg-gray-200 text-gray-700 font-bold text-sm shrink-0">
3 3
</div> </div>
<div> <div>
@@ -327,7 +335,7 @@ function PlanningView() {
<button <button
type="button" type="button"
onClick={openCreateThreadModal} onClick={openCreateThreadModal}
className="inline-flex items-center gap-2 px-5 py-2.5 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-lg transition-colors" className="inline-flex items-center gap-2 px-5 py-2.5 bg-gray-700 hover:bg-gray-800 text-white text-sm font-medium rounded-lg transition-colors"
> >
<svg <svg
aria-hidden="true" aria-hidden="true"
@@ -374,3 +382,87 @@ function PlanningView() {
</div> </div>
); );
} }
function SetupsView() {
const [newSetupName, setNewSetupName] = useState("");
const { data: setups, isLoading } = useSetups();
const createSetup = useCreateSetup();
function handleCreateSetup(e: React.FormEvent) {
e.preventDefault();
const name = newSetupName.trim();
if (!name) return;
createSetup.mutate({ name }, { onSuccess: () => setNewSetupName("") });
}
return (
<div>
{/* Create setup form */}
<form onSubmit={handleCreateSetup} className="flex gap-2 mb-6">
<input
type="text"
value={newSetupName}
onChange={(e) => setNewSetupName(e.target.value)}
placeholder="New setup name..."
className="flex-1 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"
/>
<button
type="submit"
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 ? "Creating..." : "Create"}
</button>
</form>
{/* Loading skeleton */}
{isLoading && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{[1, 2].map((i) => (
<div
key={i}
className="h-24 bg-gray-200 rounded-xl animate-pulse"
/>
))}
</div>
)}
{/* Empty state */}
{!isLoading && (!setups || setups.length === 0) && (
<div className="py-16 text-center">
<div className="max-w-md mx-auto">
<div className="mb-4">
<LucideIcon
name="tent"
size={48}
className="text-gray-400 mx-auto"
/>
</div>
<h2 className="text-xl font-semibold text-gray-900 mb-2">
No setups yet
</h2>
<p className="text-sm text-gray-500">
Create one to plan your loadout.
</p>
</div>
</div>
)}
{/* Setup grid */}
{!isLoading && setups && setups.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{setups.map((setup) => (
<SetupCard
key={setup.id}
id={setup.id}
name={setup.name}
itemCount={setup.itemCount}
totalWeight={setup.totalWeight}
totalCost={setup.totalCost}
/>
))}
</div>
)}
</div>
);
}

View File

@@ -45,7 +45,8 @@ function DashboardPage() {
]} ]}
/> />
<DashboardCard <DashboardCard
to="/setups" to="/collection"
search={{ tab: "setups" }}
title="Setups" title="Setups"
icon="tent" icon="tent"
stats={[{ label: "Setups", value: String(setupCount) }]} stats={[{ label: "Setups", value: String(setupCount) }]}

View File

@@ -124,7 +124,7 @@ function SetupDetailPage() {
<button <button
type="button" type="button"
onClick={() => setPickerOpen(true)} onClick={() => setPickerOpen(true)}
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-lg transition-colors" className="inline-flex items-center gap-2 px-4 py-2 bg-gray-700 hover:bg-gray-800 text-white text-sm font-medium rounded-lg transition-colors"
> >
<svg <svg
className="w-4 h-4" className="w-4 h-4"
@@ -170,7 +170,7 @@ function SetupDetailPage() {
<button <button
type="button" type="button"
onClick={() => setPickerOpen(true)} onClick={() => setPickerOpen(true)}
className="inline-flex items-center gap-2 px-5 py-2.5 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-lg transition-colors" className="inline-flex items-center gap-2 px-5 py-2.5 bg-gray-700 hover:bg-gray-800 text-white text-sm font-medium rounded-lg transition-colors"
> >
Add Items Add Items
</button> </button>

View File

@@ -1,93 +0,0 @@
import { createFileRoute } from "@tanstack/react-router";
import { useState } from "react";
import { SetupCard } from "../../components/SetupCard";
import { useCreateSetup, useSetups } from "../../hooks/useSetups";
import { LucideIcon } from "../../lib/iconData";
export const Route = createFileRoute("/setups/")({
component: SetupsListPage,
});
function SetupsListPage() {
const [newSetupName, setNewSetupName] = useState("");
const { data: setups, isLoading } = useSetups();
const createSetup = useCreateSetup();
function handleCreateSetup(e: React.FormEvent) {
e.preventDefault();
const name = newSetupName.trim();
if (!name) return;
createSetup.mutate({ name }, { onSuccess: () => setNewSetupName("") });
}
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
{/* Create setup form */}
<form onSubmit={handleCreateSetup} className="flex gap-2 mb-6">
<input
type="text"
value={newSetupName}
onChange={(e) => setNewSetupName(e.target.value)}
placeholder="New setup name..."
className="flex-1 px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
<button
type="submit"
disabled={!newSetupName.trim() || createSetup.isPending}
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white text-sm font-medium rounded-lg transition-colors"
>
{createSetup.isPending ? "Creating..." : "Create"}
</button>
</form>
{/* Loading skeleton */}
{isLoading && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{[1, 2].map((i) => (
<div
key={i}
className="h-24 bg-gray-200 rounded-xl animate-pulse"
/>
))}
</div>
)}
{/* Empty state */}
{!isLoading && (!setups || setups.length === 0) && (
<div className="py-16 text-center">
<div className="max-w-md mx-auto">
<div className="mb-4">
<LucideIcon
name="tent"
size={48}
className="text-gray-400 mx-auto"
/>
</div>
<h2 className="text-xl font-semibold text-gray-900 mb-2">
No setups yet
</h2>
<p className="text-sm text-gray-500">
Create one to plan your loadout.
</p>
</div>
</div>
)}
{/* Setup grid */}
{!isLoading && setups && setups.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{setups.map((setup) => (
<SetupCard
key={setup.id}
id={setup.id}
name={setup.name}
itemCount={setup.itemCount}
totalWeight={setup.totalWeight}
totalCost={setup.totalCost}
/>
))}
</div>
)}
</div>
);
}

View File

@@ -38,7 +38,7 @@ function ThreadDetailPage() {
<Link <Link
to="/" to="/"
search={{ tab: "planning" }} search={{ tab: "planning" }}
className="text-sm text-blue-600 hover:text-blue-700" className="text-sm text-gray-600 hover:text-gray-700"
> >
Back to planning Back to planning
</Link> </Link>
@@ -67,7 +67,7 @@ function ThreadDetailPage() {
<span <span
className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${ className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
isActive isActive
? "bg-blue-50 text-blue-700" ? "bg-gray-100 text-gray-600"
: "bg-gray-100 text-gray-500" : "bg-gray-100 text-gray-500"
}`} }`}
> >
@@ -92,7 +92,7 @@ function ThreadDetailPage() {
<button <button
type="button" type="button"
onClick={openCandidateAddPanel} onClick={openCandidateAddPanel}
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-lg transition-colors" className="inline-flex items-center gap-2 px-4 py-2 bg-gray-700 hover:bg-gray-800 text-white text-sm font-medium rounded-lg transition-colors"
> >
<svg <svg
className="w-4 h-4" className="w-4 h-4"

13
src/db/migrate.ts Normal file
View File

@@ -0,0 +1,13 @@
import { Database } from "bun:sqlite";
import { drizzle } from "drizzle-orm/bun-sqlite";
import { migrate } from "drizzle-orm/bun-sqlite/migrator";
const sqlite = new Database(process.env.DATABASE_PATH || "gearbox.db");
sqlite.run("PRAGMA journal_mode = WAL");
sqlite.run("PRAGMA foreign_keys = ON");
const db = drizzle(sqlite);
migrate(db, { migrationsFolder: "./drizzle" });
sqlite.close();
console.log("Migrations applied successfully");

View File

@@ -1,4 +1,4 @@
import { sqliteTable, text, integer, real } from "drizzle-orm/sqlite-core"; import { integer, real, sqliteTable, text } from "drizzle-orm/sqlite-core";
export const categories = sqliteTable("categories", { export const categories = sqliteTable("categories", {
id: integer("id").primaryKey({ autoIncrement: true }), id: integer("id").primaryKey({ autoIncrement: true }),

View File

@@ -1,13 +1,13 @@
import { Hono } from "hono"; import { Hono } from "hono";
import { serveStatic } from "hono/bun"; import { serveStatic } from "hono/bun";
import { seedDefaults } from "../db/seed.ts"; import { seedDefaults } from "../db/seed.ts";
import { itemRoutes } from "./routes/items.ts";
import { categoryRoutes } from "./routes/categories.ts"; import { categoryRoutes } from "./routes/categories.ts";
import { totalRoutes } from "./routes/totals.ts";
import { imageRoutes } from "./routes/images.ts"; import { imageRoutes } from "./routes/images.ts";
import { itemRoutes } from "./routes/items.ts";
import { settingsRoutes } from "./routes/settings.ts"; import { settingsRoutes } from "./routes/settings.ts";
import { threadRoutes } from "./routes/threads.ts";
import { setupRoutes } from "./routes/setups.ts"; import { setupRoutes } from "./routes/setups.ts";
import { threadRoutes } from "./routes/threads.ts";
import { totalRoutes } from "./routes/totals.ts";
// Seed default data on startup // Seed default data on startup
seedDefaults(); seedDefaults();

View File

@@ -1,14 +1,14 @@
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator"; import { zValidator } from "@hono/zod-validator";
import { Hono } from "hono";
import { import {
createCategorySchema, createCategorySchema,
updateCategorySchema, updateCategorySchema,
} from "../../shared/schemas.ts"; } from "../../shared/schemas.ts";
import { import {
getAllCategories,
createCategory, createCategory,
updateCategory,
deleteCategory, deleteCategory,
getAllCategories,
updateCategory,
} from "../services/category.service.ts"; } from "../services/category.service.ts";
type Env = { Variables: { db?: any } }; type Env = { Variables: { db?: any } };

View File

@@ -1,7 +1,7 @@
import { Hono } from "hono";
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { join } from "node:path";
import { mkdir } from "node:fs/promises"; import { mkdir } from "node:fs/promises";
import { join } from "node:path";
import { Hono } from "hono";
const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/webp"]; const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/webp"];
const MAX_SIZE = 5 * 1024 * 1024; // 5MB const MAX_SIZE = 5 * 1024 * 1024; // 5MB
@@ -10,7 +10,7 @@ const app = new Hono();
app.post("/", async (c) => { app.post("/", async (c) => {
const body = await c.req.parseBody(); const body = await c.req.parseBody();
const file = body["image"]; const file = body.image;
if (!file || typeof file === "string") { if (!file || typeof file === "string") {
return c.json({ error: "No image file provided" }, 400); return c.json({ error: "No image file provided" }, 400);
@@ -30,7 +30,8 @@ app.post("/", async (c) => {
} }
// Generate unique filename // Generate unique filename
const ext = file.type.split("/")[1] === "jpeg" ? "jpg" : file.type.split("/")[1]; const ext =
file.type.split("/")[1] === "jpeg" ? "jpg" : file.type.split("/")[1];
const filename = `${Date.now()}-${randomUUID()}.${ext}`; const filename = `${Date.now()}-${randomUUID()}.${ext}`;
// Ensure uploads directory exists // Ensure uploads directory exists

View File

@@ -1,15 +1,15 @@
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { createItemSchema, updateItemSchema } from "../../shared/schemas.ts";
import {
getAllItems,
getItemById,
createItem,
updateItem,
deleteItem,
} from "../services/item.service.ts";
import { unlink } from "node:fs/promises"; import { unlink } from "node:fs/promises";
import { join } from "node:path"; import { join } from "node:path";
import { zValidator } from "@hono/zod-validator";
import { Hono } from "hono";
import { createItemSchema, updateItemSchema } from "../../shared/schemas.ts";
import {
createItem,
deleteItem,
getAllItems,
getItemById,
updateItem,
} from "../services/item.service.ts";
type Env = { Variables: { db?: any } }; type Env = { Variables: { db?: any } };
@@ -36,14 +36,18 @@ app.post("/", zValidator("json", createItemSchema), (c) => {
return c.json(item, 201); return c.json(item, 201);
}); });
app.put("/:id", zValidator("json", updateItemSchema.omit({ id: true })), (c) => { app.put(
"/:id",
zValidator("json", updateItemSchema.omit({ id: true })),
(c) => {
const db = c.get("db"); const db = c.get("db");
const id = Number(c.req.param("id")); const id = Number(c.req.param("id"));
const data = c.req.valid("json"); const data = c.req.valid("json");
const item = updateItem(db, id, data); const item = updateItem(db, id, data);
if (!item) return c.json({ error: "Item not found" }, 404); if (!item) return c.json({ error: "Item not found" }, 404);
return c.json(item); return c.json(item);
}); },
);
app.delete("/:id", async (c) => { app.delete("/:id", async (c) => {
const db = c.get("db"); const db = c.get("db");

View File

@@ -1,5 +1,5 @@
import { Hono } from "hono";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { Hono } from "hono";
import { db as prodDb } from "../../db/index.ts"; import { db as prodDb } from "../../db/index.ts";
import { settings } from "../../db/schema.ts"; import { settings } from "../../db/schema.ts";
@@ -10,7 +10,11 @@ const app = new Hono<Env>();
app.get("/:key", (c) => { app.get("/:key", (c) => {
const database = c.get("db") ?? prodDb; const database = c.get("db") ?? prodDb;
const key = c.req.param("key"); const key = c.req.param("key");
const row = database.select().from(settings).where(eq(settings.key, key)).get(); const row = database
.select()
.from(settings)
.where(eq(settings.key, key))
.get();
if (!row) return c.json({ error: "Setting not found" }, 404); if (!row) return c.json({ error: "Setting not found" }, 404);
return c.json(row); return c.json(row);
}); });
@@ -30,7 +34,11 @@ app.put("/:key", async (c) => {
.onConflictDoUpdate({ target: settings.key, set: { value: body.value } }) .onConflictDoUpdate({ target: settings.key, set: { value: body.value } })
.run(); .run();
const row = database.select().from(settings).where(eq(settings.key, key)).get(); const row = database
.select()
.from(settings)
.where(eq(settings.key, key))
.get();
return c.json(row); return c.json(row);
}); });

View File

@@ -1,18 +1,18 @@
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator"; import { zValidator } from "@hono/zod-validator";
import { Hono } from "hono";
import { import {
createSetupSchema, createSetupSchema,
updateSetupSchema,
syncSetupItemsSchema, syncSetupItemsSchema,
updateSetupSchema,
} from "../../shared/schemas.ts"; } from "../../shared/schemas.ts";
import { import {
createSetup,
deleteSetup,
getAllSetups, getAllSetups,
getSetupWithItems, getSetupWithItems,
createSetup,
updateSetup,
deleteSetup,
syncSetupItems,
removeSetupItem, removeSetupItem,
syncSetupItems,
updateSetup,
} from "../services/setup.service.ts"; } from "../services/setup.service.ts";
type Env = { Variables: { db?: any } }; type Env = { Variables: { db?: any } };

View File

@@ -1,25 +1,25 @@
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import {
createThreadSchema,
updateThreadSchema,
createCandidateSchema,
updateCandidateSchema,
resolveThreadSchema,
} from "../../shared/schemas.ts";
import {
getAllThreads,
getThreadWithCandidates,
createThread,
updateThread,
deleteThread,
createCandidate,
updateCandidate,
deleteCandidate,
resolveThread,
} from "../services/thread.service.ts";
import { unlink } from "node:fs/promises"; import { unlink } from "node:fs/promises";
import { join } from "node:path"; import { join } from "node:path";
import { zValidator } from "@hono/zod-validator";
import { Hono } from "hono";
import {
createCandidateSchema,
createThreadSchema,
resolveThreadSchema,
updateCandidateSchema,
updateThreadSchema,
} from "../../shared/schemas.ts";
import {
createCandidate,
createThread,
deleteCandidate,
deleteThread,
getAllThreads,
getThreadWithCandidates,
resolveThread,
updateCandidate,
updateThread,
} from "../services/thread.service.ts";
type Env = { Variables: { db?: any } }; type Env = { Variables: { db?: any } };
@@ -91,14 +91,18 @@ app.post("/:id/candidates", zValidator("json", createCandidateSchema), (c) => {
return c.json(candidate, 201); return c.json(candidate, 201);
}); });
app.put("/:threadId/candidates/:candidateId", zValidator("json", updateCandidateSchema), (c) => { app.put(
"/:threadId/candidates/:candidateId",
zValidator("json", updateCandidateSchema),
(c) => {
const db = c.get("db"); const db = c.get("db");
const candidateId = Number(c.req.param("candidateId")); const candidateId = Number(c.req.param("candidateId"));
const data = c.req.valid("json"); const data = c.req.valid("json");
const candidate = updateCandidate(db, candidateId, data); const candidate = updateCandidate(db, candidateId, data);
if (!candidate) return c.json({ error: "Candidate not found" }, 404); if (!candidate) return c.json({ error: "Candidate not found" }, 404);
return c.json(candidate); return c.json(candidate);
}); },
);
app.delete("/:threadId/candidates/:candidateId", async (c) => { app.delete("/:threadId/candidates/:candidateId", async (c) => {
const db = c.get("db"); const db = c.get("db");

View File

@@ -1,6 +1,6 @@
import { eq, asc } from "drizzle-orm"; import { asc, eq } from "drizzle-orm";
import { categories, items } from "../../db/schema.ts";
import { db as prodDb } from "../../db/index.ts"; import { db as prodDb } from "../../db/index.ts";
import { categories, items } from "../../db/schema.ts";
type Db = typeof prodDb; type Db = typeof prodDb;
@@ -49,7 +49,10 @@ export function deleteCategory(
): { success: boolean; error?: string } { ): { success: boolean; error?: string } {
// Guard: cannot delete Uncategorized (id=1) // Guard: cannot delete Uncategorized (id=1)
if (id === 1) { if (id === 1) {
return { success: false, error: "Cannot delete the Uncategorized category" }; return {
success: false,
error: "Cannot delete the Uncategorized category",
};
} }
// Check if category exists // Check if category exists

View File

@@ -1,6 +1,6 @@
import { eq, sql } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { items, categories } from "../../db/schema.ts";
import { db as prodDb } from "../../db/index.ts"; import { db as prodDb } from "../../db/index.ts";
import { categories, items } from "../../db/schema.ts";
import type { CreateItem } from "../../shared/types.ts"; import type { CreateItem } from "../../shared/types.ts";
type Db = typeof prodDb; type Db = typeof prodDb;
@@ -49,7 +49,11 @@ export function getItemById(db: Db = prodDb, id: number) {
export function createItem( export function createItem(
db: Db = prodDb, db: Db = prodDb,
data: Partial<CreateItem> & { name: string; categoryId: number; imageFilename?: string }, data: Partial<CreateItem> & {
name: string;
categoryId: number;
imageFilename?: string;
},
) { ) {
return db return db
.insert(items) .insert(items)
@@ -98,11 +102,7 @@ export function updateItem(
export function deleteItem(db: Db = prodDb, id: number) { export function deleteItem(db: Db = prodDb, id: number) {
// Get item first (for image cleanup info) // Get item first (for image cleanup info)
const item = db const item = db.select().from(items).where(eq(items.id, id)).get();
.select()
.from(items)
.where(eq(items.id, id))
.get();
if (!item) return null; if (!item) return null;

View File

@@ -1,16 +1,12 @@
import { eq, sql } from "drizzle-orm"; import { eq, sql } from "drizzle-orm";
import { setups, setupItems, items, categories } from "../../db/schema.ts";
import { db as prodDb } from "../../db/index.ts"; import { db as prodDb } from "../../db/index.ts";
import { categories, items, setupItems, setups } from "../../db/schema.ts";
import type { CreateSetup, UpdateSetup } from "../../shared/types.ts"; import type { CreateSetup, UpdateSetup } from "../../shared/types.ts";
type Db = typeof prodDb; type Db = typeof prodDb;
export function createSetup(db: Db = prodDb, data: CreateSetup) { export function createSetup(db: Db = prodDb, data: CreateSetup) {
return db return db.insert(setups).values({ name: data.name }).returning().get();
.insert(setups)
.values({ name: data.name })
.returning()
.get();
} }
export function getAllSetups(db: Db = prodDb) { export function getAllSetups(db: Db = prodDb) {
@@ -40,8 +36,7 @@ export function getAllSetups(db: Db = prodDb) {
} }
export function getSetupWithItems(db: Db = prodDb, setupId: number) { export function getSetupWithItems(db: Db = prodDb, setupId: number) {
const setup = db.select().from(setups) const setup = db.select().from(setups).where(eq(setups.id, setupId)).get();
.where(eq(setups.id, setupId)).get();
if (!setup) return null; if (!setup) return null;
const itemList = db const itemList = db
@@ -68,9 +63,16 @@ export function getSetupWithItems(db: Db = prodDb, setupId: number) {
return { ...setup, items: itemList }; return { ...setup, items: itemList };
} }
export function updateSetup(db: Db = prodDb, setupId: number, data: UpdateSetup) { export function updateSetup(
const existing = db.select({ id: setups.id }).from(setups) db: Db = prodDb,
.where(eq(setups.id, setupId)).get(); setupId: number,
data: UpdateSetup,
) {
const existing = db
.select({ id: setups.id })
.from(setups)
.where(eq(setups.id, setupId))
.get();
if (!existing) return null; if (!existing) return null;
return db return db
@@ -82,15 +84,22 @@ export function updateSetup(db: Db = prodDb, setupId: number, data: UpdateSetup)
} }
export function deleteSetup(db: Db = prodDb, setupId: number) { export function deleteSetup(db: Db = prodDb, setupId: number) {
const existing = db.select({ id: setups.id }).from(setups) const existing = db
.where(eq(setups.id, setupId)).get(); .select({ id: setups.id })
.from(setups)
.where(eq(setups.id, setupId))
.get();
if (!existing) return false; if (!existing) return false;
db.delete(setups).where(eq(setups.id, setupId)).run(); db.delete(setups).where(eq(setups.id, setupId)).run();
return true; return true;
} }
export function syncSetupItems(db: Db = prodDb, setupId: number, itemIds: number[]) { export function syncSetupItems(
db: Db = prodDb,
setupId: number,
itemIds: number[],
) {
return db.transaction((tx) => { return db.transaction((tx) => {
// Delete all existing items for this setup // Delete all existing items for this setup
tx.delete(setupItems).where(eq(setupItems.setupId, setupId)).run(); tx.delete(setupItems).where(eq(setupItems.setupId, setupId)).run();
@@ -102,10 +111,14 @@ export function syncSetupItems(db: Db = prodDb, setupId: number, itemIds: number
}); });
} }
export function removeSetupItem(db: Db = prodDb, setupId: number, itemId: number) { export function removeSetupItem(
db: Db = prodDb,
setupId: number,
itemId: number,
) {
db.delete(setupItems) db.delete(setupItems)
.where( .where(
sql`${setupItems.setupId} = ${setupId} AND ${setupItems.itemId} = ${itemId}` sql`${setupItems.setupId} = ${setupId} AND ${setupItems.itemId} = ${itemId}`,
) )
.run(); .run();
} }

View File

@@ -1,7 +1,12 @@
import { eq, desc, sql } from "drizzle-orm"; import { desc, eq, sql } from "drizzle-orm";
import { threads, threadCandidates, items, categories } from "../../db/schema.ts";
import { db as prodDb } from "../../db/index.ts"; import { db as prodDb } from "../../db/index.ts";
import type { CreateThread, UpdateThread, CreateCandidate } from "../../shared/types.ts"; import {
categories,
items,
threadCandidates,
threads,
} from "../../db/schema.ts";
import type { CreateCandidate, CreateThread } from "../../shared/types.ts";
type Db = typeof prodDb; type Db = typeof prodDb;
@@ -49,8 +54,11 @@ export function getAllThreads(db: Db = prodDb, includeResolved = false) {
} }
export function getThreadWithCandidates(db: Db = prodDb, threadId: number) { export function getThreadWithCandidates(db: Db = prodDb, threadId: number) {
const thread = db.select().from(threads) const thread = db
.where(eq(threads.id, threadId)).get(); .select()
.from(threads)
.where(eq(threads.id, threadId))
.get();
if (!thread) return null; if (!thread) return null;
const candidateList = db const candidateList = db
@@ -77,9 +85,16 @@ export function getThreadWithCandidates(db: Db = prodDb, threadId: number) {
return { ...thread, candidates: candidateList }; return { ...thread, candidates: candidateList };
} }
export function updateThread(db: Db = prodDb, threadId: number, data: Partial<{ name: string; categoryId: number }>) { export function updateThread(
const existing = db.select({ id: threads.id }).from(threads) db: Db = prodDb,
.where(eq(threads.id, threadId)).get(); threadId: number,
data: Partial<{ name: string; categoryId: number }>,
) {
const existing = db
.select({ id: threads.id })
.from(threads)
.where(eq(threads.id, threadId))
.get();
if (!existing) return null; if (!existing) return null;
return db return db
@@ -91,8 +106,11 @@ export function updateThread(db: Db = prodDb, threadId: number, data: Partial<{
} }
export function deleteThread(db: Db = prodDb, threadId: number) { export function deleteThread(db: Db = prodDb, threadId: number) {
const thread = db.select().from(threads) const thread = db
.where(eq(threads.id, threadId)).get(); .select()
.from(threads)
.where(eq(threads.id, threadId))
.get();
if (!thread) return null; if (!thread) return null;
// Collect candidate image filenames for cleanup // Collect candidate image filenames for cleanup
@@ -105,13 +123,20 @@ export function deleteThread(db: Db = prodDb, threadId: number) {
db.delete(threads).where(eq(threads.id, threadId)).run(); db.delete(threads).where(eq(threads.id, threadId)).run();
return { ...thread, candidateImages: candidatesWithImages.map((c) => c.imageFilename!) }; return {
...thread,
candidateImages: candidatesWithImages.map((c) => c.imageFilename!),
};
} }
export function createCandidate( export function createCandidate(
db: Db = prodDb, db: Db = prodDb,
threadId: number, threadId: number,
data: Partial<CreateCandidate> & { name: string; categoryId: number; imageFilename?: string }, data: Partial<CreateCandidate> & {
name: string;
categoryId: number;
imageFilename?: string;
},
) { ) {
return db return db
.insert(threadCandidates) .insert(threadCandidates)
@@ -142,8 +167,11 @@ export function updateCandidate(
imageFilename: string; imageFilename: string;
}>, }>,
) { ) {
const existing = db.select({ id: threadCandidates.id }).from(threadCandidates) const existing = db
.where(eq(threadCandidates.id, candidateId)).get(); .select({ id: threadCandidates.id })
.from(threadCandidates)
.where(eq(threadCandidates.id, candidateId))
.get();
if (!existing) return null; if (!existing) return null;
return db return db
@@ -155,8 +183,11 @@ export function updateCandidate(
} }
export function deleteCandidate(db: Db = prodDb, candidateId: number) { export function deleteCandidate(db: Db = prodDb, candidateId: number) {
const candidate = db.select().from(threadCandidates) const candidate = db
.where(eq(threadCandidates.id, candidateId)).get(); .select()
.from(threadCandidates)
.where(eq(threadCandidates.id, candidateId))
.get();
if (!candidate) return null; if (!candidate) return null;
db.delete(threadCandidates).where(eq(threadCandidates.id, candidateId)).run(); db.delete(threadCandidates).where(eq(threadCandidates.id, candidateId)).run();
@@ -170,15 +201,21 @@ export function resolveThread(
): { success: boolean; item?: any; error?: string } { ): { success: boolean; item?: any; error?: string } {
return db.transaction((tx) => { return db.transaction((tx) => {
// 1. Check thread is active // 1. Check thread is active
const thread = tx.select().from(threads) const thread = tx
.where(eq(threads.id, threadId)).get(); .select()
.from(threads)
.where(eq(threads.id, threadId))
.get();
if (!thread || thread.status !== "active") { if (!thread || thread.status !== "active") {
return { success: false, error: "Thread not active" }; return { success: false, error: "Thread not active" };
} }
// 2. Get the candidate data // 2. Get the candidate data
const candidate = tx.select().from(threadCandidates) const candidate = tx
.where(eq(threadCandidates.id, candidateId)).get(); .select()
.from(threadCandidates)
.where(eq(threadCandidates.id, candidateId))
.get();
if (!candidate) { if (!candidate) {
return { success: false, error: "Candidate not found" }; return { success: false, error: "Candidate not found" };
} }
@@ -187,8 +224,11 @@ export function resolveThread(
} }
// 3. Verify categoryId still exists, fallback to Uncategorized (id=1) // 3. Verify categoryId still exists, fallback to Uncategorized (id=1)
const category = tx.select({ id: categories.id }).from(categories) const category = tx
.where(eq(categories.id, candidate.categoryId)).get(); .select({ id: categories.id })
.from(categories)
.where(eq(categories.id, candidate.categoryId))
.get();
const safeCategoryId = category ? candidate.categoryId : 1; const safeCategoryId = category ? candidate.categoryId : 1;
// 4. Create collection item from candidate data // 4. Create collection item from candidate data

View File

@@ -1,6 +1,6 @@
import { eq, sql } from "drizzle-orm"; import { eq, sql } from "drizzle-orm";
import { items, categories } from "../../db/schema.ts";
import { db as prodDb } from "../../db/index.ts"; import { db as prodDb } from "../../db/index.ts";
import { categories, items } from "../../db/schema.ts";
type Db = typeof prodDb; type Db = typeof prodDb;

View File

@@ -1,19 +1,26 @@
import type { z } from "zod"; import type { z } from "zod";
import type { import type {
createItemSchema, categories,
updateItemSchema, items,
createCategorySchema, setupItems,
updateCategorySchema, setups,
createThreadSchema, threadCandidates,
updateThreadSchema, threads,
} from "../db/schema.ts";
import type {
createCandidateSchema, createCandidateSchema,
updateCandidateSchema, createCategorySchema,
resolveThreadSchema, createItemSchema,
createSetupSchema, createSetupSchema,
updateSetupSchema, createThreadSchema,
resolveThreadSchema,
syncSetupItemsSchema, syncSetupItemsSchema,
updateCandidateSchema,
updateCategorySchema,
updateItemSchema,
updateSetupSchema,
updateThreadSchema,
} from "./schemas.ts"; } from "./schemas.ts";
import type { items, categories, threads, threadCandidates, setups, setupItems } from "../db/schema.ts";
// Types inferred from Zod schemas // Types inferred from Zod schemas
export type CreateItem = z.infer<typeof createItemSchema>; export type CreateItem = z.infer<typeof createItemSchema>;

View File

@@ -1,8 +1,8 @@
import { describe, it, expect, beforeEach } from "bun:test"; import { beforeEach, describe, expect, it } from "bun:test";
import { Hono } from "hono"; import { Hono } from "hono";
import { createTestDb } from "../helpers/db.ts";
import { categoryRoutes } from "../../src/server/routes/categories.ts"; import { categoryRoutes } from "../../src/server/routes/categories.ts";
import { itemRoutes } from "../../src/server/routes/items.ts"; import { itemRoutes } from "../../src/server/routes/items.ts";
import { createTestDb } from "../helpers/db.ts";
function createTestApp() { function createTestApp() {
const db = createTestDb(); const db = createTestDb();

View File

@@ -1,8 +1,8 @@
import { describe, it, expect, beforeEach } from "bun:test"; import { beforeEach, describe, expect, it } from "bun:test";
import { Hono } from "hono"; import { Hono } from "hono";
import { createTestDb } from "../helpers/db.ts";
import { itemRoutes } from "../../src/server/routes/items.ts";
import { categoryRoutes } from "../../src/server/routes/categories.ts"; import { categoryRoutes } from "../../src/server/routes/categories.ts";
import { itemRoutes } from "../../src/server/routes/items.ts";
import { createTestDb } from "../helpers/db.ts";
function createTestApp() { function createTestApp() {
const db = createTestDb(); const db = createTestDb();

View File

@@ -1,8 +1,8 @@
import { describe, it, expect, beforeEach } from "bun:test"; import { beforeEach, describe, expect, it } from "bun:test";
import { Hono } from "hono"; import { Hono } from "hono";
import { createTestDb } from "../helpers/db.ts";
import { setupRoutes } from "../../src/server/routes/setups.ts";
import { itemRoutes } from "../../src/server/routes/items.ts"; import { itemRoutes } from "../../src/server/routes/items.ts";
import { setupRoutes } from "../../src/server/routes/setups.ts";
import { createTestDb } from "../helpers/db.ts";
function createTestApp() { function createTestApp() {
const db = createTestDb(); const db = createTestDb();
@@ -179,8 +179,14 @@ describe("Setup Routes", () => {
describe("PUT /api/setups/:id/items", () => { describe("PUT /api/setups/:id/items", () => {
it("syncs items to setup", async () => { it("syncs items to setup", async () => {
const setup = await createSetupViaAPI(app, "Kit"); const setup = await createSetupViaAPI(app, "Kit");
const item1 = await createItemViaAPI(app, { name: "Item 1", categoryId: 1 }); const item1 = await createItemViaAPI(app, {
const item2 = await createItemViaAPI(app, { name: "Item 2", categoryId: 1 }); name: "Item 1",
categoryId: 1,
});
const item2 = await createItemViaAPI(app, {
name: "Item 2",
categoryId: 1,
});
const res = await app.request(`/api/setups/${setup.id}/items`, { const res = await app.request(`/api/setups/${setup.id}/items`, {
method: "PUT", method: "PUT",
@@ -202,8 +208,14 @@ describe("Setup Routes", () => {
describe("DELETE /api/setups/:id/items/:itemId", () => { describe("DELETE /api/setups/:id/items/:itemId", () => {
it("removes single item from setup", async () => { it("removes single item from setup", async () => {
const setup = await createSetupViaAPI(app, "Kit"); const setup = await createSetupViaAPI(app, "Kit");
const item1 = await createItemViaAPI(app, { name: "Item 1", categoryId: 1 }); const item1 = await createItemViaAPI(app, {
const item2 = await createItemViaAPI(app, { name: "Item 2", categoryId: 1 }); name: "Item 1",
categoryId: 1,
});
const item2 = await createItemViaAPI(app, {
name: "Item 2",
categoryId: 1,
});
// Sync both items // Sync both items
await app.request(`/api/setups/${setup.id}/items`, { await app.request(`/api/setups/${setup.id}/items`, {
@@ -213,9 +225,12 @@ describe("Setup Routes", () => {
}); });
// Remove one // Remove one
const res = await app.request(`/api/setups/${setup.id}/items/${item1.id}`, { const res = await app.request(
`/api/setups/${setup.id}/items/${item1.id}`,
{
method: "DELETE", method: "DELETE",
}); },
);
expect(res.status).toBe(200); expect(res.status).toBe(200);

View File

@@ -1,7 +1,7 @@
import { describe, it, expect, beforeEach } from "bun:test"; import { beforeEach, describe, expect, it } from "bun:test";
import { Hono } from "hono"; import { Hono } from "hono";
import { createTestDb } from "../helpers/db.ts";
import { threadRoutes } from "../../src/server/routes/threads.ts"; import { threadRoutes } from "../../src/server/routes/threads.ts";
import { createTestDb } from "../helpers/db.ts";
function createTestApp() { function createTestApp() {
const db = createTestDb(); const db = createTestDb();
@@ -87,7 +87,7 @@ describe("Thread Routes", () => {
}); });
it("?includeResolved=true includes archived threads", async () => { it("?includeResolved=true includes archived threads", async () => {
const t1 = await createThreadViaAPI(app, "Active"); const _t1 = await createThreadViaAPI(app, "Active");
const t2 = await createThreadViaAPI(app, "To Resolve"); const t2 = await createThreadViaAPI(app, "To Resolve");
const candidate = await createCandidateViaAPI(app, t2.id, { const candidate = await createCandidateViaAPI(app, t2.id, {
name: "Winner", name: "Winner",

View File

@@ -1,14 +1,14 @@
import { describe, it, expect, beforeEach } from "bun:test"; import { beforeEach, describe, expect, it } from "bun:test";
import { createTestDb } from "../helpers/db.ts"; import { eq } from "drizzle-orm";
import { items } from "../../src/db/schema.ts";
import { import {
getAllCategories,
createCategory, createCategory,
updateCategory,
deleteCategory, deleteCategory,
getAllCategories,
updateCategory,
} from "../../src/server/services/category.service.ts"; } from "../../src/server/services/category.service.ts";
import { createItem } from "../../src/server/services/item.service.ts"; import { createItem } from "../../src/server/services/item.service.ts";
import { items } from "../../src/db/schema.ts"; import { createTestDb } from "../helpers/db.ts";
import { eq } from "drizzle-orm";
describe("Category Service", () => { describe("Category Service", () => {
let db: ReturnType<typeof createTestDb>; let db: ReturnType<typeof createTestDb>;
@@ -22,16 +22,16 @@ describe("Category Service", () => {
const cat = createCategory(db, { name: "Shelter", icon: "tent" }); const cat = createCategory(db, { name: "Shelter", icon: "tent" });
expect(cat).toBeDefined(); expect(cat).toBeDefined();
expect(cat!.id).toBeGreaterThan(0); expect(cat?.id).toBeGreaterThan(0);
expect(cat!.name).toBe("Shelter"); expect(cat?.name).toBe("Shelter");
expect(cat!.icon).toBe("tent"); expect(cat?.icon).toBe("tent");
}); });
it("uses default icon if not provided", () => { it("uses default icon if not provided", () => {
const cat = createCategory(db, { name: "Cooking" }); const cat = createCategory(db, { name: "Cooking" });
expect(cat).toBeDefined(); expect(cat).toBeDefined();
expect(cat!.icon).toBe("package"); expect(cat?.icon).toBe("package");
}); });
}); });
@@ -49,19 +49,19 @@ describe("Category Service", () => {
describe("updateCategory", () => { describe("updateCategory", () => {
it("renames category", () => { it("renames category", () => {
const cat = createCategory(db, { name: "Shelter", icon: "tent" }); const cat = createCategory(db, { name: "Shelter", icon: "tent" });
const updated = updateCategory(db, cat!.id, { name: "Sleep System" }); const updated = updateCategory(db, cat?.id, { name: "Sleep System" });
expect(updated).toBeDefined(); expect(updated).toBeDefined();
expect(updated!.name).toBe("Sleep System"); expect(updated?.name).toBe("Sleep System");
expect(updated!.icon).toBe("tent"); expect(updated?.icon).toBe("tent");
}); });
it("changes icon", () => { it("changes icon", () => {
const cat = createCategory(db, { name: "Shelter", icon: "tent" }); const cat = createCategory(db, { name: "Shelter", icon: "tent" });
const updated = updateCategory(db, cat!.id, { icon: "home" }); const updated = updateCategory(db, cat?.id, { icon: "home" });
expect(updated).toBeDefined(); expect(updated).toBeDefined();
expect(updated!.icon).toBe("home"); expect(updated?.icon).toBe("home");
}); });
it("returns null for non-existent id", () => { it("returns null for non-existent id", () => {
@@ -73,10 +73,10 @@ describe("Category Service", () => {
describe("deleteCategory", () => { describe("deleteCategory", () => {
it("reassigns items to Uncategorized (id=1) then deletes", () => { it("reassigns items to Uncategorized (id=1) then deletes", () => {
const shelter = createCategory(db, { name: "Shelter", icon: "tent" }); const shelter = createCategory(db, { name: "Shelter", icon: "tent" });
createItem(db, { name: "Tent", categoryId: shelter!.id }); createItem(db, { name: "Tent", categoryId: shelter?.id });
createItem(db, { name: "Tarp", categoryId: shelter!.id }); createItem(db, { name: "Tarp", categoryId: shelter?.id });
const result = deleteCategory(db, shelter!.id); const result = deleteCategory(db, shelter?.id);
expect(result.success).toBe(true); expect(result.success).toBe(true);
// Items should now be in Uncategorized (id=1) // Items should now be in Uncategorized (id=1)

View File

@@ -1,12 +1,12 @@
import { describe, it, expect, beforeEach } from "bun:test"; import { beforeEach, describe, expect, it } from "bun:test";
import { createTestDb } from "../helpers/db.ts";
import { import {
createItem,
deleteItem,
getAllItems, getAllItems,
getItemById, getItemById,
createItem,
updateItem, updateItem,
deleteItem,
} from "../../src/server/services/item.service.ts"; } from "../../src/server/services/item.service.ts";
import { createTestDb } from "../helpers/db.ts";
describe("Item Service", () => { describe("Item Service", () => {
let db: ReturnType<typeof createTestDb>; let db: ReturnType<typeof createTestDb>;
@@ -17,39 +17,36 @@ describe("Item Service", () => {
describe("createItem", () => { describe("createItem", () => {
it("creates item with all fields, returns item with id and timestamps", () => { it("creates item with all fields, returns item with id and timestamps", () => {
const item = createItem( const item = createItem(db, {
db,
{
name: "Tent", name: "Tent",
weightGrams: 1200, weightGrams: 1200,
priceCents: 35000, priceCents: 35000,
categoryId: 1, categoryId: 1,
notes: "Ultralight 2-person", notes: "Ultralight 2-person",
productUrl: "https://example.com/tent", productUrl: "https://example.com/tent",
}, });
);
expect(item).toBeDefined(); expect(item).toBeDefined();
expect(item!.id).toBeGreaterThan(0); expect(item?.id).toBeGreaterThan(0);
expect(item!.name).toBe("Tent"); expect(item?.name).toBe("Tent");
expect(item!.weightGrams).toBe(1200); expect(item?.weightGrams).toBe(1200);
expect(item!.priceCents).toBe(35000); expect(item?.priceCents).toBe(35000);
expect(item!.categoryId).toBe(1); expect(item?.categoryId).toBe(1);
expect(item!.notes).toBe("Ultralight 2-person"); expect(item?.notes).toBe("Ultralight 2-person");
expect(item!.productUrl).toBe("https://example.com/tent"); expect(item?.productUrl).toBe("https://example.com/tent");
expect(item!.createdAt).toBeDefined(); expect(item?.createdAt).toBeDefined();
expect(item!.updatedAt).toBeDefined(); expect(item?.updatedAt).toBeDefined();
}); });
it("only name and categoryId are required, other fields optional", () => { it("only name and categoryId are required, other fields optional", () => {
const item = createItem(db, { name: "Spork", categoryId: 1 }); const item = createItem(db, { name: "Spork", categoryId: 1 });
expect(item).toBeDefined(); expect(item).toBeDefined();
expect(item!.name).toBe("Spork"); expect(item?.name).toBe("Spork");
expect(item!.weightGrams).toBeNull(); expect(item?.weightGrams).toBeNull();
expect(item!.priceCents).toBeNull(); expect(item?.priceCents).toBeNull();
expect(item!.notes).toBeNull(); expect(item?.notes).toBeNull();
expect(item!.productUrl).toBeNull(); expect(item?.productUrl).toBeNull();
}); });
}); });
@@ -68,9 +65,9 @@ describe("Item Service", () => {
describe("getItemById", () => { describe("getItemById", () => {
it("returns single item or null", () => { it("returns single item or null", () => {
const created = createItem(db, { name: "Tent", categoryId: 1 }); const created = createItem(db, { name: "Tent", categoryId: 1 });
const found = getItemById(db, created!.id); const found = getItemById(db, created?.id);
expect(found).toBeDefined(); expect(found).toBeDefined();
expect(found!.name).toBe("Tent"); expect(found?.name).toBe("Tent");
const notFound = getItemById(db, 9999); const notFound = getItemById(db, 9999);
expect(notFound).toBeNull(); expect(notFound).toBeNull();
@@ -85,14 +82,14 @@ describe("Item Service", () => {
categoryId: 1, categoryId: 1,
}); });
const updated = updateItem(db, created!.id, { const updated = updateItem(db, created?.id, {
name: "Big Agnes Tent", name: "Big Agnes Tent",
weightGrams: 1100, weightGrams: 1100,
}); });
expect(updated).toBeDefined(); expect(updated).toBeDefined();
expect(updated!.name).toBe("Big Agnes Tent"); expect(updated?.name).toBe("Big Agnes Tent");
expect(updated!.weightGrams).toBe(1100); expect(updated?.weightGrams).toBe(1100);
}); });
it("returns null for non-existent id", () => { it("returns null for non-existent id", () => {
@@ -109,13 +106,13 @@ describe("Item Service", () => {
imageFilename: "tent.jpg", imageFilename: "tent.jpg",
}); });
const deleted = deleteItem(db, created!.id); const deleted = deleteItem(db, created?.id);
expect(deleted).toBeDefined(); expect(deleted).toBeDefined();
expect(deleted!.name).toBe("Tent"); expect(deleted?.name).toBe("Tent");
expect(deleted!.imageFilename).toBe("tent.jpg"); expect(deleted?.imageFilename).toBe("tent.jpg");
// Verify it's gone // Verify it's gone
const found = getItemById(db, created!.id); const found = getItemById(db, created?.id);
expect(found).toBeNull(); expect(found).toBeNull();
}); });

View File

@@ -1,15 +1,15 @@
import { describe, it, expect, beforeEach } from "bun:test"; import { beforeEach, describe, expect, it } from "bun:test";
import { createTestDb } from "../helpers/db.ts"; import { createItem } from "../../src/server/services/item.service.ts";
import { import {
createSetup,
deleteSetup,
getAllSetups, getAllSetups,
getSetupWithItems, getSetupWithItems,
createSetup,
updateSetup,
deleteSetup,
syncSetupItems,
removeSetupItem, removeSetupItem,
syncSetupItems,
updateSetup,
} from "../../src/server/services/setup.service.ts"; } from "../../src/server/services/setup.service.ts";
import { createItem } from "../../src/server/services/item.service.ts"; import { createTestDb } from "../helpers/db.ts";
describe("Setup Service", () => { describe("Setup Service", () => {
let db: ReturnType<typeof createTestDb>; let db: ReturnType<typeof createTestDb>;
@@ -79,11 +79,11 @@ describe("Setup Service", () => {
const result = getSetupWithItems(db, setup.id); const result = getSetupWithItems(db, setup.id);
expect(result).toBeDefined(); expect(result).toBeDefined();
expect(result!.name).toBe("Day Hike"); expect(result?.name).toBe("Day Hike");
expect(result!.items).toHaveLength(1); expect(result?.items).toHaveLength(1);
expect(result!.items[0].name).toBe("Water Bottle"); expect(result?.items[0].name).toBe("Water Bottle");
expect(result!.items[0].categoryName).toBe("Uncategorized"); expect(result?.items[0].categoryName).toBe("Uncategorized");
expect(result!.items[0].categoryIcon).toBeDefined(); expect(result?.items[0].categoryIcon).toBeDefined();
}); });
it("returns null for non-existent setup", () => { it("returns null for non-existent setup", () => {
@@ -98,7 +98,7 @@ describe("Setup Service", () => {
const updated = updateSetup(db, setup.id, { name: "Renamed" }); const updated = updateSetup(db, setup.id, { name: "Renamed" });
expect(updated).toBeDefined(); expect(updated).toBeDefined();
expect(updated!.name).toBe("Renamed"); expect(updated?.name).toBe("Renamed");
}); });
it("returns null for non-existent setup", () => { it("returns null for non-existent setup", () => {
@@ -137,13 +137,13 @@ describe("Setup Service", () => {
// Initial sync // Initial sync
syncSetupItems(db, setup.id, [item1.id, item2.id]); syncSetupItems(db, setup.id, [item1.id, item2.id]);
let result = getSetupWithItems(db, setup.id); let result = getSetupWithItems(db, setup.id);
expect(result!.items).toHaveLength(2); expect(result?.items).toHaveLength(2);
// Re-sync with different items // Re-sync with different items
syncSetupItems(db, setup.id, [item2.id, item3.id]); syncSetupItems(db, setup.id, [item2.id, item3.id]);
result = getSetupWithItems(db, setup.id); result = getSetupWithItems(db, setup.id);
expect(result!.items).toHaveLength(2); expect(result?.items).toHaveLength(2);
const names = result!.items.map((i: any) => i.name).sort(); const names = result?.items.map((i: any) => i.name).sort();
expect(names).toEqual(["Item 2", "Item 3"]); expect(names).toEqual(["Item 2", "Item 3"]);
}); });
@@ -154,7 +154,7 @@ describe("Setup Service", () => {
syncSetupItems(db, setup.id, []); syncSetupItems(db, setup.id, []);
const result = getSetupWithItems(db, setup.id); const result = getSetupWithItems(db, setup.id);
expect(result!.items).toHaveLength(0); expect(result?.items).toHaveLength(0);
}); });
}); });
@@ -167,8 +167,8 @@ describe("Setup Service", () => {
removeSetupItem(db, setup.id, item1.id); removeSetupItem(db, setup.id, item1.id);
const result = getSetupWithItems(db, setup.id); const result = getSetupWithItems(db, setup.id);
expect(result!.items).toHaveLength(1); expect(result?.items).toHaveLength(1);
expect(result!.items[0].name).toBe("Item 2"); expect(result?.items[0].name).toBe("Item 2");
}); });
}); });
@@ -185,8 +185,8 @@ describe("Setup Service", () => {
db.delete(itemsTable).where(eq(itemsTable.id, item1.id)).run(); db.delete(itemsTable).where(eq(itemsTable.id, item1.id)).run();
const result = getSetupWithItems(db, setup.id); const result = getSetupWithItems(db, setup.id);
expect(result!.items).toHaveLength(1); expect(result?.items).toHaveLength(1);
expect(result!.items[0].name).toBe("Item 2"); expect(result?.items[0].name).toBe("Item 2");
}); });
}); });
}); });

View File

@@ -1,17 +1,16 @@
import { describe, it, expect, beforeEach } from "bun:test"; import { beforeEach, describe, expect, it } from "bun:test";
import { createTestDb } from "../helpers/db.ts";
import { import {
createCandidate,
createThread, createThread,
deleteCandidate,
deleteThread,
getAllThreads, getAllThreads,
getThreadWithCandidates, getThreadWithCandidates,
createCandidate,
updateCandidate,
deleteCandidate,
updateThread,
deleteThread,
resolveThread, resolveThread,
updateCandidate,
updateThread,
} from "../../src/server/services/thread.service.ts"; } from "../../src/server/services/thread.service.ts";
import { createItem } from "../../src/server/services/item.service.ts"; import { createTestDb } from "../helpers/db.ts";
describe("Thread Service", () => { describe("Thread Service", () => {
let db: ReturnType<typeof createTestDb>; let db: ReturnType<typeof createTestDb>;
@@ -36,7 +35,10 @@ describe("Thread Service", () => {
describe("getAllThreads", () => { describe("getAllThreads", () => {
it("returns active threads with candidateCount and price range", () => { it("returns active threads with candidateCount and price range", () => {
const thread = createThread(db, { name: "Backpack Options", categoryId: 1 }); const thread = createThread(db, {
name: "Backpack Options",
categoryId: 1,
});
createCandidate(db, thread.id, { createCandidate(db, thread.id, {
name: "Pack A", name: "Pack A",
categoryId: 1, categoryId: 1,
@@ -57,7 +59,7 @@ describe("Thread Service", () => {
}); });
it("excludes resolved threads by default", () => { it("excludes resolved threads by default", () => {
const t1 = createThread(db, { name: "Active Thread", categoryId: 1 }); const _t1 = createThread(db, { name: "Active Thread", categoryId: 1 });
const t2 = createThread(db, { name: "Resolved Thread", categoryId: 1 }); const t2 = createThread(db, { name: "Resolved Thread", categoryId: 1 });
const candidate = createCandidate(db, t2.id, { const candidate = createCandidate(db, t2.id, {
name: "Winner", name: "Winner",
@@ -71,7 +73,7 @@ describe("Thread Service", () => {
}); });
it("includes resolved threads when includeResolved=true", () => { it("includes resolved threads when includeResolved=true", () => {
const t1 = createThread(db, { name: "Active Thread", categoryId: 1 }); const _t1 = createThread(db, { name: "Active Thread", categoryId: 1 });
const t2 = createThread(db, { name: "Resolved Thread", categoryId: 1 }); const t2 = createThread(db, { name: "Resolved Thread", categoryId: 1 });
const candidate = createCandidate(db, t2.id, { const candidate = createCandidate(db, t2.id, {
name: "Winner", name: "Winner",
@@ -96,11 +98,11 @@ describe("Thread Service", () => {
const result = getThreadWithCandidates(db, thread.id); const result = getThreadWithCandidates(db, thread.id);
expect(result).toBeDefined(); expect(result).toBeDefined();
expect(result!.name).toBe("Tent Options"); expect(result?.name).toBe("Tent Options");
expect(result!.candidates).toHaveLength(1); expect(result?.candidates).toHaveLength(1);
expect(result!.candidates[0].name).toBe("Tent A"); expect(result?.candidates[0].name).toBe("Tent A");
expect(result!.candidates[0].categoryName).toBe("Uncategorized"); expect(result?.candidates[0].categoryName).toBe("Uncategorized");
expect(result!.candidates[0].categoryIcon).toBeDefined(); expect(result?.candidates[0].categoryIcon).toBeDefined();
}); });
it("returns null for non-existent thread", () => { it("returns null for non-existent thread", () => {
@@ -147,8 +149,8 @@ describe("Thread Service", () => {
}); });
expect(updated).toBeDefined(); expect(updated).toBeDefined();
expect(updated!.name).toBe("Updated Name"); expect(updated?.name).toBe("Updated Name");
expect(updated!.priceCents).toBe(15000); expect(updated?.priceCents).toBe(15000);
}); });
it("returns null for non-existent candidate", () => { it("returns null for non-existent candidate", () => {
@@ -167,11 +169,11 @@ describe("Thread Service", () => {
const deleted = deleteCandidate(db, candidate.id); const deleted = deleteCandidate(db, candidate.id);
expect(deleted).toBeDefined(); expect(deleted).toBeDefined();
expect(deleted!.name).toBe("To Delete"); expect(deleted?.name).toBe("To Delete");
// Verify it's gone // Verify it's gone
const result = getThreadWithCandidates(db, thread.id); const result = getThreadWithCandidates(db, thread.id);
expect(result!.candidates).toHaveLength(0); expect(result?.candidates).toHaveLength(0);
}); });
it("returns null for non-existent candidate", () => { it("returns null for non-existent candidate", () => {
@@ -186,7 +188,7 @@ describe("Thread Service", () => {
const updated = updateThread(db, thread.id, { name: "Renamed" }); const updated = updateThread(db, thread.id, { name: "Renamed" });
expect(updated).toBeDefined(); expect(updated).toBeDefined();
expect(updated!.name).toBe("Renamed"); expect(updated?.name).toBe("Renamed");
}); });
it("returns null for non-existent thread", () => { it("returns null for non-existent thread", () => {
@@ -202,7 +204,7 @@ describe("Thread Service", () => {
const deleted = deleteThread(db, thread.id); const deleted = deleteThread(db, thread.id);
expect(deleted).toBeDefined(); expect(deleted).toBeDefined();
expect(deleted!.name).toBe("To Delete"); expect(deleted?.name).toBe("To Delete");
// Thread and candidates gone // Thread and candidates gone
const result = getThreadWithCandidates(db, thread.id); const result = getThreadWithCandidates(db, thread.id);
@@ -230,21 +232,24 @@ describe("Thread Service", () => {
const result = resolveThread(db, thread.id, candidate.id); const result = resolveThread(db, thread.id, candidate.id);
expect(result.success).toBe(true); expect(result.success).toBe(true);
expect(result.item).toBeDefined(); expect(result.item).toBeDefined();
expect(result.item!.name).toBe("Winner Tent"); expect(result.item?.name).toBe("Winner Tent");
expect(result.item!.weightGrams).toBe(1200); expect(result.item?.weightGrams).toBe(1200);
expect(result.item!.priceCents).toBe(30000); expect(result.item?.priceCents).toBe(30000);
expect(result.item!.categoryId).toBe(1); expect(result.item?.categoryId).toBe(1);
expect(result.item!.notes).toBe("Best choice"); expect(result.item?.notes).toBe("Best choice");
expect(result.item!.productUrl).toBe("https://example.com/tent"); expect(result.item?.productUrl).toBe("https://example.com/tent");
// Thread should be resolved // Thread should be resolved
const resolved = getThreadWithCandidates(db, thread.id); const resolved = getThreadWithCandidates(db, thread.id);
expect(resolved!.status).toBe("resolved"); expect(resolved?.status).toBe("resolved");
expect(resolved!.resolvedCandidateId).toBe(candidate.id); expect(resolved?.resolvedCandidateId).toBe(candidate.id);
}); });
it("fails if thread is not active", () => { it("fails if thread is not active", () => {
const thread = createThread(db, { name: "Already Resolved", categoryId: 1 }); const thread = createThread(db, {
name: "Already Resolved",
categoryId: 1,
});
const candidate = createCandidate(db, thread.id, { const candidate = createCandidate(db, thread.id, {
name: "Winner", name: "Winner",
categoryId: 1, categoryId: 1,

View File

@@ -1,11 +1,11 @@
import { describe, it, expect, beforeEach } from "bun:test"; import { beforeEach, describe, expect, it } from "bun:test";
import { createTestDb } from "../helpers/db.ts";
import { createItem } from "../../src/server/services/item.service.ts";
import { createCategory } from "../../src/server/services/category.service.ts"; import { createCategory } from "../../src/server/services/category.service.ts";
import { createItem } from "../../src/server/services/item.service.ts";
import { import {
getCategoryTotals, getCategoryTotals,
getGlobalTotals, getGlobalTotals,
} from "../../src/server/services/totals.service.ts"; } from "../../src/server/services/totals.service.ts";
import { createTestDb } from "../helpers/db.ts";
describe("Totals Service", () => { describe("Totals Service", () => {
let db: ReturnType<typeof createTestDb>; let db: ReturnType<typeof createTestDb>;
@@ -21,13 +21,13 @@ describe("Totals Service", () => {
name: "Tent", name: "Tent",
weightGrams: 1200, weightGrams: 1200,
priceCents: 35000, priceCents: 35000,
categoryId: shelter!.id, categoryId: shelter?.id,
}); });
createItem(db, { createItem(db, {
name: "Tarp", name: "Tarp",
weightGrams: 300, weightGrams: 300,
priceCents: 8000, priceCents: 8000,
categoryId: shelter!.id, categoryId: shelter?.id,
}); });
const totals = getCategoryTotals(db); const totals = getCategoryTotals(db);
@@ -63,17 +63,17 @@ describe("Totals Service", () => {
const totals = getGlobalTotals(db); const totals = getGlobalTotals(db);
expect(totals).toBeDefined(); expect(totals).toBeDefined();
expect(totals!.totalWeight).toBe(1220); expect(totals?.totalWeight).toBe(1220);
expect(totals!.totalCost).toBe(35500); expect(totals?.totalCost).toBe(35500);
expect(totals!.itemCount).toBe(2); expect(totals?.itemCount).toBe(2);
}); });
it("returns zeros when no items exist", () => { it("returns zeros when no items exist", () => {
const totals = getGlobalTotals(db); const totals = getGlobalTotals(db);
expect(totals).toBeDefined(); expect(totals).toBeDefined();
expect(totals!.totalWeight).toBe(0); expect(totals?.totalWeight).toBe(0);
expect(totals!.totalCost).toBe(0); expect(totals?.totalCost).toBe(0);
expect(totals!.itemCount).toBe(0); expect(totals?.itemCount).toBe(0);
}); });
}); });
}); });

View File

@@ -1,7 +1,7 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite"; import tailwindcss from "@tailwindcss/vite";
import { TanStackRouterVite } from "@tanstack/router-plugin/vite"; import { TanStackRouterVite } from "@tanstack/router-plugin/vite";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
export default defineConfig({ export default defineConfig({
plugins: [ plugins: [