14 Commits

Author SHA1 Message Date
bb60168ffb Merge pull request 'feat: user menu dropdown + fix MCP tool schemas' (#10) from feature/user-menu-dropdown into Develop
All checks were successful
CI / ci (push) Successful in 25s
CI / e2e (push) Successful in 1m2s
2026-04-03 18:59:59 +00:00
68f6647f76 fix: convert MCP tool schemas from JSON Schema to Zod for SDK v1.29.0
All checks were successful
CI / ci (push) Successful in 28s
CI / ci (pull_request) Successful in 25s
CI / e2e (push) Successful in 1m2s
CI / e2e (pull_request) Successful in 1m3s
The MCP SDK v1.29.0 changed server.tool() to require Zod schemas
(raw shapes) instead of plain JSON Schema objects. The old format
triggered "expected a Zod schema or ToolAnnotations" errors.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 20:54:20 +02:00
0a40d7627f feat: add user menu dropdown with settings link and sign out
Replace the plain "Sign out" button in the header with a user icon
that opens a dropdown menu containing Settings and Sign out options.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 20:19:53 +02:00
3eccbb12fd Merge pull request 'v1.4 Collection Tools' (#9) from feature/v1.4-collection-tools into Develop
All checks were successful
CI / ci (push) Successful in 24s
CI / e2e (push) Successful in 1m0s
2026-04-03 18:05:23 +00:00
fb925a9dce fix: include quantity in getAllItems select, createItem values, and updateItem type
All checks were successful
CI / ci (push) Successful in 24s
CI / ci (pull_request) Successful in 25s
CI / e2e (push) Successful in 1m3s
CI / e2e (pull_request) Successful in 1m1s
Quantity was missing from three places in item.service.ts:
- getAllItems didn't select it (API returned undefined)
- createItem didn't pass it to insert (always used DB default of 1)
- updateItem type didn't include it (silently stripped from updates)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 19:57:25 +02:00
70e7cd2f0f fix: show Add Candidate button in comparison view
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 18:51:49 +02:00
33f735af67 fix: remove scale/shadow whileDrag effect that stuck after release
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 18:50:49 +02:00
f8a1a00e0a fix: prevent snap-back after drag and click-opens-edit during drag
Two fixes:
- Remove onSettled clearing tempItems before refetch completes,
  let useEffect clear it when fresh server data arrives
- Track isDragging ref to suppress edit panel click after drag
- Remove layout="position" which interfered with reorder detection

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 18:49:39 +02:00
27c36b6b9a fix: make entire candidate row draggable instead of handle-only
Remove dragControls/dragListener pattern which prevented onReorder
from firing. The whole row is now the drag target with visual feedback
(scale + shadow). Grip icon remains as a visual indicator.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 18:46:52 +02:00
684cfd3789 fix: stabilize drag-to-reorder with layout animation fixes
- Remove transition-all from list items (fights framer-motion layout)
- Add layout="position" to Reorder.Item for proper sibling tracking
- Replace CSS gap with marginBottom (gap confuses layout engine)
- Add explicit short transition duration for snappy reorder

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 18:44:39 +02:00
52751ae9d4 fix: use onDragEnd on Reorder.Item instead of onPointerUp on Group
The previous approach used onPointerUp on the Reorder.Group which
fired unreliably — triggering on non-drag clicks and sometimes not
at all after a drag. Moving to onDragEnd on each Reorder.Item gives
clean, predictable drag-to-reorder behavior.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 18:42:16 +02:00
3fc737c872 fix: add tab navigation to collection page and skip 404 retries
Adds Gear/Planning/Setups pill tabs to the collection page so users
can switch tabs without going back to the dashboard. Also skips
React Query retries on 404 responses for immediate error display.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 18:31:57 +02:00
b993a0a831 fix: skip retries on 404 for single-resource queries
Prevents 10-second loading skeleton when navigating to non-existent
threads, setups, or items. Shows error/not-found state immediately.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 18:28:04 +02:00
a8696c2a85 fix: commit missing migration metadata and run CI on all branches
All checks were successful
CI / ci (push) Successful in 25s
CI / ci (pull_request) Successful in 26s
CI / e2e (push) Successful in 1m24s
CI / e2e (pull_request) Successful in 1m23s
The Drizzle migration journal and snapshot for 0008 (quantity column)
were not committed, causing test failures in CI. Also updates CI to
trigger on all branches, not just Develop.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 18:18:12 +02:00
19 changed files with 950 additions and 222 deletions

View File

@@ -2,9 +2,7 @@ name: CI
on:
push:
branches: [Develop]
pull_request:
branches: [Develop]
jobs:
ci:

View File

@@ -0,0 +1,35 @@
# User Menu Design
## Summary
Replace the plain "Sign out" button in the header with a user icon that opens a dropdown menu containing Settings and Sign out options. This provides a way to navigate to the Settings page from the header.
## Components
### UserMenu (`src/client/components/UserMenu.tsx`)
New component rendered by `TotalsBar` when authenticated.
**Trigger:** Circular `CircleUser` icon button (Lucide). Styled consistently with surrounding header elements.
**Dropdown:** Absolutely-positioned popover anchored to the right edge, appearing below the icon:
1. **Settings**`Settings` (gear) icon + "Settings" label, `<Link to="/settings">`
2. **Divider** — thin horizontal line
3. **Sign out**`LogOut` icon + "Sign out" label, calls `logout.mutate()` from `useLogout()`
**Behavior:**
- Click icon toggles open/close
- Click outside closes (via `useEffect` with document click listener)
- Clicking a menu item closes the dropdown
- Dropdown anchored right so it doesn't overflow viewport
### TotalsBar Changes (`src/client/components/TotalsBar.tsx`)
- When `isAuthenticated`: render `<UserMenu />` in place of the current "Sign out" button
- When not authenticated: keep the existing "Sign in" link unchanged
- Remove the `useLogout` hook usage from TotalsBar (moved into UserMenu)
## No Backend Changes
The existing `/api/auth/me` endpoint and `useAuth` hook are sufficient. No username display needed — using a generic user icon.

View File

@@ -0,0 +1,663 @@
{
"version": "6",
"dialect": "sqlite",
"id": "ede9f482-7af0-42bc-9672-43f5fba289d0",
"prevId": "738e67c5-ebad-46c1-9261-6ab60ec4bdb1",
"tables": {
"api_keys": {
"name": "api_keys",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"key_hash": {
"name": "key_hash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"key_prefix": {
"name": "key_prefix",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"categories": {
"name": "categories",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"icon": {
"name": "icon",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'package'"
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"categories_name_unique": {
"name": "categories_name_unique",
"columns": [
"name"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"items": {
"name": "items",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"weight_grams": {
"name": "weight_grams",
"type": "real",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"price_cents": {
"name": "price_cents",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"category_id": {
"name": "category_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"notes": {
"name": "notes",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"product_url": {
"name": "product_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"image_filename": {
"name": "image_filename",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"image_source_url": {
"name": "image_source_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"quantity": {
"name": "quantity",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 1
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"items_category_id_categories_id_fk": {
"name": "items_category_id_categories_id_fk",
"tableFrom": "items",
"tableTo": "categories",
"columnsFrom": [
"category_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"sessions": {
"name": "sessions",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"sessions_user_id_users_id_fk": {
"name": "sessions_user_id_users_id_fk",
"tableFrom": "sessions",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"settings": {
"name": "settings",
"columns": {
"key": {
"name": "key",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"setup_items": {
"name": "setup_items",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"setup_id": {
"name": "setup_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"item_id": {
"name": "item_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"classification": {
"name": "classification",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'base'"
}
},
"indexes": {},
"foreignKeys": {
"setup_items_setup_id_setups_id_fk": {
"name": "setup_items_setup_id_setups_id_fk",
"tableFrom": "setup_items",
"tableTo": "setups",
"columnsFrom": [
"setup_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"setup_items_item_id_items_id_fk": {
"name": "setup_items_item_id_items_id_fk",
"tableFrom": "setup_items",
"tableTo": "items",
"columnsFrom": [
"item_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"setups": {
"name": "setups",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"thread_candidates": {
"name": "thread_candidates",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"thread_id": {
"name": "thread_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"weight_grams": {
"name": "weight_grams",
"type": "real",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"price_cents": {
"name": "price_cents",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"category_id": {
"name": "category_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"notes": {
"name": "notes",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"product_url": {
"name": "product_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"image_filename": {
"name": "image_filename",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"image_source_url": {
"name": "image_source_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'researching'"
},
"pros": {
"name": "pros",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"cons": {
"name": "cons",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"sort_order": {
"name": "sort_order",
"type": "real",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"thread_candidates_thread_id_threads_id_fk": {
"name": "thread_candidates_thread_id_threads_id_fk",
"tableFrom": "thread_candidates",
"tableTo": "threads",
"columnsFrom": [
"thread_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"thread_candidates_category_id_categories_id_fk": {
"name": "thread_candidates_category_id_categories_id_fk",
"tableFrom": "thread_candidates",
"tableTo": "categories",
"columnsFrom": [
"category_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"threads": {
"name": "threads",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'active'"
},
"resolved_candidate_id": {
"name": "resolved_candidate_id",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"category_id": {
"name": "category_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"threads_category_id_categories_id_fk": {
"name": "threads_category_id_categories_id_fk",
"tableFrom": "threads",
"tableTo": "categories",
"columnsFrom": [
"category_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"password_hash": {
"name": "password_hash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"users_username_unique": {
"name": "users_username_unique",
"columns": [
"username"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -57,6 +57,13 @@
"when": 1775215076284,
"tag": "0007_icy_prodigy",
"breakpoints": true
},
{
"idx": 8,
"version": "6",
"when": 1775232090363,
"tag": "0008_loving_colossus",
"breakpoints": true
}
]
}

View File

@@ -1,4 +1,5 @@
import { Reorder, useDragControls } from "framer-motion";
import { Reorder } from "framer-motion";
import { useRef } from "react";
import { useFormatters } from "../hooks/useFormatters";
import type { CandidateDelta } from "../hooks/useImpactDeltas";
import { LucideIcon } from "../lib/iconData";
@@ -31,6 +32,7 @@ interface CandidateListItemProps {
isActive: boolean;
onStatusChange: (status: "researching" | "ordered" | "arrived") => void;
delta?: CandidateDelta;
onDragEnd?: () => void;
}
const RANK_COLORS = ["#D4AF37", "#C0C0C0", "#CD7F32"]; // gold, silver, bronze
@@ -53,8 +55,9 @@ export function CandidateListItem({
isActive,
onStatusChange,
delta,
onDragEnd,
}: CandidateListItemProps) {
const controls = useDragControls();
const isDragging = useRef(false);
const { weight, price } = useFormatters();
const openCandidateEditPanel = useUIStore((s) => s.openCandidateEditPanel);
const openConfirmDeleteCandidate = useUIStore(
@@ -64,20 +67,15 @@ export function CandidateListItem({
const openExternalLink = useUIStore((s) => s.openExternalLink);
const sharedClassName =
"flex items-center gap-3 bg-white rounded-xl border border-gray-100 p-3 hover:border-gray-200 hover:shadow-sm transition-all group cursor-default";
"flex items-center gap-3 bg-white rounded-xl border border-gray-100 p-3 hover:border-gray-200 hover:shadow-sm group cursor-default";
const innerContent = (
<>
{/* Drag handle */}
{/* Drag handle indicator */}
{isActive && (
<button
type="button"
onPointerDown={(e) => controls.start(e)}
className="cursor-grab active:cursor-grabbing text-gray-300 hover:text-gray-500 touch-none shrink-0"
title="Drag to reorder"
>
<span className="text-gray-300 shrink-0">
<LucideIcon name="grip-vertical" size={16} />
</button>
</span>
)}
{/* Rank badge */}
@@ -103,7 +101,10 @@ export function CandidateListItem({
{/* Name + badges */}
<button
type="button"
onClick={() => openCandidateEditPanel(candidate.id)}
onClick={() => {
if (isDragging.current) return;
openCandidateEditPanel(candidate.id);
}}
className="flex-1 min-w-0 text-left"
>
<p className="text-sm font-semibold text-gray-900 truncate">
@@ -215,8 +216,17 @@ export function CandidateListItem({
return (
<Reorder.Item
value={candidate}
dragControls={controls}
dragListener={false}
onDragStart={() => {
isDragging.current = true;
}}
onDragEnd={() => {
setTimeout(() => {
isDragging.current = false;
}, 0);
onDragEnd?.();
}}
whileDrag={{ cursor: "grabbing" }}
style={{ marginBottom: 8, cursor: "grab" }}
className={sharedClassName}
>
{innerContent}

View File

@@ -1,10 +1,11 @@
import { Link } from "@tanstack/react-router";
import { useAuth, useLogout } from "../hooks/useAuth";
import { useAuth } from "../hooks/useAuth";
import { useFormatters } from "../hooks/useFormatters";
import { useUpdateSetting } from "../hooks/useSettings";
import { useTotals } from "../hooks/useTotals";
import type { WeightUnit } from "../lib/formatters";
import { LucideIcon } from "../lib/iconData";
import { UserMenu } from "./UserMenu";
const UNITS: WeightUnit[] = ["g", "oz", "lb", "kg"];
@@ -21,7 +22,6 @@ export function TotalsBar({
}: TotalsBarProps) {
const { data } = useTotals();
const { data: auth } = useAuth();
const logout = useLogout();
const isAuthenticated = !!auth?.user;
const { weight, price, unit } = useFormatters();
const updateSetting = useUpdateSetting();
@@ -104,24 +104,16 @@ export function TotalsBar({
))}
</div>
)}
<div className="flex items-center gap-2 ml-auto">
{isAuthenticated ? (
<button
type="button"
onClick={() => logout.mutate()}
className="text-xs text-gray-500 hover:text-gray-700 transition-colors"
>
Sign out
</button>
) : (
<Link
to="/login"
className="text-xs text-gray-500 hover:text-gray-700 transition-colors"
>
Sign in
</Link>
)}
</div>
{isAuthenticated ? (
<UserMenu />
) : (
<Link
to="/login"
className="text-xs text-gray-500 hover:text-gray-700 transition-colors"
>
Sign in
</Link>
)}
</div>
</div>
</div>

View File

@@ -0,0 +1,57 @@
import { Link } from "@tanstack/react-router";
import { useEffect, useRef, useState } from "react";
import { useLogout } from "../hooks/useAuth";
import { LucideIcon } from "../lib/iconData";
export function UserMenu() {
const [open, setOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
const logout = useLogout();
useEffect(() => {
if (!open) return;
function handleClick(e: MouseEvent) {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setOpen(false);
}
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, [open]);
return (
<div ref={menuRef} className="relative">
<button
type="button"
onClick={() => setOpen((prev) => !prev)}
className="flex items-center justify-center w-8 h-8 rounded-full text-gray-500 hover:text-gray-700 hover:bg-gray-100 transition-colors"
>
<LucideIcon name="circle-user" size={22} />
</button>
{open && (
<div className="absolute right-0 mt-1 w-40 bg-white rounded-lg shadow-lg border border-gray-200 py-1 z-50">
<Link
to="/settings"
onClick={() => setOpen(false)}
className="flex items-center gap-2 px-3 py-2 text-sm text-gray-700 hover:bg-gray-50 transition-colors"
>
<LucideIcon name="settings" size={16} className="text-gray-400" />
Settings
</Link>
<div className="border-t border-gray-100 my-1" />
<button
type="button"
onClick={() => {
setOpen(false);
logout.mutate();
}}
className="flex items-center gap-2 w-full px-3 py-2 text-sm text-gray-700 hover:bg-gray-50 transition-colors"
>
<LucideIcon name="log-out" size={16} className="text-gray-400" />
Sign out
</button>
</div>
)}
</div>
);
}

View File

@@ -1,6 +1,13 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { CreateItem } from "../../shared/types";
import { apiDelete, apiGet, apiPost, apiPut, apiUpload } from "../lib/api";
import {
ApiError,
apiDelete,
apiGet,
apiPost,
apiPut,
apiUpload,
} from "../lib/api";
interface Item {
id: number;
@@ -45,6 +52,8 @@ export function useItem(id: number | null) {
queryKey: ["items", id],
queryFn: () => apiGet<ItemWithCategory>(`/api/items/${id}`),
enabled: id != null,
retry: (count, error) =>
error instanceof ApiError && error.status === 404 ? false : count < 3,
});
}

View File

@@ -1,5 +1,12 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiDelete, apiGet, apiPatch, apiPost, apiPut } from "../lib/api";
import {
ApiError,
apiDelete,
apiGet,
apiPatch,
apiPost,
apiPut,
} from "../lib/api";
interface SetupListItem {
id: number;
@@ -50,6 +57,8 @@ export function useSetup(setupId: number | null) {
queryKey: ["setups", setupId],
queryFn: () => apiGet<SetupWithItems>(`/api/setups/${setupId}`),
enabled: setupId != null,
retry: (count, error) =>
error instanceof ApiError && error.status === 404 ? false : count < 3,
});
}

View File

@@ -1,5 +1,5 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiDelete, apiGet, apiPost, apiPut } from "../lib/api";
import { ApiError, apiDelete, apiGet, apiPost, apiPut } from "../lib/api";
interface ThreadListItem {
id: number;
@@ -61,6 +61,8 @@ export function useThread(threadId: number | null) {
queryKey: ["threads", threadId],
queryFn: () => apiGet<ThreadWithCandidates>(`/api/threads/${threadId}`),
enabled: threadId != null,
retry: (count, error) =>
error instanceof ApiError && error.status === 404 ? false : count < 3,
});
}

View File

@@ -1,4 +1,4 @@
class ApiError extends Error {
export class ApiError extends Error {
constructor(
message: string,
public status: number,

View File

@@ -1,4 +1,4 @@
import { createFileRoute } from "@tanstack/react-router";
import { createFileRoute, Link } from "@tanstack/react-router";
import { AnimatePresence, motion } from "framer-motion";
import { useRef } from "react";
import { z } from "zod";
@@ -16,6 +16,11 @@ export const Route = createFileRoute("/collection/")({
});
const TAB_ORDER = ["gear", "planning", "setups"] as const;
const TAB_LABELS: Record<(typeof TAB_ORDER)[number], string> = {
gear: "Gear",
planning: "Planning",
setups: "Setups",
};
const slideVariants = {
enter: (dir: number) => ({ x: `${dir * 15}%`, opacity: 0 }),
@@ -33,6 +38,26 @@ function CollectionPage() {
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 overflow-x-hidden">
{/* Tab navigation */}
<div className="flex justify-center mb-6">
<div className="flex bg-gray-100 rounded-full p-0.5 gap-0.5">
{TAB_ORDER.map((t) => (
<Link
key={t}
to="/collection"
search={{ tab: t }}
className={`px-4 py-1.5 text-sm font-medium rounded-full transition-colors ${
tab === t
? "bg-gray-700 text-white"
: "text-gray-600 hover:bg-gray-200"
}`}
>
{TAB_LABELS[t]}
</Link>
))}
</div>
</div>
<AnimatePresence mode="wait" initial={false} custom={direction}>
<motion.div
key={tab}

View File

@@ -36,12 +36,11 @@ function ThreadDetailPage() {
thread?.categoryId ?? 0,
);
const [tempItems, setTempItems] =
useState<typeof thread extends { candidates: infer C } ? C : never | null>(
null,
);
const [tempItems, setTempItems] = useState<
NonNullable<typeof thread>["candidates"] | null
>(null);
// Clear tempItems when server data changes (biome-ignore: thread?.candidates is intentional dep)
// Clear tempItems when server data changes
// biome-ignore lint/correctness/useExhaustiveDependencies: thread?.candidates is the intended trigger
useEffect(() => {
setTempItems(null);
@@ -88,10 +87,9 @@ function ThreadDetailPage() {
function handleDragEnd() {
if (!tempItems) return;
reorderMutation.mutate(
{ orderedIds: tempItems.map((c) => c.id) },
{ onSettled: () => setTempItems(null) },
);
reorderMutation.mutate({
orderedIds: tempItems.map((c) => c.id),
});
}
return (
@@ -131,7 +129,7 @@ function ThreadDetailPage() {
{/* Toolbar: Add candidate + view toggle */}
<div className="mb-6 flex items-center gap-3 flex-wrap">
{isActive && candidateViewMode !== "compare" && (
{isActive && (
<button
type="button"
onClick={openCandidateAddPanel}
@@ -227,8 +225,7 @@ function ThreadDetailPage() {
axis="y"
values={displayItems}
onReorder={setTempItems}
onPointerUp={handleDragEnd}
className="flex flex-col gap-2"
className="flex flex-col"
>
{displayItems.map((candidate, index) => (
<CandidateListItem
@@ -243,6 +240,7 @@ function ThreadDetailPage() {
})
}
delta={deltas[candidate.id]}
onDragEnd={handleDragEnd}
/>
))}
</Reorder.Group>

View File

@@ -1,3 +1,4 @@
import { z } from "zod";
import type { db as prodDb } from "../../../db/index.ts";
import {
createCategory,
@@ -24,24 +25,14 @@ export const categoryToolDefinitions = [
{
name: "list_categories",
description: "List all gear categories.",
inputSchema: {
type: "object" as const,
properties: {},
},
inputSchema: {},
},
{
name: "create_category",
description: "Create a new gear category.",
inputSchema: {
type: "object" as const,
properties: {
name: { type: "string", description: "Category name" },
icon: {
type: "string",
description: "Icon name (defaults to 'package')",
},
},
required: ["name"],
name: z.string().describe("Category name"),
icon: z.string().optional().describe("Icon name (defaults to 'package')"),
},
},
];

View File

@@ -1,3 +1,4 @@
import { z } from "zod";
import { fetchImageFromUrl } from "../../services/image.service.ts";
interface ToolResult {
@@ -20,14 +21,9 @@ export const imageToolDefinitions = [
description:
"Fetch an image from a URL and save it locally. Returns the filename to use with create_item or add_candidate.",
inputSchema: {
type: "object" as const,
properties: {
url: {
type: "string",
description: "URL of the image to fetch (jpeg, png, or webp)",
},
},
required: ["url"],
url: z
.string()
.describe("URL of the image to fetch (jpeg, png, or webp)"),
},
},
];

View File

@@ -1,3 +1,4 @@
import { z } from "zod";
import type { db as prodDb } from "../../../db/index.ts";
import {
createItem,
@@ -29,24 +30,14 @@ export const itemToolDefinitions = [
description:
"List all items in the gear collection, optionally filtered by category.",
inputSchema: {
type: "object" as const,
properties: {
categoryId: {
type: "number",
description: "Filter items by category ID",
},
},
categoryId: z.number().optional().describe("Filter items by category ID"),
},
},
{
name: "get_item",
description: "Get a single item by its ID, including all details.",
inputSchema: {
type: "object" as const,
properties: {
id: { type: "number", description: "The item ID" },
},
required: ["id"],
id: z.number().describe("The item ID"),
},
},
{
@@ -54,60 +45,48 @@ export const itemToolDefinitions = [
description:
"Add a new item to the gear collection. Use this for items you've already decided on. For items you're still researching, use create_thread instead.",
inputSchema: {
type: "object" as const,
properties: {
name: { type: "string", description: "Item name" },
categoryId: { type: "number", description: "Category ID" },
weightGrams: { type: "number", description: "Weight in grams" },
priceCents: { type: "number", description: "Price in cents" },
notes: { type: "string", description: "Notes about the item" },
productUrl: { type: "string", description: "URL to the product page" },
imageFilename: {
type: "string",
description: "Filename of an uploaded image",
},
imageSourceUrl: {
type: "string",
description: "Original URL the image was fetched from",
},
},
required: ["name", "categoryId"],
name: z.string().describe("Item name"),
categoryId: z.number().describe("Category ID"),
weightGrams: z.number().optional().describe("Weight in grams"),
priceCents: z.number().optional().describe("Price in cents"),
notes: z.string().optional().describe("Notes about the item"),
productUrl: z.string().optional().describe("URL to the product page"),
imageFilename: z
.string()
.optional()
.describe("Filename of an uploaded image"),
imageSourceUrl: z
.string()
.optional()
.describe("Original URL the image was fetched from"),
},
},
{
name: "update_item",
description: "Update an existing item's fields.",
inputSchema: {
type: "object" as const,
properties: {
id: { type: "number", description: "The item ID to update" },
name: { type: "string", description: "Item name" },
categoryId: { type: "number", description: "Category ID" },
weightGrams: { type: "number", description: "Weight in grams" },
priceCents: { type: "number", description: "Price in cents" },
notes: { type: "string", description: "Notes about the item" },
productUrl: { type: "string", description: "URL to the product page" },
imageFilename: {
type: "string",
description: "Filename of an uploaded image",
},
imageSourceUrl: {
type: "string",
description: "Original URL the image was fetched from",
},
},
required: ["id"],
id: z.number().describe("The item ID to update"),
name: z.string().optional().describe("Item name"),
categoryId: z.number().optional().describe("Category ID"),
weightGrams: z.number().optional().describe("Weight in grams"),
priceCents: z.number().optional().describe("Price in cents"),
notes: z.string().optional().describe("Notes about the item"),
productUrl: z.string().optional().describe("URL to the product page"),
imageFilename: z
.string()
.optional()
.describe("Filename of an uploaded image"),
imageSourceUrl: z
.string()
.optional()
.describe("Original URL the image was fetched from"),
},
},
{
name: "delete_item",
description: "Delete an item from the gear collection by ID.",
inputSchema: {
type: "object" as const,
properties: {
id: { type: "number", description: "The item ID to delete" },
},
required: ["id"],
id: z.number().describe("The item ID to delete"),
},
},
];

View File

@@ -1,3 +1,4 @@
import { z } from "zod";
import type { db as prodDb } from "../../../db/index.ts";
import {
createSetup,
@@ -28,31 +29,20 @@ export const setupToolDefinitions = [
name: "list_setups",
description:
"List all gear setups with item counts and weight/cost totals.",
inputSchema: {
type: "object" as const,
properties: {},
},
inputSchema: {},
},
{
name: "get_setup",
description: "Get a setup with all its items and details.",
inputSchema: {
type: "object" as const,
properties: {
id: { type: "number", description: "Setup ID" },
},
required: ["id"],
id: z.number().describe("Setup ID"),
},
},
{
name: "create_setup",
description: "Create a new gear setup (e.g. 'Bikepacking weekend').",
inputSchema: {
type: "object" as const,
properties: {
name: { type: "string", description: "Setup name" },
},
required: ["name"],
name: z.string().describe("Setup name"),
},
},
{
@@ -60,17 +50,12 @@ export const setupToolDefinitions = [
description:
"Update a setup's name and/or replace its item list. Pass itemIds to set exactly which items belong to this setup.",
inputSchema: {
type: "object" as const,
properties: {
id: { type: "number", description: "Setup ID" },
name: { type: "string", description: "New setup name" },
itemIds: {
type: "array",
items: { type: "number" },
description: "Array of item IDs to include in the setup",
},
},
required: ["id"],
id: z.number().describe("Setup ID"),
name: z.string().optional().describe("New setup name"),
itemIds: z
.array(z.number())
.optional()
.describe("Array of item IDs to include in the setup"),
},
},
];

View File

@@ -1,3 +1,4 @@
import { z } from "zod";
import type { db as prodDb } from "../../../db/index.ts";
import {
createCandidate,
@@ -31,14 +32,12 @@ export const threadToolDefinitions = [
description:
"List research threads. Threads are the recommended way to evaluate gear purchases — each thread tracks multiple candidates for a single gear slot, making it easy to compare options before committing.",
inputSchema: {
type: "object" as const,
properties: {
includeResolved: {
type: "boolean",
description:
"Include resolved threads (default: false, only active threads)",
},
},
includeResolved: z
.boolean()
.optional()
.describe(
"Include resolved threads (default: false, only active threads)",
),
},
},
{
@@ -46,11 +45,7 @@ export const threadToolDefinitions = [
description:
"Get a thread with all its candidates for detailed comparison.",
inputSchema: {
type: "object" as const,
properties: {
id: { type: "number", description: "Thread ID" },
},
required: ["id"],
id: z.number().describe("Thread ID"),
},
},
{
@@ -58,15 +53,8 @@ export const threadToolDefinitions = [
description:
"Start a new research thread for a gear slot. This is the preferred workflow: create a thread, add candidates with pros/cons/prices, compare them, then resolve the thread to add the winner to your collection.",
inputSchema: {
type: "object" as const,
properties: {
name: {
type: "string",
description: "Thread name (e.g. 'Handlebar bag')",
},
categoryId: { type: "number", description: "Category ID" },
},
required: ["name", "categoryId"],
name: z.string().describe("Thread name (e.g. 'Handlebar bag')"),
categoryId: z.number().describe("Category ID"),
},
},
{
@@ -74,35 +62,24 @@ export const threadToolDefinitions = [
description:
"Resolve a research thread by picking the winning candidate. The winner is automatically added to the gear collection as a new item, and the thread is marked as resolved.",
inputSchema: {
type: "object" as const,
properties: {
threadId: { type: "number", description: "Thread ID" },
candidateId: {
type: "number",
description: "ID of the winning candidate",
},
},
required: ["threadId", "candidateId"],
threadId: z.number().describe("Thread ID"),
candidateId: z.number().describe("ID of the winning candidate"),
},
},
{
name: "add_candidate",
description: "Add a candidate option to a research thread for comparison.",
inputSchema: {
type: "object" as const,
properties: {
threadId: { type: "number", description: "Thread ID" },
name: { type: "string", description: "Candidate name" },
categoryId: { type: "number", description: "Category ID" },
weightGrams: { type: "number", description: "Weight in grams" },
priceCents: { type: "number", description: "Price in cents" },
notes: { type: "string", description: "Notes" },
productUrl: { type: "string", description: "Product URL" },
imageFilename: { type: "string", description: "Image filename" },
pros: { type: "string", description: "Pros of this candidate" },
cons: { type: "string", description: "Cons of this candidate" },
},
required: ["threadId", "name", "categoryId"],
threadId: z.number().describe("Thread ID"),
name: z.string().describe("Candidate name"),
categoryId: z.number().describe("Category ID"),
weightGrams: z.number().optional().describe("Weight in grams"),
priceCents: z.number().optional().describe("Price in cents"),
notes: z.string().optional().describe("Notes"),
productUrl: z.string().optional().describe("Product URL"),
imageFilename: z.string().optional().describe("Image filename"),
pros: z.string().optional().describe("Pros of this candidate"),
cons: z.string().optional().describe("Cons of this candidate"),
},
},
{
@@ -110,36 +87,28 @@ export const threadToolDefinitions = [
description:
"Update a candidate's details (name, price, pros, cons, etc.).",
inputSchema: {
type: "object" as const,
properties: {
id: { type: "number", description: "Candidate ID" },
name: { type: "string", description: "Candidate name" },
weightGrams: { type: "number", description: "Weight in grams" },
priceCents: { type: "number", description: "Price in cents" },
categoryId: { type: "number", description: "Category ID" },
notes: { type: "string", description: "Notes" },
productUrl: { type: "string", description: "Product URL" },
imageFilename: { type: "string", description: "Image filename" },
imageSourceUrl: { type: "string", description: "Image source URL" },
status: {
type: "string",
description: "Status: researching, ordered, or arrived",
},
pros: { type: "string", description: "Pros" },
cons: { type: "string", description: "Cons" },
},
required: ["id"],
id: z.number().describe("Candidate ID"),
name: z.string().optional().describe("Candidate name"),
weightGrams: z.number().optional().describe("Weight in grams"),
priceCents: z.number().optional().describe("Price in cents"),
categoryId: z.number().optional().describe("Category ID"),
notes: z.string().optional().describe("Notes"),
productUrl: z.string().optional().describe("Product URL"),
imageFilename: z.string().optional().describe("Image filename"),
imageSourceUrl: z.string().optional().describe("Image source URL"),
status: z
.string()
.optional()
.describe("Status: researching, ordered, or arrived"),
pros: z.string().optional().describe("Pros"),
cons: z.string().optional().describe("Cons"),
},
},
{
name: "remove_candidate",
description: "Remove a candidate from a research thread.",
inputSchema: {
type: "object" as const,
properties: {
id: { type: "number", description: "Candidate ID to remove" },
},
required: ["id"],
id: z.number().describe("Candidate ID to remove"),
},
},
];

View File

@@ -12,6 +12,7 @@ export function getAllItems(db: Db = prodDb) {
name: items.name,
weightGrams: items.weightGrams,
priceCents: items.priceCents,
quantity: items.quantity,
categoryId: items.categoryId,
notes: items.notes,
productUrl: items.productUrl,
@@ -63,6 +64,7 @@ export function createItem(
name: data.name,
weightGrams: data.weightGrams ?? null,
priceCents: data.priceCents ?? null,
quantity: data.quantity ?? 1,
categoryId: data.categoryId,
notes: data.notes ?? null,
productUrl: data.productUrl ?? null,
@@ -80,6 +82,7 @@ export function updateItem(
name: string;
weightGrams: number;
priceCents: number;
quantity: number;
categoryId: number;
notes: string;
productUrl: string;