Compare commits
39 Commits
50672cb662
...
v1.3.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 6ae41c4ffa | |||
| be168c8329 | |||
| 9191f0fe24 | |||
| e34a2cad11 | |||
| 790fc07f5a | |||
| 4148833644 | |||
| 17d76761bb | |||
| ba9662aeaf | |||
| 6f51432d42 | |||
| 8919829167 | |||
| a10156142f | |||
| 5bb728e545 | |||
| 511fece4c7 | |||
| 87a367d41b | |||
| 66dc8ec8ee | |||
| e0e7bfce3e | |||
| 8138458d8d | |||
| 7c4fa9d9d2 | |||
| 32c7b41ce5 | |||
| b3a13fa974 | |||
| 0004329895 | |||
| d104e9788f | |||
| 1eb4a786ce | |||
| 0998f65c6f | |||
| a6a4ffda2e | |||
| dde2fc241d | |||
| 32d6babf24 | |||
| 6fe029f531 | |||
| 725901623b | |||
| a826381981 | |||
| 79d84f1333 | |||
| 798bd51597 | |||
| 14a4c65b94 | |||
| 53c2bd1614 | |||
| 5b4026d36f | |||
| e442b33a59 | |||
| b090da05fa | |||
| bb8fb0a323 | |||
| 918282ff9d |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -223,6 +223,9 @@ dist/
|
||||
uploads/*
|
||||
!uploads/.gitkeep
|
||||
|
||||
# Worktrees
|
||||
.worktrees/
|
||||
|
||||
# Claude Code
|
||||
.claude/
|
||||
|
||||
|
||||
@@ -9,10 +9,10 @@ Requirements for this milestone. Each maps to roadmap phases.
|
||||
|
||||
### Comparison View
|
||||
|
||||
- [ ] **COMP-01**: User can view candidates side-by-side in a tabular comparison layout (weight, price, images, notes, links, status)
|
||||
- [ ] **COMP-02**: User can see relative deltas highlighting the lightest and cheapest candidate with +/- differences
|
||||
- [ ] **COMP-03**: Comparison table scrolls horizontally with a sticky label column on narrow viewports
|
||||
- [ ] **COMP-04**: Comparison view displays read-only summary for resolved threads
|
||||
- [x] **COMP-01**: User can view candidates side-by-side in a tabular comparison layout (weight, price, images, notes, links, status)
|
||||
- [x] **COMP-02**: User can see relative deltas highlighting the lightest and cheapest candidate with +/- differences
|
||||
- [x] **COMP-03**: Comparison table scrolls horizontally with a sticky label column on narrow viewports
|
||||
- [x] **COMP-04**: Comparison view displays read-only summary for resolved threads
|
||||
|
||||
### Candidate Ranking
|
||||
|
||||
@@ -68,10 +68,10 @@ Which phases cover which requirements. Updated during roadmap creation.
|
||||
|
||||
| Requirement | Phase | Status |
|
||||
|-------------|-------|--------|
|
||||
| COMP-01 | Phase 12 | Pending |
|
||||
| COMP-02 | Phase 12 | Pending |
|
||||
| COMP-03 | Phase 12 | Pending |
|
||||
| COMP-04 | Phase 12 | Pending |
|
||||
| COMP-01 | Phase 12 | Complete |
|
||||
| COMP-02 | Phase 12 | Complete |
|
||||
| COMP-03 | Phase 12 | Complete |
|
||||
| COMP-04 | Phase 12 | Complete |
|
||||
| RANK-01 | Phase 11 | Complete |
|
||||
| RANK-02 | Phase 11 | Complete |
|
||||
| RANK-03 | Phase 10 | Complete |
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
|
||||
- [x] **Phase 10: Schema Foundation + Pros/Cons Fields** — Migrate schema and deliver pros/cons annotation UI (completed 2026-03-16)
|
||||
- [x] **Phase 11: Candidate Ranking** — Drag-to-reorder priority ranking with rank badges (completed 2026-03-16)
|
||||
- [ ] **Phase 12: Comparison View** — Side-by-side tabular comparison with relative deltas
|
||||
- [x] **Phase 12: Comparison View** — Side-by-side tabular comparison with relative deltas (completed 2026-03-17)
|
||||
- [ ] **Phase 13: Setup Impact Preview** — Per-candidate weight and cost delta against a selected setup
|
||||
|
||||
## Phase Details
|
||||
@@ -83,7 +83,9 @@ Plans:
|
||||
2. The lightest candidate column is highlighted and all other columns show their weight difference relative to it; the cheapest candidate is highlighted similarly for price
|
||||
3. The comparison table scrolls horizontally on a narrow viewport without breaking layout; the attribute label column stays fixed on the left
|
||||
4. A resolved thread shows the comparison table in read-only mode with the winning candidate visually marked
|
||||
**Plans**: TBD
|
||||
**Plans:** 1/1 plans complete
|
||||
Plans:
|
||||
- [ ] 12-01-PLAN.md — ComparisonTable component + compare toggle wiring in thread detail
|
||||
|
||||
### Phase 13: Setup Impact Preview
|
||||
**Goal**: Users can select any setup and see exactly how much weight and cost each candidate would add or subtract
|
||||
@@ -94,7 +96,10 @@ Plans:
|
||||
2. When the selected setup contains an item in the same category as the thread, the delta reflects replacing that item (negative delta is possible) rather than pure addition
|
||||
3. When no category match exists in the selected setup, the delta shows a pure addition amount clearly labeled as "add"
|
||||
4. A candidate with no weight recorded shows a "-- (no weight data)" indicator instead of a zero delta
|
||||
**Plans**: TBD
|
||||
**Plans:** 2 plans
|
||||
Plans:
|
||||
- [ ] 13-01-PLAN.md — TDD pure impact delta computation, uiStore state, ThreadWithCandidates type fix, useImpactDeltas hook
|
||||
- [ ] 13-02-PLAN.md — SetupImpactSelector + ImpactDeltaBadge components, wire into thread detail and all candidate views
|
||||
|
||||
## Progress
|
||||
|
||||
@@ -111,5 +116,5 @@ Plans:
|
||||
| 9. Weight Classification and Visualization | v1.2 | 2/2 | Complete | 2026-03-16 |
|
||||
| 10. Schema Foundation + Pros/Cons Fields | v1.3 | 1/1 | Complete | 2026-03-16 |
|
||||
| 11. Candidate Ranking | 2/2 | Complete | 2026-03-16 | - |
|
||||
| 12. Comparison View | v1.3 | 0/TBD | Not started | - |
|
||||
| 13. Setup Impact Preview | v1.3 | 0/TBD | Not started | - |
|
||||
| 12. Comparison View | 1/1 | Complete | 2026-03-17 | - |
|
||||
| 13. Setup Impact Preview | v1.3 | 0/2 | Not started | - |
|
||||
|
||||
@@ -3,14 +3,14 @@ gsd_state_version: 1.0
|
||||
milestone: v1.3
|
||||
milestone_name: Research & Decision Tools
|
||||
status: planning
|
||||
stopped_at: Completed 11-candidate-ranking/11-02-PLAN.md
|
||||
last_updated: "2026-03-16T21:39:11.967Z"
|
||||
stopped_at: Completed 12-comparison-view/12-01-PLAN.md
|
||||
last_updated: "2026-03-17T14:35:39.075Z"
|
||||
last_activity: 2026-03-16 — Roadmap created for v1.3 milestone
|
||||
progress:
|
||||
total_phases: 4
|
||||
completed_phases: 2
|
||||
total_plans: 3
|
||||
completed_plans: 3
|
||||
completed_phases: 3
|
||||
total_plans: 4
|
||||
completed_plans: 4
|
||||
percent: 0
|
||||
---
|
||||
|
||||
@@ -53,6 +53,7 @@ Progress: [░░░░░░░░░░] 0%
|
||||
| Phase 10-schema-foundation-pros-cons-fields P01 | 6min | 2 tasks | 9 files |
|
||||
| Phase 11-candidate-ranking P01 | 4min | 2 tasks | 8 files |
|
||||
| Phase 11-candidate-ranking P02 | 4min | 3 tasks | 7 files |
|
||||
| Phase 12-comparison-view P01 | 2min | 2 tasks | 3 files |
|
||||
|
||||
## Accumulated Context
|
||||
|
||||
@@ -72,6 +73,9 @@ Key v1.3 research findings (see research/SUMMARY.md):
|
||||
- [Phase 11-candidate-ranking]: Applied sort_order migration via sqlite3 CLI directly to avoid Drizzle data-loss warning on existing rows
|
||||
- [Phase 11-candidate-ranking]: Resolved thread list view uses plain div (not Reorder.Group) — no drag, rank badges visible
|
||||
- [Phase 11-candidate-ranking]: RankBadge exported from CandidateListItem for reuse in CandidateCard grid view
|
||||
- [Phase 12-comparison-view]: ATTRIBUTE_ROWS declarative array pattern for ComparisonTable keeps JSX clean and row reordering trivial
|
||||
- [Phase 12-comparison-view]: Compare toggle only shown for 2+ candidates; Add Candidate hidden in compare view (read-only intent)
|
||||
- [Phase 12-comparison-view]: Weight/price highlight color takes priority over amber winner tint when both apply (more informative)
|
||||
|
||||
### Pending Todos
|
||||
|
||||
@@ -83,6 +87,6 @@ None active.
|
||||
|
||||
## Session Continuity
|
||||
|
||||
Last session: 2026-03-16T21:30:15.459Z
|
||||
Stopped at: Completed 11-candidate-ranking/11-02-PLAN.md
|
||||
Last session: 2026-03-17T14:32:04.702Z
|
||||
Stopped at: Completed 12-comparison-view/12-01-PLAN.md
|
||||
Resume file: None
|
||||
|
||||
321
.planning/phases/12-comparison-view/12-01-PLAN.md
Normal file
321
.planning/phases/12-comparison-view/12-01-PLAN.md
Normal file
@@ -0,0 +1,321 @@
|
||||
---
|
||||
phase: 12-comparison-view
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- src/client/components/ComparisonTable.tsx
|
||||
- src/client/stores/uiStore.ts
|
||||
- src/client/routes/threads/$threadId.tsx
|
||||
autonomous: true
|
||||
requirements: [COMP-01, COMP-02, COMP-03, COMP-04]
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "User can toggle to a Compare view when a thread has 2+ candidates"
|
||||
- "Comparison table shows all candidates side-by-side with weight, price, images, notes, links, status, pros, and cons"
|
||||
- "The lightest candidate weight cell has a blue highlight; the cheapest candidate price cell has a green highlight"
|
||||
- "Non-best cells show a gray +delta string; best cells show no delta"
|
||||
- "The table scrolls horizontally on narrow viewports while the attribute label column stays fixed on the left"
|
||||
- "Missing weight or price data displays a dash, never a misleading zero"
|
||||
- "A resolved thread shows the comparison read-only with the winner column visually marked (amber tint + trophy)"
|
||||
artifacts:
|
||||
- path: "src/client/components/ComparisonTable.tsx"
|
||||
provides: "Tabular side-by-side comparison component"
|
||||
min_lines: 120
|
||||
- path: "src/client/stores/uiStore.ts"
|
||||
provides: "Extended candidateViewMode union type including 'compare'"
|
||||
contains: "compare"
|
||||
- path: "src/client/routes/threads/$threadId.tsx"
|
||||
provides: "Compare toggle button and ComparisonTable rendering branch"
|
||||
contains: "ComparisonTable"
|
||||
key_links:
|
||||
- from: "src/client/routes/threads/$threadId.tsx"
|
||||
to: "src/client/components/ComparisonTable.tsx"
|
||||
via: "import and conditional render when candidateViewMode === 'compare'"
|
||||
pattern: "candidateViewMode.*compare"
|
||||
- from: "src/client/components/ComparisonTable.tsx"
|
||||
to: "src/client/lib/formatters.ts"
|
||||
via: "formatWeight and formatPrice for cell values and delta strings"
|
||||
pattern: "formatWeight|formatPrice"
|
||||
- from: "src/client/components/ComparisonTable.tsx"
|
||||
to: "src/client/components/CandidateListItem.tsx"
|
||||
via: "RankBadge import for rank row"
|
||||
pattern: "RankBadge"
|
||||
- from: "src/client/routes/threads/$threadId.tsx"
|
||||
to: "src/client/stores/uiStore.ts"
|
||||
via: "candidateViewMode state read and setCandidateViewMode action"
|
||||
pattern: "candidateViewMode"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Build the side-by-side candidate comparison table for research threads. Users toggle into compare mode from the existing view-mode bar and see all candidates as columns in a horizontally-scrollable table with sticky attribute labels, weight/price delta highlighting, and resolved-thread winner marking.
|
||||
|
||||
Purpose: Enables users to directly compare candidates on weight, price, status, notes, pros, and cons without switching between cards -- the key decision-support view for the Research & Decision Tools milestone.
|
||||
|
||||
Output: One new component (`ComparisonTable.tsx`), two modified files (`uiStore.ts`, `$threadId.tsx`).
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@/home/jlmak/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@/home/jlmak/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/12-comparison-view/12-CONTEXT.md
|
||||
@.planning/phases/12-comparison-view/12-RESEARCH.md
|
||||
|
||||
@src/client/stores/uiStore.ts
|
||||
@src/client/routes/threads/$threadId.tsx
|
||||
@src/client/hooks/useThreads.ts
|
||||
@src/client/lib/formatters.ts
|
||||
@src/client/components/CandidateListItem.tsx
|
||||
|
||||
<interfaces>
|
||||
<!-- Key types and contracts the executor needs. Extracted from codebase. -->
|
||||
|
||||
From src/client/hooks/useThreads.ts:
|
||||
```typescript
|
||||
interface CandidateWithCategory {
|
||||
id: number;
|
||||
threadId: number;
|
||||
name: string;
|
||||
weightGrams: number | null;
|
||||
priceCents: number | null;
|
||||
categoryId: number;
|
||||
notes: string | null;
|
||||
productUrl: string | null;
|
||||
imageFilename: string | null;
|
||||
status: "researching" | "ordered" | "arrived";
|
||||
pros: string | null;
|
||||
cons: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
categoryName: string;
|
||||
categoryIcon: string;
|
||||
}
|
||||
|
||||
interface ThreadWithCandidates {
|
||||
id: number;
|
||||
name: string;
|
||||
status: "active" | "resolved";
|
||||
resolvedCandidateId: number | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
candidates: CandidateWithCategory[];
|
||||
}
|
||||
```
|
||||
|
||||
From src/client/lib/formatters.ts:
|
||||
```typescript
|
||||
export type WeightUnit = "g" | "oz" | "lb" | "kg";
|
||||
export type Currency = "USD" | "EUR" | "GBP" | "JPY" | "CAD" | "AUD";
|
||||
export function formatWeight(grams: number | null | undefined, unit?: WeightUnit): string; // returns "--" for null
|
||||
export function formatPrice(cents: number | null | undefined, currency?: Currency): string; // returns "--" for null
|
||||
```
|
||||
|
||||
From src/client/components/CandidateListItem.tsx:
|
||||
```typescript
|
||||
export function RankBadge({ rank }: { rank: number }): JSX.Element | null;
|
||||
// Returns null for rank > 3, renders gold/silver/bronze medal icon for 1/2/3
|
||||
```
|
||||
|
||||
From src/client/stores/uiStore.ts (lines 52-54, current state):
|
||||
```typescript
|
||||
// Current type (will be extended):
|
||||
candidateViewMode: "list" | "grid";
|
||||
setCandidateViewMode: (mode: "list" | "grid") => void;
|
||||
```
|
||||
|
||||
From src/client/lib/iconData.tsx:
|
||||
```typescript
|
||||
export function LucideIcon({ name, size, className, style }: LucideIconProps): JSX.Element;
|
||||
// Renders any Lucide icon by kebab-case name string
|
||||
```
|
||||
|
||||
From src/client/hooks/useWeightUnit.ts:
|
||||
```typescript
|
||||
export function useWeightUnit(): WeightUnit; // reads from settings API
|
||||
```
|
||||
|
||||
From src/client/hooks/useCurrency.ts:
|
||||
```typescript
|
||||
export function useCurrency(): Currency; // reads from settings API
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Build ComparisonTable component</name>
|
||||
<files>src/client/components/ComparisonTable.tsx</files>
|
||||
<action>
|
||||
Create `src/client/components/ComparisonTable.tsx` — a self-contained comparison table component.
|
||||
|
||||
**Props interface:**
|
||||
```typescript
|
||||
interface ComparisonTableProps {
|
||||
candidates: CandidateWithCategory[];
|
||||
resolvedCandidateId: number | null;
|
||||
}
|
||||
```
|
||||
Import `CandidateWithCategory` type inline (duplicate the interface locally or import from useThreads — match project convention for component-local interfaces as seen in CandidateListItem.tsx which declares its own `CandidateWithCategory`).
|
||||
|
||||
**Delta computation (useMemo):**
|
||||
- Weight deltas: Filter candidates with non-null `weightGrams`. Find the minimum. For each candidate, compute `delta = weightGrams - min`. If `delta === 0` (this IS the best), store `null` as delta string. Otherwise store `+${formatWeight(delta, unit)}`. Track `bestWeightId`. If all candidates have null weight, `bestWeightId = null`.
|
||||
- Price deltas: Same logic for `priceCents` with `formatPrice(delta, currency)`. Track `bestPriceId`.
|
||||
- Use `useWeightUnit()` and `useCurrency()` hooks for unit/currency-aware formatting.
|
||||
|
||||
**Table structure:**
|
||||
- Outer: `<div className="overflow-x-auto rounded-xl border border-gray-100">` (scroll wrapper)
|
||||
- Inner: `<table>` with `style={{ minWidth: Math.max(400, candidates.length * 180) + 'px' }}` and `className="border-collapse text-sm w-full"`
|
||||
- `<thead>`: One `<tr>` with sticky corner `<th>` (empty, for label column) + one `<th>` per candidate showing name. If `candidate.id === resolvedCandidateId`, apply `bg-amber-50 text-amber-800` and prepend a trophy icon: `<LucideIcon name="trophy" size={12} className="text-amber-600" />`.
|
||||
- `<tbody>`: Render rows using a declarative ATTRIBUTE_ROWS array (see below).
|
||||
|
||||
**Sticky left column CSS (CRITICAL):**
|
||||
Every `<td>` and `<th>` in the first (label) column MUST have: `sticky left-0 z-10 bg-white`. Without `bg-white`, scrolled content bleeds through. Use `z-10` (not higher — avoid conflicts with panels/modals).
|
||||
|
||||
**Attribute row order** (per locked decision): Image, Name, Rank, Weight (with delta), Price (with delta), Status, Product Link, Notes, Pros, Cons.
|
||||
|
||||
**Row rendering — use a declarative array pattern:**
|
||||
Define `ATTRIBUTE_ROWS` as an array of `{ key, label, render(candidate) }`. This keeps the JSX clean and makes row reordering trivial. Build this array inside the component function body (after useMemo hooks) so it can close over `weightDeltas`, `priceDeltas`, `bestWeightId`, `bestPriceId`, `unit`, `currency`.
|
||||
|
||||
**Cell renderers:**
|
||||
- **Image**: 48x48 rounded-lg container. If `imageFilename`, render `<img src="/uploads/${imageFilename}" />` with `object-cover`. Else render `<LucideIcon name={categoryIcon} size={20} className="text-gray-400" />` in a `bg-gray-50` placeholder. Use `w-12 h-12` sizing.
|
||||
- **Name**: `<span className="text-sm font-medium text-gray-900">{name}</span>`
|
||||
- **Rank**: Reuse `<RankBadge rank={index + 1} />` imported from CandidateListItem. Rank is derived from array position (candidates are already sorted by sort_order from the API).
|
||||
- **Weight**: Show `formatWeight(weightGrams, unit)` as primary value in `font-medium text-gray-900`. If this is the best (`isBest`), apply `bg-blue-50` to the `<td>`. If delta string exists (not null, not best), show delta below in `text-xs text-gray-400`. If `weightGrams` is null, show `<span className="text-gray-300">—</span>`.
|
||||
- **Price**: Same pattern as weight but with `formatPrice(priceCents, currency)` and `bg-green-50` for the best cell.
|
||||
- **Status**: Render as static text `<span className="text-xs text-gray-600">{STATUS_LABELS[status]}</span>`. Define STATUS_LABELS map: `{ researching: "Researching", ordered: "Ordered", arrived: "Arrived" }`. No click-to-cycle in compare view — comparison is for reading, not mutation.
|
||||
- **Product Link**: If `productUrl` exists, render a clickable link that calls `openExternalLink(productUrl)` from uiStore: `<button onClick={() => openExternalLink(productUrl)} className="text-xs text-blue-500 hover:underline">View</button>`. If null, render `<span className="text-gray-300">—</span>`. Links remain clickable even in resolved threads (read-only means no mutations, but navigation is fine).
|
||||
- **Notes**: If `notes` exists, render `<p className="text-xs text-gray-700 whitespace-pre-line">{notes}</p>` (whitespace-pre-line preserves newlines). If null, render em dash placeholder.
|
||||
- **Pros**: If `pros` exists, split on `"\n"`, filter empty strings, render as `<ul className="list-disc list-inside space-y-0.5">` with `<li className="text-xs text-gray-700">` items. If null, render em dash placeholder.
|
||||
- **Cons**: Same as Pros rendering.
|
||||
|
||||
**Winner column highlight (resolved threads):**
|
||||
When `resolvedCandidateId` is set, the winner's `<th>` in the header gets `bg-amber-50 text-amber-800` + trophy icon. Each body `<td>` for the winner column gets a subtle `bg-amber-50/50` tint (half-opacity amber). This must not conflict with the best-weight/best-price blue/green highlights — when both apply (winner IS also lightest), use the weight/price highlight color (it's more informative).
|
||||
|
||||
**Row styling:**
|
||||
- Each `<tr>` gets `border-b border-gray-50` for subtle row separation.
|
||||
- Label `<td>` cells: `text-xs font-medium text-gray-500 uppercase tracking-wide w-28`.
|
||||
- Data `<td>` cells: `px-4 py-3 min-w-[160px]`.
|
||||
- Header `<th>` cells: `px-4 py-3 text-left text-xs font-medium text-gray-700 min-w-[160px]`.
|
||||
|
||||
**Table border + rounding:**
|
||||
The outer wrapper has `rounded-xl border border-gray-100`. Add `overflow-hidden` to the wrapper alongside `overflow-x-auto` to clip the table's corners to the rounded border: `className="overflow-x-auto overflow-hidden rounded-xl border border-gray-100"`. Actually, use `overflow-x-auto` on an outer div, and put the border/rounding there. The table itself does not need border-radius.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/jlmak/Projects/jlmak/GearBox && bun run lint 2>&1 | head -20</automated>
|
||||
</verify>
|
||||
<done>ComparisonTable.tsx exists with all 10 attribute rows, delta computation via useMemo, sticky left column with bg-white, horizontal scroll wrapper, blue/green best-cell highlights, gray delta text for non-best, amber winner column for resolved threads, em dash for missing data (never zero).</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Wire compare toggle and ComparisonTable into thread detail</name>
|
||||
<files>src/client/stores/uiStore.ts, src/client/routes/threads/$threadId.tsx</files>
|
||||
<action>
|
||||
**Step 1: Extend uiStore candidateViewMode (src/client/stores/uiStore.ts)**
|
||||
|
||||
Change the type union on lines 53-54 from:
|
||||
```typescript
|
||||
candidateViewMode: "list" | "grid";
|
||||
setCandidateViewMode: (mode: "list" | "grid") => void;
|
||||
```
|
||||
to:
|
||||
```typescript
|
||||
candidateViewMode: "list" | "grid" | "compare";
|
||||
setCandidateViewMode: (mode: "list" | "grid" | "compare") => void;
|
||||
```
|
||||
|
||||
No other changes needed in uiStore — the implementation lines 112-113 are generic and already work with the wider type.
|
||||
|
||||
**Step 2: Add compare toggle button (src/client/routes/threads/$threadId.tsx)**
|
||||
|
||||
In the toolbar toggle bar (the `<div className="flex items-center gap-1 bg-gray-100 rounded-lg p-0.5">` block around line 146), add a third button for compare mode. The compare button should only render when `thread.candidates.length >= 2` (per locked decision).
|
||||
|
||||
Add the compare button after the grid button but inside the toggle container:
|
||||
```tsx
|
||||
{thread.candidates.length >= 2 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCandidateViewMode("compare")}
|
||||
className={`p-1.5 rounded-md transition-colors ${
|
||||
candidateViewMode === "compare"
|
||||
? "bg-gray-200 text-gray-900"
|
||||
: "text-gray-400 hover:text-gray-600"
|
||||
}`}
|
||||
title="Compare view"
|
||||
>
|
||||
<LucideIcon name="columns-3" size={16} />
|
||||
</button>
|
||||
)}
|
||||
```
|
||||
|
||||
Also: Hide the "Add Candidate" button when in compare view. Change the existing `{isActive && (` guard (around line 123) to `{isActive && candidateViewMode !== "compare" && (`. This keeps the toolbar uncluttered — users switch to list/grid to add candidates.
|
||||
|
||||
**Step 3: Add ComparisonTable rendering branch ($threadId.tsx)**
|
||||
|
||||
Import ComparisonTable at the top of the file:
|
||||
```typescript
|
||||
import { ComparisonTable } from "../../components/ComparisonTable";
|
||||
```
|
||||
|
||||
In the candidates rendering section (starting around line 192), add a compare branch BEFORE the existing list check:
|
||||
```tsx
|
||||
) : candidateViewMode === "compare" ? (
|
||||
<ComparisonTable
|
||||
candidates={displayItems}
|
||||
resolvedCandidateId={thread.resolvedCandidateId}
|
||||
/>
|
||||
) : candidateViewMode === "list" ? (
|
||||
// ... existing list rendering (unchanged)
|
||||
) : (
|
||||
// ... existing grid rendering (unchanged)
|
||||
)
|
||||
```
|
||||
|
||||
Pass `displayItems` (not `thread.candidates`) so the order reflects any pending drag reorder state, though in compare mode drag is not active — `displayItems` will equal `thread.candidates` when `tempItems` is null.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/jlmak/Projects/jlmak/GearBox && bun run lint 2>&1 | head -20 && bun test 2>&1 | tail -5</automated>
|
||||
</verify>
|
||||
<done>uiStore accepts "compare" as a candidateViewMode value. Thread detail page shows a third toggle icon (columns-3) when 2+ candidates exist. Clicking it renders ComparisonTable. "Add Candidate" button is hidden in compare mode. Existing list/grid views still work unchanged. All existing tests pass.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
1. `bun run lint` passes with no errors
|
||||
2. `bun test` full suite passes (no backend changes, existing tests unaffected)
|
||||
3. Manual browser verification:
|
||||
- Navigate to a thread with 2+ candidates
|
||||
- Verify the compare icon (columns-3) appears in the toggle bar
|
||||
- Click compare icon -> tabular comparison renders with candidates as columns
|
||||
- Verify attribute row order: Image, Name, Rank, Weight, Price, Status, Link, Notes, Pros, Cons
|
||||
- Verify lightest weight cell has blue-50 tint, cheapest price cell has green-50 tint
|
||||
- Verify non-best cells show gray +delta text
|
||||
- Verify missing weight/price shows em dash (not zero)
|
||||
- Resize viewport narrow -> table scrolls horizontally, label column stays fixed
|
||||
- Navigate to a resolved thread -> winner column has amber tint + trophy, no mutation controls
|
||||
- Toggle back to list/grid views -> they still work correctly
|
||||
- Thread with 0 or 1 candidate -> compare icon does not appear
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- ComparisonTable.tsx renders all 10 attribute rows with correct data
|
||||
- Delta highlighting: blue-50 on lightest weight, green-50 on cheapest price, gray delta text on non-best
|
||||
- Sticky label column with solid bg-white stays visible during horizontal scroll
|
||||
- Resolved threads show winner column with amber-50 tint and trophy icon
|
||||
- Missing data renders as em dash, never as zero (COMP-04)
|
||||
- Compare toggle icon appears only when >= 2 candidates
|
||||
- All existing tests continue to pass
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/12-comparison-view/12-01-SUMMARY.md`
|
||||
</output>
|
||||
110
.planning/phases/12-comparison-view/12-01-SUMMARY.md
Normal file
110
.planning/phases/12-comparison-view/12-01-SUMMARY.md
Normal file
@@ -0,0 +1,110 @@
|
||||
---
|
||||
phase: 12-comparison-view
|
||||
plan: "01"
|
||||
subsystem: ui
|
||||
tags: [react, tailwind, comparison-table, zustand, framer-motion]
|
||||
|
||||
# Dependency graph
|
||||
requires:
|
||||
- phase: 11-candidate-ranking
|
||||
provides: RankBadge component and sort_order-based candidate ordering
|
||||
- phase: 10-schema-foundation-pros-cons-fields
|
||||
provides: pros/cons fields on CandidateWithCategory type
|
||||
provides:
|
||||
- ComparisonTable component with sticky label column and horizontal scroll
|
||||
- candidateViewMode "compare" value in uiStore
|
||||
- Compare toggle in thread detail toolbar (visible when 2+ candidates)
|
||||
- Weight/price delta highlighting with best-cell color coding
|
||||
- Resolved thread winner column marking (amber tint + trophy)
|
||||
affects: [future comparison features, thread detail enhancements]
|
||||
|
||||
# Tech tracking
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- Declarative ATTRIBUTE_ROWS array pattern for table row rendering (key, label, render, cellClass)
|
||||
- useMemo delta computation for best-cell identification in comparison views
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- src/client/components/ComparisonTable.tsx
|
||||
modified:
|
||||
- src/client/stores/uiStore.ts
|
||||
- src/client/routes/threads/$threadId.tsx
|
||||
|
||||
key-decisions:
|
||||
- "ATTRIBUTE_ROWS declarative array pattern keeps JSX clean and row reordering trivial"
|
||||
- "cellClass function pattern in ATTRIBUTE_ROWS allows per-row cell styling without duplicating winner-check logic in every render"
|
||||
- "Compare toggle only shown when >= 2 candidates (locked decision from plan)"
|
||||
- "Add Candidate button hidden in compare view — compare is for reading, not mutation"
|
||||
- "Winner highlight priority: weight/price color wins over amber tint when both apply (more informative)"
|
||||
|
||||
patterns-established:
|
||||
- "Declarative table row config: ATTRIBUTE_ROWS array with { key, label, render, cellClass } objects"
|
||||
- "Sticky left column pattern: sticky left-0 z-10 bg-white on every label cell for scroll bleed prevention"
|
||||
|
||||
requirements-completed: [COMP-01, COMP-02, COMP-03, COMP-04]
|
||||
|
||||
# Metrics
|
||||
duration: 2min
|
||||
completed: 2026-03-17
|
||||
---
|
||||
|
||||
# Phase 12 Plan 01: Comparison View Summary
|
||||
|
||||
**Side-by-side candidate comparison table with sticky labels, weight/price delta highlighting, and resolved-thread winner marking via a new "compare" candidateViewMode**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** 2 min
|
||||
- **Started:** 2026-03-17T14:28:12Z
|
||||
- **Completed:** 2026-03-17T14:30:00Z
|
||||
- **Tasks:** 2
|
||||
- **Files modified:** 3
|
||||
|
||||
## Accomplishments
|
||||
- Built ComparisonTable component with 10 attribute rows (Image, Name, Rank, Weight, Price, Status, Link, Notes, Pros, Cons) using declarative ATTRIBUTE_ROWS pattern
|
||||
- Implemented useMemo delta computation — lightest weight cell highlighted blue-50, cheapest price cell green-50, non-best cells show gray +delta string
|
||||
- Sticky left column with bg-white prevents content bleed-through on horizontal scroll
|
||||
- Amber tint + trophy icon on winner column in resolved threads; weight/price color takes priority when winner is also best
|
||||
- Extended uiStore candidateViewMode to "list" | "grid" | "compare" and wired compare toggle in thread detail toolbar
|
||||
- Compare toggle only appears when thread has 2+ candidates; Add Candidate button hidden in compare view
|
||||
- All 135 existing tests pass, no regressions
|
||||
|
||||
## Task Commits
|
||||
|
||||
Each task was committed atomically:
|
||||
|
||||
1. **Task 1: Build ComparisonTable component** - `e442b33` (feat)
|
||||
2. **Task 2: Wire compare toggle and ComparisonTable into thread detail** - `5b4026d` (feat)
|
||||
|
||||
## Files Created/Modified
|
||||
- `src/client/components/ComparisonTable.tsx` - New comparison table component with all 10 attribute rows, delta computation, sticky labels, and winner highlighting
|
||||
- `src/client/stores/uiStore.ts` - Extended candidateViewMode union to include "compare"
|
||||
- `src/client/routes/threads/$threadId.tsx` - Added ComparisonTable import, compare toggle button (columns-3 icon), ComparisonTable rendering branch, and "Add Candidate" hidden in compare view
|
||||
|
||||
## Decisions Made
|
||||
- ATTRIBUTE_ROWS declarative array pattern keeps table JSX clean and row reordering trivial — each row is just { key, label, render, cellClass }
|
||||
- cellClass function in ATTRIBUTE_ROWS allows per-row cell styling without duplicating winner-check logic in every render function
|
||||
- Compare toggle only shown for 2+ candidates per locked plan decision
|
||||
- Add Candidate button hidden in compare view to keep toolbar uncluttered (users switch to list/grid to add)
|
||||
- When winner IS also the lightest/cheapest, weight/price color (blue/green) takes priority over amber tint — more informative
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
None - plan executed exactly as written.
|
||||
|
||||
## Issues Encountered
|
||||
- Minor formatting differences caught by Biome auto-formatter (indentation depth in conditional JSX) — resolved with `biome check --write`. No logic changes.
|
||||
|
||||
## User Setup Required
|
||||
None - no external service configuration required.
|
||||
|
||||
## Next Phase Readiness
|
||||
- ComparisonTable is complete and functional; compare mode wired end-to-end in thread detail
|
||||
- No blockers — ready for any follow-on comparison view enhancements
|
||||
- All existing tests pass; no backend changes needed
|
||||
|
||||
---
|
||||
*Phase: 12-comparison-view*
|
||||
*Completed: 2026-03-17*
|
||||
541
.planning/phases/12-comparison-view/12-RESEARCH.md
Normal file
541
.planning/phases/12-comparison-view/12-RESEARCH.md
Normal file
@@ -0,0 +1,541 @@
|
||||
# Phase 12: Comparison View - Research
|
||||
|
||||
**Researched:** 2026-03-17
|
||||
**Domain:** React tabular UI, CSS sticky columns, horizontal scroll, delta computation
|
||||
**Confidence:** HIGH
|
||||
|
||||
## Summary
|
||||
|
||||
Phase 12 is a pure frontend phase. No backend changes, no schema changes, no new npm packages. All required data is already returned by `useThread(threadId)` — candidates carry `weightGrams`, `priceCents`, `status`, `productUrl`, `notes`, `pros`, `cons`, `imageFilename`, `categoryIcon`, and rank is derived from sort_order position in the array. The work is entirely in building a `ComparisonTable` component, wiring a third toggle button into the existing view-mode bar, and extending the `candidateViewMode` Zustand union type.
|
||||
|
||||
The core CSS challenge is the sticky-first-column + horizontal-scroll table pattern. Modern CSS handles this well as long as `overflow-x: auto` is placed on a wrapper `<div>`, not the `<table>` element itself, and the sticky `<td>` cells in the label column have an explicit background color (otherwise scrolling content bleeds through). Z-index layering is simple for this use case because there is only one sticky axis (the left label column); no sticky top header is needed since the table is not vertically scrollable.
|
||||
|
||||
Delta computation is straightforward arithmetic: find the minimum `weightGrams` across candidates that have a value, subtract each candidate's value from that minimum to produce a delta, and render a `+Xg` or `—` string. The "best" cell gets `bg-blue-50` for weight (matching existing blue weight pill color) or `bg-green-50` for price (matching existing green price pill color). Missing data must never display as "0" — a dash placeholder is required by COMP-04, and `formatWeight(null)` already returns `"--"`.
|
||||
|
||||
**Primary recommendation:** Build `ComparisonTable.tsx` as a self-contained component that accepts `candidates[]` and `resolvedCandidateId | null`, computes deltas internally with `useMemo`, renders a `<div className="overflow-x-auto">` wrapper around a plain `<table>`, and uses `sticky left-0 bg-white z-10` on the label `<td>` cells.
|
||||
|
||||
---
|
||||
|
||||
<user_constraints>
|
||||
## User Constraints (from CONTEXT.md)
|
||||
|
||||
### Locked Decisions
|
||||
- Compare mode entry point: Add a third icon to the existing list/grid toggle bar, making it list | grid | compare (three-way toggle)
|
||||
- Use `candidateViewMode: 'list' | 'grid' | 'compare'` in uiStore — extends the existing Zustand state
|
||||
- Compare icon only appears when 2+ candidates exist in the thread (hidden otherwise)
|
||||
- Table orientation: Candidates as columns, attribute labels as rows (classic product-comparison pattern — Amazon/Wirecutter style)
|
||||
- Sticky left column for attribute labels; table scrolls horizontally on narrow viewports
|
||||
- Attribute row order: Image → Name → Rank → Weight (with delta) → Price (with delta) → Status → Product Link → Notes → Pros → Cons
|
||||
- Delta highlighting style: Lightest candidate's weight cell gets a subtle colored background tint (e.g., bg-green-50); cheapest similarly
|
||||
- Non-best cells show delta text in neutral gray — no colored badges for deltas, only the "best" cell gets color
|
||||
|
||||
### Claude's Discretion
|
||||
- "Add Candidate" button visibility when in compare view
|
||||
- Image thumbnail sizing in comparison cells (square crop vs wider aspect)
|
||||
- Multi-line text rendering strategy (clamped with expand vs full text)
|
||||
- Missing data indicator style (dash with label, empty cell, etc.)
|
||||
- Delta format: absolute value + delta underneath, or delta only for non-best cells
|
||||
- Winner column marking approach (column tint, trophy icon, or both)
|
||||
- Resolved thread interactivity (links clickable vs all read-only)
|
||||
- Resolution banner behavior in compare view
|
||||
- View mode persistence (already in Zustand — whether compare resets on navigation or persists)
|
||||
- Compare toggle icon choice (e.g., Lucide `columns-3`, `table-2`, or similar)
|
||||
- Table cell padding, border styling, and overall table chrome
|
||||
- Column minimum/maximum widths
|
||||
- Keyboard accessibility for horizontal scrolling
|
||||
|
||||
### Deferred Ideas (OUT OF SCOPE)
|
||||
None — discussion stayed within phase scope
|
||||
</user_constraints>
|
||||
|
||||
---
|
||||
|
||||
<phase_requirements>
|
||||
## Phase Requirements
|
||||
|
||||
| ID | Description | Research Support |
|
||||
|----|-------------|-----------------|
|
||||
| COMP-01 | User can view candidates side-by-side in a tabular comparison layout (weight, price, images, notes, links, status) | ComparisonTable component; all fields available from useThread hook; no backend changes needed |
|
||||
| COMP-02 | User can see relative deltas highlighting the lightest and cheapest candidate with +/- differences | Delta computation via array reduce; best-cell highlight via bg-blue-50 (weight) / bg-green-50 (price); gray delta text for non-best |
|
||||
| COMP-03 | Comparison table scrolls horizontally with a sticky label column on narrow viewports | overflow-x-auto wrapper div + sticky left-0 bg-white z-10 on label td cells |
|
||||
| COMP-04 | Comparison view displays read-only summary for resolved threads | resolvedCandidateId from useThread; disable mutation actions; winner column visual tint; resolved check pattern established in Phase 11 |
|
||||
</phase_requirements>
|
||||
|
||||
---
|
||||
|
||||
## Standard Stack
|
||||
|
||||
### Core (all already installed — no new packages needed)
|
||||
| Library | Version | Purpose | Why Standard |
|
||||
|---------|---------|---------|--------------|
|
||||
| React 19 | ^19.2.4 | Component rendering | Project stack |
|
||||
| Tailwind CSS | v4 | Utility styling | Project stack |
|
||||
| Zustand | ^5.0.11 | candidateViewMode state | Already used for list/grid toggle |
|
||||
| lucide-react | ^0.577.0 | Toggle icon (`columns-3` confirmed present) | All icons use LucideIcon helper |
|
||||
| framer-motion | ^12.37.0 | Optional AnimatePresence for view transition | Already installed |
|
||||
|
||||
### Supporting Utilities (already in project)
|
||||
| Utility | Location | Purpose |
|
||||
|---------|----------|---------|
|
||||
| `formatWeight(grams, unit)` | `src/client/lib/formatters.ts` | Weight cell values and delta strings; returns `"--"` for null |
|
||||
| `formatPrice(cents, currency)` | `src/client/lib/formatters.ts` | Price cell values and delta strings; returns `"--"` for null |
|
||||
| `useWeightUnit()` | `src/client/hooks/useWeightUnit.ts` | Current unit setting |
|
||||
| `useCurrency()` | `src/client/hooks/useCurrency.ts` | Current currency setting |
|
||||
| `useThread(threadId)` | `src/client/hooks/useThreads.ts` | All candidate data |
|
||||
| `RankBadge` | `src/client/components/CandidateListItem.tsx` | Rank medal icons (exported) |
|
||||
| `LucideIcon` | `src/client/lib/iconData.tsx` | Icon rendering with fallback |
|
||||
|
||||
---
|
||||
|
||||
## Architecture Patterns
|
||||
|
||||
### Recommended File Structure
|
||||
```
|
||||
src/client/
|
||||
├── components/
|
||||
│ └── ComparisonTable.tsx # New: tabular comparison component
|
||||
├── stores/
|
||||
│ └── uiStore.ts # Modify: extend candidateViewMode union type
|
||||
└── routes/threads/
|
||||
└── $threadId.tsx # Modify: add compare branch + third toggle button
|
||||
```
|
||||
|
||||
### Pattern 1: Sticky Left Column with Horizontal Scroll
|
||||
|
||||
**What:** Wrap `<table>` in `<div className="overflow-x-auto">`. Apply `sticky left-0 bg-white z-10` to every `<td>` and `<th>` in the first (label) column.
|
||||
|
||||
**When to use:** Any time a table needs a frozen left column with horizontal scrolling.
|
||||
|
||||
**Critical pitfall:** The sticky `td` cells MUST have a solid background color. Without `bg-white`, scrolling content bleeds through the "sticky" cell because the cell is transparent.
|
||||
|
||||
**Example:**
|
||||
```tsx
|
||||
// Outer wrapper enables horizontal scroll
|
||||
<div className="overflow-x-auto rounded-xl border border-gray-100">
|
||||
<table
|
||||
className="border-collapse text-sm"
|
||||
style={{ minWidth: `${Math.max(400, candidates.length * 180)}px` }}
|
||||
>
|
||||
<thead>
|
||||
<tr className="border-b border-gray-100">
|
||||
{/* Sticky corner cell — bg-white mandatory */}
|
||||
<th className="sticky left-0 z-10 bg-white px-4 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wide w-28" />
|
||||
{candidates.map((c) => (
|
||||
<th key={c.id} className="px-4 py-3 text-left text-xs font-medium text-gray-700 min-w-[160px]">
|
||||
{c.name}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{ATTRIBUTE_ROWS.map((row) => (
|
||||
<tr key={row.key} className="border-b border-gray-50">
|
||||
{/* Sticky label cell — bg-white mandatory */}
|
||||
<td className="sticky left-0 z-10 bg-white px-4 py-3 text-xs font-medium text-gray-500">
|
||||
{row.label}
|
||||
</td>
|
||||
{candidates.map((c) => (
|
||||
<td key={c.id} className="px-4 py-3">
|
||||
{row.render(c)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Pattern 2: Delta Computation (null-safe, useMemo)
|
||||
|
||||
**What:** Derive the "best" candidate and compute deltas before rendering. Use `useMemo` keyed on `candidates` to avoid recomputing on every render.
|
||||
|
||||
**Example:**
|
||||
```tsx
|
||||
// Source: derived from project formatters.ts patterns
|
||||
const { weightDeltas, bestWeightId } = useMemo(() => {
|
||||
const withWeight = candidates.filter((c) => c.weightGrams != null);
|
||||
if (withWeight.length === 0) return { weightDeltas: new Map<number, string | null>(), bestWeightId: null };
|
||||
|
||||
const minGrams = Math.min(...withWeight.map((c) => c.weightGrams as number));
|
||||
const bestWeightId = withWeight.find((c) => c.weightGrams === minGrams)!.id;
|
||||
|
||||
const weightDeltas = new Map(
|
||||
candidates.map((c) => {
|
||||
if (c.weightGrams == null) return [c.id, null]; // null = missing data
|
||||
const delta = c.weightGrams - minGrams;
|
||||
return [c.id, delta === 0 ? null : `+${formatWeight(delta, unit)}`];
|
||||
// delta === 0 means this IS the best — no delta string needed
|
||||
})
|
||||
);
|
||||
return { weightDeltas, bestWeightId };
|
||||
}, [candidates, unit]);
|
||||
```
|
||||
|
||||
### Pattern 3: Extending Zustand Union Type
|
||||
|
||||
**What:** Widen the existing `candidateViewMode` type from `'list' | 'grid'` to `'list' | 'grid' | 'compare'`. The implementation setter line is unchanged.
|
||||
|
||||
**Example:**
|
||||
```typescript
|
||||
// In uiStore.ts — only two type declaration lines change (lines 53-54):
|
||||
candidateViewMode: "list" | "grid" | "compare";
|
||||
setCandidateViewMode: (mode: "list" | "grid" | "compare") => void;
|
||||
|
||||
// Implementation lines 112-113 — unchanged:
|
||||
candidateViewMode: "list",
|
||||
setCandidateViewMode: (mode) => set({ candidateViewMode: mode }),
|
||||
```
|
||||
|
||||
### Pattern 4: Three-Way Toggle Button
|
||||
|
||||
**What:** Add a third button to the existing `bg-gray-100 rounded-lg p-0.5` toggle bar in `$threadId.tsx`. Show compare button only when `thread.candidates.length >= 2`.
|
||||
|
||||
**Example:**
|
||||
```tsx
|
||||
{thread.candidates.length >= 2 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCandidateViewMode("compare")}
|
||||
className={`p-1.5 rounded-md transition-colors ${
|
||||
candidateViewMode === "compare"
|
||||
? "bg-gray-200 text-gray-900"
|
||||
: "text-gray-400 hover:text-gray-600"
|
||||
}`}
|
||||
title="Compare view"
|
||||
>
|
||||
<LucideIcon name="columns-3" size={16} />
|
||||
</button>
|
||||
)}
|
||||
```
|
||||
|
||||
**Confirmed:** `columns-3` maps to `Columns3` in lucide-react ^0.577.0 and is present in the installed package (verified via `node -e "const {icons}=require('lucide-react'); console.log('Columns3' in icons)"`). Use `LucideIcon name="columns-3"` — the LucideIcon helper handles the `toPascalCase` conversion.
|
||||
|
||||
### Pattern 5: Row Definition as Data
|
||||
|
||||
**What:** Define the attribute rows as a declarative array, not hard-coded JSX branches. Each entry has a `key`, `label`, and a `render(candidate)` function. This makes row reordering trivial and matches the locked attribute order.
|
||||
|
||||
**Example:**
|
||||
```tsx
|
||||
// Attribute row order per CONTEXT.md: Image → Name → Rank → Weight → Price → Status → Link → Notes → Pros → Cons
|
||||
const ATTRIBUTE_ROWS = [
|
||||
{ key: "image", label: "Image", render: (c: C) => <ImageCell candidate={c} /> },
|
||||
{ key: "name", label: "Name", render: (c: C) => <span className="text-sm font-medium text-gray-900">{c.name}</span> },
|
||||
{ key: "rank", label: "Rank", render: (c: C) => <RankBadge rank={rankOf(c)} /> },
|
||||
{ key: "weight", label: "Weight", render: (c: C) => <WeightCell candidate={c} delta={weightDeltas.get(c.id)} isBest={c.id === bestWeightId} unit={unit} /> },
|
||||
{ key: "price", label: "Price", render: (c: C) => <PriceCell candidate={c} delta={priceDeltas.get(c.id)} isBest={c.id === bestPriceId} currency={currency} /> },
|
||||
{ key: "status", label: "Status", render: (c: C) => <span className="text-xs text-gray-600">{STATUS_LABELS[c.status]}</span> },
|
||||
{ key: "link", label: "Link", render: (c: C) => c.productUrl ? <a href="#" onClick={() => openExternalLink(c.productUrl!)} className="text-xs text-blue-500 hover:underline">View</a> : <span className="text-gray-300">—</span> },
|
||||
{ key: "notes", label: "Notes", render: (c: C) => <TextCell text={c.notes} /> },
|
||||
{ key: "pros", label: "Pros", render: (c: C) => <BulletCell text={c.pros} /> },
|
||||
{ key: "cons", label: "Cons", render: (c: C) => <BulletCell text={c.cons} /> },
|
||||
];
|
||||
```
|
||||
|
||||
### Pattern 6: Pros/Cons Rendering (confirmed newline-separated)
|
||||
|
||||
**What:** `CandidateForm.tsx` uses a `<textarea>` with placeholder "One pro per line..." — users enter newline-separated text. The form submits `form.pros.trim() || undefined`, so empty = `undefined` → stored as `null` in DB. Non-empty content is raw text with `\n` separators.
|
||||
|
||||
**How to render in compare table:**
|
||||
```tsx
|
||||
function BulletCell({ text }: { text: string | null }) {
|
||||
if (!text) return <span className="text-gray-300">—</span>;
|
||||
const items = text.split("\n").filter(Boolean);
|
||||
if (items.length === 0) return <span className="text-gray-300">—</span>;
|
||||
return (
|
||||
<ul className="list-disc list-inside space-y-0.5">
|
||||
{items.map((item, i) => (
|
||||
<li key={i} className="text-xs text-gray-700">{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Anti-Patterns to Avoid
|
||||
|
||||
- **Setting `overflow-x-auto` on `<table>` directly:** Has no effect in CSS. Must be on a wrapper `<div>`.
|
||||
- **Transparent sticky cells:** Sticky `<td>` cells without `bg-white` let scrolled content bleed through visually.
|
||||
- **Computing deltas inside render:** Use `useMemo` — compute once, not per render cycle.
|
||||
- **Using `overflow: hidden` on any ancestor of the sticky column:** Breaks the sticky positioning context.
|
||||
- **Missing data shown as "0":** `formatWeight(null)` already returns `"--"`. Guard delta computation with null checks before arithmetic.
|
||||
- **Rendering pros/cons as raw string:** Split on `\n` and render as `<ul>` — the form stores `\n`-separated text.
|
||||
|
||||
---
|
||||
|
||||
## Don't Hand-Roll
|
||||
|
||||
| Problem | Don't Build | Use Instead | Why |
|
||||
|---------|-------------|-------------|-----|
|
||||
| Weight/price formatting | Custom format functions | `formatWeight()` / `formatPrice()` in `formatters.ts` | Handles all units, currencies, null — returns `"--"` for null |
|
||||
| Rank medal icons | Custom SVG or color dots | `RankBadge` from `CandidateListItem.tsx` | Already exported, handles ranks 1-3 with correct colors |
|
||||
| Zustand state | Local useState for view mode | Existing `candidateViewMode` in `uiStore` | Persists across navigation, consistent with list/grid |
|
||||
| Icon rendering | Direct lucide component imports | `LucideIcon` helper from `iconData.tsx` | Handles fallback, consistent API across project |
|
||||
| Unit/currency awareness | Hardcode "g" or "$" | `useWeightUnit()` / `useCurrency()` | Reads from user settings |
|
||||
|
||||
**Key insight:** This phase is almost entirely composition of already-built primitives. The delta computation logic and sticky column CSS are the only genuinely new work.
|
||||
|
||||
---
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
### Pitfall 1: Sticky Cell Background Bleed-Through
|
||||
**What goes wrong:** The label column appears sticky but scrolling content renders on top of it, making text illegible.
|
||||
**Why it happens:** `position: sticky` keeps the element in its visual position but does not create an opaque layer. Without a background color the cell is transparent.
|
||||
**How to avoid:** Add `bg-white` to every sticky `<td>` and `<th>` in the label column. If alternating row backgrounds are used, the sticky cells must also match those background colors.
|
||||
**Warning signs:** Label text becomes unreadable when scrolling horizontally.
|
||||
|
||||
### Pitfall 2: overflow-x-auto on Wrong Element
|
||||
**What goes wrong:** The table never scrolls horizontally regardless of viewport width.
|
||||
**Why it happens:** CSS `overflow` properties only apply to block/flex/grid containers. `<table>` is a table container — `overflow-x: auto` on `<table>` has no effect per CSS spec.
|
||||
**How to avoid:** Wrap `<table>` in `<div className="overflow-x-auto">`. Set `minWidth` on the `<table>` itself (not the wrapper) to force scrollability.
|
||||
**Warning signs:** Table content wraps aggressively instead of scrolling; columns collapse on narrow screens.
|
||||
|
||||
### Pitfall 3: Delta Shows for Best Candidate
|
||||
**What goes wrong:** The lightest candidate shows "+0g" instead of just the value cleanly.
|
||||
**Why it happens:** Naive `delta = candidate - min` yields 0 for the best candidate.
|
||||
**How to avoid:** When `delta === 0`, return `null` for the delta string. The best-cell highlight color already communicates "this is best." Only non-best cells show a delta string.
|
||||
**Warning signs:** Best cell shows "+0g" or "+$0.00" alongside the colored highlight.
|
||||
|
||||
### Pitfall 4: Missing Data Rendered as Zero (COMP-04 violation)
|
||||
**What goes wrong:** A candidate with `weightGrams: null` shows "0g" in the weight row, misleading the user.
|
||||
**Why it happens:** Passing `null` through subtraction arithmetic silently produces 0 in JavaScript.
|
||||
**How to avoid:** Guard before computing: `if (c.weightGrams == null) return [c.id, null]`. In the cell renderer, when value is null, render `—` (em dash).
|
||||
**Warning signs:** COMP-04 violated; user appears to see "0g" for an item with no weight entered.
|
||||
|
||||
### Pitfall 5: z-index Conflicts with Panels/Dropdowns
|
||||
**What goes wrong:** Sticky label column renders above the SlideOutPanel or modal overlays.
|
||||
**Why it happens:** Using `z-index: 50` or higher on sticky cells competes with panel z-index values.
|
||||
**How to avoid:** Use `z-index: 10` (Tailwind `z-10`) for sticky cells. They only need to be above the regular table body cells (z-index: auto). The compare view has no interactive StatusBadge dropdowns (read-only in resolved mode; in active mode the compare view is navigational, not mutation-focused).
|
||||
**Warning signs:** Sticky column clips or obscures slide-out panels.
|
||||
|
||||
### Pitfall 6: Pros/Cons Rendered as Raw String
|
||||
**What goes wrong:** A candidate's pros appear as a single run-on text block with no formatting.
|
||||
**Why it happens:** `CandidateForm` stores pros/cons as newline-separated plain text. Plain JSX `{candidate.pros}` ignores newlines in HTML.
|
||||
**How to avoid:** Split on `"\n"`, filter empty strings, render as `<ul>/<li>`. Confirmed from `CandidateForm.tsx` textarea with "One pro per line..." placeholder.
|
||||
**Warning signs:** All pro/con items concatenated without separation.
|
||||
|
||||
---
|
||||
|
||||
## Code Examples
|
||||
|
||||
Verified patterns from project source:
|
||||
|
||||
### Extending uiStore candidateViewMode
|
||||
```typescript
|
||||
// src/client/stores/uiStore.ts — lines 53-54 today read:
|
||||
// candidateViewMode: "list" | "grid";
|
||||
// setCandidateViewMode: (mode: "list" | "grid") => void;
|
||||
|
||||
// After change (only these two lines change in the interface):
|
||||
candidateViewMode: "list" | "grid" | "compare";
|
||||
setCandidateViewMode: (mode: "list" | "grid" | "compare") => void;
|
||||
|
||||
// Implementation at lines 112-113 — no change needed:
|
||||
candidateViewMode: "list",
|
||||
setCandidateViewMode: (mode) => set({ candidateViewMode: mode }),
|
||||
```
|
||||
|
||||
### threadId.tsx integration point (current line 192)
|
||||
```tsx
|
||||
// Current conditional rendering at line 192:
|
||||
// ) : candidateViewMode === "list" ? (
|
||||
// <Reorder.Group ... /> or <div ... />
|
||||
// ) : (
|
||||
// <div className="grid ..."> (grid view)
|
||||
// )
|
||||
|
||||
// After change — add compare branch before list check:
|
||||
} : candidateViewMode === "compare" ? (
|
||||
<ComparisonTable
|
||||
candidates={displayItems}
|
||||
resolvedCandidateId={thread.resolvedCandidateId}
|
||||
/>
|
||||
) : candidateViewMode === "list" ? (
|
||||
// list rendering (unchanged)
|
||||
) : (
|
||||
// grid rendering (unchanged)
|
||||
)
|
||||
```
|
||||
|
||||
### Minimum viable ComparisonTable props interface
|
||||
```typescript
|
||||
// Reuse the CandidateWithCategory type from hooks/useThreads.ts
|
||||
interface ComparisonTableProps {
|
||||
candidates: CandidateWithCategory[]; // already typed in useThreads.ts
|
||||
resolvedCandidateId: number | null; // for winner column highlight
|
||||
}
|
||||
```
|
||||
|
||||
### Full delta computation with useMemo (null-safe)
|
||||
```typescript
|
||||
const { priceDeltas, bestPriceId } = useMemo(() => {
|
||||
const withPrice = candidates.filter((c) => c.priceCents != null);
|
||||
if (withPrice.length === 0) {
|
||||
return { priceDeltas: new Map<number, string | null>(), bestPriceId: null };
|
||||
}
|
||||
const minCents = Math.min(...withPrice.map((c) => c.priceCents as number));
|
||||
const bestPriceId = withPrice.find((c) => c.priceCents === minCents)!.id;
|
||||
const priceDeltas = new Map(
|
||||
candidates.map((c) => {
|
||||
if (c.priceCents == null) return [c.id, null]; // missing data
|
||||
const delta = c.priceCents - minCents;
|
||||
return [c.id, delta === 0 ? null : `+${formatPrice(delta, currency)}`];
|
||||
})
|
||||
);
|
||||
return { priceDeltas, bestPriceId };
|
||||
}, [candidates, currency]);
|
||||
```
|
||||
|
||||
### Best-cell highlight pattern (weight example)
|
||||
```tsx
|
||||
// Weight cell — bg-blue-50 for "lightest" (matches existing blue weight pills in CandidateListItem)
|
||||
function WeightCell({ candidate, delta, isBest, unit }: WeightCellProps) {
|
||||
return (
|
||||
<td className={`px-4 py-3 text-sm ${isBest ? "bg-blue-50" : ""}`}>
|
||||
{candidate.weightGrams != null ? (
|
||||
<>
|
||||
<span className="font-medium text-gray-900">
|
||||
{formatWeight(candidate.weightGrams, unit)}
|
||||
</span>
|
||||
{delta && (
|
||||
<span className="block text-xs text-gray-400 mt-0.5">{delta}</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className="text-gray-300">—</span>
|
||||
)}
|
||||
</td>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Note: CONTEXT.md uses "bg-green-50" as the example for lightest weight. Recommend aligning with existing project badge colors: lightest weight → `bg-blue-50` (consistent with blue weight pills), cheapest price → `bg-green-50` (consistent with green price pills). This is within Claude's discretion.
|
||||
|
||||
### Winner column pattern (resolved threads)
|
||||
```tsx
|
||||
// Column header for the winning candidate gets amber tint (matches resolution banner)
|
||||
<th
|
||||
key={c.id}
|
||||
className={`px-4 py-3 text-left text-xs font-medium min-w-[160px] ${
|
||||
c.id === resolvedCandidateId
|
||||
? "bg-amber-50 text-amber-800"
|
||||
: "text-gray-700"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{c.id === resolvedCandidateId && (
|
||||
<LucideIcon name="trophy" size={12} className="text-amber-600" />
|
||||
)}
|
||||
{c.name}
|
||||
</div>
|
||||
</th>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## State of the Art
|
||||
|
||||
| Old Approach | Current Approach | Notes |
|
||||
|--------------|------------------|-------|
|
||||
| Hand-rolled format functions | Reuse `formatWeight` / `formatPrice` with delta arithmetic | Project formatters already handle all units, currencies, and null |
|
||||
| `overflow-x: auto` on `<table>` | `overflow-x-auto` on wrapper `<div>` | CSS spec: overflow only applies to block containers |
|
||||
| JS-based sticky columns | CSS `position: sticky` with `left: 0` | 92%+ browser support, zero JS overhead |
|
||||
| Inline column rendering | Declarative row-definition array | Matches the locked attribute order, easy to maintain |
|
||||
|
||||
**Deprecated/outdated:**
|
||||
- Direct lucide icon component imports (e.g., `import { LayoutList } from "lucide-react"`): project uses `LucideIcon` helper uniformly — follow the same pattern.
|
||||
|
||||
---
|
||||
|
||||
## Open Questions
|
||||
|
||||
All questions resolved during research:
|
||||
|
||||
1. **Pros/cons storage format — RESOLVED**
|
||||
- `CandidateForm.tsx` uses a `<textarea>` with "One pro per line..." placeholder
|
||||
- Submit handler: `pros: form.pros.trim() || undefined` — empty string → not sent → stored as `null`
|
||||
- Non-empty content: raw multiline text stored as-is, newline-separated
|
||||
- **Action for planner:** Use `BulletCell` pattern (split on `\n`, render `<ul>/<li>`)
|
||||
|
||||
2. **`columns-3` icon availability — RESOLVED**
|
||||
- Verified: `Columns3` is present in lucide-react ^0.577.0 installed package
|
||||
- Use `<LucideIcon name="columns-3" size={16} />` — the LucideIcon helper converts to PascalCase
|
||||
- `table-2` is also present as a backup if needed
|
||||
|
||||
3. **"Add Candidate" button in compare mode — RECOMMENDATION**
|
||||
- Currently guarded by `{isActive && ...}` in `$threadId.tsx`
|
||||
- Recommendation: hide "Add Candidate" when `candidateViewMode === "compare"` (keep toolbar uncluttered; users switch to list/grid to add)
|
||||
- Implementation: add `&& candidateViewMode !== "compare"` to the existing `isActive` guard
|
||||
|
||||
---
|
||||
|
||||
## Validation Architecture
|
||||
|
||||
### Test Framework
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| Framework | Bun test (built-in) |
|
||||
| Config file | None — uses `bun test` directly |
|
||||
| Quick run command | `bun test tests/lib/` |
|
||||
| Full suite command | `bun test` |
|
||||
|
||||
### Phase Requirements → Test Map
|
||||
|
||||
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|
||||
|--------|----------|-----------|-------------------|-------------|
|
||||
| COMP-01 | Candidates display all required fields in table | manual-only (UI/browser) | — | N/A |
|
||||
| COMP-02 | Delta computation: null-safe, best candidate identified, zero-delta suppressed | unit (if extracted to util) | `bun test tests/lib/comparison-deltas.test.ts` | ❌ Wave 0 gap (optional) |
|
||||
| COMP-03 | Table scrolls horizontally / sticky label column stays fixed | manual-only (CSS/browser) | — | N/A |
|
||||
| COMP-04 | Resolved thread shows read-only view with winner marked; no zero for missing data | manual-only (UI state) | — | N/A |
|
||||
|
||||
**Note on testing scope:** COMP-01, COMP-03, COMP-04 are UI/browser behaviors. COMP-02 delta logic is pure arithmetic — testable if extracted to a standalone utility function. This is a pure frontend phase; the existing `bun test` suite covers backend services only and will not be broken by this phase.
|
||||
|
||||
### Sampling Rate
|
||||
- **Per task commit:** `bun test` (full suite, fast — no UI tests in suite)
|
||||
- **Per wave merge:** `bun test`
|
||||
- **Phase gate:** Full suite green + manual browser verification of scroll/sticky behavior on a narrow viewport
|
||||
|
||||
### Wave 0 Gaps
|
||||
- [ ] `tests/lib/comparison-deltas.test.ts` — covers COMP-02 delta logic if extracted to a pure utility (optional; skip if deltas stay inlined in the React component)
|
||||
|
||||
*(If delta computation stays in the React component via `useMemo`, no new test files are needed — COMP-02 is verified manually in the browser.)*
|
||||
|
||||
---
|
||||
|
||||
## Sources
|
||||
|
||||
### Primary (HIGH confidence)
|
||||
- Project codebase direct inspection:
|
||||
- `src/client/stores/uiStore.ts` — confirmed `candidateViewMode` type and setter
|
||||
- `src/client/routes/threads/$threadId.tsx` — confirmed integration points, toggle bar pattern, lines to modify
|
||||
- `src/client/components/CandidateListItem.tsx` — confirmed `RankBadge` export, `CandidateWithCategory` interface
|
||||
- `src/client/components/CandidateCard.tsx` — confirmed field usage patterns
|
||||
- `src/client/components/CandidateForm.tsx` — confirmed pros/cons are newline-separated textarea input; empty = null
|
||||
- `src/client/lib/formatters.ts` — confirmed null handling, `"--"` return for null
|
||||
- `src/client/hooks/useThreads.ts` — confirmed `CandidateWithCategory` shape with all fields needed
|
||||
- `package.json` — confirmed no new dependencies needed
|
||||
- Runtime verification: `Columns3` in lucide-react ^0.577.0 confirmed present via node script
|
||||
|
||||
### Secondary (MEDIUM confidence)
|
||||
- [Tailwind CSS overflow docs](https://tailwindcss.com/docs/overflow) — `overflow-x-auto` on wrapper div pattern
|
||||
- [Tailwind CSS position docs](https://tailwindcss.com/docs/position) — sticky utility, z-index behavior
|
||||
- [Lexington Themes — scrollable sticky header table](https://lexingtonthemes.com/blog/how-to-build-a-scrollable-table-with-sticky-header-using-tailwind-css) — confirmed sticky thead pattern with Tailwind
|
||||
|
||||
### Tertiary (LOW confidence — WebSearch, not verified against official spec)
|
||||
- [Multi-Directional Sticky CSS (Medium, Jan 2026)](https://medium.com/@ashutoshgautam10b11/multi-directional-sticky-css-and-horizontal-scroll-in-tables-41fc25c3ce8b) — z-index layering reference; this phase only needs one sticky axis (left column, z-10 suffices)
|
||||
- [DEV Community — sticky frozen column](https://dev.to/nicolaserny/table-with-a-fixed-first-column-2c5b) — background color requirement for sticky cells confirmed
|
||||
- [freeCodeCamp Forum — overflow + sticky](https://forum.freecodecamp.org/t/fixing-sticky-table-header-with-horizontal-scroll-in-a-scrollable-container/735559) — overflow-x-auto must be on wrapper div, not table element
|
||||
|
||||
---
|
||||
|
||||
## Metadata
|
||||
|
||||
**Confidence breakdown:**
|
||||
- Standard stack: HIGH — all dependencies confirmed present in package.json; no new packages
|
||||
- Architecture: HIGH — directly derived from reading all relevant project source files
|
||||
- Pitfalls: HIGH — sticky bg issue confirmed by multiple sources; overflow-on-table confirmed by CSS spec; pros/cons newline format confirmed from CandidateForm source
|
||||
- Delta computation: HIGH — pure arithmetic, formatters already handle null, confirmed return values
|
||||
|
||||
**Research date:** 2026-03-17
|
||||
**Valid until:** 2026-04-17 (stable CSS, stable React, stable project codebase)
|
||||
78
.planning/phases/12-comparison-view/12-VALIDATION.md
Normal file
78
.planning/phases/12-comparison-view/12-VALIDATION.md
Normal file
@@ -0,0 +1,78 @@
|
||||
---
|
||||
phase: 12
|
||||
slug: comparison-view
|
||||
status: draft
|
||||
nyquist_compliant: false
|
||||
wave_0_complete: false
|
||||
created: 2026-03-17
|
||||
---
|
||||
|
||||
# Phase 12 — Validation Strategy
|
||||
|
||||
> Per-phase validation contract for feedback sampling during execution.
|
||||
|
||||
---
|
||||
|
||||
## Test Infrastructure
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| **Framework** | Bun test (built-in) |
|
||||
| **Config file** | None — uses `bun test` directly |
|
||||
| **Quick run command** | `bun test` |
|
||||
| **Full suite command** | `bun test` |
|
||||
| **Estimated runtime** | ~5 seconds |
|
||||
|
||||
---
|
||||
|
||||
## Sampling Rate
|
||||
|
||||
- **After every task commit:** Run `bun test`
|
||||
- **After every plan wave:** Run `bun test`
|
||||
- **Before `/gsd:verify-work`:** Full suite must be green
|
||||
- **Max feedback latency:** 5 seconds
|
||||
|
||||
---
|
||||
|
||||
## Per-Task Verification Map
|
||||
|
||||
| Task ID | Plan | Wave | Requirement | Test Type | Automated Command | File Exists | Status |
|
||||
|---------|------|------|-------------|-----------|-------------------|-------------|--------|
|
||||
| 12-01-01 | 01 | 1 | COMP-01 | manual-only (UI/browser) | — | N/A | ⬜ pending |
|
||||
| 12-01-02 | 01 | 1 | COMP-02 | unit (if delta util extracted) | `bun test tests/lib/comparison-deltas.test.ts` | ❌ W0 | ⬜ pending |
|
||||
| 12-01-03 | 01 | 1 | COMP-03 | manual-only (CSS/browser) | — | N/A | ⬜ pending |
|
||||
| 12-01-04 | 01 | 1 | COMP-04 | manual-only (UI state) | — | N/A | ⬜ pending |
|
||||
|
||||
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
|
||||
|
||||
---
|
||||
|
||||
## Wave 0 Requirements
|
||||
|
||||
- [ ] `tests/lib/comparison-deltas.test.ts` — stubs for COMP-02 delta computation (optional; skip if deltas stay inlined in React component via useMemo)
|
||||
|
||||
*Existing `bun test` infrastructure covers all backend services. This phase is pure frontend — no backend tests are broken.*
|
||||
|
||||
---
|
||||
|
||||
## Manual-Only Verifications
|
||||
|
||||
| Behavior | Requirement | Why Manual | Test Instructions |
|
||||
|----------|-------------|------------|-------------------|
|
||||
| Candidates display all fields in tabular columns | COMP-01 | UI rendering — no backend logic | Open thread with 2+ candidates, toggle compare view, verify all fields visible |
|
||||
| Table scrolls horizontally with sticky label column | COMP-03 | CSS behavior — requires browser | Narrow viewport to <768px, verify horizontal scroll, verify label column stays fixed |
|
||||
| Resolved thread shows read-only view with winner marked | COMP-04 | UI state — requires resolved thread | Open resolved thread, toggle compare view, verify winner column highlighted, no interactive elements |
|
||||
| Missing weight/price shows dash, not zero | COMP-04 | UI rendering for null data | Add candidate with no weight, toggle compare, verify "—" not "0g" |
|
||||
|
||||
---
|
||||
|
||||
## Validation Sign-Off
|
||||
|
||||
- [ ] All tasks have `<automated>` verify or Wave 0 dependencies
|
||||
- [ ] Sampling continuity: no 3 consecutive tasks without automated verify
|
||||
- [ ] Wave 0 covers all MISSING references
|
||||
- [ ] No watch-mode flags
|
||||
- [ ] Feedback latency < 5s
|
||||
- [ ] `nyquist_compliant: true` set in frontmatter
|
||||
|
||||
**Approval:** pending
|
||||
95
.planning/phases/12-comparison-view/12-VERIFICATION.md
Normal file
95
.planning/phases/12-comparison-view/12-VERIFICATION.md
Normal file
@@ -0,0 +1,95 @@
|
||||
---
|
||||
phase: 12-comparison-view
|
||||
verified: 2026-03-17T00:00:00Z
|
||||
status: passed
|
||||
score: 7/7 must-haves verified
|
||||
re_verification: false
|
||||
---
|
||||
|
||||
# Phase 12: Comparison View Verification Report
|
||||
|
||||
**Phase Goal:** Users can view all candidates for a thread side-by-side in a table with relative weight and price deltas
|
||||
**Verified:** 2026-03-17
|
||||
**Status:** passed
|
||||
**Re-verification:** No — initial verification
|
||||
|
||||
## Goal Achievement
|
||||
|
||||
### Observable Truths
|
||||
|
||||
| # | Truth | Status | Evidence |
|
||||
|---|-------|--------|----------|
|
||||
| 1 | User can toggle to a Compare view when a thread has 2+ candidates | VERIFIED | `$threadId.tsx:172` — compare button wrapped in `{thread.candidates.length >= 2 && (...)}`; clicking calls `setCandidateViewMode("compare")` |
|
||||
| 2 | Comparison table shows all candidates side-by-side with weight, price, images, notes, links, status, pros, and cons | VERIFIED | `ComparisonTable.tsx:104-269` — ATTRIBUTE_ROWS array defines all 10 rows: Image, Name, Rank, Weight, Price, Status, Link, Notes, Pros, Cons |
|
||||
| 3 | The lightest candidate weight cell has a blue highlight; the cheapest candidate price cell has a green highlight | VERIFIED | `ComparisonTable.tsx:167` — `cellClass` returns `"bg-blue-50"` when `c.id === bestWeightId`; `ComparisonTable.tsx:193` — `"bg-green-50"` when `c.id === bestPriceId` |
|
||||
| 4 | Non-best cells show a gray +delta string; best cells show no delta | VERIFIED | `ComparisonTable.tsx:64-66` — delta stored as `null` when `delta === 0` (best), else `+${formatWeight(delta, unit)}`; `ComparisonTable.tsx:160-162` — delta div only rendered when `!isBest && delta` |
|
||||
| 5 | The table scrolls horizontally on narrow viewports while the attribute label column stays fixed on the left | VERIFIED | `ComparisonTable.tsx:274` — outer `<div className="overflow-x-auto ...">` for scroll; `ComparisonTable.tsx:282,311` — every label cell has `sticky left-0 z-10 bg-white` |
|
||||
| 6 | Missing weight or price data displays a dash, never a misleading zero | VERIFIED | `ComparisonTable.tsx:152-153` — `if (c.weightGrams == null) return <span className="text-gray-300">—</span>`; `ComparisonTable.tsx:178-179` — same pattern for price |
|
||||
| 7 | A resolved thread shows the comparison read-only with the winner column visually marked (amber tint + trophy) | VERIFIED | `ComparisonTable.tsx:284-301` — winner `<th>` gets `bg-amber-50 text-amber-800` + trophy icon; body cells get `bg-amber-50/50` tint via default `extraClass` branch at line 318-320; no mutation controls exist inside ComparisonTable |
|
||||
|
||||
**Score:** 7/7 truths verified
|
||||
|
||||
### Required Artifacts
|
||||
|
||||
| Artifact | Expected | Status | Details |
|
||||
|----------|----------|--------|---------|
|
||||
| `src/client/components/ComparisonTable.tsx` | Tabular side-by-side comparison component; min 120 lines | VERIFIED | 336 lines; full ATTRIBUTE_ROWS declarative pattern, useMemo deltas, sticky column, winner highlighting |
|
||||
| `src/client/stores/uiStore.ts` | Extended candidateViewMode union including "compare" | VERIFIED | Line 53: `candidateViewMode: "list" \| "grid" \| "compare"` and line 54: `setCandidateViewMode: (mode: "list" \| "grid" \| "compare") => void` |
|
||||
| `src/client/routes/threads/$threadId.tsx` | Compare toggle button and ComparisonTable rendering branch | VERIFIED | Line 6 imports ComparisonTable; line 172-185 conditionally renders compare button; line 207-211 renders `<ComparisonTable>` when `candidateViewMode === "compare"` |
|
||||
|
||||
### Key Link Verification
|
||||
|
||||
| From | To | Via | Status | Details |
|
||||
|------|----|-----|--------|---------|
|
||||
| `$threadId.tsx` | `ComparisonTable.tsx` | import + conditional render when `candidateViewMode === "compare"` | WIRED | Line 6 imports; line 207 `candidateViewMode === "compare"` branch renders `<ComparisonTable candidates={displayItems} resolvedCandidateId={thread.resolvedCandidateId} />` |
|
||||
| `ComparisonTable.tsx` | `lib/formatters.ts` | `formatWeight` and `formatPrice` for cell values and delta strings | WIRED | Line 4 imports both; used at lines 66, 92, 158, 184 for both display values and delta string construction |
|
||||
| `ComparisonTable.tsx` | `CandidateListItem.tsx` | `RankBadge` import for rank row | WIRED | Line 7 imports `RankBadge`; used at line 144 in the rank ATTRIBUTE_ROW render function |
|
||||
| `$threadId.tsx` | `uiStore.ts` | `candidateViewMode` state read and `setCandidateViewMode` action | WIRED | Lines 24-25 read both from `useUIStore`; `candidateViewMode` read at lines 124, 152, 164, 177, 207, 212; `setCandidateViewMode` called at lines 150, 163, 175 |
|
||||
|
||||
### Requirements Coverage
|
||||
|
||||
| Requirement | Source Plan | Description | Status | Evidence |
|
||||
|-------------|-------------|-------------|--------|----------|
|
||||
| COMP-01 | 12-01-PLAN.md | User can view candidates side-by-side in a tabular comparison layout (weight, price, images, notes, links, status) | SATISFIED | ComparisonTable renders all fields as columns; toggle wired in $threadId.tsx |
|
||||
| COMP-02 | 12-01-PLAN.md | User can see relative deltas highlighting the lightest and cheapest candidate with +/- differences | SATISFIED | useMemo delta computation at lines 47-102; blue-50/green-50 highlights; gray delta text for non-best cells |
|
||||
| COMP-03 | 12-01-PLAN.md | Comparison table scrolls horizontally with a sticky label column on narrow viewports | SATISFIED | `overflow-x-auto` on wrapper; `sticky left-0 z-10 bg-white` on all label cells; `minWidth` computed from candidate count |
|
||||
| COMP-04 | 12-01-PLAN.md | Comparison view displays read-only summary for resolved threads | SATISFIED | No mutation actions in ComparisonTable; winner column amber-marked with trophy; `resolvedCandidateId` prop drives the read-only winner state |
|
||||
|
||||
No orphaned requirements found. REQUIREMENTS.md maps COMP-01 through COMP-04 exclusively to Phase 12, all are accounted for by plan 12-01.
|
||||
|
||||
### Anti-Patterns Found
|
||||
|
||||
| File | Line | Pattern | Severity | Impact |
|
||||
|------|------|---------|----------|--------|
|
||||
| — | — | None found | — | — |
|
||||
|
||||
No TODO/FIXME/placeholder comments, empty implementations, or stub returns found in any of the three phase 12 files. Biome lint passes cleanly on all three files (only pre-existing unrelated issues in other src files; none in phase 12 files).
|
||||
|
||||
### Human Verification Required
|
||||
|
||||
#### 1. Horizontal scroll with sticky label column
|
||||
|
||||
**Test:** Open a thread with 3+ candidates on a narrow viewport (< 600px). Scroll the table right.
|
||||
**Expected:** Candidate columns scroll off-screen left; the attribute label column (Image, Name, Rank, etc.) remains fixed at the left edge without content bleed-through.
|
||||
**Why human:** CSS `sticky` behavior with `overflow-x-auto` interactions cannot be asserted by grep; only visual browser confirmation validates the `bg-white` bleed-through prevention.
|
||||
|
||||
#### 2. Winner column amber tint + trophy on resolved thread
|
||||
|
||||
**Test:** Navigate to a thread that has been resolved. Switch to compare view.
|
||||
**Expected:** The winning candidate's column header shows a trophy icon and amber background; every row of that column has a subtle amber-50/50 tint. Weight/price highlight colors (blue/green) take priority over amber when the winner is also the lightest/cheapest.
|
||||
**Why human:** Color layering and opacity compositing require visual verification.
|
||||
|
||||
#### 3. Delta display with mixed null/non-null data
|
||||
|
||||
**Test:** Add two candidates to a thread where one has weight data and the other does not. Switch to compare view.
|
||||
**Expected:** The candidate with no weight shows an em dash in the weight row (not 0g). The one with weight shows its value with no delta label (it is trivially the best). No misleading zero appears.
|
||||
**Why human:** Edge-case rendering for the null path requires runtime React state to confirm the `formatWeight(null)` → `"--"` path is reached and displayed as `—` (em dash span), not the `"--"` string fallback from formatters.
|
||||
|
||||
### Gaps Summary
|
||||
|
||||
No gaps found. All seven observable truths are verified, all three artifacts exist and are substantive, all four key links are wired end-to-end, all four COMP requirements are satisfied by traceable code, lint passes on all phase files, and 135 tests pass with zero regressions.
|
||||
|
||||
---
|
||||
|
||||
_Verified: 2026-03-17_
|
||||
_Verifier: Claude (gsd-verifier)_
|
||||
253
.planning/phases/13-setup-impact-preview/13-01-PLAN.md
Normal file
253
.planning/phases/13-setup-impact-preview/13-01-PLAN.md
Normal file
@@ -0,0 +1,253 @@
|
||||
---
|
||||
phase: 13-setup-impact-preview
|
||||
plan: 01
|
||||
type: tdd
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- src/client/lib/impactDeltas.ts
|
||||
- src/client/hooks/useImpactDeltas.ts
|
||||
- src/client/hooks/useThreads.ts
|
||||
- src/client/stores/uiStore.ts
|
||||
- tests/lib/impactDeltas.test.ts
|
||||
autonomous: true
|
||||
requirements: [IMPC-01, IMPC-02, IMPC-03, IMPC-04]
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "computeImpactDeltas returns replace-mode deltas when a setup item matches the thread category"
|
||||
- "computeImpactDeltas returns add-mode deltas (candidate value only) when no category match exists"
|
||||
- "computeImpactDeltas returns null weightDelta/priceDelta when candidate has null weight/price"
|
||||
- "computeImpactDeltas returns mode 'none' with empty deltas when setupItems is undefined"
|
||||
- "selectedSetupId state persists in uiStore and can be set/cleared"
|
||||
- "ThreadWithCandidates interface includes categoryId field"
|
||||
artifacts:
|
||||
- path: "src/client/lib/impactDeltas.ts"
|
||||
provides: "Pure computeImpactDeltas function with CandidateDelta, ImpactDeltas types"
|
||||
exports: ["computeImpactDeltas", "CandidateInput", "CandidateDelta", "DeltaMode", "ImpactDeltas"]
|
||||
- path: "src/client/hooks/useImpactDeltas.ts"
|
||||
provides: "React hook wrapping computeImpactDeltas in useMemo"
|
||||
exports: ["useImpactDeltas"]
|
||||
- path: "tests/lib/impactDeltas.test.ts"
|
||||
provides: "Unit tests for all four IMPC requirements"
|
||||
contains: "computeImpactDeltas"
|
||||
key_links:
|
||||
- from: "src/client/hooks/useImpactDeltas.ts"
|
||||
to: "src/client/lib/impactDeltas.ts"
|
||||
via: "import computeImpactDeltas"
|
||||
pattern: "import.*computeImpactDeltas.*from.*lib/impactDeltas"
|
||||
- from: "src/client/hooks/useImpactDeltas.ts"
|
||||
to: "src/client/hooks/useSetups.ts"
|
||||
via: "SetupItemWithCategory type import"
|
||||
pattern: "import.*SetupItemWithCategory.*from.*useSetups"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Create the pure impact delta computation logic with full TDD coverage, add selectedSetupId to uiStore, fix the ThreadWithCandidates type to include categoryId, and wrap it all in a useImpactDeltas hook.
|
||||
|
||||
Purpose: Establish the data layer and contracts that Plan 02 will consume for rendering delta indicators. TDD ensures the replace/add mode logic and null-weight handling are correct before any UI work.
|
||||
Output: Tested pure function, React hook, updated types, uiStore state.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@/home/jlmak/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@/home/jlmak/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/13-setup-impact-preview/13-RESEARCH.md
|
||||
|
||||
<interfaces>
|
||||
<!-- Key types and contracts the executor needs. Extracted from codebase. -->
|
||||
|
||||
From src/client/hooks/useSetups.ts:
|
||||
```typescript
|
||||
interface SetupItemWithCategory {
|
||||
id: number;
|
||||
name: string;
|
||||
weightGrams: number | null;
|
||||
priceCents: number | null;
|
||||
categoryId: number;
|
||||
notes: string | null;
|
||||
productUrl: string | null;
|
||||
imageFilename: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
categoryName: string;
|
||||
categoryIcon: string;
|
||||
classification: string;
|
||||
}
|
||||
|
||||
export function useSetup(setupId: number | null) {
|
||||
return useQuery({
|
||||
queryKey: ["setups", setupId],
|
||||
queryFn: () => apiGet<SetupWithItems>(`/api/setups/${setupId}`),
|
||||
enabled: setupId != null, // CRITICAL: prevents null fetch
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
From src/client/hooks/useThreads.ts (BEFORE fix):
|
||||
```typescript
|
||||
interface ThreadWithCandidates {
|
||||
id: number;
|
||||
name: string;
|
||||
status: "active" | "resolved";
|
||||
resolvedCandidateId: number | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
candidates: CandidateWithCategory[];
|
||||
// NOTE: categoryId is MISSING — server returns it but type omits it
|
||||
}
|
||||
```
|
||||
|
||||
From src/client/stores/uiStore.ts (existing pattern):
|
||||
```typescript
|
||||
candidateViewMode: "list" | "grid" | "compare";
|
||||
setCandidateViewMode: (mode: "list" | "grid" | "compare") => void;
|
||||
// Add selectedSetupId + setter using same pattern
|
||||
```
|
||||
|
||||
From src/client/lib/formatters.ts:
|
||||
```typescript
|
||||
export type WeightUnit = "g" | "oz" | "lb" | "kg";
|
||||
export function formatWeight(grams: number | null | undefined, unit: WeightUnit = "g"): string;
|
||||
export type Currency = "USD" | "EUR" | "GBP" | "JPY" | "CAD" | "AUD";
|
||||
export function formatPrice(cents: number | null | undefined, currency: Currency = "USD"): string;
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<feature>
|
||||
<name>Impact Delta Computation</name>
|
||||
<files>src/client/lib/impactDeltas.ts, tests/lib/impactDeltas.test.ts, src/client/hooks/useImpactDeltas.ts, src/client/hooks/useThreads.ts, src/client/stores/uiStore.ts</files>
|
||||
<behavior>
|
||||
IMPC-01 (setup selected, deltas computed):
|
||||
- Given candidates with weight/price and a setup with items, returns per-candidate delta objects with weightDelta and priceDelta numbers
|
||||
- Given no setup selected (setupItems = undefined), returns { mode: "none", deltas: {} }
|
||||
|
||||
IMPC-02 (replace mode auto-detection):
|
||||
- Given setup items where one has categoryId === threadCategoryId, mode is "replace"
|
||||
- In replace mode, weightDelta = candidate.weightGrams - replacedItem.weightGrams
|
||||
- In replace mode, priceDelta = candidate.priceCents - replacedItem.priceCents
|
||||
- replacedItemName is populated with the matched item's name
|
||||
|
||||
IMPC-03 (add mode):
|
||||
- Given setup items where NONE have categoryId === threadCategoryId, mode is "add"
|
||||
- In add mode, weightDelta = candidate.weightGrams (pure addition)
|
||||
- In add mode, priceDelta = candidate.priceCents (pure addition)
|
||||
- replacedItemName is null
|
||||
|
||||
IMPC-04 (null weight handling):
|
||||
- Given candidate.weightGrams is null, weightDelta is null (not 0, not NaN)
|
||||
- Given candidate.priceCents is null, priceDelta is null
|
||||
- In replace mode with replacedItem.weightGrams null but candidate has weight, weightDelta = candidate.weightGrams (treat as add for that field)
|
||||
|
||||
Edge cases:
|
||||
- Empty candidates array -> returns { mode based on setup, deltas: {} }
|
||||
- Multiple setup items in same category as thread -> first match used for replacement
|
||||
</behavior>
|
||||
<implementation>
|
||||
1. Create src/client/lib/impactDeltas.ts with:
|
||||
- CandidateInput interface: { id: number; weightGrams: number | null; priceCents: number | null }
|
||||
- DeltaMode type: "replace" | "add" | "none"
|
||||
- CandidateDelta interface: { candidateId, mode, weightDelta, priceDelta, replacedItemName }
|
||||
- ImpactDeltas interface: { mode: DeltaMode; deltas: Record<number, CandidateDelta> }
|
||||
- SetupItemInput interface: { categoryId: number; weightGrams: number | null; priceCents: number | null; name: string } (minimal subset of SetupItemWithCategory)
|
||||
- computeImpactDeltas(candidates, setupItems, threadCategoryId) pure function
|
||||
|
||||
2. Create src/client/hooks/useImpactDeltas.ts wrapping in useMemo
|
||||
|
||||
3. Add categoryId to ThreadWithCandidates in useThreads.ts
|
||||
|
||||
4. Add selectedSetupId + setSelectedSetupId to uiStore.ts
|
||||
</implementation>
|
||||
</feature>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 1: TDD pure computeImpactDeltas function</name>
|
||||
<files>src/client/lib/impactDeltas.ts, tests/lib/impactDeltas.test.ts</files>
|
||||
<behavior>
|
||||
- Test: no setup selected (undefined) returns mode "none", empty deltas
|
||||
- Test: replace mode — setup item matches threadCategoryId, deltas are candidate minus replaced item
|
||||
- Test: add mode — no setup item matches, deltas equal candidate values
|
||||
- Test: null candidate weight returns null weightDelta, not zero
|
||||
- Test: null candidate price returns null priceDelta
|
||||
- Test: replace mode with null replacedItem weight but valid candidate weight returns candidate weight as delta (add-like for that field)
|
||||
- Test: negative delta in replace mode (candidate lighter than replaced item)
|
||||
- Test: zero delta in replace mode (identical weight)
|
||||
- Test: replacedItemName populated in replace mode, null in add mode
|
||||
</behavior>
|
||||
<action>
|
||||
RED: Create tests/lib/impactDeltas.test.ts importing computeImpactDeltas from @/client/lib/impactDeltas. Write all test cases above using Bun test (describe/test/expect). Run tests — they MUST fail (module not found).
|
||||
|
||||
GREEN: Create src/client/lib/impactDeltas.ts with:
|
||||
- Export types: CandidateInput, SetupItemInput, DeltaMode, CandidateDelta, ImpactDeltas
|
||||
- Export function computeImpactDeltas(candidates: CandidateInput[], setupItems: SetupItemInput[] | undefined, threadCategoryId: number): ImpactDeltas
|
||||
- Logic: if !setupItems return { mode: "none", deltas: {} }
|
||||
- Find replacedItem = setupItems.find(item => item.categoryId === threadCategoryId) ?? null
|
||||
- mode = replacedItem ? "replace" : "add"
|
||||
- For each candidate: null-guard weight/price BEFORE arithmetic. In replace mode with non-null replaced value, delta = candidate - replaced. In replace mode with null replaced value, delta = candidate value (like add). In add mode, delta = candidate value.
|
||||
- Run tests — all MUST pass.
|
||||
|
||||
REFACTOR: None needed for pure function.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/jlmak/Projects/jlmak/GearBox && bun test tests/lib/impactDeltas.test.ts</automated>
|
||||
</verify>
|
||||
<done>All 9+ test cases pass. computeImpactDeltas correctly handles replace mode, add mode, null weights, null prices, and edge cases. Types are exported.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Add uiStore state, fix ThreadWithCandidates type, create useImpactDeltas hook</name>
|
||||
<files>src/client/stores/uiStore.ts, src/client/hooks/useThreads.ts, src/client/hooks/useImpactDeltas.ts</files>
|
||||
<action>
|
||||
1. In src/client/stores/uiStore.ts, add to UIState interface:
|
||||
- selectedSetupId: number | null;
|
||||
- setSelectedSetupId: (id: number | null) => void;
|
||||
Add to create() initializer:
|
||||
- selectedSetupId: null,
|
||||
- setSelectedSetupId: (id) => set({ selectedSetupId: id }),
|
||||
Place after the "Candidate view mode" section as "// Setup impact preview" section.
|
||||
|
||||
2. In src/client/hooks/useThreads.ts, add `categoryId: number;` to the ThreadWithCandidates interface, after the `resolvedCandidateId` field. The server already returns this field — the type was simply missing it.
|
||||
|
||||
3. Create src/client/hooks/useImpactDeltas.ts:
|
||||
- Import useMemo from react
|
||||
- Import computeImpactDeltas and types from "../lib/impactDeltas"
|
||||
- Import type { SetupItemWithCategory } from "./useSetups"
|
||||
- Export function useImpactDeltas(candidates: CandidateInput[], setupItems: SetupItemWithCategory[] | undefined, threadCategoryId: number): ImpactDeltas
|
||||
- Body: return useMemo(() => computeImpactDeltas(candidates, setupItems, threadCategoryId), [candidates, setupItems, threadCategoryId])
|
||||
- Re-export CandidateDelta, DeltaMode, ImpactDeltas types for convenience
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/jlmak/Projects/jlmak/GearBox && bun test tests/lib/impactDeltas.test.ts && bun test tests/lib/formatters.test.ts</automated>
|
||||
</verify>
|
||||
<done>uiStore has selectedSetupId + setter. ThreadWithCandidates includes categoryId. useImpactDeltas hook created and exports types. All existing tests still pass.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
- `bun test tests/lib/` passes all tests (impactDeltas + formatters)
|
||||
- `bun test` full suite passes (no regressions)
|
||||
- Types export correctly: CandidateInput, CandidateDelta, DeltaMode, ImpactDeltas, SetupItemInput from impactDeltas.ts
|
||||
- useImpactDeltas hook wraps pure function in useMemo
|
||||
- uiStore.selectedSetupId defaults to null
|
||||
- ThreadWithCandidates.categoryId is declared as number
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- All IMPC requirement behaviors are tested and passing via pure function unit tests
|
||||
- Data layer contracts (types, hook, uiStore state) are ready for Plan 02 UI consumption
|
||||
- Zero regressions in existing test suite
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/13-setup-impact-preview/13-01-SUMMARY.md`
|
||||
</output>
|
||||
330
.planning/phases/13-setup-impact-preview/13-02-PLAN.md
Normal file
330
.planning/phases/13-setup-impact-preview/13-02-PLAN.md
Normal file
@@ -0,0 +1,330 @@
|
||||
---
|
||||
phase: 13-setup-impact-preview
|
||||
plan: 02
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on: ["13-01"]
|
||||
files_modified:
|
||||
- src/client/components/SetupImpactSelector.tsx
|
||||
- src/client/components/ImpactDeltaBadge.tsx
|
||||
- src/client/routes/threads/$threadId.tsx
|
||||
- src/client/components/CandidateListItem.tsx
|
||||
- src/client/components/CandidateCard.tsx
|
||||
- src/client/components/ComparisonTable.tsx
|
||||
autonomous: true
|
||||
requirements: [IMPC-01, IMPC-02, IMPC-03, IMPC-04]
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "User can select a setup from a dropdown in the thread header"
|
||||
- "Each candidate displays weight and cost delta badges when a setup is selected"
|
||||
- "Replace mode shows signed delta with replaced item name context"
|
||||
- "Add mode shows positive delta labeled as '(add)'"
|
||||
- "Candidate with no weight shows '-- (no weight data)' instead of a zero"
|
||||
- "Candidate with no price shows '-- (no price data)' instead of a zero"
|
||||
- "Deselecting setup ('None') clears all delta indicators"
|
||||
- "Deltas appear in list view, grid view, and comparison table"
|
||||
artifacts:
|
||||
- path: "src/client/components/SetupImpactSelector.tsx"
|
||||
provides: "Setup dropdown for thread header"
|
||||
exports: ["SetupImpactSelector"]
|
||||
- path: "src/client/components/ImpactDeltaBadge.tsx"
|
||||
provides: "Inline delta indicator component"
|
||||
exports: ["ImpactDeltaBadge"]
|
||||
- path: "src/client/routes/threads/$threadId.tsx"
|
||||
provides: "Thread detail page wired with impact preview"
|
||||
- path: "src/client/components/CandidateListItem.tsx"
|
||||
provides: "List item with delta badges"
|
||||
- path: "src/client/components/CandidateCard.tsx"
|
||||
provides: "Card with delta badges"
|
||||
- path: "src/client/components/ComparisonTable.tsx"
|
||||
provides: "Comparison table with impact delta rows"
|
||||
key_links:
|
||||
- from: "src/client/routes/threads/$threadId.tsx"
|
||||
to: "src/client/hooks/useImpactDeltas.ts"
|
||||
via: "useImpactDeltas hook call at page level"
|
||||
pattern: "useImpactDeltas"
|
||||
- from: "src/client/routes/threads/$threadId.tsx"
|
||||
to: "src/client/hooks/useSetups.ts"
|
||||
via: "useSetup(selectedSetupId) for setup item data"
|
||||
pattern: "useSetup\\(selectedSetupId"
|
||||
- from: "src/client/routes/threads/$threadId.tsx"
|
||||
to: "src/client/stores/uiStore.ts"
|
||||
via: "selectedSetupId state read"
|
||||
pattern: "useUIStore.*selectedSetupId"
|
||||
- from: "src/client/components/SetupImpactSelector.tsx"
|
||||
to: "src/client/stores/uiStore.ts"
|
||||
via: "setSelectedSetupId state write"
|
||||
pattern: "setSelectedSetupId"
|
||||
- from: "src/client/components/ImpactDeltaBadge.tsx"
|
||||
to: "src/client/lib/impactDeltas.ts"
|
||||
via: "CandidateDelta type import"
|
||||
pattern: "import.*CandidateDelta"
|
||||
- from: "src/client/components/ComparisonTable.tsx"
|
||||
to: "src/client/lib/impactDeltas.ts"
|
||||
via: "ImpactDeltas type for deltas prop"
|
||||
pattern: "import.*ImpactDeltas"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Build the UI components (setup dropdown + delta badges) and wire them into the thread detail page across all three view modes (list, grid, compare).
|
||||
|
||||
Purpose: This is the user-facing delivery of the impact preview feature. Plan 01 built the logic; this plan renders it.
|
||||
Output: SetupImpactSelector component, ImpactDeltaBadge component, updated CandidateListItem/CandidateCard/ComparisonTable with delta rendering, wired thread detail page.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@/home/jlmak/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@/home/jlmak/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/13-setup-impact-preview/13-RESEARCH.md
|
||||
@.planning/phases/13-setup-impact-preview/13-01-SUMMARY.md
|
||||
|
||||
<interfaces>
|
||||
<!-- Types created by Plan 01 that this plan consumes -->
|
||||
|
||||
From src/client/lib/impactDeltas.ts (created in Plan 01):
|
||||
```typescript
|
||||
export interface CandidateInput {
|
||||
id: number;
|
||||
weightGrams: number | null;
|
||||
priceCents: number | null;
|
||||
}
|
||||
|
||||
export type DeltaMode = "replace" | "add" | "none";
|
||||
|
||||
export interface CandidateDelta {
|
||||
candidateId: number;
|
||||
mode: DeltaMode;
|
||||
weightDelta: number | null;
|
||||
priceDelta: number | null;
|
||||
replacedItemName: string | null;
|
||||
}
|
||||
|
||||
export interface ImpactDeltas {
|
||||
mode: DeltaMode;
|
||||
deltas: Record<number, CandidateDelta>;
|
||||
}
|
||||
```
|
||||
|
||||
From src/client/hooks/useImpactDeltas.ts (created in Plan 01):
|
||||
```typescript
|
||||
export function useImpactDeltas(
|
||||
candidates: CandidateInput[],
|
||||
setupItems: SetupItemWithCategory[] | undefined,
|
||||
threadCategoryId: number,
|
||||
): ImpactDeltas;
|
||||
```
|
||||
|
||||
From src/client/hooks/useSetups.ts:
|
||||
```typescript
|
||||
export function useSetups(): UseQueryResult<SetupListItem[]>;
|
||||
export function useSetup(setupId: number | null): UseQueryResult<SetupWithItems>;
|
||||
```
|
||||
|
||||
From src/client/stores/uiStore.ts (updated in Plan 01):
|
||||
```typescript
|
||||
selectedSetupId: number | null;
|
||||
setSelectedSetupId: (id: number | null) => void;
|
||||
```
|
||||
|
||||
From src/client/hooks/useThreads.ts (updated in Plan 01):
|
||||
```typescript
|
||||
interface ThreadWithCandidates {
|
||||
id: number;
|
||||
name: string;
|
||||
status: "active" | "resolved";
|
||||
resolvedCandidateId: number | null;
|
||||
categoryId: number; // <-- Added in Plan 01
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
candidates: CandidateWithCategory[];
|
||||
}
|
||||
```
|
||||
|
||||
From src/client/lib/formatters.ts:
|
||||
```typescript
|
||||
export function formatWeight(grams: number | null | undefined, unit: WeightUnit = "g"): string;
|
||||
export function formatPrice(cents: number | null | undefined, currency: Currency = "USD"): string;
|
||||
```
|
||||
|
||||
Existing component props that need delta additions:
|
||||
|
||||
CandidateListItem props:
|
||||
```typescript
|
||||
interface CandidateListItemProps {
|
||||
candidate: CandidateWithCategory;
|
||||
rank: number;
|
||||
isActive: boolean;
|
||||
onStatusChange: (status: "researching" | "ordered" | "arrived") => void;
|
||||
// Will add: delta?: CandidateDelta;
|
||||
}
|
||||
```
|
||||
|
||||
CandidateCard props:
|
||||
```typescript
|
||||
interface CandidateCardProps {
|
||||
id: number;
|
||||
name: string;
|
||||
weightGrams: number | null;
|
||||
priceCents: number | null;
|
||||
// ... other props
|
||||
// Will add: delta?: CandidateDelta;
|
||||
}
|
||||
```
|
||||
|
||||
ComparisonTable props:
|
||||
```typescript
|
||||
interface ComparisonTableProps {
|
||||
candidates: CandidateWithCategory[];
|
||||
resolvedCandidateId: number | null;
|
||||
// Will add: deltas?: Record<number, CandidateDelta>;
|
||||
}
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Create SetupImpactSelector and ImpactDeltaBadge components</name>
|
||||
<files>src/client/components/SetupImpactSelector.tsx, src/client/components/ImpactDeltaBadge.tsx</files>
|
||||
<action>
|
||||
1. Create src/client/components/SetupImpactSelector.tsx:
|
||||
- Import useSetups from hooks/useSetups
|
||||
- Import useUIStore from stores/uiStore
|
||||
- Export function SetupImpactSelector()
|
||||
- Read selectedSetupId and setSelectedSetupId from uiStore
|
||||
- Fetch setups via useSetups()
|
||||
- If no setups or loading, return null
|
||||
- Render: a flex row with label "Impact on setup:" (text-xs text-gray-500) and a native `<select>` element
|
||||
- Select value = selectedSetupId ?? "", onChange parses to number or null
|
||||
- Options: "None" (value="") + each setup by name
|
||||
- Styling: text-sm border border-gray-200 rounded-lg px-2 py-1 text-gray-700 bg-white focus:outline-none focus:ring-1 focus:ring-gray-300
|
||||
|
||||
2. Create src/client/components/ImpactDeltaBadge.tsx:
|
||||
- Import type CandidateDelta from lib/impactDeltas
|
||||
- Import formatWeight, formatPrice, WeightUnit, Currency from lib/formatters
|
||||
- Import useWeightUnit from hooks/useWeightUnit
|
||||
- Import useCurrency from hooks/useCurrency
|
||||
- Export function ImpactDeltaBadge({ delta, type }: { delta: CandidateDelta | undefined; type: "weight" | "price" })
|
||||
- If !delta or delta.mode === "none", return null
|
||||
- Pick value: type === "weight" ? delta.weightDelta : delta.priceDelta
|
||||
- If value === null (no data): render `<span className="text-xs text-gray-300">` with "-- (no weight data)" or "-- (no price data)" depending on type
|
||||
- If value is a number:
|
||||
- formatted = type === "weight" ? formatWeight(Math.abs(value), unit) : formatPrice(Math.abs(value), currency)
|
||||
- sign: value > 0 -> "+" , value < 0 -> "-" (use minus sign), value === 0 -> +/-
|
||||
- colorClass: value < 0 -> "text-green-600" (lighter/cheaper is good), value > 0 -> "text-red-500", value === 0 -> "text-gray-400"
|
||||
- modeLabel: delta.mode === "add" ? " (add)" : ""
|
||||
- vsLabel: delta.mode === "replace" && delta.replacedItemName ? ` vs ${delta.replacedItemName}` : "" (only show this as a title attribute on the span, not inline text -- too long)
|
||||
- Render: `<span className="text-xs font-medium {colorClass}" title={vsLabel || undefined}>{sign}{formatted}{modeLabel}</span>`
|
||||
- The component reads unit/currency internally via hooks so callers don't need to pass them.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/jlmak/Projects/jlmak/GearBox && bun run lint 2>&1 | head -20</automated>
|
||||
</verify>
|
||||
<done>SetupImpactSelector renders a setup dropdown reading from uiStore. ImpactDeltaBadge renders signed, colored delta indicators with null-data handling. Both components lint-clean.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Wire impact preview into thread detail page and all candidate views</name>
|
||||
<files>src/client/routes/threads/$threadId.tsx, src/client/components/CandidateListItem.tsx, src/client/components/CandidateCard.tsx, src/client/components/ComparisonTable.tsx</files>
|
||||
<action>
|
||||
1. In src/client/routes/threads/$threadId.tsx:
|
||||
- Add imports: useSetup from hooks/useSetups, useImpactDeltas from hooks/useImpactDeltas, SetupImpactSelector from components/SetupImpactSelector, type CandidateDelta from lib/impactDeltas
|
||||
- Read selectedSetupId from useUIStore: `const selectedSetupId = useUIStore((s) => s.selectedSetupId);`
|
||||
- Fetch setup data: `const { data: setupData } = useSetup(selectedSetupId ?? null);`
|
||||
- Compute deltas: `const impactDeltas = useImpactDeltas(thread.candidates, setupData?.items, thread.categoryId);` (place after thread is loaded, inside the render body after the isLoading/isError guards)
|
||||
- Place `<SetupImpactSelector />` in the header section, after the thread name/status row and before the toolbar. Wrap it in a div for spacing if needed.
|
||||
- Pass delta to CandidateListItem: add prop `delta={impactDeltas.deltas[candidate.id]}` to each CandidateListItem (both Reorder.Group and static div renderings)
|
||||
- Pass delta to CandidateCard: add prop `delta={impactDeltas.deltas[candidate.id]}` (the CandidateCard receives individual props, so pass it as `delta={impactDeltas.deltas[candidate.id]}`)
|
||||
- Pass deltas to ComparisonTable: add prop `deltas={impactDeltas.deltas}` alongside existing candidates and resolvedCandidateId
|
||||
|
||||
2. In src/client/components/CandidateListItem.tsx:
|
||||
- Import type CandidateDelta from lib/impactDeltas
|
||||
- Import ImpactDeltaBadge from ./ImpactDeltaBadge
|
||||
- Add `delta?: CandidateDelta;` to CandidateListItemProps
|
||||
- Add `delta` to destructured props
|
||||
- Render two ImpactDeltaBadge components inside the badges flex-wrap div (after the existing weight and price badges):
|
||||
- `{delta && <ImpactDeltaBadge delta={delta} type="weight" />}`
|
||||
- `{delta && <ImpactDeltaBadge delta={delta} type="price" />}`
|
||||
- Place these AFTER the existing weight/price badges so they appear as secondary indicators
|
||||
|
||||
3. In src/client/components/CandidateCard.tsx:
|
||||
- Import type CandidateDelta from lib/impactDeltas
|
||||
- Import ImpactDeltaBadge from ./ImpactDeltaBadge
|
||||
- Add `delta?: CandidateDelta;` to CandidateCardProps
|
||||
- Add `delta` to destructured props
|
||||
- Render two ImpactDeltaBadge components inside the badges flex-wrap div (after weight/price badges):
|
||||
- `{delta && <ImpactDeltaBadge delta={delta} type="weight" />}`
|
||||
- `{delta && <ImpactDeltaBadge delta={delta} type="price" />}`
|
||||
|
||||
4. In src/client/components/ComparisonTable.tsx:
|
||||
- Import type CandidateDelta from lib/impactDeltas
|
||||
- Import ImpactDeltaBadge from ./ImpactDeltaBadge
|
||||
- Add `deltas?: Record<number, CandidateDelta>;` to ComparisonTableProps
|
||||
- Add `deltas` to destructured props
|
||||
- Add two new rows to ATTRIBUTE_ROWS array, placed right after the "weight" row and "price" row respectively:
|
||||
a. After "weight" row, add:
|
||||
```
|
||||
{
|
||||
key: "impact-weight",
|
||||
label: "Impact (wt)",
|
||||
render: (c) => deltas?.[c.id] ? <ImpactDeltaBadge delta={deltas[c.id]} type="weight" /> : <span className="text-gray-300">--</span>,
|
||||
}
|
||||
```
|
||||
b. After "price" row, add:
|
||||
```
|
||||
{
|
||||
key: "impact-price",
|
||||
label: "Impact ($)",
|
||||
render: (c) => deltas?.[c.id] ? <ImpactDeltaBadge delta={deltas[c.id]} type="price" /> : <span className="text-gray-300">--</span>,
|
||||
}
|
||||
```
|
||||
- These are separate rows (per research recommendation) to avoid conflating candidate-relative deltas with setup impact deltas.
|
||||
- The impact rows show "--" when no setup is selected (deltas undefined or no entry for candidate).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/jlmak/Projects/jlmak/GearBox && bun test && bun run lint 2>&1 | head -20</automated>
|
||||
</verify>
|
||||
<done>
|
||||
- SetupImpactSelector dropdown visible in thread header
|
||||
- Selecting a setup shows weight/cost delta badges on each candidate in list, grid, and compare views
|
||||
- Replace mode: signed delta with green (lighter/cheaper) or red (heavier/pricier) coloring
|
||||
- Add mode: positive delta with "(add)" label
|
||||
- Null weight/price: shows "-- (no weight data)" / "-- (no price data)" indicator
|
||||
- Deselecting setup clears all delta indicators
|
||||
- ComparisonTable has dedicated "Impact (wt)" and "Impact ($)" rows
|
||||
- All tests pass, lint clean
|
||||
</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
- `bun test` full suite passes
|
||||
- `bun run lint` clean
|
||||
- SetupImpactSelector renders in thread header with all setups as options
|
||||
- Selecting a setup triggers useSetup fetch and delta computation
|
||||
- CandidateListItem, CandidateCard, ComparisonTable all render delta badges
|
||||
- Replace mode detected when setup has item in same category as thread
|
||||
- Add mode used otherwise
|
||||
- Null weight/price shows clear indicator
|
||||
- Deselecting shows no deltas (clean state)
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- All four IMPC requirements visible in the UI
|
||||
- Delta rendering works across list, grid, and compare views
|
||||
- No regressions in existing functionality
|
||||
- Clean lint output
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/13-setup-impact-preview/13-02-SUMMARY.md`
|
||||
</output>
|
||||
518
.planning/phases/13-setup-impact-preview/13-RESEARCH.md
Normal file
518
.planning/phases/13-setup-impact-preview/13-RESEARCH.md
Normal file
@@ -0,0 +1,518 @@
|
||||
# Phase 13: Setup Impact Preview - Research
|
||||
|
||||
**Researched:** 2026-03-17
|
||||
**Domain:** Pure frontend — delta computation + UI (React, Zustand, React Query)
|
||||
**Confidence:** HIGH
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
Phase 13 adds a setup-selector dropdown to the thread detail header. When a user picks a setup, every candidate card and list row gains two delta indicators: weight delta and cost delta. The computation has two modes determined at render time — replace mode (a setup item exists in the same category as the thread) and add mode (no category match). The entire feature is a pure frontend addition: all required data is already available through existing hooks with zero backend or schema changes needed.
|
||||
|
||||
The delta logic is straightforward arithmetic over nullable numbers: `candidate.weightGrams - replacedItem.weightGrams` in replace mode, or `candidate.weightGrams` in add mode. The only real complexity is the null-weight indicator (IMPC-04) and driving the selected setup ID through Zustand state so it persists across view-mode switches within the same thread session.
|
||||
|
||||
**Primary recommendation:** Add `selectedSetupId: number | null` to uiStore, render a setup dropdown in the thread header, compute deltas in a `useMemo` inside a new `useImpactDeltas` hook, and render inline delta indicators below the candidate weight/price badges in both list and grid views.
|
||||
|
||||
---
|
||||
|
||||
<phase_requirements>
|
||||
## Phase Requirements
|
||||
|
||||
| ID | Description | Research Support |
|
||||
|----|-------------|-----------------|
|
||||
| IMPC-01 | User can select a setup and see weight and cost delta for each candidate | `useSetups()` returns all setups for dropdown; `useSetup(id)` returns items with categoryId for matching; delta computed in useMemo |
|
||||
| IMPC-02 | Impact preview auto-detects replace mode when a setup item exists in the same category as the thread | Thread has `categoryId` (from `threads.categoryId`); setup items have `categoryId` via join; match on `categoryId` equality |
|
||||
| IMPC-03 | Impact preview shows add mode (pure addition) when no category match exists in the selected setup | Default when no setup item matches `thread.categoryId`; label clearly as "+add" |
|
||||
| IMPC-04 | Candidates with missing weight data show a clear indicator instead of misleading zero deltas | `candidate.weightGrams == null` → render `"-- (no weight data)"` instead of computing |
|
||||
</phase_requirements>
|
||||
|
||||
---
|
||||
|
||||
## Standard Stack
|
||||
|
||||
### Core
|
||||
| Library | Version | Purpose | Why Standard |
|
||||
|---------|---------|---------|--------------|
|
||||
| React 19 | ^19.2.4 | UI rendering | Project foundation |
|
||||
| Zustand | ^5.0.11 | `selectedSetupId` UI state | Established pattern for all UI-only state (panel open/close, view mode) |
|
||||
| TanStack React Query | ^5.90.21 | `useSetup(id)` for setup items | Established data fetching pattern |
|
||||
| Tailwind CSS v4 | ^4.2.1 | Delta badge styling | Project styling system |
|
||||
|
||||
### Supporting
|
||||
| Library | Version | Purpose | When to Use |
|
||||
|---------|---------|---------|-------------|
|
||||
| framer-motion | ^12.37.0 | Optional entrance animation for delta indicators | Already installed; use AnimatePresence if subtle fade needed |
|
||||
| lucide-react | ^0.577.0 | Dropdown chevron icon, delta arrow icons | Project icon system |
|
||||
|
||||
### No New Dependencies
|
||||
This phase requires zero new npm dependencies. All needed libraries are installed.
|
||||
|
||||
**Installation:**
|
||||
```bash
|
||||
# No new packages needed
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Architecture Patterns
|
||||
|
||||
### Recommended Project Structure
|
||||
```
|
||||
src/client/
|
||||
├── stores/uiStore.ts # Add selectedSetupId: number | null + setter
|
||||
├── hooks/
|
||||
│ └── useImpactDeltas.ts # New: compute add/replace deltas per candidate
|
||||
├── components/
|
||||
│ ├── SetupImpactSelector.tsx # New: setup dropdown rendered in thread header
|
||||
│ └── ImpactDeltaBadge.tsx # New (or inline): weight/cost delta pill
|
||||
└── routes/threads/$threadId.tsx # Add selector to header, pass deltas down
|
||||
```
|
||||
|
||||
### Pattern 1: selectedSetupId in Zustand
|
||||
|
||||
**What:** Store selected setup ID as UI state in `uiStore.ts`, not as URL state or server state.
|
||||
|
||||
**When to use:** The selection is ephemeral per session (no permalink needed), needs to survive view-mode switches (list/grid/compare), and must be accessible from any component in the thread page without prop drilling.
|
||||
|
||||
**Example:**
|
||||
```typescript
|
||||
// In uiStore.ts — add to UIState interface
|
||||
selectedSetupId: number | null;
|
||||
setSelectedSetupId: (id: number | null) => void;
|
||||
|
||||
// In create() initializer
|
||||
selectedSetupId: null,
|
||||
setSelectedSetupId: (id) => set({ selectedSetupId: id }),
|
||||
```
|
||||
|
||||
### Pattern 2: useImpactDeltas Hook
|
||||
|
||||
**What:** A pure computation hook that accepts candidates + a setup's item list + the thread's categoryId, and returns per-candidate delta objects.
|
||||
|
||||
**When to use:** Delta computation must run in a single place so list, grid, and compare views all show consistent numbers.
|
||||
|
||||
**Interface:**
|
||||
```typescript
|
||||
// src/client/hooks/useImpactDeltas.ts
|
||||
import type { SetupItemWithCategory } from "./useSetups";
|
||||
|
||||
interface CandidateInput {
|
||||
id: number;
|
||||
weightGrams: number | null;
|
||||
priceCents: number | null;
|
||||
}
|
||||
|
||||
type DeltaMode = "replace" | "add" | "none"; // "none" = no setup selected
|
||||
|
||||
interface CandidateDelta {
|
||||
candidateId: number;
|
||||
mode: DeltaMode;
|
||||
weightDelta: number | null; // null = candidate has no weight data
|
||||
priceDelta: number | null; // null = candidate has no price data
|
||||
replacedItemName: string | null; // populated in replace mode for tooltip
|
||||
}
|
||||
|
||||
interface ImpactDeltas {
|
||||
mode: DeltaMode;
|
||||
deltas: Record<number, CandidateDelta>;
|
||||
}
|
||||
|
||||
export function useImpactDeltas(
|
||||
candidates: CandidateInput[],
|
||||
setupItems: SetupItemWithCategory[] | undefined,
|
||||
threadCategoryId: number,
|
||||
): ImpactDeltas
|
||||
```
|
||||
|
||||
**Logic:**
|
||||
```typescript
|
||||
// Source: project codebase pattern — mirrors ComparisonTable useMemo
|
||||
const impactDeltas = useMemo(() => {
|
||||
if (!setupItems) return { mode: "none", deltas: {} };
|
||||
|
||||
// Find replaced item: setup item whose categoryId matches thread's categoryId
|
||||
const replacedItem = setupItems.find(
|
||||
(item) => item.categoryId === threadCategoryId
|
||||
) ?? null;
|
||||
|
||||
const mode: DeltaMode = replacedItem ? "replace" : "add";
|
||||
|
||||
const deltas: Record<number, CandidateDelta> = {};
|
||||
for (const c of candidates) {
|
||||
let weightDelta: number | null = null;
|
||||
let priceDelta: number | null = null;
|
||||
|
||||
if (c.weightGrams != null) {
|
||||
weightDelta = mode === "replace" && replacedItem?.weightGrams != null
|
||||
? c.weightGrams - replacedItem.weightGrams
|
||||
: c.weightGrams;
|
||||
}
|
||||
// priceCents is integer (cents), same arithmetic
|
||||
if (c.priceCents != null) {
|
||||
priceDelta = mode === "replace" && replacedItem?.priceCents != null
|
||||
? c.priceCents - replacedItem.priceCents
|
||||
: c.priceCents;
|
||||
}
|
||||
|
||||
deltas[c.id] = {
|
||||
candidateId: c.id,
|
||||
mode,
|
||||
weightDelta,
|
||||
priceDelta,
|
||||
replacedItemName: replacedItem?.name ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
return { mode, deltas };
|
||||
}, [candidates, setupItems, threadCategoryId]);
|
||||
```
|
||||
|
||||
### Pattern 3: SetupImpactSelector Component
|
||||
|
||||
**What:** A compact `<select>` dropdown in the thread detail header, rendered between the thread title and the toolbar.
|
||||
|
||||
**When to use:** Always present on active and resolved thread pages (impact preview is read-only, no mutation side effects).
|
||||
|
||||
**Example:**
|
||||
```typescript
|
||||
// Placed in thread header, after thread name row
|
||||
function SetupImpactSelector() {
|
||||
const { data: setups } = useSetups();
|
||||
const selectedSetupId = useUIStore((s) => s.selectedSetupId);
|
||||
const setSelectedSetupId = useUIStore((s) => s.setSelectedSetupId);
|
||||
|
||||
if (!setups || setups.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs text-gray-500 whitespace-nowrap">
|
||||
Impact on setup:
|
||||
</label>
|
||||
<select
|
||||
value={selectedSetupId ?? ""}
|
||||
onChange={(e) => setSelectedSetupId(e.target.value ? Number(e.target.value) : null)}
|
||||
className="text-sm border border-gray-200 rounded-lg px-2 py-1 text-gray-700 bg-white focus:outline-none focus:ring-1 focus:ring-gray-300"
|
||||
>
|
||||
<option value="">None</option>
|
||||
{setups.map((s) => (
|
||||
<option key={s.id} value={s.id}>{s.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 4: ImpactDeltaBadge Rendering
|
||||
|
||||
**What:** Small inline indicator rendered below weight/price badges on each candidate. Three rendering cases per field:
|
||||
|
||||
| Case | Render |
|
||||
|------|--------|
|
||||
| No setup selected | Nothing (no change to existing layout) |
|
||||
| Candidate has no weight | `"-- (no weight data)"` in muted gray |
|
||||
| Weight exists, replace mode | `"±Xg vs [ItemName]"` with sign-colored text |
|
||||
| Weight exists, add mode | `"+Xg (add)"` in gray |
|
||||
|
||||
**Where it renders:** Below the existing `formatWeight` / `formatPrice` badges in `CandidateListItem` and `CandidateCard`. In `ComparisonTable`, can be added as a sub-row or a second line within the weight/price cells.
|
||||
|
||||
**Sign coloring convention:**
|
||||
- Negative delta (lighter/cheaper when replacing) → green text
|
||||
- Positive delta (heavier/more expensive) → red text
|
||||
- Zero delta → gray text
|
||||
- No weight data → muted gray, em-dash prefix
|
||||
|
||||
```typescript
|
||||
// Reusable inline component
|
||||
function ImpactDeltaBadge({
|
||||
delta,
|
||||
noDataLabel = "-- (no weight data)",
|
||||
unit,
|
||||
currency,
|
||||
type,
|
||||
}: {
|
||||
delta: CandidateDelta | undefined;
|
||||
noDataLabel?: string;
|
||||
unit?: WeightUnit;
|
||||
currency?: Currency;
|
||||
type: "weight" | "price";
|
||||
}) {
|
||||
if (!delta || delta.mode === "none") return null;
|
||||
|
||||
const value = type === "weight" ? delta.weightDelta : delta.priceDelta;
|
||||
|
||||
if (value === null) {
|
||||
// Candidate has no data for this field
|
||||
return (
|
||||
<span className="text-xs text-gray-300">{noDataLabel}</span>
|
||||
);
|
||||
}
|
||||
|
||||
const formatted = type === "weight"
|
||||
? formatWeight(Math.abs(value), unit)
|
||||
: formatPrice(Math.abs(value), currency);
|
||||
|
||||
const sign = value > 0 ? "+" : value < 0 ? "−" : "±";
|
||||
const colorClass = value < 0 ? "text-green-600" : value > 0 ? "text-red-500" : "text-gray-400";
|
||||
const modeLabel = delta.mode === "add" ? " (add)" : "";
|
||||
|
||||
return (
|
||||
<span className={`text-xs font-medium ${colorClass}`}>
|
||||
{sign}{formatted}{modeLabel}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Data Flow
|
||||
|
||||
```
|
||||
$threadId.tsx
|
||||
├── selectedSetupId ← useUIStore
|
||||
├── thread ← useThread(threadId) // has thread.categoryId + candidates
|
||||
├── setupData ← useSetup(selectedSetupId) // null when none selected
|
||||
├── impactDeltas ← useImpactDeltas(candidates, setupData?.items, thread.categoryId)
|
||||
│
|
||||
├── <SetupImpactSelector /> // sets selectedSetupId in uiStore
|
||||
│
|
||||
├── <CandidateListItem delta={impactDeltas.deltas[c.id]} />
|
||||
├── <CandidateCard delta={impactDeltas.deltas[c.id]} />
|
||||
└── <ComparisonTable deltas={impactDeltas.deltas} />
|
||||
```
|
||||
|
||||
### Anti-Patterns to Avoid
|
||||
|
||||
- **Computing deltas in each candidate component:** Delta mode (add vs replace) must be determined once from the full setup. Computing per-component means each card independently decides mode — a setup with multiple items in different categories could give inconsistent signals if the logic is subtle.
|
||||
- **Storing selectedSetupId in URL search params:** Adds routing complexity with no benefit; the selection is ephemeral and non-shareable per project scope.
|
||||
- **Calling `useSetup` inside each candidate component:** Causes N redundant React Query calls. Call once at page level, pass deltas down.
|
||||
- **Treating `priceDelta = 0` as "no data":** Zero cost delta is a valid result (exact price match). The `null` check distinguishes missing data from zero.
|
||||
|
||||
---
|
||||
|
||||
## Don't Hand-Roll
|
||||
|
||||
| Problem | Don't Build | Use Instead | Why |
|
||||
|---------|-------------|-------------|-----|
|
||||
| Formatted weight delta strings | Custom formatter | Reuse `formatWeight(Math.abs(delta), unit)` + sign prefix | Already handles all 4 units (g/oz/lb/kg) correctly |
|
||||
| Formatted price delta strings | Custom formatter | Reuse `formatPrice(Math.abs(delta), currency)` + sign prefix | Already handles all currencies and JPY integer case |
|
||||
| Setup list fetching | Custom fetch | `useSetups()` hook | Already defined, cached by React Query |
|
||||
| Setup items fetching | Custom fetch | `useSetup(id)` hook | Already defined with enabled guard |
|
||||
| UI state management | Local useState | Zustand `selectedSetupId` | Persists across view mode switches within same session |
|
||||
|
||||
**Key insight:** All data infrastructure exists. This phase is arithmetic + UI only.
|
||||
|
||||
---
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
### Pitfall 1: Thread categoryId vs Candidate categoryId
|
||||
|
||||
**What goes wrong:** Using `candidate.categoryId` instead of `thread.categoryId` to find the replaced setup item. Candidates inherit the thread's category (they're in the same decision thread), but a user could theoretically pick a different category per candidate. The impact preview is about "what does buying something for this research thread do to my setup," so the match must be on the **thread** category, not individual candidate category.
|
||||
|
||||
**Why it happens:** `CandidateWithCategory` has a `categoryId` field that looks natural to use.
|
||||
|
||||
**How to avoid:** In `useImpactDeltas`, accept `threadCategoryId` as a separate parameter sourced from `thread.categoryId`, not from `candidate.categoryId`.
|
||||
|
||||
**Warning signs:** Replace mode never triggers even when a setup contains an item in the expected category.
|
||||
|
||||
### Pitfall 2: Null vs Zero for Missing Data (IMPC-04)
|
||||
|
||||
**What goes wrong:** When `candidate.weightGrams` is `null`, the delta would be `null - replacedItem.weightGrams = NaN` or JavaScript coerces to `0`. Rendering "0g" is actively misleading — it implies the candidate has been weighed at zero.
|
||||
|
||||
**Why it happens:** JavaScript's `null - 200 = -200` is NaN, not zero, but string formatting might swallow this silently.
|
||||
|
||||
**How to avoid:** Explicit null guard BEFORE arithmetic: `if (c.weightGrams == null) { weightDelta = null; }`. Render `null` delta as `"-- (no weight data)"` per IMPC-04.
|
||||
|
||||
**Warning signs:** Candidates with no weight show "0g" or "−200g" delta.
|
||||
|
||||
### Pitfall 3: useSetup Enabled Guard
|
||||
|
||||
**What goes wrong:** Calling `useSetup(null)` triggers a request to `/api/setups/null` — a 404 or server error.
|
||||
|
||||
**Why it happens:** `useSetup` has `enabled: setupId != null` guard, but if the `selectedSetupId` from Zustand is not passed correctly, it might be `undefined` rather than `null`.
|
||||
|
||||
**How to avoid:** Coerce to `null` explicitly: `useSetup(selectedSetupId ?? null)`.
|
||||
|
||||
**Warning signs:** Network errors in dev tools when no setup is selected.
|
||||
|
||||
### Pitfall 4: selectedSetupId Stale Across Thread Navigation
|
||||
|
||||
**What goes wrong:** User selects "Setup A" on thread 1, navigates to thread 2, sees impact deltas for "Setup A" which may not be relevant.
|
||||
|
||||
**Why it happens:** Zustand state persists in memory across route changes.
|
||||
|
||||
**How to avoid:** Two acceptable approaches:
|
||||
1. **Accept it** — the user chose a setup globally; they can clear it. Simplest.
|
||||
2. **Reset on thread change** — call `setSelectedSetupId(null)` in a `useEffect` that fires on `threadId` change.
|
||||
|
||||
Recommended: Accept cross-thread persistence (simpler, matches how `candidateViewMode` works currently).
|
||||
|
||||
### Pitfall 5: ComparisonTable Integration
|
||||
|
||||
**What goes wrong:** ComparisonTable already has its own `weightDeltas` computation (candidate-relative deltas: lightest vs others). Adding setup deltas as a third numeric display in the same weight cell risks visual clutter and ambiguity about which delta is which.
|
||||
|
||||
**Why it happens:** Two delta systems in one cell with no visual separation.
|
||||
|
||||
**How to avoid:** Render setup impact deltas in a **separate row** in ATTRIBUTE_ROWS, or as a clearly labeled sub-row below weight. Label it "Impact" with a small setup name indicator.
|
||||
|
||||
---
|
||||
|
||||
## Code Examples
|
||||
|
||||
Verified patterns from existing codebase:
|
||||
|
||||
### Existing Delta Pattern (ComparisonTable.tsx)
|
||||
```typescript
|
||||
// Source: src/client/components/ComparisonTable.tsx
|
||||
// This shows the established useMemo pattern for delta computation
|
||||
const { bestWeightId, bestPriceId, weightDeltas, priceDeltas } =
|
||||
useMemo(() => {
|
||||
const withWeight = candidates.filter((c) => c.weightGrams != null);
|
||||
let bestWeightId: number | null = null;
|
||||
const weightDeltas: Record<number, string | null> = {};
|
||||
// ... arithmetic over nullable numbers
|
||||
return { bestWeightId, bestPriceId, weightDeltas, priceDeltas };
|
||||
}, [candidates, unit, currency]);
|
||||
```
|
||||
|
||||
### Existing useSetup Hook
|
||||
```typescript
|
||||
// Source: src/client/hooks/useSetups.ts
|
||||
export function useSetup(setupId: number | null) {
|
||||
return useQuery({
|
||||
queryKey: ["setups", setupId],
|
||||
queryFn: () => apiGet<SetupWithItems>(`/api/setups/${setupId}`),
|
||||
enabled: setupId != null, // CRITICAL: prevents null fetch
|
||||
});
|
||||
}
|
||||
// SetupItemWithCategory includes: categoryId, weightGrams, priceCents, name
|
||||
```
|
||||
|
||||
### Existing formatWeight / formatPrice
|
||||
```typescript
|
||||
// Source: src/client/lib/formatters.ts
|
||||
export function formatWeight(grams: number | null | undefined, unit: WeightUnit = "g"): string {
|
||||
if (grams == null) return "--";
|
||||
// handles g / oz / lb / kg
|
||||
}
|
||||
export function formatPrice(cents: number | null | undefined, currency: Currency = "USD"): string {
|
||||
if (cents == null) return "--";
|
||||
// handles JPY integer case, others to 2dp
|
||||
}
|
||||
// Pass Math.abs(delta) to these, prefix sign manually
|
||||
```
|
||||
|
||||
### Existing Zustand UI State Pattern
|
||||
```typescript
|
||||
// Source: src/client/stores/uiStore.ts
|
||||
// All ephemeral UI state lives here — follow same pattern
|
||||
candidateViewMode: "list" | "grid" | "compare";
|
||||
setCandidateViewMode: (mode: "list" | "grid" | "compare") => void;
|
||||
// Add analogously:
|
||||
// selectedSetupId: number | null;
|
||||
// setSelectedSetupId: (id: number | null) => void;
|
||||
```
|
||||
|
||||
### Thread categoryId Availability
|
||||
```typescript
|
||||
// Source: src/server/services/thread.service.ts — getThreadWithCandidates
|
||||
// thread object from useThread() has .categoryId (integer) directly available
|
||||
// Note: ThreadWithCandidates interface in useThreads.ts does NOT expose categoryId
|
||||
// The raw DB thread select does, but the hook return type may need updating
|
||||
```
|
||||
|
||||
**Important finding:** The `ThreadWithCandidates` interface in `useThreads.ts` does NOT currently include `categoryId`. The server does return it (from `db.select().from(threads)`), but the TypeScript interface omits it. The planner must add `categoryId: number` to `ThreadWithCandidates` or source it from the candidates (each `CandidateWithCategory` has `categoryId`).
|
||||
|
||||
---
|
||||
|
||||
## State of the Art
|
||||
|
||||
| Old Approach | Current Approach | When Changed | Impact |
|
||||
|--------------|------------------|--------------|--------|
|
||||
| Fetch data inside components | Custom hooks with React Query | Established in project | All data fetching via hooks |
|
||||
| Local component state for UI | Zustand store | Established in project | All UI state centralized |
|
||||
|
||||
**No deprecated patterns in scope for this phase.**
|
||||
|
||||
---
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **Thread categoryId exposure in ThreadWithCandidates**
|
||||
- What we know: `getThreadWithCandidates` in thread.service.ts returns the full thread row (including `categoryId`), but `ThreadWithCandidates` TypeScript interface in `useThreads.ts` does not declare `categoryId`
|
||||
- What's unclear: Does the API actually serialize `categoryId` in the response or is it filtered?
|
||||
- Recommendation: Planner should add `categoryId: number` to `ThreadWithCandidates` interface and verify the server route includes it. Alternatively, use `thread.candidates[0]?.categoryId` as a fallback since all candidates share the thread's category.
|
||||
|
||||
2. **Selector placement on narrow viewports**
|
||||
- What we know: Thread header already has breadcrumb, title/status pill, and toolbar row
|
||||
- What's unclear: Three rows in mobile header may feel cramped
|
||||
- Recommendation: Planner's discretion — can be inline with toolbar or as a third header row. Research finds no hard constraint.
|
||||
|
||||
3. **ComparisonTable delta row placement**
|
||||
- What we know: ATTRIBUTE_ROWS pattern is extensible (just add an object to the array)
|
||||
- What's unclear: Whether impact rows should live inside the weight/price rows or as separate "Impact Weight" / "Impact Price" rows
|
||||
- Recommendation: Separate labeled rows to avoid conflating candidate-relative deltas (lightest/cheapest highlighting) with setup impact deltas.
|
||||
|
||||
---
|
||||
|
||||
## Validation Architecture
|
||||
|
||||
### Test Framework
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| Framework | Bun test (built-in) |
|
||||
| Config file | none — bun test discovers tests automatically |
|
||||
| Quick run command | `bun test tests/services/` |
|
||||
| Full suite command | `bun test` |
|
||||
|
||||
### Phase Requirements → Test Map
|
||||
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|
||||
|--------|----------|-----------|-------------------|-------------|
|
||||
| IMPC-01 | Delta values computed and passed to candidates when setup selected | unit (hook logic) | `bun test tests/lib/impactDeltas.test.ts` | Wave 0 |
|
||||
| IMPC-02 | Replace mode triggered when setup contains item in same category as thread | unit | `bun test tests/lib/impactDeltas.test.ts` | Wave 0 |
|
||||
| IMPC-03 | Add mode used when no category match, delta equals candidate value | unit | `bun test tests/lib/impactDeltas.test.ts` | Wave 0 |
|
||||
| IMPC-04 | Null weight candidate returns null delta (not zero, not NaN) | unit | `bun test tests/lib/impactDeltas.test.ts` | Wave 0 |
|
||||
|
||||
**Note:** Delta computation is pure arithmetic logic and can be tested outside React via an extracted pure function. The recommended approach is to extract `computeImpactDeltas(candidates, setupItems, threadCategoryId)` as a pure function and test it directly — no React Testing Library needed.
|
||||
|
||||
### Sampling Rate
|
||||
- **Per task commit:** `bun test tests/lib/`
|
||||
- **Per wave merge:** `bun test`
|
||||
- **Phase gate:** Full suite green before `/gsd:verify-work`
|
||||
|
||||
### Wave 0 Gaps
|
||||
- [ ] `tests/lib/impactDeltas.test.ts` — covers IMPC-01 through IMPC-04 via pure function extracted from `useImpactDeltas`
|
||||
- [ ] `tests/lib/` directory — create if not exists (pure utility tests go here)
|
||||
|
||||
---
|
||||
|
||||
## Sources
|
||||
|
||||
### Primary (HIGH confidence)
|
||||
- Direct codebase read — `src/db/schema.ts` — verified `threadCandidates.categoryId`, `items.categoryId`, `setupItems` join structure
|
||||
- Direct codebase read — `src/client/hooks/useSetups.ts` — verified `SetupItemWithCategory` type includes `categoryId`, `weightGrams`, `priceCents`
|
||||
- Direct codebase read — `src/client/hooks/useThreads.ts` — identified missing `categoryId` in `ThreadWithCandidates` interface
|
||||
- Direct codebase read — `src/client/components/ComparisonTable.tsx` — verified ATTRIBUTE_ROWS pattern and existing delta computation pattern
|
||||
- Direct codebase read — `src/client/stores/uiStore.ts` — verified `selectedSetupId` does not yet exist, pattern for adding it
|
||||
- Direct codebase read — `src/client/lib/formatters.ts` — verified `formatWeight` / `formatPrice` reusability with abs values
|
||||
- Direct codebase read — `tests/helpers/db.ts` — verified test infrastructure, no schema changes needed
|
||||
|
||||
### Secondary (MEDIUM confidence)
|
||||
- `.planning/STATE.md` — confirms "Impact preview must distinguish add-mode vs replace-mode by category match" as locked architectural decision
|
||||
|
||||
### Tertiary (LOW confidence)
|
||||
- None
|
||||
|
||||
---
|
||||
|
||||
## Metadata
|
||||
|
||||
**Confidence breakdown:**
|
||||
- Standard stack: HIGH — zero new dependencies; all libraries confirmed in package.json
|
||||
- Architecture: HIGH — derived entirely from reading existing codebase; patterns are directly reusable
|
||||
- Pitfalls: HIGH — identified from code inspection (ThreadWithCandidates missing categoryId is a concrete finding, not speculation)
|
||||
- Delta math: HIGH — straightforward arithmetic, verified types from schema
|
||||
|
||||
**Research date:** 2026-03-17
|
||||
**Valid until:** 2026-04-17 (stable codebase; no external library research)
|
||||
78
.planning/phases/13-setup-impact-preview/13-VALIDATION.md
Normal file
78
.planning/phases/13-setup-impact-preview/13-VALIDATION.md
Normal file
@@ -0,0 +1,78 @@
|
||||
---
|
||||
phase: 13
|
||||
slug: setup-impact-preview
|
||||
status: draft
|
||||
nyquist_compliant: false
|
||||
wave_0_complete: false
|
||||
created: 2026-03-17
|
||||
---
|
||||
|
||||
# Phase 13 — Validation Strategy
|
||||
|
||||
> Per-phase validation contract for feedback sampling during execution.
|
||||
|
||||
---
|
||||
|
||||
## Test Infrastructure
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| **Framework** | bun test (built-in) |
|
||||
| **Config file** | bunfig.toml |
|
||||
| **Quick run command** | `bun test` |
|
||||
| **Full suite command** | `bun test` |
|
||||
| **Estimated runtime** | ~5 seconds |
|
||||
|
||||
---
|
||||
|
||||
## Sampling Rate
|
||||
|
||||
- **After every task commit:** Run `bun test`
|
||||
- **After every plan wave:** Run `bun test`
|
||||
- **Before `/gsd:verify-work`:** Full suite must be green
|
||||
- **Max feedback latency:** 5 seconds
|
||||
|
||||
---
|
||||
|
||||
## Per-Task Verification Map
|
||||
|
||||
| Task ID | Plan | Wave | Requirement | Test Type | Automated Command | File Exists | Status |
|
||||
|---------|------|------|-------------|-----------|-------------------|-------------|--------|
|
||||
| 13-01-01 | 01 | 1 | IMPC-01 | unit | `bun test` | ❌ W0 | ⬜ pending |
|
||||
| 13-01-02 | 01 | 1 | IMPC-02 | unit | `bun test` | ❌ W0 | ⬜ pending |
|
||||
| 13-01-03 | 01 | 1 | IMPC-03 | unit | `bun test` | ❌ W0 | ⬜ pending |
|
||||
| 13-01-04 | 01 | 1 | IMPC-04 | unit | `bun test` | ❌ W0 | ⬜ pending |
|
||||
|
||||
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
|
||||
|
||||
---
|
||||
|
||||
## Wave 0 Requirements
|
||||
|
||||
- [ ] Test stubs for IMPC-01 through IMPC-04 impact delta computation
|
||||
- [ ] Test fixtures for setup items and thread candidates with weight/price data
|
||||
|
||||
*If none: "Existing infrastructure covers all phase requirements."*
|
||||
|
||||
---
|
||||
|
||||
## Manual-Only Verifications
|
||||
|
||||
| Behavior | Requirement | Why Manual | Test Instructions |
|
||||
|----------|-------------|------------|-------------------|
|
||||
| Setup dropdown renders in thread header | IMPC-01 | Visual/UI placement | Open a thread, verify dropdown appears with all setups listed |
|
||||
| Delta labels display correctly (add vs replace) | IMPC-03 | Visual formatting | Select setup with no category match, verify "add" label |
|
||||
| Missing weight shows "-- (no weight data)" | IMPC-04 | Visual indicator | Add candidate with no weight, verify placeholder text |
|
||||
|
||||
---
|
||||
|
||||
## Validation Sign-Off
|
||||
|
||||
- [ ] All tasks have `<automated>` verify or Wave 0 dependencies
|
||||
- [ ] Sampling continuity: no 3 consecutive tasks without automated verify
|
||||
- [ ] Wave 0 covers all MISSING references
|
||||
- [ ] No watch-mode flags
|
||||
- [ ] Feedback latency < 5s
|
||||
- [ ] `nyquist_compliant: true` set in frontmatter
|
||||
|
||||
**Approval:** pending
|
||||
81
CLAUDE.md
81
CLAUDE.md
@@ -9,7 +9,8 @@ GearBox is a single-user web app for managing gear collections (bikepacking, sim
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# Development (run both in separate terminals)
|
||||
# Development
|
||||
bun run dev # Starts both Vite client (:5173) and Hono server (:3000) concurrently
|
||||
bun run dev:client # Vite dev server on :5173 (proxies /api to :3000)
|
||||
bun run dev:server # Hono server on :3000 with hot reload
|
||||
|
||||
@@ -52,7 +53,7 @@ bun run build # Vite build → dist/client/
|
||||
- **Schema**: `schema.ts` — Drizzle table definitions for SQLite.
|
||||
- **Prices stored as cents** (`priceCents: integer`) to avoid float rounding.
|
||||
- **Timestamps**: stored as integers (unix epoch) with `{ mode: "timestamp" }`.
|
||||
- Tables: `categories`, `items`, `threads`, `threadCandidates`, `setups`, `setupItems`, `settings`.
|
||||
- Tables: `categories`, `items`, `threads`, `threadCandidates`, `setups`, `setupItems`, `settings`, `users`, `sessions`, `apiKeys`.
|
||||
|
||||
### Testing (`tests/`)
|
||||
- Bun test runner. Tests at service level and route level.
|
||||
@@ -67,4 +68,80 @@ bun run build # Vite build → dist/client/
|
||||
- **Thread resolution**: Resolving a thread copies the winning candidate's data into a new item in the collection, sets `resolvedCandidateId`, and changes status to "resolved".
|
||||
- **Setup item sync**: `PUT /api/setups/:id/items` replaces all setup_items atomically (delete all, re-insert).
|
||||
- **Image uploads**: `POST /api/images` saves to `./uploads/` with UUID filename, returned as `imageFilename` on item/candidate records.
|
||||
- **Image URL fetching**: `POST /api/images/from-url` fetches an image from a URL, saves locally, returns `{ filename, sourceUrl }`.
|
||||
- **Aggregates** (weight/cost totals): Computed via SQL on read, not stored on records.
|
||||
- **Authentication**: Public-read, authenticated-write. Cookie sessions for web UI, API keys (`X-API-Key` header) for programmatic access. `POST /api/auth/setup` for first-time account creation. Auth middleware protects all POST/PUT/DELETE on `/api/*` except `/api/auth/*`.
|
||||
|
||||
## Authentication
|
||||
|
||||
- **First run**: No users exist. Visit `/login` to create your admin account.
|
||||
- **Web UI**: Cookie-based sessions (`gearbox_session`), 30-day expiry, auto-refreshed.
|
||||
- **Programmatic access**: API keys created in Settings > API Keys. Pass via `X-API-Key` header.
|
||||
- **Public read**: All GET endpoints work without auth. POST/PUT/DELETE require auth.
|
||||
- **Auth routes**: `/api/auth/login`, `/api/auth/logout`, `/api/auth/setup`, `/api/auth/me`, `/api/auth/password`, `/api/auth/keys`.
|
||||
|
||||
## MCP Server
|
||||
|
||||
GearBox includes a built-in MCP server for integration with Claude Code and Claude Desktop. Enabled by default, disable with `GEARBOX_MCP=false`. Authenticated via API key.
|
||||
|
||||
### Tools (19 total)
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `list_items` | List all items, optionally filter by category |
|
||||
| `get_item` | Get item details by ID |
|
||||
| `create_item` | Add item to collection (for decided items; use `create_thread` for research) |
|
||||
| `update_item` | Update item details |
|
||||
| `delete_item` | Remove item from collection |
|
||||
| `list_categories` | List all categories |
|
||||
| `create_category` | Create a new category |
|
||||
| `list_threads` | List research threads (recommended workflow for gear purchases) |
|
||||
| `get_thread` | Get thread with candidates |
|
||||
| `create_thread` | Start a research thread to compare gear options |
|
||||
| `resolve_thread` | Pick winning candidate, adds it to collection |
|
||||
| `add_candidate` | Add candidate to a research thread |
|
||||
| `update_candidate` | Update candidate details |
|
||||
| `remove_candidate` | Remove candidate from thread |
|
||||
| `list_setups` | List gear setups |
|
||||
| `get_setup` | Get setup with items and totals |
|
||||
| `create_setup` | Create a new setup |
|
||||
| `update_setup` | Update setup name or items |
|
||||
| `upload_image_from_url` | Fetch image from URL, save locally |
|
||||
|
||||
### Resources
|
||||
|
||||
- `gearbox://collection/summary` — Overview of collection: totals, items per category, active threads.
|
||||
|
||||
### Configuration
|
||||
|
||||
**Claude Code** (`.claude/settings.json`):
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"gearbox": {
|
||||
"type": "streamable-http",
|
||||
"url": "http://localhost:3000/mcp",
|
||||
"headers": {
|
||||
"X-API-Key": "<your-api-key>"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Claude Desktop** (`claude_desktop_config.json`):
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"gearbox": {
|
||||
"type": "streamable-http",
|
||||
"url": "http://localhost:3000/mcp",
|
||||
"headers": {
|
||||
"X-API-Key": "<your-api-key>"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Generate an API key from Settings > API Keys after logging in.
|
||||
41
README.md
41
README.md
@@ -10,7 +10,7 @@ A single-user web app for managing gear collections (bikepacking, sim racing, et
|
||||
- Research threads for comparing candidates before buying
|
||||
- Image uploads for items and candidates
|
||||
|
||||
## Quick Start
|
||||
## Quick Start (Docker)
|
||||
|
||||
### Docker Compose (recommended)
|
||||
|
||||
@@ -81,3 +81,42 @@ docker compose up -d
|
||||
```
|
||||
|
||||
Database migrations run automatically on startup.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Runtime & Package Manager:** [Bun](https://bun.sh)
|
||||
- **Frontend:** React 19, Vite, TanStack Router, TanStack Query, Tailwind CSS v4, Zustand
|
||||
- **Backend:** Hono, Drizzle ORM, SQLite (`bun:sqlite`)
|
||||
|
||||
## Local Development Setup
|
||||
|
||||
### Prerequisites
|
||||
|
||||
You must have [Bun](https://bun.sh/) installed on your machine. Docker is not required for local development.
|
||||
|
||||
### Installation
|
||||
|
||||
1. Install all dependencies:
|
||||
```bash
|
||||
bun install
|
||||
```
|
||||
|
||||
2. Initialize the local SQLite database (`gearbox.db`):
|
||||
```bash
|
||||
bun run db:push
|
||||
```
|
||||
|
||||
3. Start the development servers:
|
||||
```bash
|
||||
bun run dev
|
||||
```
|
||||
This single command will start both the Vite frontend server (port `5173`) and the Hono backend server (port `3000`) concurrently.
|
||||
|
||||
Open [http://localhost:5173](http://localhost:5173) in your browser to view the app.
|
||||
|
||||
## Additional Commands
|
||||
|
||||
- `bun run build` — Build the production assets into `dist/client/`
|
||||
- `bun test` — Run the test suite
|
||||
- `bun run lint` — Check formatting and lint rules using Biome
|
||||
- `bun run db:generate` — Generate Drizzle migrations after making schema changes
|
||||
@@ -7,7 +7,7 @@
|
||||
},
|
||||
"files": {
|
||||
"ignoreUnknown": false,
|
||||
"includes": ["**", "!src/client/routeTree.gen.ts"]
|
||||
"includes": ["**", "!src/client/routeTree.gen.ts", "!drizzle", "!.planning"]
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
|
||||
223
bun.lock
223
bun.lock
@@ -6,11 +6,13 @@
|
||||
"name": "gearbox",
|
||||
"dependencies": {
|
||||
"@hono/zod-validator": "^0.7.6",
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"@tanstack/react-query": "^5.90.21",
|
||||
"@tanstack/react-router": "^1.167.0",
|
||||
"clsx": "^2.1.1",
|
||||
"drizzle-orm": "^0.45.1",
|
||||
"framer-motion": "^12.38.0",
|
||||
"hono": "^4.12.8",
|
||||
"lucide-react": "^0.577.0",
|
||||
"react": "^19.2.4",
|
||||
@@ -31,6 +33,7 @@
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"better-sqlite3": "^12.8.0",
|
||||
"concurrently": "^9.1.2",
|
||||
"drizzle-kit": "^0.31.9",
|
||||
"vite": "^8.0.0",
|
||||
},
|
||||
@@ -160,6 +163,8 @@
|
||||
|
||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
|
||||
|
||||
"@hono/node-server": ["@hono/node-server@1.19.12", "", { "peerDependencies": { "hono": "^4" } }, "sha512-txsUW4SQ1iilgE0l9/e9VQWmELXifEFvmdA1j6WFh/aFPj99hIntrSsq/if0UWyGVkmrRPKA1wCeP+UCr1B9Uw=="],
|
||||
|
||||
"@hono/zod-validator": ["@hono/zod-validator@0.7.6", "", { "peerDependencies": { "hono": ">=3.9.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Io1B6d011Gj1KknV4rXYz4le5+5EubcWEU/speUjuw9XMMIaP3n78yXLhjd2A3PXaXaUwEAluOiAyLqhBEJgsw=="],
|
||||
|
||||
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||
@@ -172,6 +177,8 @@
|
||||
|
||||
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||
|
||||
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="],
|
||||
|
||||
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" } }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="],
|
||||
|
||||
"@oxc-project/runtime": ["@oxc-project/runtime@0.115.0", "", {}, "sha512-Rg8Wlt5dCbXhQnsXPrkOjL1DTSvXLgb2R/KYfnf1/K+R0k6UMLEmbQXPM+kwrWqSmWA2t0B1EtHy2/3zikQpvQ=="],
|
||||
@@ -310,8 +317,18 @@
|
||||
|
||||
"@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.1", "", { "dependencies": { "@rolldown/pluginutils": "1.0.0-rc.7" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ=="],
|
||||
|
||||
"accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
|
||||
|
||||
"acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
|
||||
|
||||
"ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="],
|
||||
|
||||
"ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="],
|
||||
|
||||
"ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
|
||||
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
||||
|
||||
"ansis": ["ansis@4.2.0", "", {}, "sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig=="],
|
||||
|
||||
"anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="],
|
||||
@@ -332,6 +349,8 @@
|
||||
|
||||
"bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="],
|
||||
|
||||
"body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="],
|
||||
|
||||
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
|
||||
|
||||
"browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="],
|
||||
@@ -342,18 +361,46 @@
|
||||
|
||||
"bun-types": ["bun-types@1.3.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="],
|
||||
|
||||
"bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
|
||||
|
||||
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
|
||||
|
||||
"call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
|
||||
|
||||
"caniuse-lite": ["caniuse-lite@1.0.30001778", "", {}, "sha512-PN7uxFL+ExFJO61aVmP1aIEG4i9whQd4eoSCebav62UwDyp5OHh06zN4jqKSMePVgxHifCw1QJxdRkA1Pisekg=="],
|
||||
|
||||
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
||||
|
||||
"chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
|
||||
|
||||
"chownr": ["chownr@1.1.4", "", {}, "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="],
|
||||
|
||||
"cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
|
||||
|
||||
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
||||
|
||||
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
|
||||
|
||||
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
|
||||
|
||||
"concurrently": ["concurrently@9.2.1", "", { "dependencies": { "chalk": "4.1.2", "rxjs": "7.8.2", "shell-quote": "1.8.3", "supports-color": "8.1.1", "tree-kill": "1.2.2", "yargs": "17.7.2" }, "bin": { "conc": "dist/bin/concurrently.js", "concurrently": "dist/bin/concurrently.js" } }, "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng=="],
|
||||
|
||||
"content-disposition": ["content-disposition@1.0.1", "", {}, "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q=="],
|
||||
|
||||
"content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="],
|
||||
|
||||
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
|
||||
|
||||
"cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
|
||||
|
||||
"cookie-es": ["cookie-es@2.0.0", "", {}, "sha512-RAj4E421UYRgqokKUmotqAwuplYw15qtdXfY+hGzgCJ/MBjCVZcSoHK/kH9kocfjRjcDME7IiDWR/1WX1TM2Pg=="],
|
||||
|
||||
"cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="],
|
||||
|
||||
"cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="],
|
||||
|
||||
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
||||
|
||||
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||
|
||||
"d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="],
|
||||
@@ -386,6 +433,8 @@
|
||||
|
||||
"deep-extend": ["deep-extend@0.6.0", "", {}, "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA=="],
|
||||
|
||||
"depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
|
||||
|
||||
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
|
||||
"diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="],
|
||||
@@ -394,12 +443,26 @@
|
||||
|
||||
"drizzle-orm": ["drizzle-orm@0.45.1", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-Te0FOdKIistGNPMq2jscdqngBRfBpC8uMFVwqjf6gtTVJHIQ/dosgV/CLBU2N4ZJBsXL5savCba9b0YJskKdcA=="],
|
||||
|
||||
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
|
||||
|
||||
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
|
||||
|
||||
"electron-to-chromium": ["electron-to-chromium@1.5.313", "", {}, "sha512-QBMrTWEf00GXZmJyx2lbYD45jpI3TUFnNIzJ5BBc8piGUDwMPa1GV6HJWTZVvY/eiN3fSopl7NRbgGp9sZ9LTA=="],
|
||||
|
||||
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
||||
|
||||
"encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
|
||||
|
||||
"end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="],
|
||||
|
||||
"enhanced-resolve": ["enhanced-resolve@5.20.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ=="],
|
||||
|
||||
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
|
||||
|
||||
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
|
||||
|
||||
"es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
|
||||
|
||||
"es-toolkit": ["es-toolkit@1.45.1", "", {}, "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw=="],
|
||||
|
||||
"esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
|
||||
@@ -408,24 +471,56 @@
|
||||
|
||||
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
|
||||
|
||||
"escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
|
||||
|
||||
"esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="],
|
||||
|
||||
"etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="],
|
||||
|
||||
"eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="],
|
||||
|
||||
"eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="],
|
||||
|
||||
"eventsource-parser": ["eventsource-parser@3.0.6", "", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="],
|
||||
|
||||
"expand-template": ["expand-template@2.0.3", "", {}, "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg=="],
|
||||
|
||||
"express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="],
|
||||
|
||||
"express-rate-limit": ["express-rate-limit@8.3.2", "", { "dependencies": { "ip-address": "10.1.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg=="],
|
||||
|
||||
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
||||
|
||||
"fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="],
|
||||
|
||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||
|
||||
"file-uri-to-path": ["file-uri-to-path@1.0.0", "", {}, "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="],
|
||||
|
||||
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
|
||||
|
||||
"finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="],
|
||||
|
||||
"forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="],
|
||||
|
||||
"framer-motion": ["framer-motion@12.38.0", "", { "dependencies": { "motion-dom": "^12.38.0", "motion-utils": "^12.36.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g=="],
|
||||
|
||||
"fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="],
|
||||
|
||||
"fs-constants": ["fs-constants@1.0.0", "", {}, "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="],
|
||||
|
||||
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
||||
|
||||
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
|
||||
|
||||
"get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
|
||||
|
||||
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
|
||||
|
||||
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
|
||||
|
||||
"get-tsconfig": ["get-tsconfig@4.13.6", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw=="],
|
||||
|
||||
"github-from-package": ["github-from-package@0.0.0", "", {}, "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw=="],
|
||||
@@ -434,10 +529,22 @@
|
||||
|
||||
"goober": ["goober@2.1.18", "", { "peerDependencies": { "csstype": "^3.0.10" } }, "sha512-2vFqsaDVIT9Gz7N6kAL++pLpp41l3PfDuusHcjnGLfR6+huZkl6ziX+zgVC3ZxpqWhzH6pyDdGrCeDhMIvwaxw=="],
|
||||
|
||||
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
|
||||
|
||||
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
||||
|
||||
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
|
||||
|
||||
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
|
||||
|
||||
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
|
||||
|
||||
"hono": ["hono@4.12.8", "", {}, "sha512-VJCEvtrezO1IAR+kqEYnxUOoStaQPGrCmX3j4wDTNOcD1uRPFpGlwQUIW8niPuvHXaTUxeOUl5MMDGrl+tmO9A=="],
|
||||
|
||||
"http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
|
||||
|
||||
"iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
|
||||
|
||||
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
|
||||
|
||||
"immer": ["immer@10.2.0", "", {}, "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw=="],
|
||||
@@ -448,22 +555,38 @@
|
||||
|
||||
"internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="],
|
||||
|
||||
"ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="],
|
||||
|
||||
"ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
|
||||
|
||||
"is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="],
|
||||
|
||||
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
|
||||
|
||||
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
|
||||
|
||||
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
|
||||
|
||||
"is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="],
|
||||
|
||||
"is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="],
|
||||
|
||||
"isbot": ["isbot@5.1.36", "", {}, "sha512-C/ZtXyJqDPZ7G7JPr06ApWyYoHjYexQbS6hPYD4WYCzpv2Qes6Z+CCEfTX4Owzf+1EJ933PoI2p+B9v7wpGZBQ=="],
|
||||
|
||||
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||
|
||||
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
|
||||
|
||||
"jose": ["jose@6.2.2", "", {}, "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ=="],
|
||||
|
||||
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
||||
|
||||
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
|
||||
|
||||
"json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
|
||||
|
||||
"json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="],
|
||||
|
||||
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
|
||||
|
||||
"lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
|
||||
@@ -496,40 +619,78 @@
|
||||
|
||||
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||
|
||||
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
|
||||
|
||||
"media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="],
|
||||
|
||||
"merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="],
|
||||
|
||||
"mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
|
||||
|
||||
"mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
|
||||
|
||||
"mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="],
|
||||
|
||||
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
|
||||
|
||||
"mkdirp-classic": ["mkdirp-classic@0.5.3", "", {}, "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="],
|
||||
|
||||
"motion-dom": ["motion-dom@12.38.0", "", { "dependencies": { "motion-utils": "^12.36.0" } }, "sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA=="],
|
||||
|
||||
"motion-utils": ["motion-utils@12.36.0", "", {}, "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
||||
|
||||
"napi-build-utils": ["napi-build-utils@2.0.0", "", {}, "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA=="],
|
||||
|
||||
"negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
|
||||
|
||||
"node-abi": ["node-abi@3.88.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-At6b4UqIEVudaqPsXjmUO1r/N5BUr4yhDGs5PkBE8/oG5+TfLPhFechiskFsnT6Ql0VfUXbalUUCbfXxtj7K+w=="],
|
||||
|
||||
"node-releases": ["node-releases@2.0.36", "", {}, "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA=="],
|
||||
|
||||
"normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],
|
||||
|
||||
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
|
||||
|
||||
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
|
||||
|
||||
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],
|
||||
|
||||
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
|
||||
|
||||
"parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
|
||||
|
||||
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
|
||||
|
||||
"path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="],
|
||||
|
||||
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
|
||||
|
||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
||||
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
|
||||
|
||||
"pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="],
|
||||
|
||||
"postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="],
|
||||
|
||||
"prebuild-install": ["prebuild-install@7.1.3", "", { "dependencies": { "detect-libc": "^2.0.0", "expand-template": "^2.0.3", "github-from-package": "0.0.0", "minimist": "^1.2.3", "mkdirp-classic": "^0.5.3", "napi-build-utils": "^2.0.0", "node-abi": "^3.3.0", "pump": "^3.0.0", "rc": "^1.2.7", "simple-get": "^4.0.0", "tar-fs": "^2.0.0", "tunnel-agent": "^0.6.0" }, "bin": { "prebuild-install": "bin.js" } }, "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug=="],
|
||||
|
||||
"prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="],
|
||||
|
||||
"proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
|
||||
|
||||
"pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="],
|
||||
|
||||
"qs": ["qs@6.15.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ=="],
|
||||
|
||||
"range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="],
|
||||
|
||||
"raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],
|
||||
|
||||
"rc": ["rc@1.2.8", "", { "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" }, "bin": { "rc": "./cli.js" } }, "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw=="],
|
||||
|
||||
"react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="],
|
||||
@@ -552,22 +713,52 @@
|
||||
|
||||
"redux-thunk": ["redux-thunk@3.1.0", "", { "peerDependencies": { "redux": "^5.0.0" } }, "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw=="],
|
||||
|
||||
"require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
|
||||
|
||||
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
|
||||
|
||||
"reselect": ["reselect@5.1.1", "", {}, "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w=="],
|
||||
|
||||
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
|
||||
|
||||
"rolldown": ["rolldown@1.0.0-rc.9", "", { "dependencies": { "@oxc-project/types": "=0.115.0", "@rolldown/pluginutils": "1.0.0-rc.9" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.9", "@rolldown/binding-darwin-arm64": "1.0.0-rc.9", "@rolldown/binding-darwin-x64": "1.0.0-rc.9", "@rolldown/binding-freebsd-x64": "1.0.0-rc.9", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.9", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.9", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.9", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.9", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.9", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.9", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.9" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-9EbgWge7ZH+yqb4d2EnELAntgPTWbfL8ajiTW+SyhJEC4qhBbkCKbqFV4Ge4zmu5ziQuVbWxb/XwLZ+RIO7E8Q=="],
|
||||
|
||||
"router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="],
|
||||
|
||||
"rxjs": ["rxjs@7.8.2", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA=="],
|
||||
|
||||
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
|
||||
|
||||
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
|
||||
|
||||
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
|
||||
|
||||
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||
|
||||
"send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="],
|
||||
|
||||
"seroval": ["seroval@1.5.1", "", {}, "sha512-OwrZRZAfhHww0WEnKHDY8OM0U/Qs8OTfIDWhUD4BLpNJUfXK4cGmjiagGze086m+mhI+V2nD0gfbHEnJjb9STA=="],
|
||||
|
||||
"seroval-plugins": ["seroval-plugins@1.5.1", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-4FbuZ/TMl02sqv0RTFexu0SP6V+ywaIe5bAWCCEik0fk17BhALgwvUDVF7e3Uvf9pxmwCEJsRPmlkUE6HdzLAw=="],
|
||||
|
||||
"serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="],
|
||||
|
||||
"setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
|
||||
|
||||
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
|
||||
|
||||
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
|
||||
|
||||
"shell-quote": ["shell-quote@1.8.3", "", {}, "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw=="],
|
||||
|
||||
"side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="],
|
||||
|
||||
"side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="],
|
||||
|
||||
"side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="],
|
||||
|
||||
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
|
||||
|
||||
"simple-concat": ["simple-concat@1.0.1", "", {}, "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q=="],
|
||||
|
||||
"simple-get": ["simple-get@4.0.1", "", { "dependencies": { "decompress-response": "^6.0.0", "once": "^1.3.1", "simple-concat": "^1.0.0" } }, "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA=="],
|
||||
@@ -578,10 +769,18 @@
|
||||
|
||||
"source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="],
|
||||
|
||||
"statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
|
||||
|
||||
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
||||
|
||||
"string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
|
||||
|
||||
"strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||
|
||||
"strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="],
|
||||
|
||||
"supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="],
|
||||
|
||||
"tailwindcss": ["tailwindcss@4.2.1", "", {}, "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw=="],
|
||||
|
||||
"tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],
|
||||
@@ -598,16 +797,24 @@
|
||||
|
||||
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
|
||||
|
||||
"toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
|
||||
|
||||
"tree-kill": ["tree-kill@1.2.2", "", { "bin": { "tree-kill": "cli.js" } }, "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A=="],
|
||||
|
||||
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"tsx": ["tsx@4.21.0", "", { "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw=="],
|
||||
|
||||
"tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="],
|
||||
|
||||
"type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="],
|
||||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
||||
|
||||
"unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
|
||||
|
||||
"unplugin": ["unplugin@2.3.11", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "acorn": "^8.15.0", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww=="],
|
||||
|
||||
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
|
||||
@@ -616,18 +823,32 @@
|
||||
|
||||
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
|
||||
|
||||
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
|
||||
|
||||
"victory-vendor": ["victory-vendor@37.3.6", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ=="],
|
||||
|
||||
"vite": ["vite@8.0.0", "", { "dependencies": { "@oxc-project/runtime": "0.115.0", "lightningcss": "^1.32.0", "picomatch": "^4.0.3", "postcss": "^8.5.8", "rolldown": "1.0.0-rc.9", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.0.0-alpha.31", "esbuild": "^0.27.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-fPGaRNj9Zytaf8LEiBhY7Z6ijnFKdzU/+mL8EFBaKr7Vw1/FWcTBAMW0wLPJAGMPX38ZPVCVgLceWiEqeoqL2Q=="],
|
||||
|
||||
"webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="],
|
||||
|
||||
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||
|
||||
"wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
|
||||
|
||||
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
|
||||
|
||||
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
|
||||
|
||||
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
|
||||
|
||||
"yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
|
||||
|
||||
"yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="],
|
||||
|
||||
"zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
|
||||
|
||||
"zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="],
|
||||
|
||||
"zustand": ["zustand@5.0.11", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="],
|
||||
@@ -654,6 +875,8 @@
|
||||
|
||||
"anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
||||
|
||||
"chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
|
||||
|
||||
"node-abi/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
|
||||
|
||||
"readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
||||
|
||||
642
docs/api.md
Normal file
642
docs/api.md
Normal file
@@ -0,0 +1,642 @@
|
||||
# API Reference
|
||||
|
||||
Base URL: `http://localhost:3000`
|
||||
|
||||
**Auth:** GET endpoints are public. POST, PUT, PATCH, and DELETE require authentication via session cookie or `X-API-Key` header. See [authentication.md](authentication.md) for details.
|
||||
|
||||
**Prices** are stored and returned in cents (e.g., `2500` = $25.00).
|
||||
**Weights** are in grams.
|
||||
**Timestamps** are unix epoch integers.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Health](#health)
|
||||
- [Items](#items)
|
||||
- [Categories](#categories)
|
||||
- [Threads](#threads)
|
||||
- [Setups](#setups)
|
||||
- [Images](#images)
|
||||
- [Settings](#settings)
|
||||
- [Totals](#totals)
|
||||
- [Auth](#auth)
|
||||
|
||||
---
|
||||
|
||||
## Health
|
||||
|
||||
### `GET /api/health`
|
||||
|
||||
Returns server status. Always public.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{ "status": "ok" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Items
|
||||
|
||||
### `GET /api/items`
|
||||
|
||||
List all items in the collection.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Revelate Tangle Frame Bag",
|
||||
"categoryId": 2,
|
||||
"weightGrams": 185,
|
||||
"priceCents": 12000,
|
||||
"notes": "Medium size, fits 2020 Surly Straggler",
|
||||
"productUrl": "https://revelatedesigns.com/...",
|
||||
"imageFilename": "1710000000000-uuid.jpg",
|
||||
"imageSourceUrl": "https://example.com/image.jpg",
|
||||
"createdAt": 1710000000,
|
||||
"updatedAt": 1710000000
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `GET /api/items/:id`
|
||||
|
||||
Get a single item by ID.
|
||||
|
||||
**Response:** item object (see above), or `404 { "error": "Item not found" }`.
|
||||
|
||||
---
|
||||
|
||||
### `POST /api/items`
|
||||
|
||||
Create a new item. Auth required.
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"name": "Revelate Tangle Frame Bag",
|
||||
"categoryId": 2,
|
||||
"weightGrams": 185,
|
||||
"priceCents": 12000,
|
||||
"notes": "Medium size",
|
||||
"productUrl": "https://revelatedesigns.com/...",
|
||||
"imageFilename": "1710000000000-uuid.jpg",
|
||||
"imageSourceUrl": "https://example.com/image.jpg"
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-----------------|---------|----------|--------------------------------------|
|
||||
| `name` | string | yes | Item name |
|
||||
| `categoryId` | integer | yes | ID of an existing category |
|
||||
| `weightGrams` | number | no | Weight in grams (non-negative) |
|
||||
| `priceCents` | integer | no | Price in cents (non-negative) |
|
||||
| `notes` | string | no | Free-text notes |
|
||||
| `productUrl` | string | no | URL to product page |
|
||||
| `imageFilename` | string | no | Filename from a prior image upload |
|
||||
| `imageSourceUrl`| string | no | Original URL the image came from |
|
||||
|
||||
**Response:** `201` — created item object.
|
||||
|
||||
---
|
||||
|
||||
### `PUT /api/items/:id`
|
||||
|
||||
Update an existing item. All fields are optional. Auth required.
|
||||
|
||||
**Request:** same fields as POST, all optional.
|
||||
|
||||
**Response:** updated item object, or `404`.
|
||||
|
||||
---
|
||||
|
||||
### `DELETE /api/items/:id`
|
||||
|
||||
Delete an item and clean up its image file if one exists. Auth required.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{ "success": true }
|
||||
```
|
||||
|
||||
Returns `404` if item not found.
|
||||
|
||||
---
|
||||
|
||||
## Categories
|
||||
|
||||
### `GET /api/categories`
|
||||
|
||||
List all categories.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
[
|
||||
{ "id": 1, "name": "Uncategorized", "icon": "package" },
|
||||
{ "id": 2, "name": "Bags", "icon": "backpack" }
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `POST /api/categories`
|
||||
|
||||
Create a new category. Auth required.
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{ "name": "Bags", "icon": "backpack" }
|
||||
```
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|--------|--------|----------|--------------------------------------|
|
||||
| `name` | string | yes | Category name |
|
||||
| `icon` | string | no | Icon name (defaults to `"package"`) |
|
||||
|
||||
**Response:** `201` — created category object.
|
||||
|
||||
---
|
||||
|
||||
### `PUT /api/categories/:id`
|
||||
|
||||
Update a category. Auth required.
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{ "name": "Carry", "icon": "bag" }
|
||||
```
|
||||
|
||||
**Response:** updated category object, or `404`.
|
||||
|
||||
---
|
||||
|
||||
### `DELETE /api/categories/:id`
|
||||
|
||||
Delete a category. Auth required.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{ "success": true }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Threads
|
||||
|
||||
Research threads track multiple candidate options for a single gear slot before committing to a purchase.
|
||||
|
||||
### `GET /api/threads`
|
||||
|
||||
List threads. By default only active (unresolved) threads are returned.
|
||||
|
||||
**Query parameters:**
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|-------------------|---------|---------|------------------------------------|
|
||||
| `includeResolved` | boolean | `false` | Set to `true` to include resolved threads |
|
||||
|
||||
**Example:** `GET /api/threads?includeResolved=true`
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Handlebar bag",
|
||||
"categoryId": 2,
|
||||
"status": "active",
|
||||
"resolvedCandidateId": null,
|
||||
"createdAt": 1710000000,
|
||||
"updatedAt": 1710000000
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `POST /api/threads`
|
||||
|
||||
Create a new research thread. Auth required.
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{ "name": "Handlebar bag", "categoryId": 2 }
|
||||
```
|
||||
|
||||
**Response:** `201` — created thread object.
|
||||
|
||||
---
|
||||
|
||||
### `GET /api/threads/:id`
|
||||
|
||||
Get a thread with all its candidates.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Handlebar bag",
|
||||
"categoryId": 2,
|
||||
"status": "active",
|
||||
"resolvedCandidateId": null,
|
||||
"candidates": [
|
||||
{
|
||||
"id": 10,
|
||||
"threadId": 1,
|
||||
"name": "Revelate Sweetroll",
|
||||
"categoryId": 2,
|
||||
"weightGrams": 290,
|
||||
"priceCents": 13500,
|
||||
"notes": "16L, fits most drop bars",
|
||||
"productUrl": "https://revelatedesigns.com/...",
|
||||
"imageFilename": null,
|
||||
"imageSourceUrl": null,
|
||||
"status": "researching",
|
||||
"pros": "Waterproof, large capacity",
|
||||
"cons": "Pricey",
|
||||
"sortOrder": 0,
|
||||
"createdAt": 1710000000,
|
||||
"updatedAt": 1710000000
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Returns `404` if thread not found.
|
||||
|
||||
---
|
||||
|
||||
### `PUT /api/threads/:id`
|
||||
|
||||
Update a thread's name or category. Auth required.
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{ "name": "Bar bag", "categoryId": 2 }
|
||||
```
|
||||
|
||||
**Response:** updated thread object, or `404`.
|
||||
|
||||
---
|
||||
|
||||
### `DELETE /api/threads/:id`
|
||||
|
||||
Delete a thread and all its candidates. Cleans up any candidate image files. Auth required.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{ "success": true }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `POST /api/threads/:id/candidates`
|
||||
|
||||
Add a candidate to a thread. Auth required.
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"name": "Revelate Sweetroll",
|
||||
"categoryId": 2,
|
||||
"weightGrams": 290,
|
||||
"priceCents": 13500,
|
||||
"notes": "16L capacity",
|
||||
"productUrl": "https://revelatedesigns.com/...",
|
||||
"imageFilename": "1710000000000-uuid.jpg",
|
||||
"imageSourceUrl": "https://example.com/image.jpg",
|
||||
"status": "researching",
|
||||
"pros": "Waterproof, large capacity",
|
||||
"cons": "Expensive"
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-----------------|---------|----------|----------------------------------------------------|
|
||||
| `name` | string | yes | Candidate name |
|
||||
| `categoryId` | integer | yes | Category ID |
|
||||
| `weightGrams` | number | no | Weight in grams |
|
||||
| `priceCents` | integer | no | Price in cents |
|
||||
| `notes` | string | no | Notes |
|
||||
| `productUrl` | string | no | Product URL |
|
||||
| `imageFilename` | string | no | Filename from a prior image upload |
|
||||
| `imageSourceUrl`| string | no | Original image URL |
|
||||
| `status` | string | no | `"researching"`, `"ordered"`, or `"arrived"` |
|
||||
| `pros` | string | no | Pros of this option |
|
||||
| `cons` | string | no | Cons of this option |
|
||||
|
||||
**Response:** `201` — created candidate object. Returns `404` if thread not found.
|
||||
|
||||
---
|
||||
|
||||
### `PUT /api/threads/:threadId/candidates/:candidateId`
|
||||
|
||||
Update a candidate. All fields optional. Auth required.
|
||||
|
||||
**Request:** same fields as POST, all optional.
|
||||
|
||||
**Response:** updated candidate object, or `404`.
|
||||
|
||||
---
|
||||
|
||||
### `DELETE /api/threads/:threadId/candidates/:candidateId`
|
||||
|
||||
Delete a candidate and clean up its image. Auth required.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{ "success": true }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `PATCH /api/threads/:id/candidates/reorder`
|
||||
|
||||
Reorder candidates within a thread. Auth required.
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{ "orderedIds": [12, 10, 11] }
|
||||
```
|
||||
|
||||
`orderedIds` is an array of candidate IDs in the desired display order. All IDs must belong to the thread.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{ "success": true }
|
||||
```
|
||||
|
||||
Returns `400` with an error message if validation fails.
|
||||
|
||||
---
|
||||
|
||||
### `POST /api/threads/:id/resolve`
|
||||
|
||||
Resolve a thread by selecting the winning candidate. Auth required.
|
||||
|
||||
The winning candidate's data is copied into a new item in the collection. The thread status is set to `"resolved"` and `resolvedCandidateId` is set.
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{ "candidateId": 10 }
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"item": {
|
||||
"id": 42,
|
||||
"name": "Revelate Sweetroll",
|
||||
"categoryId": 2,
|
||||
"weightGrams": 290,
|
||||
"priceCents": 13500
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Returns `400` if the candidate does not belong to the thread or another error occurs.
|
||||
|
||||
---
|
||||
|
||||
## Setups
|
||||
|
||||
Setups are named collections of items (e.g., "Bikepacking weekend", "Commute loadout").
|
||||
|
||||
### `GET /api/setups`
|
||||
|
||||
List all setups with item counts and weight/cost totals.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Bikepacking weekend",
|
||||
"itemCount": 12,
|
||||
"totalWeightGrams": 4800,
|
||||
"totalPriceCents": 285000,
|
||||
"createdAt": 1710000000,
|
||||
"updatedAt": 1710000000
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `POST /api/setups`
|
||||
|
||||
Create a new setup. Auth required.
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{ "name": "Bikepacking weekend" }
|
||||
```
|
||||
|
||||
**Response:** `201` — created setup object.
|
||||
|
||||
---
|
||||
|
||||
### `GET /api/setups/:id`
|
||||
|
||||
Get a setup with its full item list.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Bikepacking weekend",
|
||||
"items": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Revelate Tangle Frame Bag",
|
||||
"weightGrams": 185,
|
||||
"priceCents": 12000,
|
||||
"categoryId": 2,
|
||||
"classification": "base"
|
||||
}
|
||||
],
|
||||
"totalWeightGrams": 185,
|
||||
"totalPriceCents": 12000
|
||||
}
|
||||
```
|
||||
|
||||
Returns `404` if setup not found.
|
||||
|
||||
---
|
||||
|
||||
### `PUT /api/setups/:id`
|
||||
|
||||
Update a setup's name. Auth required.
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{ "name": "Bikepacking overnighter" }
|
||||
```
|
||||
|
||||
**Response:** updated setup object, or `404`.
|
||||
|
||||
---
|
||||
|
||||
### `PUT /api/setups/:id/items`
|
||||
|
||||
Replace all items in a setup atomically. Deletes all existing setup-item associations and re-inserts with the provided IDs. Auth required.
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{ "itemIds": [1, 5, 12, 17] }
|
||||
```
|
||||
|
||||
Pass an empty array to remove all items from the setup.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{ "success": true }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `DELETE /api/setups/:id`
|
||||
|
||||
Delete a setup. Does not delete the items themselves. Auth required.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{ "success": true }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Images
|
||||
|
||||
### `POST /api/images`
|
||||
|
||||
Upload an image file. Auth required.
|
||||
|
||||
**Request:** `multipart/form-data` with an `image` field.
|
||||
|
||||
```
|
||||
POST /api/images
|
||||
Content-Type: multipart/form-data; boundary=...
|
||||
|
||||
--boundary
|
||||
Content-Disposition: form-data; name="image"; filename="bag.jpg"
|
||||
Content-Type: image/jpeg
|
||||
|
||||
<binary data>
|
||||
--boundary--
|
||||
```
|
||||
|
||||
Constraints:
|
||||
- Accepted types: `image/jpeg`, `image/png`, `image/webp`
|
||||
- Maximum size: 5MB
|
||||
- Files are saved to `./uploads/` with a UUID-based filename
|
||||
|
||||
**Response:** `201`
|
||||
```json
|
||||
{ "filename": "1710000000000-550e8400-e29b-41d4-a716-446655440000.jpg" }
|
||||
```
|
||||
|
||||
Use the returned `filename` as `imageFilename` when creating or updating items or candidates.
|
||||
|
||||
---
|
||||
|
||||
### `POST /api/images/from-url`
|
||||
|
||||
Fetch an image from a remote URL and save it locally. Auth required.
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{ "url": "https://example.com/product-image.jpg" }
|
||||
```
|
||||
|
||||
**Response:** `201`
|
||||
```json
|
||||
{
|
||||
"filename": "1710000000000-uuid.jpg",
|
||||
"sourceUrl": "https://example.com/product-image.jpg"
|
||||
}
|
||||
```
|
||||
|
||||
Returns `400` for invalid URLs, unsupported content types, files over 5MB, or non-200 HTTP responses.
|
||||
|
||||
---
|
||||
|
||||
## Settings
|
||||
|
||||
Key-value store for application settings.
|
||||
|
||||
### `GET /api/settings/:key`
|
||||
|
||||
Get a setting by key. Public.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{ "key": "collectionName", "value": "My Bikepacking Gear" }
|
||||
```
|
||||
|
||||
Returns `404` if the key does not exist.
|
||||
|
||||
---
|
||||
|
||||
### `PUT /api/settings/:key`
|
||||
|
||||
Create or update a setting. Auth required.
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{ "value": "My Bikepacking Gear" }
|
||||
```
|
||||
|
||||
**Response:** updated setting object.
|
||||
|
||||
---
|
||||
|
||||
## Totals
|
||||
|
||||
### `GET /api/totals`
|
||||
|
||||
Global and per-category weight and cost totals. Computed from current item data. Public.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"global": {
|
||||
"totalWeightGrams": 12500,
|
||||
"totalPriceCents": 450000,
|
||||
"itemCount": 34
|
||||
},
|
||||
"categories": [
|
||||
{
|
||||
"categoryId": 2,
|
||||
"name": "Bags",
|
||||
"icon": "backpack",
|
||||
"totalWeightGrams": 1200,
|
||||
"totalPriceCents": 78000,
|
||||
"itemCount": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Auth
|
||||
|
||||
Authentication endpoints are documented in [authentication.md](authentication.md).
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|-------------------------|----------|---------------------------|
|
||||
| GET | `/api/auth/me` | public | Current session state |
|
||||
| POST | `/api/auth/setup` | public | Create first admin user |
|
||||
| POST | `/api/auth/login` | public | Log in, receive cookie |
|
||||
| POST | `/api/auth/logout` | public | Clear session |
|
||||
| PUT | `/api/auth/password` | session | Change password |
|
||||
| GET | `/api/auth/keys` | required | List API keys |
|
||||
| POST | `/api/auth/keys` | required | Create API key |
|
||||
| DELETE | `/api/auth/keys/:id` | required | Revoke API key |
|
||||
281
docs/authentication.md
Normal file
281
docs/authentication.md
Normal file
@@ -0,0 +1,281 @@
|
||||
# Authentication
|
||||
|
||||
GearBox uses a public-read, authenticated-write model. All GET endpoints are publicly accessible with no credentials required. Any request that modifies data (POST, PUT, PATCH, DELETE) requires authentication.
|
||||
|
||||
This is a single-user app. There is exactly one admin account.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [First-Time Setup](#first-time-setup)
|
||||
- [Web UI Authentication](#web-ui-authentication)
|
||||
- [API Keys](#api-keys)
|
||||
- [Auth Middleware Behavior](#auth-middleware-behavior)
|
||||
- [Auth API Reference](#auth-api-reference)
|
||||
- [Frontend Behavior](#frontend-behavior)
|
||||
|
||||
---
|
||||
|
||||
## First-Time Setup
|
||||
|
||||
When no users exist, all write endpoints return `403` with `{ "error": "setup_required" }`. To create the admin account, visit `/login` in the browser and complete the setup form, or call the setup endpoint directly:
|
||||
|
||||
```http
|
||||
POST /api/auth/setup
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"username": "admin",
|
||||
"password": "yourpassword"
|
||||
}
|
||||
```
|
||||
|
||||
Requirements:
|
||||
- `username`: any non-empty string
|
||||
- `password`: minimum 6 characters
|
||||
|
||||
This endpoint only works when no users exist. Subsequent calls return `403 { "error": "Setup already completed" }`.
|
||||
|
||||
On success, a session cookie is set and `201` is returned:
|
||||
|
||||
```json
|
||||
{ "username": "admin" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Web UI Authentication
|
||||
|
||||
Sessions use an `httpOnly` cookie named `gearbox_session`.
|
||||
|
||||
| Property | Value |
|
||||
|------------|--------------------|
|
||||
| Cookie name | `gearbox_session` |
|
||||
| httpOnly | true |
|
||||
| sameSite | Lax |
|
||||
| path | / |
|
||||
| Max age | 30 days |
|
||||
|
||||
The session expiry is **automatically refreshed** on each authenticated request. As long as the app is used at least once every 30 days, the session stays active.
|
||||
|
||||
Passwords are hashed with **argon2** via `Bun.password`.
|
||||
|
||||
### Changing Your Password
|
||||
|
||||
Requires an active session cookie.
|
||||
|
||||
```http
|
||||
PUT /api/auth/password
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"currentPassword": "oldpassword",
|
||||
"newPassword": "newpassword"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API Keys
|
||||
|
||||
API keys are intended for programmatic access (scripts, MCP clients, integrations). They are managed under **Settings > API Keys** in the web UI, or via the API endpoints listed below.
|
||||
|
||||
### Key behavior
|
||||
|
||||
- Keys are shown **once** at creation time. Store them securely.
|
||||
- Keys are stored as an argon2 hash. Only the 8-character prefix is stored in plaintext for display and lookup purposes.
|
||||
- Pass the key via the `X-API-Key` request header on any write request.
|
||||
|
||||
```http
|
||||
POST /api/items
|
||||
X-API-Key: gbk_a1b2c3d4...
|
||||
Content-Type: application/json
|
||||
|
||||
{ "name": "Revelate Tangle", "categoryId": 2 }
|
||||
```
|
||||
|
||||
If both a session cookie and an `X-API-Key` header are present, the API key is checked first.
|
||||
|
||||
---
|
||||
|
||||
## Auth Middleware Behavior
|
||||
|
||||
The middleware applied to `/api/*` (excluding `/api/auth/*`) follows these rules:
|
||||
|
||||
1. `GET` requests — always allowed, no auth check.
|
||||
2. No users exist — returns `403 { "error": "setup_required" }`.
|
||||
3. `X-API-Key` header present — verified against stored hashes; `401` on failure.
|
||||
4. `gearbox_session` cookie present — verified against sessions table; refreshed on success; `401` on failure.
|
||||
5. Neither credential present — returns `401 { "error": "Authentication required" }`.
|
||||
|
||||
The `/api/auth/*` routes handle their own auth logic and are excluded from the global middleware.
|
||||
|
||||
---
|
||||
|
||||
## Auth API Reference
|
||||
|
||||
### `GET /api/auth/me`
|
||||
|
||||
Returns the current session state. Always public.
|
||||
|
||||
**Response when logged in:**
|
||||
```json
|
||||
{
|
||||
"user": { "id": 1 },
|
||||
"setupRequired": false
|
||||
}
|
||||
```
|
||||
|
||||
**Response when logged out, setup complete:**
|
||||
```json
|
||||
{
|
||||
"user": null,
|
||||
"setupRequired": false
|
||||
}
|
||||
```
|
||||
|
||||
**Response when no users exist:**
|
||||
```json
|
||||
{
|
||||
"user": null,
|
||||
"setupRequired": true
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `POST /api/auth/setup`
|
||||
|
||||
Create the first admin account. Only works when no users exist.
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"username": "admin",
|
||||
"password": "yourpassword"
|
||||
}
|
||||
```
|
||||
|
||||
**Response:** `201`
|
||||
```json
|
||||
{ "username": "admin" }
|
||||
```
|
||||
|
||||
Sets `gearbox_session` cookie.
|
||||
|
||||
---
|
||||
|
||||
### `POST /api/auth/login`
|
||||
|
||||
Log in with username and password.
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"username": "admin",
|
||||
"password": "yourpassword"
|
||||
}
|
||||
```
|
||||
|
||||
**Response:** `200`
|
||||
```json
|
||||
{ "username": "admin" }
|
||||
```
|
||||
|
||||
Sets `gearbox_session` cookie. Returns `401` on invalid credentials.
|
||||
|
||||
---
|
||||
|
||||
### `POST /api/auth/logout`
|
||||
|
||||
Clear the current session. No request body needed.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{ "ok": true }
|
||||
```
|
||||
|
||||
Clears the `gearbox_session` cookie and deletes the session from the database.
|
||||
|
||||
---
|
||||
|
||||
### `PUT /api/auth/password`
|
||||
|
||||
Change the admin password. Requires an active session cookie (not API key).
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"currentPassword": "oldpassword",
|
||||
"newPassword": "newpassword"
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{ "ok": true }
|
||||
```
|
||||
|
||||
Returns `401` if `currentPassword` is incorrect.
|
||||
|
||||
---
|
||||
|
||||
### `GET /api/auth/keys`
|
||||
|
||||
List all API keys. Returns name, prefix, and creation timestamp — never the full key.
|
||||
|
||||
Requires auth.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Claude Code",
|
||||
"prefix": "gbk_a1b2",
|
||||
"createdAt": "2025-03-01T10:00:00.000Z"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `POST /api/auth/keys`
|
||||
|
||||
Create a new API key. The full key is returned **once** and cannot be retrieved again.
|
||||
|
||||
Requires auth.
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{ "name": "Claude Code" }
|
||||
```
|
||||
|
||||
**Response:** `201`
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Claude Code",
|
||||
"key": "gbk_a1b2c3d4e5f6g7h8i9j0...",
|
||||
"prefix": "gbk_a1b2"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `DELETE /api/auth/keys/:id`
|
||||
|
||||
Revoke an API key by ID. Requires auth.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{ "ok": true }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Frontend Behavior
|
||||
|
||||
- A login button is shown in the top-right corner of the UI (Gitea-style).
|
||||
- The floating action button (FAB) for adding items is hidden when not logged in.
|
||||
- Edit and delete actions on items, threads, and setups require auth. Unauthenticated users see read-only views.
|
||||
- When `setupRequired` is true, the UI redirects to the setup flow.
|
||||
253
docs/mcp-server.md
Normal file
253
docs/mcp-server.md
Normal file
@@ -0,0 +1,253 @@
|
||||
# MCP Server
|
||||
|
||||
GearBox includes a built-in MCP (Model Context Protocol) server that exposes your gear collection to AI assistants. It runs inside the Hono process — no separate service is needed.
|
||||
|
||||
The MCP server implements the [Streamable HTTP transport](https://modelcontextprotocol.io/specification) from `@modelcontextprotocol/sdk` and is mounted at `/mcp`.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Enabling and Disabling](#enabling-and-disabling)
|
||||
- [Authentication](#authentication)
|
||||
- [Connecting an AI Client](#connecting-an-ai-client)
|
||||
- [Claude Code](#claude-code)
|
||||
- [Claude Desktop](#claude-desktop)
|
||||
- [Tools](#tools)
|
||||
- [Resources](#resources)
|
||||
- [Recommended Workflow](#recommended-workflow)
|
||||
- [Example Session](#example-session)
|
||||
- [Implementation Structure](#implementation-structure)
|
||||
|
||||
---
|
||||
|
||||
## Enabling and Disabling
|
||||
|
||||
The MCP server is **enabled by default**. To disable it, set the environment variable:
|
||||
|
||||
```bash
|
||||
GEARBOX_MCP=false bun run dev:server
|
||||
```
|
||||
|
||||
Or in your environment file:
|
||||
|
||||
```bash
|
||||
GEARBOX_MCP=false
|
||||
```
|
||||
|
||||
When disabled, the `/mcp` route is not registered and any requests to it return 404.
|
||||
|
||||
---
|
||||
|
||||
## Authentication
|
||||
|
||||
The MCP server requires an API key when the app has been set up (i.e., at least one user exists). Pass the key via the `X-API-Key` header in your MCP client configuration.
|
||||
|
||||
To create an API key:
|
||||
1. Log in to the web UI.
|
||||
2. Go to **Settings > API Keys**.
|
||||
3. Click **New Key**, give it a name, and copy the full key shown.
|
||||
|
||||
The full key is only shown once. Store it in your client configuration immediately.
|
||||
|
||||
See [authentication.md](authentication.md) for full API key documentation.
|
||||
|
||||
---
|
||||
|
||||
## Connecting an AI Client
|
||||
|
||||
### Claude Code
|
||||
|
||||
Add the MCP server to `.claude/settings.json` in your project or globally at `~/.claude/settings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"gearbox": {
|
||||
"type": "streamable-http",
|
||||
"url": "http://localhost:3000/mcp",
|
||||
"headers": {
|
||||
"X-API-Key": "your-api-key-here"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Claude Desktop
|
||||
|
||||
Add the server to `claude_desktop_config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"gearbox": {
|
||||
"type": "streamable-http",
|
||||
"url": "http://localhost:3000/mcp",
|
||||
"headers": {
|
||||
"X-API-Key": "your-api-key-here"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
On macOS, the config file is at `~/Library/Application Support/Claude/claude_desktop_config.json`.
|
||||
|
||||
After updating the config, restart Claude Desktop (or reload the window in Claude Code) for the server to connect.
|
||||
|
||||
---
|
||||
|
||||
## Tools
|
||||
|
||||
The MCP server exposes 18 tools across five categories.
|
||||
|
||||
### Items
|
||||
|
||||
| Tool | Description |
|
||||
|----------------|------------------------------------------------------------------------------------------------------------|
|
||||
| `list_items` | List all items in the gear collection, optionally filtered by `categoryId`. |
|
||||
| `get_item` | Get a single item by ID with all details. |
|
||||
| `create_item` | Add a new item directly to the collection. Use for items already decided on; use `create_thread` for research. |
|
||||
| `update_item` | Update an existing item's fields. |
|
||||
| `delete_item` | Delete an item from the collection by ID. |
|
||||
|
||||
### Categories
|
||||
|
||||
| Tool | Description |
|
||||
|-------------------|--------------------------------------|
|
||||
| `list_categories` | List all gear categories. |
|
||||
| `create_category` | Create a new gear category. |
|
||||
|
||||
### Threads
|
||||
|
||||
| Tool | Description |
|
||||
|------------------|------------------------------------------------------------------------------------------------------|
|
||||
| `list_threads` | List research threads. Pass `includeResolved: true` to include resolved threads. |
|
||||
| `get_thread` | Get a thread with all its candidates for detailed comparison. |
|
||||
| `create_thread` | Start a new research thread for a gear slot. Preferred entry point for gear research. |
|
||||
| `resolve_thread` | Resolve a thread by picking the winning candidate. Automatically creates a new item in the collection. |
|
||||
|
||||
### Candidates
|
||||
|
||||
| Tool | Description |
|
||||
|--------------------|-----------------------------------------------------------------------|
|
||||
| `add_candidate` | Add a candidate option to a research thread for comparison. |
|
||||
| `update_candidate` | Update a candidate's details (name, price, pros, cons, status, etc.).|
|
||||
| `remove_candidate` | Remove a candidate from a research thread. |
|
||||
|
||||
### Setups
|
||||
|
||||
| Tool | Description |
|
||||
|-----------------|-------------------------------------------------------------------------------------------------------|
|
||||
| `list_setups` | List all gear setups with item counts and weight/cost totals. |
|
||||
| `get_setup` | Get a setup with all its items and details. |
|
||||
| `create_setup` | Create a new gear setup (e.g., "Bikepacking weekend"). |
|
||||
| `update_setup` | Update a setup's name and/or replace its item list by passing `itemIds`. |
|
||||
|
||||
### Images
|
||||
|
||||
| Tool | Description |
|
||||
|-------------------------|--------------------------------------------------------------------------------------------------|
|
||||
| `upload_image_from_url` | Fetch an image from a URL and save it locally. Returns a `filename` to use with items or candidates. |
|
||||
|
||||
---
|
||||
|
||||
## Resources
|
||||
|
||||
### `gearbox://collection/summary`
|
||||
|
||||
Returns a JSON overview of the entire gear collection, including:
|
||||
- Total item count, weight, and cost
|
||||
- Per-category breakdowns
|
||||
- Active research thread count
|
||||
|
||||
Read this resource first to orient yourself before running queries or making changes.
|
||||
|
||||
---
|
||||
|
||||
## Recommended Workflow
|
||||
|
||||
Tool descriptions are written to guide AI assistants toward the research thread workflow rather than creating items directly. The intended flow for evaluating a new piece of gear is:
|
||||
|
||||
1. **`create_thread`** — open a research thread for the gear slot (e.g., "Handlebar bag")
|
||||
2. **`add_candidate`** — add options with prices, weights, pros, and cons
|
||||
3. **`get_thread`** — review all candidates side by side
|
||||
4. **`resolve_thread`** — pick the winner; it becomes a new item in the collection automatically
|
||||
|
||||
Use `create_item` directly only when you already know exactly what you're adding and no research is needed.
|
||||
|
||||
---
|
||||
|
||||
## Example Session
|
||||
|
||||
The following illustrates a typical assistant interaction via the MCP server.
|
||||
|
||||
**User:** "I need a new handlebar bag. Can you research some options and add them?"
|
||||
|
||||
**Assistant actions:**
|
||||
|
||||
```
|
||||
1. list_categories
|
||||
-> finds "Bags" category (id: 2)
|
||||
|
||||
2. create_thread { name: "Handlebar bag", categoryId: 2 }
|
||||
-> thread id: 7
|
||||
|
||||
3. upload_image_from_url { url: "https://revelatedesigns.com/sweetroll.jpg" }
|
||||
-> filename: "1710000000000-uuid.jpg"
|
||||
|
||||
4. add_candidate {
|
||||
threadId: 7,
|
||||
name: "Revelate Sweetroll",
|
||||
categoryId: 2,
|
||||
weightGrams: 290,
|
||||
priceCents: 13500,
|
||||
productUrl: "https://revelatedesigns.com/...",
|
||||
imageFilename: "1710000000000-uuid.jpg",
|
||||
pros: "Waterproof, 16L, fits most drop bars",
|
||||
cons: "Expensive, bulky profile"
|
||||
}
|
||||
|
||||
5. add_candidate {
|
||||
threadId: 7,
|
||||
name: "Apidura Backcountry Handlebar Pack",
|
||||
categoryId: 2,
|
||||
weightGrams: 230,
|
||||
priceCents: 11000,
|
||||
pros: "Lighter, packable, good attachment system",
|
||||
cons: "Smaller capacity"
|
||||
}
|
||||
|
||||
6. add_candidate { ... }
|
||||
|
||||
7. get_thread { id: 7 }
|
||||
-> presents comparison to user
|
||||
|
||||
8. resolve_thread { threadId: 7, candidateId: 14 }
|
||||
-> Revelate Sweetroll added to collection as item id: 42
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Structure
|
||||
|
||||
The MCP server source lives under `src/server/mcp/`:
|
||||
|
||||
```
|
||||
src/server/mcp/
|
||||
index.ts # Server factory, transport handling, auth middleware, Hono route registration
|
||||
tools/
|
||||
items.ts # list_items, get_item, create_item, update_item, delete_item
|
||||
categories.ts # list_categories, create_category
|
||||
threads.ts # list_threads, get_thread, create_thread, resolve_thread,
|
||||
# add_candidate, update_candidate, remove_candidate
|
||||
setups.ts # list_setups, get_setup, create_setup, update_setup
|
||||
images.ts # upload_image_from_url
|
||||
resources/
|
||||
collection.ts # gearbox://collection/summary resource handler
|
||||
```
|
||||
|
||||
Each tool file exports a `*ToolDefinitions` array (name, description, inputSchema) and a `register*Tools(db)` factory function that returns handler implementations. The server in `index.ts` iterates over definitions and registers each handler with the MCP `McpServer` instance.
|
||||
|
||||
Session management uses the `WebStandardStreamableHTTPServerTransport` with UUID-based session IDs tracked in an in-memory `Map`. Sessions are cleaned up when the transport closes.
|
||||
1503
docs/superpowers/plans/2026-04-03-authentication.md
Normal file
1503
docs/superpowers/plans/2026-04-03-authentication.md
Normal file
File diff suppressed because it is too large
Load Diff
404
docs/superpowers/plans/2026-04-03-image-url-fetching.md
Normal file
404
docs/superpowers/plans/2026-04-03-image-url-fetching.md
Normal file
@@ -0,0 +1,404 @@
|
||||
# Image URL Fetching Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add a `POST /api/images/from-url` endpoint that fetches an image from a URL, saves it locally, and returns the filename. Also add `imageSourceUrl` column to items and candidates.
|
||||
|
||||
**Architecture:** New image service function handles URL fetching with validation (content-type, size, timeout). New route delegates to service. Schema changes add nullable `imageSourceUrl` to items and threadCandidates tables. Drizzle migration for the new column.
|
||||
|
||||
**Tech Stack:** Hono routes, Zod validation, Drizzle ORM, Bun's native fetch, Bun's file I/O
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| Action | Path | Responsibility |
|
||||
|--------|------|----------------|
|
||||
| Create | `src/server/services/image.service.ts` | Image fetching logic (fetch URL, validate, save to disk) |
|
||||
| Modify | `src/server/routes/images.ts` | Add `POST /from-url` route |
|
||||
| Modify | `src/db/schema.ts` | Add `imageSourceUrl` to `items` and `threadCandidates` |
|
||||
| Modify | `src/shared/schemas.ts` | Add `imageSourceUrl` to item/candidate Zod schemas |
|
||||
| Modify | `src/server/services/item.service.ts` | Pass through `imageSourceUrl` in create/update |
|
||||
| Modify | `src/server/services/thread.service.ts` | Pass through `imageSourceUrl` in candidate create/update and thread resolution |
|
||||
| Modify | `tests/helpers/db.ts` | Add `image_source_url` column to test CREATE TABLE statements |
|
||||
| Create | `tests/services/image.service.test.ts` | Tests for image fetching service |
|
||||
| Create | `tests/routes/images.test.ts` | Route-level tests for `/api/images/from-url` |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Add `imageSourceUrl` Column to Database Schema
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/db/schema.ts:12-29` (items table)
|
||||
- Modify: `src/db/schema.ts:47-71` (threadCandidates table)
|
||||
- Modify: `tests/helpers/db.ts:19-32` (items CREATE TABLE)
|
||||
- Modify: `tests/helpers/db.ts:46-64` (thread_candidates CREATE TABLE)
|
||||
|
||||
- [ ] **Step 1: Add column to Drizzle items table**
|
||||
|
||||
In `src/db/schema.ts`, add after the `imageFilename` line in the `items` table:
|
||||
|
||||
```typescript
|
||||
imageSourceUrl: text("image_source_url"),
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add column to Drizzle threadCandidates table**
|
||||
|
||||
In `src/db/schema.ts`, add after the `imageFilename` line in the `threadCandidates` table:
|
||||
|
||||
```typescript
|
||||
imageSourceUrl: text("image_source_url"),
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Update test helper — items table**
|
||||
|
||||
In `tests/helpers/db.ts`, add to the items CREATE TABLE statement after `image_filename TEXT,`:
|
||||
|
||||
```sql
|
||||
image_source_url TEXT,
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Update test helper — thread_candidates table**
|
||||
|
||||
In `tests/helpers/db.ts`, add to the thread_candidates CREATE TABLE statement after `image_filename TEXT,`:
|
||||
|
||||
```sql
|
||||
image_source_url TEXT,
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Generate Drizzle migration**
|
||||
|
||||
Run: `bun run db:generate`
|
||||
Expected: A new migration file created in `drizzle/` adding `image_source_url` to both tables.
|
||||
|
||||
- [ ] **Step 6: Apply migration**
|
||||
|
||||
Run: `bun run db:push`
|
||||
Expected: Migration applied successfully.
|
||||
|
||||
- [ ] **Step 7: Run existing tests to verify no regressions**
|
||||
|
||||
Run: `bun test`
|
||||
Expected: All existing tests pass.
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add src/db/schema.ts tests/helpers/db.ts drizzle/
|
||||
git commit -m "feat: add imageSourceUrl column to items and threadCandidates"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Update Zod Schemas and Service Functions
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/shared/schemas.ts:1-16` (item schemas)
|
||||
- Modify: `src/shared/schemas.ts:47-60` (candidate schemas)
|
||||
- Modify: `src/server/services/item.service.ts:50-71` (createItem)
|
||||
- Modify: `src/server/services/item.service.ts:73-101` (updateItem)
|
||||
|
||||
- [ ] **Step 1: Add imageSourceUrl to createItemSchema**
|
||||
|
||||
In `src/shared/schemas.ts`, add after the `imageFilename` line in `createItemSchema`:
|
||||
|
||||
```typescript
|
||||
imageSourceUrl: z.string().url().optional().or(z.literal("")),
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add imageSourceUrl to createCandidateSchema**
|
||||
|
||||
In `src/shared/schemas.ts`, add after the `imageFilename` line in `createCandidateSchema`:
|
||||
|
||||
```typescript
|
||||
imageSourceUrl: z.string().url().optional().or(z.literal("")),
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Update item service createItem**
|
||||
|
||||
In `src/server/services/item.service.ts`, update the `createItem` function's `.values()` to include:
|
||||
|
||||
```typescript
|
||||
imageSourceUrl: data.imageSourceUrl ?? null,
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Update item service updateItem**
|
||||
|
||||
In `src/server/services/item.service.ts`, add `imageSourceUrl: string` to the `Partial<{...}>` type in `updateItem`.
|
||||
|
||||
- [ ] **Step 5: Update item service getAllItems and getItemById**
|
||||
|
||||
Add `imageSourceUrl: items.imageSourceUrl` to the `.select()` objects in both `getAllItems` and `getItemById`.
|
||||
|
||||
- [ ] **Step 6: Update thread service candidate create/update**
|
||||
|
||||
In `src/server/services/thread.service.ts`, find the candidate create and update functions. Add `imageSourceUrl` passthrough in the same pattern as `imageFilename`. Also ensure that when resolving a thread (copying candidate data to a new item), `imageSourceUrl` is copied from the winning candidate.
|
||||
|
||||
- [ ] **Step 7: Run tests**
|
||||
|
||||
Run: `bun test`
|
||||
Expected: All tests pass.
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add src/shared/schemas.ts src/server/services/item.service.ts src/server/services/thread.service.ts
|
||||
git commit -m "feat: add imageSourceUrl to Zod schemas and service functions"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Create Image Fetching Service
|
||||
|
||||
**Files:**
|
||||
- Create: `src/server/services/image.service.ts`
|
||||
- Create: `tests/services/image.service.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write failing test — successful URL fetch**
|
||||
|
||||
Create `tests/services/image.service.test.ts`:
|
||||
|
||||
```typescript
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import { existsSync, rmSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { fetchImageFromUrl } from "../../src/server/services/image.service";
|
||||
|
||||
const TEST_UPLOADS_DIR = "test-uploads";
|
||||
|
||||
afterEach(() => {
|
||||
if (existsSync(TEST_UPLOADS_DIR)) {
|
||||
rmSync(TEST_UPLOADS_DIR, { recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("fetchImageFromUrl", () => {
|
||||
test("fetches a valid image URL and saves to disk", async () => {
|
||||
// Use a small, reliable test image
|
||||
const url = "https://via.placeholder.com/10x10.png";
|
||||
const result = await fetchImageFromUrl(url, TEST_UPLOADS_DIR);
|
||||
|
||||
expect(result.filename).toMatch(/^\d+-[\w-]+\.png$/);
|
||||
expect(result.sourceUrl).toBe(url);
|
||||
expect(existsSync(join(TEST_UPLOADS_DIR, result.filename))).toBe(true);
|
||||
});
|
||||
|
||||
test("rejects non-image content type", async () => {
|
||||
const url = "https://example.com/";
|
||||
await expect(fetchImageFromUrl(url, TEST_UPLOADS_DIR)).rejects.toThrow(
|
||||
"Invalid content type"
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects invalid URL", async () => {
|
||||
await expect(fetchImageFromUrl("not-a-url", TEST_UPLOADS_DIR)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `bun test tests/services/image.service.test.ts`
|
||||
Expected: FAIL — module not found.
|
||||
|
||||
- [ ] **Step 3: Implement image service**
|
||||
|
||||
Create `src/server/services/image.service.ts`:
|
||||
|
||||
```typescript
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/webp"];
|
||||
const MAX_SIZE = 5 * 1024 * 1024; // 5MB
|
||||
const FETCH_TIMEOUT = 10_000; // 10 seconds
|
||||
|
||||
interface FetchImageResult {
|
||||
filename: string;
|
||||
sourceUrl: string;
|
||||
}
|
||||
|
||||
export async function fetchImageFromUrl(
|
||||
url: string,
|
||||
uploadsDir = "uploads",
|
||||
): Promise<FetchImageResult> {
|
||||
// Validate URL format
|
||||
let parsedUrl: URL;
|
||||
try {
|
||||
parsedUrl = new URL(url);
|
||||
} catch {
|
||||
throw new Error("Invalid URL format");
|
||||
}
|
||||
|
||||
if (!["http:", "https:"].includes(parsedUrl.protocol)) {
|
||||
throw new Error("URL must use HTTP or HTTPS");
|
||||
}
|
||||
|
||||
// Fetch with timeout
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT);
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(url, { signal: controller.signal });
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === "AbortError") {
|
||||
throw new Error("Request timed out");
|
||||
}
|
||||
throw new Error(`Failed to fetch image: ${(err as Error).message}`);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: Failed to fetch image`);
|
||||
}
|
||||
|
||||
// Validate content type
|
||||
const contentType = response.headers.get("content-type")?.split(";")[0].trim();
|
||||
if (!contentType || !ALLOWED_TYPES.includes(contentType)) {
|
||||
throw new Error(
|
||||
`Invalid content type: ${contentType ?? "unknown"}. Accepted: jpeg, png, webp`,
|
||||
);
|
||||
}
|
||||
|
||||
// Check content length if available
|
||||
const contentLength = response.headers.get("content-length");
|
||||
if (contentLength && Number.parseInt(contentLength, 10) > MAX_SIZE) {
|
||||
throw new Error("File too large. Maximum size is 5MB");
|
||||
}
|
||||
|
||||
// Read body and check actual size
|
||||
const buffer = await response.arrayBuffer();
|
||||
if (buffer.byteLength > MAX_SIZE) {
|
||||
throw new Error("File too large. Maximum size is 5MB");
|
||||
}
|
||||
|
||||
// Determine extension
|
||||
const ext = contentType === "image/jpeg" ? "jpg" : contentType.split("/")[1];
|
||||
const filename = `${Date.now()}-${randomUUID()}.${ext}`;
|
||||
|
||||
// Ensure directory exists and write
|
||||
await mkdir(uploadsDir, { recursive: true });
|
||||
await Bun.write(join(uploadsDir, filename), buffer);
|
||||
|
||||
return { filename, sourceUrl: url };
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests**
|
||||
|
||||
Run: `bun test tests/services/image.service.test.ts`
|
||||
Expected: All 3 tests pass.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/server/services/image.service.ts tests/services/image.service.test.ts
|
||||
git commit -m "feat: add image URL fetching service with tests"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Add Route for URL Image Fetching
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/server/routes/images.ts`
|
||||
- Create: `tests/routes/images.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write failing route test**
|
||||
|
||||
Create `tests/routes/images.test.ts`:
|
||||
|
||||
```typescript
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { Hono } from "hono";
|
||||
import { imageRoutes } from "../../src/server/routes/images";
|
||||
|
||||
const app = new Hono();
|
||||
app.route("/api/images", imageRoutes);
|
||||
|
||||
describe("POST /api/images/from-url", () => {
|
||||
test("returns 400 for missing URL", async () => {
|
||||
const res = await app.request("/api/images/from-url", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
test("returns 400 for invalid URL", async () => {
|
||||
const res = await app.request("/api/images/from-url", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ url: "not-a-url" }),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `bun test tests/routes/images.test.ts`
|
||||
Expected: FAIL — route not found (404).
|
||||
|
||||
- [ ] **Step 3: Add the from-url route**
|
||||
|
||||
In `src/server/routes/images.ts`, add imports and the new route:
|
||||
|
||||
```typescript
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { Hono } from "hono";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { z } from "zod";
|
||||
import { fetchImageFromUrl } from "../services/image.service";
|
||||
|
||||
const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/webp"];
|
||||
const MAX_SIZE = 5 * 1024 * 1024; // 5MB
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
const fromUrlSchema = z.object({
|
||||
url: z.string().url("Invalid URL"),
|
||||
});
|
||||
|
||||
app.post("/from-url", zValidator("json", fromUrlSchema), async (c) => {
|
||||
const { url } = c.req.valid("json");
|
||||
|
||||
try {
|
||||
const result = await fetchImageFromUrl(url);
|
||||
return c.json(result, 201);
|
||||
} catch (err) {
|
||||
return c.json({ error: (err as Error).message }, 400);
|
||||
}
|
||||
});
|
||||
|
||||
// Existing file upload route stays below
|
||||
app.post("/", async (c) => {
|
||||
// ... existing code unchanged ...
|
||||
});
|
||||
```
|
||||
|
||||
Note: Keep the existing `app.post("/", ...)` handler exactly as-is. Just add the new `/from-url` route above it.
|
||||
|
||||
- [ ] **Step 4: Run tests**
|
||||
|
||||
Run: `bun test tests/routes/images.test.ts`
|
||||
Expected: Both tests pass.
|
||||
|
||||
- [ ] **Step 5: Run all tests**
|
||||
|
||||
Run: `bun test`
|
||||
Expected: All tests pass.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/server/routes/images.ts tests/routes/images.test.ts
|
||||
git commit -m "feat: add POST /api/images/from-url route"
|
||||
```
|
||||
1223
docs/superpowers/plans/2026-04-03-mcp-server.md
Normal file
1223
docs/superpowers/plans/2026-04-03-mcp-server.md
Normal file
File diff suppressed because it is too large
Load Diff
133
docs/superpowers/specs/2026-04-03-authentication-design.md
Normal file
133
docs/superpowers/specs/2026-04-03-authentication-design.md
Normal file
@@ -0,0 +1,133 @@
|
||||
# Authentication — Design Spec
|
||||
|
||||
## Overview
|
||||
|
||||
Add authentication to GearBox with a public-read, authenticated-write model. Web UI uses cookie-based sessions. Programmatic access (MCP server, scripts) uses API keys. Single-user app — one admin account, created on first setup.
|
||||
|
||||
## Database Schema
|
||||
|
||||
### `users` table
|
||||
|
||||
```typescript
|
||||
export const users = sqliteTable("users", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
username: text("username").notNull().unique(),
|
||||
passwordHash: text("password_hash").notNull(),
|
||||
createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
|
||||
});
|
||||
```
|
||||
|
||||
### `sessions` table
|
||||
|
||||
```typescript
|
||||
export const sessions = sqliteTable("sessions", {
|
||||
id: text("id").primaryKey(), // random token
|
||||
userId: integer("user_id").notNull().references(() => users.id),
|
||||
expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(),
|
||||
});
|
||||
```
|
||||
|
||||
### `apiKeys` table
|
||||
|
||||
```typescript
|
||||
export const apiKeys = sqliteTable("api_keys", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
name: text("name").notNull(),
|
||||
keyHash: text("key_hash").notNull(),
|
||||
keyPrefix: text("key_prefix").notNull(), // first 8 chars for identification
|
||||
createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
|
||||
});
|
||||
```
|
||||
|
||||
## Password Hashing
|
||||
|
||||
Use `Bun.password.hash()` and `Bun.password.verify()` with argon2 (Bun's default). No external dependencies needed.
|
||||
|
||||
## Auth Middleware
|
||||
|
||||
Hono middleware applied to all write endpoints (POST, PUT, DELETE):
|
||||
|
||||
1. Check for `X-API-Key` header — if present, hash and compare against `api_keys` table
|
||||
2. Check for session cookie (`gearbox_session`) — if present, look up in `sessions` table, verify not expired
|
||||
3. If neither is valid, return `401 Unauthorized`
|
||||
|
||||
GET endpoints remain public — no middleware applied.
|
||||
|
||||
**Exempt from auth:** `/api/auth/login`, `/api/auth/setup`, and `/api/auth/me` (GET) are not protected by the write middleware.
|
||||
|
||||
**Before setup:** If no user account exists yet, write endpoints return `403` with `{ error: "setup_required" }` so the frontend can prompt account creation.
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Auth routes (`/api/auth`)
|
||||
|
||||
- `POST /api/auth/login` — accepts `{ username, password }`, creates session, sets cookie
|
||||
- `POST /api/auth/logout` — clears session cookie, deletes session record
|
||||
- `GET /api/auth/me` — returns current user info if authenticated, or `null`
|
||||
- `POST /api/auth/setup` — initial account creation (only works if no users exist)
|
||||
- `PUT /api/auth/password` — change password (requires current password)
|
||||
|
||||
### API key routes (`/api/auth/keys`) — all authenticated
|
||||
|
||||
- `GET /api/auth/keys` — list API keys (name, prefix, createdAt — never the full key)
|
||||
- `POST /api/auth/keys` — create new key, returns the full key once
|
||||
- `DELETE /api/auth/keys/:id` — revoke a key
|
||||
|
||||
## Session Management
|
||||
|
||||
- Session token: 32-byte random hex string
|
||||
- Cookie: `gearbox_session`, httpOnly, sameSite=lax, path=/
|
||||
- Session expiry: 30 days, refreshed on each authenticated request
|
||||
- Sessions stored in SQLite — simple cleanup of expired rows
|
||||
|
||||
## Frontend Changes
|
||||
|
||||
### Login button (top-right, Gitea-style)
|
||||
|
||||
- When not logged in: "Sign in" button in the header/navbar
|
||||
- When logged in: username display with dropdown (logout, settings link)
|
||||
|
||||
### Login page
|
||||
|
||||
- Route: `/login`
|
||||
- Simple form: username + password + submit
|
||||
- Redirects back to previous page on success
|
||||
- Error message on invalid credentials
|
||||
|
||||
### Initial setup
|
||||
|
||||
- If `GET /api/auth/me` returns `null` and no users exist, show a setup prompt
|
||||
- Setup form: create username + password
|
||||
- Only shown once — after account creation, normal login flow applies
|
||||
|
||||
### Conditional UI
|
||||
|
||||
- Add/edit/delete buttons: hidden when not authenticated
|
||||
- Forms (ItemForm, CandidateForm, etc.): only accessible when authenticated
|
||||
- The app is fully browseable without login — you just can't modify anything
|
||||
|
||||
### Auth state
|
||||
|
||||
- `useAuth` hook using React Query: calls `GET /api/auth/me`
|
||||
- Returns `{ user, isAuthenticated, login, logout }`
|
||||
- Cached and invalidated on login/logout
|
||||
|
||||
### Settings page additions
|
||||
|
||||
- API key management section: list, create, revoke
|
||||
- Change password form
|
||||
|
||||
## Testing
|
||||
|
||||
- Auth service tests: login, logout, session creation/validation, password change
|
||||
- API key tests: create, verify, revoke
|
||||
- Middleware tests: write endpoints reject without auth, read endpoints work without auth
|
||||
- Setup flow test: first-user creation
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- Passwords hashed with argon2 via Bun built-in
|
||||
- Session tokens are cryptographically random
|
||||
- API keys hashed before storage (only shown once on creation)
|
||||
- httpOnly cookies prevent XSS access to session
|
||||
- No CORS changes needed (single-origin app)
|
||||
@@ -0,0 +1,87 @@
|
||||
# Image URL Fetching — Design Spec
|
||||
|
||||
## Overview
|
||||
|
||||
Add the ability to fetch images from external URLs via the API, download them to local storage, and preserve the original source URL as metadata. API-only feature — no frontend changes.
|
||||
|
||||
## New Endpoint
|
||||
|
||||
### `POST /api/images/from-url`
|
||||
|
||||
**Request:**
|
||||
|
||||
```json
|
||||
{ "url": "https://example.com/photo.jpg" }
|
||||
```
|
||||
|
||||
**Validation (Zod):**
|
||||
|
||||
- `url` — valid URL string, required
|
||||
|
||||
**Server-side behavior:**
|
||||
|
||||
1. Fetch the URL with a 10-second timeout
|
||||
2. Check response `Content-Type` is one of: `image/jpeg`, `image/png`, `image/webp`
|
||||
3. Check `Content-Length` does not exceed 5MB (match existing upload limit)
|
||||
4. Stream response body to `uploads/` directory using existing naming: `${Date.now()}-${randomUUID()}.${ext}`
|
||||
5. If any check fails, return 400 with descriptive error
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{ "filename": "1712160000000-abc123.jpg", "sourceUrl": "https://example.com/photo.jpg" }
|
||||
```
|
||||
|
||||
Callers can use `filename` for `imageFilename` and `sourceUrl` for `imageSourceUrl` when creating/updating items or candidates.
|
||||
|
||||
**Error responses:**
|
||||
|
||||
- `400` — invalid URL, unsupported content type, file too large, fetch failed
|
||||
- `500` — server error during download/save
|
||||
|
||||
## Schema Changes
|
||||
|
||||
### `items` table
|
||||
|
||||
Add column:
|
||||
|
||||
```
|
||||
imageSourceUrl: text("image_source_url") // nullable
|
||||
```
|
||||
|
||||
### `threadCandidates` table
|
||||
|
||||
Add column:
|
||||
|
||||
```
|
||||
imageSourceUrl: text("image_source_url") // nullable
|
||||
```
|
||||
|
||||
### Zod schemas
|
||||
|
||||
Add `imageSourceUrl: z.string().url().optional()` to:
|
||||
|
||||
- `createItemSchema`
|
||||
- `updateItemSchema`
|
||||
- `createCandidateSchema`
|
||||
- `updateCandidateSchema`
|
||||
|
||||
### Types
|
||||
|
||||
Types are inferred from Zod schemas and Drizzle tables — no manual updates needed.
|
||||
|
||||
## Existing Behavior Unchanged
|
||||
|
||||
- `POST /api/images` (file upload) remains as-is
|
||||
- All existing image display, cleanup, and serving logic unchanged
|
||||
- `imageFilename` continues to work identically
|
||||
|
||||
## Test Helper Updates
|
||||
|
||||
Add `image_source_url TEXT` column to the `items` and `thread_candidates` CREATE TABLE statements in `tests/helpers/db.ts`.
|
||||
|
||||
## Testing
|
||||
|
||||
- Service test: fetch from a valid URL, verify file saved and filename returned
|
||||
- Route test: POST to `/api/images/from-url` with valid/invalid URLs
|
||||
- Validation tests: wrong content type, oversized image, invalid URL format, timeout
|
||||
158
docs/superpowers/specs/2026-04-03-mcp-server-design.md
Normal file
158
docs/superpowers/specs/2026-04-03-mcp-server-design.md
Normal file
@@ -0,0 +1,158 @@
|
||||
# MCP Server — Design Spec
|
||||
|
||||
## Overview
|
||||
|
||||
Built-in MCP server running inside the GearBox Hono process, exposed via SSE transport at `/mcp`. Provides tools for managing the full gear collection with workflow guidance emphasizing research threads. Authenticates via API key.
|
||||
|
||||
## Transport & Configuration
|
||||
|
||||
- **Transport:** SSE or Streamable HTTP at `/mcp` (use whichever the MCP SDK supports best at implementation time — the newer spec favors Streamable HTTP)
|
||||
- **Enabled by default**, disable with `GEARBOX_MCP=false` env var
|
||||
- **Authentication:** API key passed in MCP client config, sent as `X-API-Key` header
|
||||
- **SDK:** `@modelcontextprotocol/sdk` TypeScript package
|
||||
|
||||
## Integration with Hono
|
||||
|
||||
The MCP server mounts as a route on the existing Hono app:
|
||||
|
||||
```typescript
|
||||
// src/server/index.ts
|
||||
if (process.env.GEARBOX_MCP !== "false") {
|
||||
app.route("/mcp", mcpRoutes);
|
||||
}
|
||||
```
|
||||
|
||||
The MCP route handler bridges SSE transport to the MCP server instance, which calls GearBox services directly (not via HTTP) for efficiency.
|
||||
|
||||
## Tools
|
||||
|
||||
All tools include descriptive names and descriptions that guide Claude toward the research thread workflow.
|
||||
|
||||
### Item Tools
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `list_items` | List all items in the gear collection. Optionally filter by category. |
|
||||
| `get_item` | Get details of a specific item by ID. |
|
||||
| `create_item` | 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. |
|
||||
| `update_item` | Update an existing item's details. |
|
||||
| `delete_item` | Remove an item from the collection. |
|
||||
|
||||
### Category Tools
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `list_categories` | List all gear categories. |
|
||||
| `create_category` | Create a new category for organizing gear. |
|
||||
|
||||
### Thread Tools (Primary Workflow)
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `list_threads` | List all research threads. Threads are the recommended way to evaluate gear purchases — create a thread, add candidates, compare them, then resolve to pick a winner. |
|
||||
| `get_thread` | Get a thread with all its candidates and comparison data. |
|
||||
| `create_thread` | Start a new research thread for evaluating a gear purchase. This is the preferred workflow: create a thread describing what you need, add candidate products, compare specs/weight/price, then resolve when you've decided. |
|
||||
| `resolve_thread` | Resolve a thread by picking the winning candidate. This adds the winner to your collection as a new item and marks the thread as resolved. |
|
||||
|
||||
### Candidate Tools
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `add_candidate` | Add a candidate product to a research thread. Include weight, price, pros, cons, and optionally an image URL. |
|
||||
| `update_candidate` | Update a candidate's details — weight, price, pros, cons, etc. |
|
||||
| `remove_candidate` | Remove a candidate from a research thread. |
|
||||
|
||||
### Setup Tools
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `list_setups` | List all gear setups (named configurations of items). |
|
||||
| `get_setup` | Get a setup with all its items, total weight, and total cost. |
|
||||
| `create_setup` | Create a new gear setup. |
|
||||
| `update_setup` | Update a setup's items or details. |
|
||||
|
||||
### Image Tools
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `upload_image_from_url` | Fetch an image from a URL and attach it to an item or candidate. |
|
||||
|
||||
## Resources
|
||||
|
||||
### `gearbox://collection-summary`
|
||||
|
||||
Provides an overview of the current gear collection:
|
||||
|
||||
- Total items, total weight, total cost
|
||||
- Items per category
|
||||
- Active research threads
|
||||
- Number of setups
|
||||
|
||||
This resource gives Claude context about the collection state before making tool calls.
|
||||
|
||||
## Workflow Guidance
|
||||
|
||||
The MCP server's tool descriptions are crafted to guide Claude toward the research thread pattern:
|
||||
|
||||
1. When the user asks about buying gear, Claude should prefer `create_thread` over `create_item`
|
||||
2. Candidates are added to threads for comparison before committing
|
||||
3. `resolve_thread` is the way to finalize a purchase decision
|
||||
4. Direct `create_item` is for items already owned or decided on
|
||||
|
||||
This guidance lives in the tool descriptions themselves — no separate system prompt needed. The `collection-summary` resource helps Claude understand what's already in the collection.
|
||||
|
||||
## Implementation Structure
|
||||
|
||||
```
|
||||
src/server/mcp/
|
||||
index.ts — MCP server setup, tool/resource registration
|
||||
tools/
|
||||
items.ts — Item tool handlers
|
||||
categories.ts — Category tool handlers
|
||||
threads.ts — Thread + candidate tool handlers
|
||||
setups.ts — Setup tool handlers
|
||||
images.ts — Image tool handlers
|
||||
resources/
|
||||
collection.ts — Collection summary resource
|
||||
```
|
||||
|
||||
## Client Configuration
|
||||
|
||||
### Claude Code (`.claude/settings.json`)
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"gearbox": {
|
||||
"type": "sse",
|
||||
"url": "http://localhost:3000/mcp",
|
||||
"headers": {
|
||||
"X-API-Key": "<your-api-key>"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Claude Desktop
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"gearbox": {
|
||||
"type": "sse",
|
||||
"url": "http://localhost:3000/mcp",
|
||||
"headers": {
|
||||
"X-API-Key": "<your-api-key>"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
- Tool handler tests: each tool with valid/invalid inputs
|
||||
- Auth test: requests without API key are rejected
|
||||
- Resource test: collection summary returns accurate data
|
||||
- Integration test: create thread -> add candidates -> resolve -> verify item created
|
||||
2
drizzle/0006_hard_the_professor.sql
Normal file
2
drizzle/0006_hard_the_professor.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE `items` ADD `image_source_url` text;--> statement-breakpoint
|
||||
ALTER TABLE `thread_candidates` ADD `image_source_url` text;
|
||||
23
drizzle/0007_icy_prodigy.sql
Normal file
23
drizzle/0007_icy_prodigy.sql
Normal file
@@ -0,0 +1,23 @@
|
||||
CREATE TABLE `api_keys` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`key_hash` text NOT NULL,
|
||||
`key_prefix` text NOT NULL,
|
||||
`created_at` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `sessions` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`user_id` integer NOT NULL,
|
||||
`expires_at` integer NOT NULL,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `users` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`username` text NOT NULL,
|
||||
`password_hash` text NOT NULL,
|
||||
`created_at` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `users_username_unique` ON `users` (`username`);
|
||||
519
drizzle/meta/0006_snapshot.json
Normal file
519
drizzle/meta/0006_snapshot.json
Normal file
@@ -0,0 +1,519 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "e26d6e96-f2cd-4b76-ad4e-58da0af6f3c0",
|
||||
"prevId": "297e86db-c777-4432-950e-b0129dedb2dc",
|
||||
"tables": {
|
||||
"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
|
||||
},
|
||||
"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": {}
|
||||
},
|
||||
"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": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
655
drizzle/meta/0007_snapshot.json
Normal file
655
drizzle/meta/0007_snapshot.json
Normal file
@@ -0,0 +1,655 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "738e67c5-ebad-46c1-9261-6ab60ec4bdb1",
|
||||
"prevId": "e26d6e96-f2cd-4b76-ad4e-58da0af6f3c0",
|
||||
"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
|
||||
},
|
||||
"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": {}
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,20 @@
|
||||
"when": 1773696058992,
|
||||
"tag": "0005_clear_micromax",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 6,
|
||||
"version": "6",
|
||||
"when": 1775214741464,
|
||||
"tag": "0006_hard_the_professor",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 7,
|
||||
"version": "6",
|
||||
"when": 1775215076284,
|
||||
"tag": "0007_icy_prodigy",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "concurrently -k -c \"blue,green\" -n \"server,client\" \"bun run dev:server\" \"bun run dev:client\"",
|
||||
"dev:client": "vite",
|
||||
"dev:server": "bun --hot src/server/index.ts",
|
||||
"build": "vite build",
|
||||
@@ -23,6 +24,7 @@
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"better-sqlite3": "^12.8.0",
|
||||
"concurrently": "^9.1.2",
|
||||
"drizzle-kit": "^0.31.9",
|
||||
"vite": "^8.0.0"
|
||||
},
|
||||
@@ -31,11 +33,13 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@hono/zod-validator": "^0.7.6",
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"@tanstack/react-query": "^5.90.21",
|
||||
"@tanstack/react-router": "^1.167.0",
|
||||
"clsx": "^2.1.1",
|
||||
"drizzle-orm": "^0.45.1",
|
||||
"framer-motion": "^12.38.0",
|
||||
"hono": "^4.12.8",
|
||||
"lucide-react": "^0.577.0",
|
||||
"react": "^19.2.4",
|
||||
|
||||
336
src/client/components/ComparisonTable.tsx
Normal file
336
src/client/components/ComparisonTable.tsx
Normal file
@@ -0,0 +1,336 @@
|
||||
import { useMemo } from "react";
|
||||
import { useCurrency } from "../hooks/useCurrency";
|
||||
import { useWeightUnit } from "../hooks/useWeightUnit";
|
||||
import { formatPrice, formatWeight } from "../lib/formatters";
|
||||
import { LucideIcon } from "../lib/iconData";
|
||||
import { useUIStore } from "../stores/uiStore";
|
||||
import { RankBadge } from "./CandidateListItem";
|
||||
|
||||
interface CandidateWithCategory {
|
||||
id: number;
|
||||
threadId: number;
|
||||
name: string;
|
||||
weightGrams: number | null;
|
||||
priceCents: number | null;
|
||||
categoryId: number;
|
||||
notes: string | null;
|
||||
productUrl: string | null;
|
||||
imageFilename: string | null;
|
||||
status: "researching" | "ordered" | "arrived";
|
||||
pros: string | null;
|
||||
cons: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
categoryName: string;
|
||||
categoryIcon: string;
|
||||
}
|
||||
|
||||
interface ComparisonTableProps {
|
||||
candidates: CandidateWithCategory[];
|
||||
resolvedCandidateId: number | null;
|
||||
}
|
||||
|
||||
const STATUS_LABELS: Record<"researching" | "ordered" | "arrived", string> = {
|
||||
researching: "Researching",
|
||||
ordered: "Ordered",
|
||||
arrived: "Arrived",
|
||||
};
|
||||
|
||||
export function ComparisonTable({
|
||||
candidates,
|
||||
resolvedCandidateId,
|
||||
}: ComparisonTableProps) {
|
||||
const unit = useWeightUnit();
|
||||
const currency = useCurrency();
|
||||
const openExternalLink = useUIStore((s) => s.openExternalLink);
|
||||
|
||||
const { bestWeightId, bestPriceId, weightDeltas, priceDeltas } =
|
||||
useMemo(() => {
|
||||
// Weight deltas
|
||||
const withWeight = candidates.filter((c) => c.weightGrams != null);
|
||||
let bestWeightId: number | null = null;
|
||||
const weightDeltas: Record<number, string | null> = {};
|
||||
|
||||
if (withWeight.length > 0) {
|
||||
const minWeight = Math.min(
|
||||
...withWeight.map((c) => c.weightGrams as number),
|
||||
);
|
||||
bestWeightId =
|
||||
withWeight.find((c) => c.weightGrams === minWeight)?.id ?? null;
|
||||
for (const c of candidates) {
|
||||
if (c.weightGrams == null) {
|
||||
weightDeltas[c.id] = null;
|
||||
} else {
|
||||
const delta = c.weightGrams - minWeight;
|
||||
weightDeltas[c.id] =
|
||||
delta === 0 ? null : `+${formatWeight(delta, unit)}`;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (const c of candidates) {
|
||||
weightDeltas[c.id] = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Price deltas
|
||||
const withPrice = candidates.filter((c) => c.priceCents != null);
|
||||
let bestPriceId: number | null = null;
|
||||
const priceDeltas: Record<number, string | null> = {};
|
||||
|
||||
if (withPrice.length > 0) {
|
||||
const minPrice = Math.min(
|
||||
...withPrice.map((c) => c.priceCents as number),
|
||||
);
|
||||
bestPriceId =
|
||||
withPrice.find((c) => c.priceCents === minPrice)?.id ?? null;
|
||||
for (const c of candidates) {
|
||||
if (c.priceCents == null) {
|
||||
priceDeltas[c.id] = null;
|
||||
} else {
|
||||
const delta = c.priceCents - minPrice;
|
||||
priceDeltas[c.id] =
|
||||
delta === 0 ? null : `+${formatPrice(delta, currency)}`;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (const c of candidates) {
|
||||
priceDeltas[c.id] = null;
|
||||
}
|
||||
}
|
||||
|
||||
return { bestWeightId, bestPriceId, weightDeltas, priceDeltas };
|
||||
}, [candidates, unit, currency]);
|
||||
|
||||
const ATTRIBUTE_ROWS: Array<{
|
||||
key: string;
|
||||
label: string;
|
||||
render: (
|
||||
candidate: CandidateWithCategory,
|
||||
index: number,
|
||||
) => React.ReactNode;
|
||||
cellClass?: (candidate: CandidateWithCategory) => string;
|
||||
}> = [
|
||||
{
|
||||
key: "image",
|
||||
label: "Image",
|
||||
render: (c) => (
|
||||
<div className="w-12 h-12 rounded-lg overflow-hidden bg-gray-50 flex items-center justify-center">
|
||||
{c.imageFilename ? (
|
||||
<img
|
||||
src={`/uploads/${c.imageFilename}`}
|
||||
alt={c.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<LucideIcon
|
||||
name={c.categoryIcon}
|
||||
size={20}
|
||||
className="text-gray-400"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "name",
|
||||
label: "Name",
|
||||
render: (c) => (
|
||||
<span className="text-sm font-medium text-gray-900">{c.name}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "rank",
|
||||
label: "Rank",
|
||||
render: (_c, index) => <RankBadge rank={index + 1} />,
|
||||
},
|
||||
{
|
||||
key: "weight",
|
||||
label: "Weight",
|
||||
render: (c) => {
|
||||
const isBest = c.id === bestWeightId;
|
||||
const delta = weightDeltas[c.id];
|
||||
if (c.weightGrams == null) {
|
||||
return <span className="text-gray-300">—</span>;
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
<span className="font-medium text-gray-900">
|
||||
{formatWeight(c.weightGrams, unit)}
|
||||
</span>
|
||||
{!isBest && delta && (
|
||||
<div className="text-xs text-gray-400">{delta}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
cellClass: (c) => {
|
||||
if (c.id === bestWeightId) return "bg-blue-50";
|
||||
if (c.id === resolvedCandidateId) return "bg-amber-50/50";
|
||||
return "";
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "price",
|
||||
label: "Price",
|
||||
render: (c) => {
|
||||
const isBest = c.id === bestPriceId;
|
||||
const delta = priceDeltas[c.id];
|
||||
if (c.priceCents == null) {
|
||||
return <span className="text-gray-300">—</span>;
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
<span className="font-medium text-gray-900">
|
||||
{formatPrice(c.priceCents, currency)}
|
||||
</span>
|
||||
{!isBest && delta && (
|
||||
<div className="text-xs text-gray-400">{delta}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
cellClass: (c) => {
|
||||
if (c.id === bestPriceId) return "bg-green-50";
|
||||
if (c.id === resolvedCandidateId) return "bg-amber-50/50";
|
||||
return "";
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
label: "Status",
|
||||
render: (c) => (
|
||||
<span className="text-xs text-gray-600">{STATUS_LABELS[c.status]}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "link",
|
||||
label: "Link",
|
||||
render: (c) =>
|
||||
c.productUrl ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openExternalLink(c.productUrl as string)}
|
||||
className="text-xs text-blue-500 hover:underline"
|
||||
>
|
||||
View
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-gray-300">—</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "notes",
|
||||
label: "Notes",
|
||||
render: (c) =>
|
||||
c.notes ? (
|
||||
<p className="text-xs text-gray-700 whitespace-pre-line">{c.notes}</p>
|
||||
) : (
|
||||
<span className="text-gray-300">—</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "pros",
|
||||
label: "Pros",
|
||||
render: (c) => {
|
||||
if (!c.pros) return <span className="text-gray-300">—</span>;
|
||||
const items = c.pros.split("\n").filter((s) => s.trim() !== "");
|
||||
if (items.length === 0) return <span className="text-gray-300">—</span>;
|
||||
return (
|
||||
<ul className="list-disc list-inside space-y-0.5">
|
||||
{items.map((item, i) => (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: stable list of static items
|
||||
<li key={i} className="text-xs text-gray-700">
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "cons",
|
||||
label: "Cons",
|
||||
render: (c) => {
|
||||
if (!c.cons) return <span className="text-gray-300">—</span>;
|
||||
const items = c.cons.split("\n").filter((s) => s.trim() !== "");
|
||||
if (items.length === 0) return <span className="text-gray-300">—</span>;
|
||||
return (
|
||||
<ul className="list-disc list-inside space-y-0.5">
|
||||
{items.map((item, i) => (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: stable list of static items
|
||||
<li key={i} className="text-xs text-gray-700">
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const tableMinWidth = Math.max(400, candidates.length * 180);
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-xl border border-gray-100">
|
||||
<table
|
||||
className="border-collapse text-sm w-full"
|
||||
style={{ minWidth: `${tableMinWidth}px` }}
|
||||
>
|
||||
<thead>
|
||||
<tr className="border-b border-gray-100">
|
||||
{/* Sticky empty corner cell */}
|
||||
<th className="sticky left-0 z-10 bg-white px-4 py-3 text-left text-xs font-medium text-gray-700 w-28" />
|
||||
{candidates.map((candidate) => {
|
||||
const isWinner = candidate.id === resolvedCandidateId;
|
||||
return (
|
||||
<th
|
||||
key={candidate.id}
|
||||
className={`px-4 py-3 text-left text-xs font-medium min-w-[160px] ${
|
||||
isWinner ? "bg-amber-50 text-amber-800" : "text-gray-700"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
{isWinner && (
|
||||
<LucideIcon
|
||||
name="trophy"
|
||||
size={12}
|
||||
className="text-amber-600"
|
||||
/>
|
||||
)}
|
||||
{candidate.name}
|
||||
</div>
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{ATTRIBUTE_ROWS.map((row) => (
|
||||
<tr key={row.key} className="border-b border-gray-50">
|
||||
{/* Sticky label cell */}
|
||||
<td className="sticky left-0 z-10 bg-white px-4 py-3 text-xs font-medium text-gray-500 uppercase tracking-wide w-28">
|
||||
{row.label}
|
||||
</td>
|
||||
{candidates.map((candidate, index) => {
|
||||
const isWinner = candidate.id === resolvedCandidateId;
|
||||
const extraClass = row.cellClass
|
||||
? row.cellClass(candidate)
|
||||
: isWinner
|
||||
? "bg-amber-50/50"
|
||||
: "";
|
||||
return (
|
||||
<td
|
||||
key={candidate.id}
|
||||
className={`px-4 py-3 min-w-[160px] ${extraClass}`}
|
||||
>
|
||||
{row.render(candidate, index)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { useAuth, useLogout } from "../hooks/useAuth";
|
||||
import { useUpdateSetting } from "../hooks/useSettings";
|
||||
import { useTotals } from "../hooks/useTotals";
|
||||
import { useWeightUnit } from "../hooks/useWeightUnit";
|
||||
@@ -19,6 +20,9 @@ export function TotalsBar({
|
||||
linkTo,
|
||||
}: TotalsBarProps) {
|
||||
const { data } = useTotals();
|
||||
const { data: auth } = useAuth();
|
||||
const logout = useLogout();
|
||||
const isAuthenticated = !!auth?.user;
|
||||
const unit = useWeightUnit();
|
||||
const updateSetting = useUpdateSetting();
|
||||
|
||||
@@ -100,6 +104,24 @@ 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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -264,12 +264,20 @@ export function WeightSummaryCard({ items }: WeightSummaryCardProps) {
|
||||
label="Consumable"
|
||||
weight={consumableWeight}
|
||||
unit={unit}
|
||||
percent={totalWeight > 0 ? consumableWeight / totalWeight : undefined}
|
||||
percent={
|
||||
totalWeight > 0 ? consumableWeight / totalWeight : undefined
|
||||
}
|
||||
/>
|
||||
<div className="border-t border-gray-200 mt-1.5 pt-1.5">
|
||||
<div className="flex items-center gap-3 py-1.5">
|
||||
<LucideIcon name="sigma" size={10} className="text-gray-400 shrink-0 ml-0.5" />
|
||||
<span className="text-sm font-medium text-gray-700 flex-1">Total</span>
|
||||
<LucideIcon
|
||||
name="sigma"
|
||||
size={10}
|
||||
className="text-gray-400 shrink-0 ml-0.5"
|
||||
/>
|
||||
<span className="text-sm font-medium text-gray-700 flex-1">
|
||||
Total
|
||||
</span>
|
||||
<span className="text-sm font-bold text-gray-900 tabular-nums">
|
||||
{formatWeight(totalWeight, unit)}
|
||||
</span>
|
||||
|
||||
97
src/client/hooks/useAuth.ts
Normal file
97
src/client/hooks/useAuth.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { apiDelete, apiGet, apiPost, apiPut } from "../lib/api";
|
||||
|
||||
interface AuthState {
|
||||
user: { id: number } | null;
|
||||
setupRequired: boolean;
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
return useQuery({
|
||||
queryKey: ["auth"],
|
||||
queryFn: () => apiGet<AuthState>("/api/auth/me"),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useLogin() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: { username: string; password: string }) =>
|
||||
apiPost<{ username: string }>("/api/auth/login", data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["auth"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useLogout() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: () => apiPost<{ success: boolean }>("/api/auth/logout", {}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["auth"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useSetup() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: { username: string; password: string }) =>
|
||||
apiPost<{ username: string }>("/api/auth/setup", data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["auth"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useChangePassword() {
|
||||
return useMutation({
|
||||
mutationFn: (data: { currentPassword: string; newPassword: string }) =>
|
||||
apiPut<{ success: boolean }>("/api/auth/password", data),
|
||||
});
|
||||
}
|
||||
|
||||
interface ApiKeyListItem {
|
||||
id: number;
|
||||
name: string;
|
||||
keyPrefix: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface ApiKeyResponse {
|
||||
id: number;
|
||||
name: string;
|
||||
key: string;
|
||||
prefix: string;
|
||||
}
|
||||
|
||||
export function useApiKeys() {
|
||||
return useQuery({
|
||||
queryKey: ["apiKeys"],
|
||||
queryFn: () => apiGet<ApiKeyListItem[]>("/api/auth/keys"),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateApiKey() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: { name: string }) =>
|
||||
apiPost<ApiKeyResponse>("/api/auth/keys", data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["apiKeys"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteApiKey() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: number) =>
|
||||
apiDelete<{ success: boolean }>(`/api/auth/keys/${id}`),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["apiKeys"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -113,7 +113,10 @@ export function useUpdateItemClassification(setupId: number) {
|
||||
mutationFn: ({
|
||||
itemId,
|
||||
classification,
|
||||
}: { itemId: number; classification: string }) =>
|
||||
}: {
|
||||
itemId: number;
|
||||
classification: string;
|
||||
}) =>
|
||||
apiPatch<{ success: boolean }>(
|
||||
`/api/setups/${setupId}/items/${itemId}/classification`,
|
||||
{ classification },
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
import { Route as rootRouteImport } from './routes/__root'
|
||||
import { Route as SettingsRouteImport } from './routes/settings'
|
||||
import { Route as LoginRouteImport } from './routes/login'
|
||||
import { Route as IndexRouteImport } from './routes/index'
|
||||
import { Route as CollectionIndexRouteImport } from './routes/collection/index'
|
||||
import { Route as ThreadsThreadIdRouteImport } from './routes/threads/$threadId'
|
||||
@@ -20,6 +21,11 @@ const SettingsRoute = SettingsRouteImport.update({
|
||||
path: '/settings',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const LoginRoute = LoginRouteImport.update({
|
||||
id: '/login',
|
||||
path: '/login',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const IndexRoute = IndexRouteImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
@@ -43,6 +49,7 @@ const SetupsSetupIdRoute = SetupsSetupIdRouteImport.update({
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
'/login': typeof LoginRoute
|
||||
'/settings': typeof SettingsRoute
|
||||
'/setups/$setupId': typeof SetupsSetupIdRoute
|
||||
'/threads/$threadId': typeof ThreadsThreadIdRoute
|
||||
@@ -50,6 +57,7 @@ export interface FileRoutesByFullPath {
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
'/login': typeof LoginRoute
|
||||
'/settings': typeof SettingsRoute
|
||||
'/setups/$setupId': typeof SetupsSetupIdRoute
|
||||
'/threads/$threadId': typeof ThreadsThreadIdRoute
|
||||
@@ -58,6 +66,7 @@ export interface FileRoutesByTo {
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
'/': typeof IndexRoute
|
||||
'/login': typeof LoginRoute
|
||||
'/settings': typeof SettingsRoute
|
||||
'/setups/$setupId': typeof SetupsSetupIdRoute
|
||||
'/threads/$threadId': typeof ThreadsThreadIdRoute
|
||||
@@ -67,6 +76,7 @@ export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
fullPaths:
|
||||
| '/'
|
||||
| '/login'
|
||||
| '/settings'
|
||||
| '/setups/$setupId'
|
||||
| '/threads/$threadId'
|
||||
@@ -74,6 +84,7 @@ export interface FileRouteTypes {
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to:
|
||||
| '/'
|
||||
| '/login'
|
||||
| '/settings'
|
||||
| '/setups/$setupId'
|
||||
| '/threads/$threadId'
|
||||
@@ -81,6 +92,7 @@ export interface FileRouteTypes {
|
||||
id:
|
||||
| '__root__'
|
||||
| '/'
|
||||
| '/login'
|
||||
| '/settings'
|
||||
| '/setups/$setupId'
|
||||
| '/threads/$threadId'
|
||||
@@ -89,6 +101,7 @@ export interface FileRouteTypes {
|
||||
}
|
||||
export interface RootRouteChildren {
|
||||
IndexRoute: typeof IndexRoute
|
||||
LoginRoute: typeof LoginRoute
|
||||
SettingsRoute: typeof SettingsRoute
|
||||
SetupsSetupIdRoute: typeof SetupsSetupIdRoute
|
||||
ThreadsThreadIdRoute: typeof ThreadsThreadIdRoute
|
||||
@@ -104,6 +117,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof SettingsRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/login': {
|
||||
id: '/login'
|
||||
path: '/login'
|
||||
fullPath: '/login'
|
||||
preLoaderRoute: typeof LoginRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/': {
|
||||
id: '/'
|
||||
path: '/'
|
||||
@@ -137,6 +157,7 @@ declare module '@tanstack/react-router' {
|
||||
|
||||
const rootRouteChildren: RootRouteChildren = {
|
||||
IndexRoute: IndexRoute,
|
||||
LoginRoute: LoginRoute,
|
||||
SettingsRoute: SettingsRoute,
|
||||
SetupsSetupIdRoute: SetupsSetupIdRoute,
|
||||
ThreadsThreadIdRoute: ThreadsThreadIdRoute,
|
||||
|
||||
@@ -13,6 +13,7 @@ import { ItemForm } from "../components/ItemForm";
|
||||
import { OnboardingWizard } from "../components/OnboardingWizard";
|
||||
import { SlideOutPanel } from "../components/SlideOutPanel";
|
||||
import { TotalsBar } from "../components/TotalsBar";
|
||||
import { useAuth } from "../hooks/useAuth";
|
||||
import { useDeleteCandidate } from "../hooks/useCandidates";
|
||||
import { useOnboardingComplete } from "../hooks/useSettings";
|
||||
import { useResolveThread, useThread } from "../hooks/useThreads";
|
||||
@@ -24,6 +25,8 @@ export const Route = createRootRoute({
|
||||
|
||||
function RootLayout() {
|
||||
const navigate = useNavigate();
|
||||
const { data: auth } = useAuth();
|
||||
const isAuthenticated = !!auth?.user;
|
||||
|
||||
// Item panel state
|
||||
const panelMode = useUIStore((s) => s.panelMode);
|
||||
@@ -54,8 +57,12 @@ function RootLayout() {
|
||||
useOnboardingComplete();
|
||||
const [wizardDismissed, setWizardDismissed] = useState(false);
|
||||
|
||||
// Don't show onboarding wizard until user has created an account
|
||||
const showWizard =
|
||||
!onboardingLoading && onboardingComplete !== "true" && !wizardDismissed;
|
||||
!onboardingLoading &&
|
||||
onboardingComplete !== "true" &&
|
||||
!wizardDismissed &&
|
||||
isAuthenticated;
|
||||
|
||||
const isItemPanelOpen = panelMode !== "closed";
|
||||
const isCandidatePanelOpen = candidatePanelMode !== "closed";
|
||||
@@ -175,7 +182,7 @@ function RootLayout() {
|
||||
)}
|
||||
|
||||
{/* Floating Add Button - only on collection gear tab */}
|
||||
{showFab && (
|
||||
{showFab && isAuthenticated && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={openAddPanel}
|
||||
|
||||
@@ -9,11 +9,11 @@ import { ItemCard } from "../../components/ItemCard";
|
||||
import { SetupCard } from "../../components/SetupCard";
|
||||
import { ThreadCard } from "../../components/ThreadCard";
|
||||
import { useCategories } from "../../hooks/useCategories";
|
||||
import { useCurrency } from "../../hooks/useCurrency";
|
||||
import { useItems } from "../../hooks/useItems";
|
||||
import { useCreateSetup, useSetups } from "../../hooks/useSetups";
|
||||
import { useThreads } from "../../hooks/useThreads";
|
||||
import { useTotals } from "../../hooks/useTotals";
|
||||
import { useCurrency } from "../../hooks/useCurrency";
|
||||
import { useWeightUnit } from "../../hooks/useWeightUnit";
|
||||
import { formatPrice, formatWeight } from "../../lib/formatters";
|
||||
import { LucideIcon } from "../../lib/iconData";
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { DashboardCard } from "../components/DashboardCard";
|
||||
import { useCurrency } from "../hooks/useCurrency";
|
||||
import { useSetups } from "../hooks/useSetups";
|
||||
import { useThreads } from "../hooks/useThreads";
|
||||
import { useTotals } from "../hooks/useTotals";
|
||||
import { useWeightUnit } from "../hooks/useWeightUnit";
|
||||
import { useCurrency } from "../hooks/useCurrency";
|
||||
import { formatPrice, formatWeight } from "../lib/formatters";
|
||||
|
||||
export const Route = createFileRoute("/")({
|
||||
@@ -35,7 +35,10 @@ function DashboardPage() {
|
||||
label: "Weight",
|
||||
value: formatWeight(global?.totalWeight ?? null, unit),
|
||||
},
|
||||
{ label: "Cost", value: formatPrice(global?.totalCost ?? null, currency) },
|
||||
{
|
||||
label: "Cost",
|
||||
value: formatPrice(global?.totalCost ?? null, currency),
|
||||
},
|
||||
]}
|
||||
emptyText="Get started"
|
||||
/>
|
||||
|
||||
104
src/client/routes/login.tsx
Normal file
104
src/client/routes/login.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { useState } from "react";
|
||||
import { useAuth, useLogin, useSetup } from "../hooks/useAuth";
|
||||
|
||||
export const Route = createFileRoute("/login")({
|
||||
component: LoginPage,
|
||||
});
|
||||
|
||||
function LoginPage() {
|
||||
const navigate = useNavigate();
|
||||
const { data: auth } = useAuth();
|
||||
const login = useLogin();
|
||||
const setup = useSetup();
|
||||
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const isSetup = auth?.setupRequired ?? false;
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
|
||||
try {
|
||||
if (isSetup) {
|
||||
await setup.mutateAsync({ username, password });
|
||||
} else {
|
||||
await login.mutateAsync({ username, password });
|
||||
}
|
||||
navigate({ to: "/" });
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
const isPending = login.isPending || setup.isPending;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center px-4">
|
||||
<div className="w-full max-w-sm">
|
||||
<h1 className="text-xl font-semibold text-gray-900 text-center mb-6">
|
||||
{isSetup ? "Create Account" : "Sign In"}
|
||||
</h1>
|
||||
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="bg-white rounded-xl border border-gray-100 p-6 space-y-4"
|
||||
>
|
||||
{isSetup && (
|
||||
<p className="text-sm text-gray-500">
|
||||
Create your admin account to manage your gear collection.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="username"
|
||||
className="block text-sm font-medium text-gray-700 mb-1"
|
||||
>
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
id="username"
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-200"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="block text-sm font-medium text-gray-700 mb-1"
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={isSetup ? 6 : undefined}
|
||||
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-200"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isPending}
|
||||
className="w-full py-2 px-4 text-sm font-medium text-white bg-gray-700 hover:bg-gray-800 disabled:opacity-50 rounded-lg transition-colors"
|
||||
>
|
||||
{isPending ? "..." : isSetup ? "Create Account" : "Sign In"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,12 @@
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
useApiKeys,
|
||||
useAuth,
|
||||
useChangePassword,
|
||||
useCreateApiKey,
|
||||
useDeleteApiKey,
|
||||
} from "../hooks/useAuth";
|
||||
import { useCurrency } from "../hooks/useCurrency";
|
||||
import { useUpdateSetting } from "../hooks/useSettings";
|
||||
import { useWeightUnit } from "../hooks/useWeightUnit";
|
||||
@@ -18,10 +26,157 @@ export const Route = createFileRoute("/settings")({
|
||||
component: SettingsPage,
|
||||
});
|
||||
|
||||
function ChangePasswordSection() {
|
||||
const changePassword = useChangePassword();
|
||||
const [currentPassword, setCurrentPassword] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [message, setMessage] = useState<{
|
||||
type: "success" | "error";
|
||||
text: string;
|
||||
} | null>(null);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setMessage(null);
|
||||
try {
|
||||
await changePassword.mutateAsync({ currentPassword, newPassword });
|
||||
setMessage({ type: "success", text: "Password changed" });
|
||||
setCurrentPassword("");
|
||||
setNewPassword("");
|
||||
} catch (err) {
|
||||
setMessage({ type: "error", text: (err as Error).message });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-3">
|
||||
<h3 className="text-sm font-medium text-gray-900">Change Password</h3>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="Current password"
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
required
|
||||
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-200"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="New password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
required
|
||||
minLength={6}
|
||||
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-200"
|
||||
/>
|
||||
{message && (
|
||||
<p
|
||||
className={`text-sm ${message.type === "success" ? "text-green-600" : "text-red-600"}`}
|
||||
>
|
||||
{message.text}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={changePassword.isPending}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-gray-700 hover:bg-gray-800 disabled:opacity-50 rounded-lg transition-colors"
|
||||
>
|
||||
{changePassword.isPending ? "..." : "Change Password"}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function ApiKeySection() {
|
||||
const { data: keys } = useApiKeys();
|
||||
const createKey = useCreateApiKey();
|
||||
const deleteKey = useDeleteApiKey();
|
||||
const [name, setName] = useState("");
|
||||
const [newKey, setNewKey] = useState<string | null>(null);
|
||||
|
||||
async function handleCreate(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
const result = await createKey.mutateAsync({ name });
|
||||
setNewKey(result.key);
|
||||
setName("");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-medium text-gray-900">API Keys</h3>
|
||||
<p className="text-xs text-gray-500">
|
||||
API keys allow programmatic access to GearBox (e.g., from Claude Desktop
|
||||
or scripts).
|
||||
</p>
|
||||
|
||||
{newKey && (
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3">
|
||||
<p className="text-xs font-medium text-amber-800 mb-1">
|
||||
Copy this key now — it won't be shown again:
|
||||
</p>
|
||||
<code className="text-xs text-amber-900 break-all select-all">
|
||||
{newKey}
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setNewKey(null)}
|
||||
className="mt-2 block text-xs text-amber-700 hover:text-amber-900"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleCreate} className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Key name (e.g., claude-desktop)"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
required
|
||||
className="flex-1 px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-200"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={createKey.isPending}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-gray-700 hover:bg-gray-800 disabled:opacity-50 rounded-lg transition-colors"
|
||||
>
|
||||
Create
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{keys && keys.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{keys.map((key) => (
|
||||
<div
|
||||
key={key.id}
|
||||
className="flex items-center justify-between py-2 px-3 bg-gray-50 rounded-lg"
|
||||
>
|
||||
<div>
|
||||
<span className="text-sm text-gray-900">{key.name}</span>
|
||||
<span className="text-xs text-gray-400 ml-2">
|
||||
{key.keyPrefix}...
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => deleteKey.mutate(key.id)}
|
||||
className="text-xs text-red-500 hover:text-red-700"
|
||||
>
|
||||
Revoke
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsPage() {
|
||||
const unit = useWeightUnit();
|
||||
const currency = useCurrency();
|
||||
const updateSetting = useUpdateSetting();
|
||||
const { data: auth } = useAuth();
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
@@ -99,6 +254,14 @@ function SettingsPage() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{auth?.user && (
|
||||
<div className="bg-white rounded-xl border border-gray-100 p-5 space-y-6 mt-4">
|
||||
<ChangePasswordSection />
|
||||
<div className="border-t border-gray-100" />
|
||||
<ApiKeySection />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,13 +4,13 @@ import { CategoryHeader } from "../../components/CategoryHeader";
|
||||
import { ItemCard } from "../../components/ItemCard";
|
||||
import { ItemPicker } from "../../components/ItemPicker";
|
||||
import { WeightSummaryCard } from "../../components/WeightSummaryCard";
|
||||
import { useCurrency } from "../../hooks/useCurrency";
|
||||
import {
|
||||
useDeleteSetup,
|
||||
useRemoveSetupItem,
|
||||
useSetup,
|
||||
useUpdateItemClassification,
|
||||
} from "../../hooks/useSetups";
|
||||
import { useCurrency } from "../../hooks/useCurrency";
|
||||
import { useWeightUnit } from "../../hooks/useWeightUnit";
|
||||
import { formatPrice, formatWeight } from "../../lib/formatters";
|
||||
import { LucideIcon } from "../../lib/iconData";
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Reorder } from "framer-motion";
|
||||
import { useEffect, useState } from "react";
|
||||
import { CandidateCard } from "../../components/CandidateCard";
|
||||
import { CandidateListItem } from "../../components/CandidateListItem";
|
||||
import { ComparisonTable } from "../../components/ComparisonTable";
|
||||
import {
|
||||
useReorderCandidates,
|
||||
useUpdateCandidate,
|
||||
@@ -120,7 +121,7 @@ function ThreadDetailPage() {
|
||||
|
||||
{/* Toolbar: Add candidate + view toggle */}
|
||||
<div className="mb-6 flex items-center gap-3">
|
||||
{isActive && (
|
||||
{isActive && candidateViewMode !== "compare" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={openCandidateAddPanel}
|
||||
@@ -168,6 +169,20 @@ function ThreadDetailPage() {
|
||||
>
|
||||
<LucideIcon name="layout-grid" size={16} />
|
||||
</button>
|
||||
{thread.candidates.length >= 2 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCandidateViewMode("compare")}
|
||||
className={`p-1.5 rounded-md transition-colors ${
|
||||
candidateViewMode === "compare"
|
||||
? "bg-gray-200 text-gray-900"
|
||||
: "text-gray-400 hover:text-gray-600"
|
||||
}`}
|
||||
title="Compare view"
|
||||
>
|
||||
<LucideIcon name="columns-3" size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -189,6 +204,11 @@ function ThreadDetailPage() {
|
||||
Add your first candidate to start comparing.
|
||||
</p>
|
||||
</div>
|
||||
) : candidateViewMode === "compare" ? (
|
||||
<ComparisonTable
|
||||
candidates={displayItems}
|
||||
resolvedCandidateId={thread.resolvedCandidateId}
|
||||
/>
|
||||
) : candidateViewMode === "list" ? (
|
||||
isActive ? (
|
||||
<Reorder.Group
|
||||
|
||||
@@ -50,8 +50,8 @@ interface UIState {
|
||||
closeExternalLink: () => void;
|
||||
|
||||
// Candidate view mode
|
||||
candidateViewMode: "list" | "grid";
|
||||
setCandidateViewMode: (mode: "list" | "grid") => void;
|
||||
candidateViewMode: "list" | "grid" | "compare";
|
||||
setCandidateViewMode: (mode: "list" | "grid" | "compare") => void;
|
||||
}
|
||||
|
||||
export const useUIStore = create<UIState>((set) => ({
|
||||
|
||||
@@ -20,6 +20,7 @@ export const items = sqliteTable("items", {
|
||||
notes: text("notes"),
|
||||
productUrl: text("product_url"),
|
||||
imageFilename: text("image_filename"),
|
||||
imageSourceUrl: text("image_source_url"),
|
||||
createdAt: integer("created_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.$defaultFn(() => new Date()),
|
||||
@@ -58,6 +59,7 @@ export const threadCandidates = sqliteTable("thread_candidates", {
|
||||
notes: text("notes"),
|
||||
productUrl: text("product_url"),
|
||||
imageFilename: text("image_filename"),
|
||||
imageSourceUrl: text("image_source_url"),
|
||||
status: text("status").notNull().default("researching"),
|
||||
pros: text("pros"),
|
||||
cons: text("cons"),
|
||||
@@ -96,3 +98,30 @@ export const settings = sqliteTable("settings", {
|
||||
key: text("key").primaryKey(),
|
||||
value: text("value").notNull(),
|
||||
});
|
||||
|
||||
export const users = sqliteTable("users", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
username: text("username").notNull().unique(),
|
||||
passwordHash: text("password_hash").notNull(),
|
||||
createdAt: integer("created_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.$defaultFn(() => new Date()),
|
||||
});
|
||||
|
||||
export const sessions = sqliteTable("sessions", {
|
||||
id: text("id").primaryKey(),
|
||||
userId: integer("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(),
|
||||
});
|
||||
|
||||
export const apiKeys = sqliteTable("api_keys", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
name: text("name").notNull(),
|
||||
keyHash: text("key_hash").notNull(),
|
||||
keyPrefix: text("key_prefix").notNull(),
|
||||
createdAt: integer("created_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.$defaultFn(() => new Date()),
|
||||
});
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { Hono } from "hono";
|
||||
import { serveStatic } from "hono/bun";
|
||||
import { seedDefaults } from "../db/seed.ts";
|
||||
import { mcpRoutes } from "./mcp/index.ts";
|
||||
import { requireAuth } from "./middleware/auth.ts";
|
||||
import { authRoutes } from "./routes/auth.ts";
|
||||
import { categoryRoutes } from "./routes/categories.ts";
|
||||
import { imageRoutes } from "./routes/images.ts";
|
||||
import { itemRoutes } from "./routes/items.ts";
|
||||
@@ -19,7 +22,18 @@ app.get("/api/health", (c) => {
|
||||
return c.json({ status: "ok" });
|
||||
});
|
||||
|
||||
// Auth middleware for write operations (POST/PUT/DELETE) on non-auth routes
|
||||
app.use("/api/*", async (c, next) => {
|
||||
// Skip auth routes — they handle their own auth
|
||||
if (c.req.path.startsWith("/api/auth")) return next();
|
||||
// Skip GET requests — read is public
|
||||
if (c.req.method === "GET") return next();
|
||||
// All other methods require auth
|
||||
return requireAuth(c, next);
|
||||
});
|
||||
|
||||
// API routes
|
||||
app.route("/api/auth", authRoutes);
|
||||
app.route("/api/items", itemRoutes);
|
||||
app.route("/api/categories", categoryRoutes);
|
||||
app.route("/api/totals", totalRoutes);
|
||||
@@ -28,6 +42,11 @@ app.route("/api/settings", settingsRoutes);
|
||||
app.route("/api/threads", threadRoutes);
|
||||
app.route("/api/setups", setupRoutes);
|
||||
|
||||
// MCP server (conditionally mounted)
|
||||
if (process.env.GEARBOX_MCP !== "false") {
|
||||
app.route("/mcp", mcpRoutes);
|
||||
}
|
||||
|
||||
// Serve uploaded images
|
||||
app.use("/uploads/*", serveStatic({ root: "./" }));
|
||||
|
||||
|
||||
174
src/server/mcp/index.ts
Normal file
174
src/server/mcp/index.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
|
||||
import { Hono } from "hono";
|
||||
import { db as prodDb } from "@/db/index.ts";
|
||||
import { getUserCount, verifyApiKey } from "../services/auth.service.ts";
|
||||
import { getCollectionSummary } from "./resources/collection.ts";
|
||||
import {
|
||||
categoryToolDefinitions,
|
||||
registerCategoryTools,
|
||||
} from "./tools/categories.ts";
|
||||
import { imageToolDefinitions, registerImageTools } from "./tools/images.ts";
|
||||
import { itemToolDefinitions, registerItemTools } from "./tools/items.ts";
|
||||
import { registerSetupTools, setupToolDefinitions } from "./tools/setups.ts";
|
||||
import { registerThreadTools, threadToolDefinitions } from "./tools/threads.ts";
|
||||
|
||||
type Db = typeof prodDb;
|
||||
|
||||
function createMcpServer(db: Db): McpServer {
|
||||
const server = new McpServer({ name: "GearBox", version: "1.0.0" });
|
||||
|
||||
// Register item tools
|
||||
const itemHandlers = registerItemTools(db);
|
||||
for (const def of itemToolDefinitions) {
|
||||
const handler = itemHandlers[def.name as keyof typeof itemHandlers];
|
||||
server.tool(def.name, def.description, def.inputSchema, handler);
|
||||
}
|
||||
|
||||
// Register category tools
|
||||
const categoryHandlers = registerCategoryTools(db);
|
||||
for (const def of categoryToolDefinitions) {
|
||||
const handler = categoryHandlers[def.name as keyof typeof categoryHandlers];
|
||||
server.tool(def.name, def.description, def.inputSchema, handler);
|
||||
}
|
||||
|
||||
// Register thread tools
|
||||
const threadHandlers = registerThreadTools(db);
|
||||
for (const def of threadToolDefinitions) {
|
||||
const handler = threadHandlers[def.name as keyof typeof threadHandlers];
|
||||
server.tool(def.name, def.description, def.inputSchema, handler);
|
||||
}
|
||||
|
||||
// Register setup tools
|
||||
const setupHandlers = registerSetupTools(db);
|
||||
for (const def of setupToolDefinitions) {
|
||||
const handler = setupHandlers[def.name as keyof typeof setupHandlers];
|
||||
server.tool(def.name, def.description, def.inputSchema, handler);
|
||||
}
|
||||
|
||||
// Register image tools
|
||||
const imageHandlers = registerImageTools();
|
||||
for (const def of imageToolDefinitions) {
|
||||
const handler = imageHandlers[def.name as keyof typeof imageHandlers];
|
||||
server.tool(def.name, def.description, def.inputSchema, handler);
|
||||
}
|
||||
|
||||
// Register collection summary resource
|
||||
server.resource(
|
||||
"collection-summary",
|
||||
"gearbox://collection/summary",
|
||||
{
|
||||
description:
|
||||
"Overview of the entire gear collection including totals, categories, and active research threads.",
|
||||
mimeType: "application/json",
|
||||
},
|
||||
async () => {
|
||||
const summary = getCollectionSummary(db);
|
||||
return {
|
||||
contents: [
|
||||
{
|
||||
uri: "gearbox://collection/summary",
|
||||
mimeType: "application/json",
|
||||
text: JSON.stringify(summary, null, 2),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
// Store active transports by session ID
|
||||
const transports = new Map<string, WebStandardStreamableHTTPServerTransport>();
|
||||
|
||||
export const mcpRoutes = new Hono();
|
||||
|
||||
// Auth middleware for all MCP requests
|
||||
mcpRoutes.use("/*", async (c, next) => {
|
||||
const db = c.get("db") ?? prodDb;
|
||||
const apiKey = c.req.header("X-API-Key");
|
||||
|
||||
// Require API key when auth is configured (users exist)
|
||||
if (getUserCount(db) > 0) {
|
||||
if (!apiKey) {
|
||||
return c.json({ error: "API key required" }, 401);
|
||||
}
|
||||
const valid = await verifyApiKey(db, apiKey);
|
||||
if (!valid) {
|
||||
return c.json({ error: "Invalid API key" }, 401);
|
||||
}
|
||||
}
|
||||
|
||||
return next();
|
||||
});
|
||||
|
||||
mcpRoutes.post("/", async (c) => {
|
||||
const db = c.get("db") ?? prodDb;
|
||||
|
||||
// Check for existing session
|
||||
const sessionId = c.req.header("mcp-session-id");
|
||||
|
||||
if (sessionId) {
|
||||
const transport = transports.get(sessionId);
|
||||
if (!transport) {
|
||||
return c.json({ error: "Session not found" }, 404);
|
||||
}
|
||||
const response = await transport.handleRequest(c.req.raw);
|
||||
return response;
|
||||
}
|
||||
|
||||
// New session: create transport and MCP server
|
||||
const transport = new WebStandardStreamableHTTPServerTransport({
|
||||
sessionIdGenerator: () => randomUUID(),
|
||||
onsessioninitialized: (newSessionId) => {
|
||||
transports.set(newSessionId, transport);
|
||||
},
|
||||
});
|
||||
|
||||
// Clean up on close
|
||||
transport.onclose = () => {
|
||||
const sid = [...transports.entries()].find(
|
||||
([_, t]) => t === transport,
|
||||
)?.[0];
|
||||
if (sid) transports.delete(sid);
|
||||
};
|
||||
|
||||
const server = createMcpServer(db);
|
||||
await server.connect(transport);
|
||||
|
||||
const response = await transport.handleRequest(c.req.raw);
|
||||
return response;
|
||||
});
|
||||
|
||||
mcpRoutes.get("/", async (c) => {
|
||||
const sessionId = c.req.header("mcp-session-id");
|
||||
if (!sessionId) {
|
||||
return c.json({ error: "Session ID required" }, 400);
|
||||
}
|
||||
|
||||
const transport = transports.get(sessionId);
|
||||
if (!transport) {
|
||||
return c.json({ error: "Session not found" }, 404);
|
||||
}
|
||||
|
||||
const response = await transport.handleRequest(c.req.raw);
|
||||
return response;
|
||||
});
|
||||
|
||||
mcpRoutes.delete("/", async (c) => {
|
||||
const sessionId = c.req.header("mcp-session-id");
|
||||
if (!sessionId) {
|
||||
return c.json({ error: "Session ID required" }, 400);
|
||||
}
|
||||
|
||||
const transport = transports.get(sessionId);
|
||||
if (!transport) {
|
||||
return c.json({ error: "Session not found" }, 404);
|
||||
}
|
||||
|
||||
await transport.close();
|
||||
transports.delete(sessionId);
|
||||
return c.text("", 200);
|
||||
});
|
||||
41
src/server/mcp/resources/collection.ts
Normal file
41
src/server/mcp/resources/collection.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import type { db as prodDb } from "@/db/index.ts";
|
||||
import { getAllCategories } from "../../services/category.service.ts";
|
||||
import { getAllItems } from "../../services/item.service.ts";
|
||||
import { getAllSetups } from "../../services/setup.service.ts";
|
||||
import { getAllThreads } from "../../services/thread.service.ts";
|
||||
import { getGlobalTotals } from "../../services/totals.service.ts";
|
||||
|
||||
type Db = typeof prodDb;
|
||||
|
||||
export function getCollectionSummary(db: Db) {
|
||||
const totals = getGlobalTotals(db);
|
||||
const categories = getAllCategories(db);
|
||||
const items = getAllItems(db);
|
||||
const setups = getAllSetups(db);
|
||||
const activeThreads = getAllThreads(db, false);
|
||||
|
||||
// Build items-by-category map
|
||||
const itemsByCategory: Record<string, number> = {};
|
||||
for (const item of items) {
|
||||
const catName = item.categoryName;
|
||||
itemsByCategory[catName] = (itemsByCategory[catName] ?? 0) + 1;
|
||||
}
|
||||
|
||||
return {
|
||||
overview: {
|
||||
totalItems: totals?.itemCount ?? 0,
|
||||
totalWeightGrams: totals?.totalWeight ?? 0,
|
||||
totalCostCents: totals?.totalCost ?? 0,
|
||||
categoryCount: categories.length,
|
||||
setupCount: setups.length,
|
||||
activeThreadCount: activeThreads.length,
|
||||
},
|
||||
itemsByCategory,
|
||||
activeThreads: activeThreads.map((t) => ({
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
candidateCount: t.candidateCount,
|
||||
category: t.categoryName,
|
||||
})),
|
||||
};
|
||||
}
|
||||
72
src/server/mcp/tools/categories.ts
Normal file
72
src/server/mcp/tools/categories.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import type { db as prodDb } from "@/db/index.ts";
|
||||
import {
|
||||
createCategory,
|
||||
getAllCategories,
|
||||
} from "../../services/category.service.ts";
|
||||
|
||||
type Db = typeof prodDb;
|
||||
|
||||
interface ToolResult {
|
||||
content: Array<{ type: "text"; text: string }>;
|
||||
}
|
||||
|
||||
function textResult(data: unknown): ToolResult {
|
||||
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
|
||||
}
|
||||
|
||||
function errorResult(message: string): ToolResult {
|
||||
return {
|
||||
content: [{ type: "text", text: JSON.stringify({ error: message }) }],
|
||||
};
|
||||
}
|
||||
|
||||
export const categoryToolDefinitions = [
|
||||
{
|
||||
name: "list_categories",
|
||||
description: "List all gear categories.",
|
||||
inputSchema: {
|
||||
type: "object" as const,
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
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"],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export function registerCategoryTools(db: Db) {
|
||||
return {
|
||||
list_categories: async (): Promise<ToolResult> => {
|
||||
try {
|
||||
const cats = getAllCategories(db);
|
||||
return textResult(cats);
|
||||
} catch (err) {
|
||||
return errorResult((err as Error).message);
|
||||
}
|
||||
},
|
||||
|
||||
create_category: async (args: {
|
||||
name: string;
|
||||
icon?: string;
|
||||
}): Promise<ToolResult> => {
|
||||
try {
|
||||
const cat = createCategory(db, args);
|
||||
return textResult(cat);
|
||||
} catch (err) {
|
||||
return errorResult((err as Error).message);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
48
src/server/mcp/tools/images.ts
Normal file
48
src/server/mcp/tools/images.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { fetchImageFromUrl } from "../../services/image.service.ts";
|
||||
|
||||
interface ToolResult {
|
||||
content: Array<{ type: "text"; text: string }>;
|
||||
}
|
||||
|
||||
function textResult(data: unknown): ToolResult {
|
||||
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
|
||||
}
|
||||
|
||||
function errorResult(message: string): ToolResult {
|
||||
return {
|
||||
content: [{ type: "text", text: JSON.stringify({ error: message }) }],
|
||||
};
|
||||
}
|
||||
|
||||
export const imageToolDefinitions = [
|
||||
{
|
||||
name: "upload_image_from_url",
|
||||
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"],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export function registerImageTools() {
|
||||
return {
|
||||
upload_image_from_url: async (args: {
|
||||
url: string;
|
||||
}): Promise<ToolResult> => {
|
||||
try {
|
||||
const result = await fetchImageFromUrl(args.url);
|
||||
return textResult(result);
|
||||
} catch (err) {
|
||||
return errorResult((err as Error).message);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
190
src/server/mcp/tools/items.ts
Normal file
190
src/server/mcp/tools/items.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import type { db as prodDb } from "@/db/index.ts";
|
||||
import {
|
||||
createItem,
|
||||
deleteItem,
|
||||
getAllItems,
|
||||
getItemById,
|
||||
updateItem,
|
||||
} from "../../services/item.service.ts";
|
||||
|
||||
type Db = typeof prodDb;
|
||||
|
||||
interface ToolResult {
|
||||
content: Array<{ type: "text"; text: string }>;
|
||||
}
|
||||
|
||||
function textResult(data: unknown): ToolResult {
|
||||
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
|
||||
}
|
||||
|
||||
function errorResult(message: string): ToolResult {
|
||||
return {
|
||||
content: [{ type: "text", text: JSON.stringify({ error: message }) }],
|
||||
};
|
||||
}
|
||||
|
||||
export const itemToolDefinitions = [
|
||||
{
|
||||
name: "list_items",
|
||||
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",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
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"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "create_item",
|
||||
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: "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"],
|
||||
},
|
||||
},
|
||||
{
|
||||
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"],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export function registerItemTools(db: Db) {
|
||||
return {
|
||||
list_items: async (args: { categoryId?: number }): Promise<ToolResult> => {
|
||||
try {
|
||||
const items = getAllItems(db);
|
||||
if (args.categoryId) {
|
||||
return textResult(
|
||||
items.filter((i) => i.categoryId === args.categoryId),
|
||||
);
|
||||
}
|
||||
return textResult(items);
|
||||
} catch (err) {
|
||||
return errorResult((err as Error).message);
|
||||
}
|
||||
},
|
||||
|
||||
get_item: async (args: { id: number }): Promise<ToolResult> => {
|
||||
try {
|
||||
const item = getItemById(db, args.id);
|
||||
if (!item) return errorResult(`Item ${args.id} not found`);
|
||||
return textResult(item);
|
||||
} catch (err) {
|
||||
return errorResult((err as Error).message);
|
||||
}
|
||||
},
|
||||
|
||||
create_item: async (args: {
|
||||
name: string;
|
||||
categoryId: number;
|
||||
weightGrams?: number;
|
||||
priceCents?: number;
|
||||
notes?: string;
|
||||
productUrl?: string;
|
||||
imageFilename?: string;
|
||||
imageSourceUrl?: string;
|
||||
}): Promise<ToolResult> => {
|
||||
try {
|
||||
const item = createItem(db, args);
|
||||
return textResult(item);
|
||||
} catch (err) {
|
||||
return errorResult((err as Error).message);
|
||||
}
|
||||
},
|
||||
|
||||
update_item: async (args: {
|
||||
id: number;
|
||||
name?: string;
|
||||
categoryId?: number;
|
||||
weightGrams?: number;
|
||||
priceCents?: number;
|
||||
notes?: string;
|
||||
productUrl?: string;
|
||||
imageFilename?: string;
|
||||
imageSourceUrl?: string;
|
||||
}): Promise<ToolResult> => {
|
||||
try {
|
||||
const { id, ...data } = args;
|
||||
const item = updateItem(db, id, data);
|
||||
if (!item) return errorResult(`Item ${id} not found`);
|
||||
return textResult(item);
|
||||
} catch (err) {
|
||||
return errorResult((err as Error).message);
|
||||
}
|
||||
},
|
||||
|
||||
delete_item: async (args: { id: number }): Promise<ToolResult> => {
|
||||
try {
|
||||
const item = deleteItem(db, args.id);
|
||||
if (!item) return errorResult(`Item ${args.id} not found`);
|
||||
return textResult({ deleted: true, item });
|
||||
} catch (err) {
|
||||
return errorResult((err as Error).message);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
131
src/server/mcp/tools/setups.ts
Normal file
131
src/server/mcp/tools/setups.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import type { db as prodDb } from "@/db/index.ts";
|
||||
import {
|
||||
createSetup,
|
||||
getAllSetups,
|
||||
getSetupWithItems,
|
||||
syncSetupItems,
|
||||
updateSetup,
|
||||
} from "../../services/setup.service.ts";
|
||||
|
||||
type Db = typeof prodDb;
|
||||
|
||||
interface ToolResult {
|
||||
content: Array<{ type: "text"; text: string }>;
|
||||
}
|
||||
|
||||
function textResult(data: unknown): ToolResult {
|
||||
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
|
||||
}
|
||||
|
||||
function errorResult(message: string): ToolResult {
|
||||
return {
|
||||
content: [{ type: "text", text: JSON.stringify({ error: message }) }],
|
||||
};
|
||||
}
|
||||
|
||||
export const setupToolDefinitions = [
|
||||
{
|
||||
name: "list_setups",
|
||||
description:
|
||||
"List all gear setups with item counts and weight/cost totals.",
|
||||
inputSchema: {
|
||||
type: "object" as const,
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
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"],
|
||||
},
|
||||
},
|
||||
{
|
||||
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: "update_setup",
|
||||
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"],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export function registerSetupTools(db: Db) {
|
||||
return {
|
||||
list_setups: async (): Promise<ToolResult> => {
|
||||
try {
|
||||
const setupList = getAllSetups(db);
|
||||
return textResult(setupList);
|
||||
} catch (err) {
|
||||
return errorResult((err as Error).message);
|
||||
}
|
||||
},
|
||||
|
||||
get_setup: async (args: { id: number }): Promise<ToolResult> => {
|
||||
try {
|
||||
const setup = getSetupWithItems(db, args.id);
|
||||
if (!setup) return errorResult(`Setup ${args.id} not found`);
|
||||
return textResult(setup);
|
||||
} catch (err) {
|
||||
return errorResult((err as Error).message);
|
||||
}
|
||||
},
|
||||
|
||||
create_setup: async (args: { name: string }): Promise<ToolResult> => {
|
||||
try {
|
||||
const setup = createSetup(db, args);
|
||||
return textResult(setup);
|
||||
} catch (err) {
|
||||
return errorResult((err as Error).message);
|
||||
}
|
||||
},
|
||||
|
||||
update_setup: async (args: {
|
||||
id: number;
|
||||
name?: string;
|
||||
itemIds?: number[];
|
||||
}): Promise<ToolResult> => {
|
||||
try {
|
||||
let setup = null;
|
||||
if (args.name) {
|
||||
setup = updateSetup(db, args.id, { name: args.name });
|
||||
if (!setup) return errorResult(`Setup ${args.id} not found`);
|
||||
}
|
||||
if (args.itemIds) {
|
||||
syncSetupItems(db, args.id, args.itemIds);
|
||||
}
|
||||
// Return updated setup with items
|
||||
const result = getSetupWithItems(db, args.id);
|
||||
if (!result) return errorResult(`Setup ${args.id} not found`);
|
||||
return textResult(result);
|
||||
} catch (err) {
|
||||
return errorResult((err as Error).message);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
252
src/server/mcp/tools/threads.ts
Normal file
252
src/server/mcp/tools/threads.ts
Normal file
@@ -0,0 +1,252 @@
|
||||
import type { db as prodDb } from "@/db/index.ts";
|
||||
import {
|
||||
createCandidate,
|
||||
createThread,
|
||||
deleteCandidate,
|
||||
getAllThreads,
|
||||
getThreadWithCandidates,
|
||||
resolveThread,
|
||||
updateCandidate,
|
||||
} from "../../services/thread.service.ts";
|
||||
|
||||
type Db = typeof prodDb;
|
||||
|
||||
interface ToolResult {
|
||||
content: Array<{ type: "text"; text: string }>;
|
||||
}
|
||||
|
||||
function textResult(data: unknown): ToolResult {
|
||||
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
|
||||
}
|
||||
|
||||
function errorResult(message: string): ToolResult {
|
||||
return {
|
||||
content: [{ type: "text", text: JSON.stringify({ error: message }) }],
|
||||
};
|
||||
}
|
||||
|
||||
export const threadToolDefinitions = [
|
||||
{
|
||||
name: "list_threads",
|
||||
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)",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "get_thread",
|
||||
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"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "create_thread",
|
||||
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: "resolve_thread",
|
||||
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"],
|
||||
},
|
||||
},
|
||||
{
|
||||
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"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "update_candidate",
|
||||
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"],
|
||||
},
|
||||
},
|
||||
{
|
||||
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"],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export function registerThreadTools(db: Db) {
|
||||
return {
|
||||
list_threads: async (args: {
|
||||
includeResolved?: boolean;
|
||||
}): Promise<ToolResult> => {
|
||||
try {
|
||||
const threadList = getAllThreads(db, args.includeResolved ?? false);
|
||||
return textResult(threadList);
|
||||
} catch (err) {
|
||||
return errorResult((err as Error).message);
|
||||
}
|
||||
},
|
||||
|
||||
get_thread: async (args: { id: number }): Promise<ToolResult> => {
|
||||
try {
|
||||
const thread = getThreadWithCandidates(db, args.id);
|
||||
if (!thread) return errorResult(`Thread ${args.id} not found`);
|
||||
return textResult(thread);
|
||||
} catch (err) {
|
||||
return errorResult((err as Error).message);
|
||||
}
|
||||
},
|
||||
|
||||
create_thread: async (args: {
|
||||
name: string;
|
||||
categoryId: number;
|
||||
}): Promise<ToolResult> => {
|
||||
try {
|
||||
const thread = createThread(db, args);
|
||||
return textResult(thread);
|
||||
} catch (err) {
|
||||
return errorResult((err as Error).message);
|
||||
}
|
||||
},
|
||||
|
||||
resolve_thread: async (args: {
|
||||
threadId: number;
|
||||
candidateId: number;
|
||||
}): Promise<ToolResult> => {
|
||||
try {
|
||||
const result = resolveThread(db, args.threadId, args.candidateId);
|
||||
if (!result.success) {
|
||||
return errorResult(result.error ?? "Failed to resolve thread");
|
||||
}
|
||||
return textResult(result);
|
||||
} catch (err) {
|
||||
return errorResult((err as Error).message);
|
||||
}
|
||||
},
|
||||
|
||||
add_candidate: async (args: {
|
||||
threadId: number;
|
||||
name: string;
|
||||
categoryId: number;
|
||||
weightGrams?: number;
|
||||
priceCents?: number;
|
||||
notes?: string;
|
||||
productUrl?: string;
|
||||
imageFilename?: string;
|
||||
pros?: string;
|
||||
cons?: string;
|
||||
}): Promise<ToolResult> => {
|
||||
try {
|
||||
const { threadId, ...data } = args;
|
||||
const candidate = createCandidate(db, threadId, data);
|
||||
return textResult(candidate);
|
||||
} catch (err) {
|
||||
return errorResult((err as Error).message);
|
||||
}
|
||||
},
|
||||
|
||||
update_candidate: async (args: {
|
||||
id: number;
|
||||
name?: string;
|
||||
weightGrams?: number;
|
||||
priceCents?: number;
|
||||
categoryId?: number;
|
||||
notes?: string;
|
||||
productUrl?: string;
|
||||
imageFilename?: string;
|
||||
imageSourceUrl?: string;
|
||||
status?: "researching" | "ordered" | "arrived";
|
||||
pros?: string;
|
||||
cons?: string;
|
||||
}): Promise<ToolResult> => {
|
||||
try {
|
||||
const { id, ...data } = args;
|
||||
const candidate = updateCandidate(db, id, data);
|
||||
if (!candidate) return errorResult(`Candidate ${id} not found`);
|
||||
return textResult(candidate);
|
||||
} catch (err) {
|
||||
return errorResult((err as Error).message);
|
||||
}
|
||||
},
|
||||
|
||||
remove_candidate: async (args: { id: number }): Promise<ToolResult> => {
|
||||
try {
|
||||
const candidate = deleteCandidate(db, args.id);
|
||||
if (!candidate) return errorResult(`Candidate ${args.id} not found`);
|
||||
return textResult({ deleted: true, candidate });
|
||||
} catch (err) {
|
||||
return errorResult((err as Error).message);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
38
src/server/middleware/auth.ts
Normal file
38
src/server/middleware/auth.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { Context, Next } from "hono";
|
||||
import { getCookie } from "hono/cookie";
|
||||
import {
|
||||
getSession,
|
||||
getUserCount,
|
||||
refreshSession,
|
||||
verifyApiKey,
|
||||
} from "../services/auth.service";
|
||||
|
||||
export async function requireAuth(c: Context, next: Next) {
|
||||
const db = c.get("db");
|
||||
|
||||
// Check if any users exist at all
|
||||
if (getUserCount(db) === 0) {
|
||||
return c.json({ error: "setup_required" }, 403);
|
||||
}
|
||||
|
||||
// Check API key first
|
||||
const apiKey = c.req.header("X-API-Key");
|
||||
if (apiKey) {
|
||||
const valid = await verifyApiKey(db, apiKey);
|
||||
if (valid) return next();
|
||||
return c.json({ error: "Invalid API key" }, 401);
|
||||
}
|
||||
|
||||
// Check session cookie
|
||||
const sessionId = getCookie(c, "gearbox_session");
|
||||
if (sessionId) {
|
||||
const session = getSession(db, sessionId);
|
||||
if (session) {
|
||||
// Refresh session expiry on use
|
||||
refreshSession(db, sessionId);
|
||||
return next();
|
||||
}
|
||||
}
|
||||
|
||||
return c.json({ error: "Authentication required" }, 401);
|
||||
}
|
||||
194
src/server/routes/auth.ts
Normal file
194
src/server/routes/auth.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { Hono } from "hono";
|
||||
import { deleteCookie, getCookie, setCookie } from "hono/cookie";
|
||||
import { z } from "zod";
|
||||
import { users } from "../../db/schema.ts";
|
||||
import { requireAuth } from "../middleware/auth.ts";
|
||||
import {
|
||||
changePassword,
|
||||
createApiKey,
|
||||
createSession,
|
||||
createUser,
|
||||
deleteApiKey,
|
||||
deleteSession,
|
||||
getSession,
|
||||
getUserCount,
|
||||
listApiKeys,
|
||||
verifyPassword,
|
||||
} from "../services/auth.service.ts";
|
||||
|
||||
type Env = { Variables: { db?: any } };
|
||||
|
||||
const loginSchema = z.object({
|
||||
username: z.string().min(1),
|
||||
password: z.string().min(1),
|
||||
});
|
||||
const setupSchema = z.object({
|
||||
username: z.string().min(1),
|
||||
password: z.string().min(6),
|
||||
});
|
||||
const changePasswordSchema = z.object({
|
||||
currentPassword: z.string().min(1),
|
||||
newPassword: z.string().min(6),
|
||||
});
|
||||
const createKeySchema = z.object({ name: z.string().min(1) });
|
||||
|
||||
const COOKIE_NAME = "gearbox_session";
|
||||
const COOKIE_MAX_AGE = 30 * 24 * 60 * 60; // 30 days in seconds
|
||||
|
||||
const app = new Hono<Env>();
|
||||
|
||||
// ── Public routes ───────────────────────────────────────────────────
|
||||
|
||||
app.get("/me", (c) => {
|
||||
const db = c.get("db");
|
||||
const sessionId = getCookie(c, COOKIE_NAME);
|
||||
|
||||
if (sessionId) {
|
||||
const session = getSession(db, sessionId);
|
||||
if (session) {
|
||||
return c.json({
|
||||
user: { id: session.userId },
|
||||
setupRequired: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const setupRequired = getUserCount(db) === 0;
|
||||
return c.json({ user: null, setupRequired });
|
||||
});
|
||||
|
||||
app.post("/setup", zValidator("json", setupSchema), async (c) => {
|
||||
const db = c.get("db");
|
||||
|
||||
if (getUserCount(db) > 0) {
|
||||
return c.json({ error: "Setup already completed" }, 403);
|
||||
}
|
||||
|
||||
const { username, password } = c.req.valid("json");
|
||||
const user = await createUser(db, username, password);
|
||||
const session = createSession(db, user.id);
|
||||
|
||||
setCookie(c, COOKIE_NAME, session.id, {
|
||||
httpOnly: true,
|
||||
sameSite: "Lax",
|
||||
path: "/",
|
||||
maxAge: COOKIE_MAX_AGE,
|
||||
});
|
||||
|
||||
return c.json({ username: user.username }, 201);
|
||||
});
|
||||
|
||||
app.post("/login", zValidator("json", loginSchema), async (c) => {
|
||||
const db = c.get("db");
|
||||
const { username, password } = c.req.valid("json");
|
||||
|
||||
const user = await verifyPassword(db, username, password);
|
||||
if (!user) {
|
||||
return c.json({ error: "Invalid credentials" }, 401);
|
||||
}
|
||||
|
||||
const session = createSession(db, user.id);
|
||||
|
||||
setCookie(c, COOKIE_NAME, session.id, {
|
||||
httpOnly: true,
|
||||
sameSite: "Lax",
|
||||
path: "/",
|
||||
maxAge: COOKIE_MAX_AGE,
|
||||
});
|
||||
|
||||
return c.json({ username: user.username });
|
||||
});
|
||||
|
||||
app.post("/logout", (c) => {
|
||||
const db = c.get("db");
|
||||
const sessionId = getCookie(c, COOKIE_NAME);
|
||||
|
||||
if (sessionId) {
|
||||
deleteSession(db, sessionId);
|
||||
}
|
||||
|
||||
deleteCookie(c, COOKIE_NAME, { path: "/" });
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
// ── Protected routes ────────────────────────────────────────────────
|
||||
|
||||
app.put(
|
||||
"/password",
|
||||
requireAuth,
|
||||
zValidator("json", changePasswordSchema),
|
||||
async (c) => {
|
||||
const db = c.get("db");
|
||||
const sessionId = getCookie(c, COOKIE_NAME);
|
||||
if (!sessionId) {
|
||||
return c.json({ error: "Session required for password change" }, 401);
|
||||
}
|
||||
const session = getSession(db, sessionId);
|
||||
|
||||
if (!session) {
|
||||
return c.json({ error: "Session required for password change" }, 401);
|
||||
}
|
||||
|
||||
const userRecord = db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.id, session.userId))
|
||||
.get();
|
||||
|
||||
if (!userRecord) {
|
||||
return c.json({ error: "User not found" }, 404);
|
||||
}
|
||||
|
||||
const { currentPassword, newPassword } = c.req.valid("json");
|
||||
const changed = await changePassword(
|
||||
db,
|
||||
userRecord.username,
|
||||
currentPassword,
|
||||
newPassword,
|
||||
);
|
||||
|
||||
if (!changed) {
|
||||
return c.json({ error: "Invalid current password" }, 401);
|
||||
}
|
||||
|
||||
return c.json({ ok: true });
|
||||
},
|
||||
);
|
||||
|
||||
app.get("/keys", requireAuth, (c) => {
|
||||
const db = c.get("db");
|
||||
const keys = listApiKeys(db);
|
||||
return c.json(keys);
|
||||
});
|
||||
|
||||
app.post(
|
||||
"/keys",
|
||||
requireAuth,
|
||||
zValidator("json", createKeySchema),
|
||||
async (c) => {
|
||||
const db = c.get("db");
|
||||
const { name } = c.req.valid("json");
|
||||
const result = await createApiKey(db, name);
|
||||
|
||||
return c.json(
|
||||
{
|
||||
id: result.id,
|
||||
name: result.name,
|
||||
key: result.rawKey,
|
||||
prefix: result.keyPrefix,
|
||||
},
|
||||
201,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
app.delete("/keys/:id", requireAuth, (c) => {
|
||||
const db = c.get("db");
|
||||
const id = Number(c.req.param("id"));
|
||||
deleteApiKey(db, id);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
export const authRoutes = app;
|
||||
@@ -1,13 +1,35 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { Hono } from "hono";
|
||||
import { z } from "zod";
|
||||
import { fetchImageFromUrl } from "../services/image.service";
|
||||
|
||||
const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/webp"];
|
||||
const MAX_SIZE = 5 * 1024 * 1024; // 5MB
|
||||
|
||||
const fromUrlSchema = z.object({ url: z.string().url("Invalid URL") });
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
app.post("/from-url", zValidator("json", fromUrlSchema), async (c) => {
|
||||
const { url } = c.req.valid("json");
|
||||
try {
|
||||
const result = await fetchImageFromUrl(url);
|
||||
return c.json(result, 201);
|
||||
} catch (err) {
|
||||
const message = (err as Error).message;
|
||||
// Known validation errors from the service
|
||||
const isValidationError =
|
||||
message.startsWith("Invalid") ||
|
||||
message.startsWith("URL must") ||
|
||||
message.startsWith("File too") ||
|
||||
message.startsWith("HTTP ");
|
||||
return c.json({ error: message }, isValidationError ? 400 : 500);
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/", async (c) => {
|
||||
const body = await c.req.parseBody();
|
||||
const file = body.image;
|
||||
|
||||
158
src/server/services/auth.service.ts
Normal file
158
src/server/services/auth.service.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { count, eq } from "drizzle-orm";
|
||||
import { db as prodDb } from "../../db/index.ts";
|
||||
import { apiKeys, sessions, users } from "../../db/schema.ts";
|
||||
|
||||
type Db = typeof prodDb;
|
||||
|
||||
// ── User Management ──────────────────────────────────────────────────
|
||||
|
||||
export async function createUser(
|
||||
db: Db = prodDb,
|
||||
username: string,
|
||||
password: string,
|
||||
) {
|
||||
const passwordHash = await Bun.password.hash(password);
|
||||
return db.insert(users).values({ username, passwordHash }).returning().get();
|
||||
}
|
||||
|
||||
export async function verifyPassword(
|
||||
db: Db = prodDb,
|
||||
username: string,
|
||||
password: string,
|
||||
) {
|
||||
const user = db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.username, username))
|
||||
.get();
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
const valid = await Bun.password.verify(password, user.passwordHash);
|
||||
return valid ? user : null;
|
||||
}
|
||||
|
||||
export function getUserCount(db: Db = prodDb): number {
|
||||
const result = db.select({ value: count() }).from(users).get();
|
||||
return result?.value ?? 0;
|
||||
}
|
||||
|
||||
export async function changePassword(
|
||||
db: Db = prodDb,
|
||||
username: string,
|
||||
currentPassword: string,
|
||||
newPassword: string,
|
||||
): Promise<boolean> {
|
||||
const user = await verifyPassword(db, username, currentPassword);
|
||||
if (!user) return false;
|
||||
|
||||
const newHash = await Bun.password.hash(newPassword);
|
||||
db.update(users)
|
||||
.set({ passwordHash: newHash })
|
||||
.where(eq(users.id, user.id))
|
||||
.run();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Session Management ───────────────────────────────────────────────
|
||||
|
||||
export function createSession(
|
||||
db: Db = prodDb,
|
||||
userId: number,
|
||||
expiryDays = 30,
|
||||
) {
|
||||
const id = randomBytes(32).toString("hex");
|
||||
const expiresAt = new Date(Date.now() + expiryDays * 24 * 60 * 60 * 1000);
|
||||
|
||||
return db
|
||||
.insert(sessions)
|
||||
.values({ id, userId, expiresAt })
|
||||
.returning()
|
||||
.get();
|
||||
}
|
||||
|
||||
export function getSession(db: Db = prodDb, sessionId: string) {
|
||||
const session = db
|
||||
.select()
|
||||
.from(sessions)
|
||||
.where(eq(sessions.id, sessionId))
|
||||
.get();
|
||||
|
||||
if (!session) return null;
|
||||
|
||||
if (session.expiresAt < new Date()) {
|
||||
db.delete(sessions).where(eq(sessions.id, sessionId)).run();
|
||||
return null;
|
||||
}
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
export function deleteSession(db: Db = prodDb, sessionId: string) {
|
||||
db.delete(sessions).where(eq(sessions.id, sessionId)).run();
|
||||
}
|
||||
|
||||
export function refreshSession(
|
||||
db: Db = prodDb,
|
||||
sessionId: string,
|
||||
expiryDays = 30,
|
||||
) {
|
||||
const expiresAt = new Date(Date.now() + expiryDays * 24 * 60 * 60 * 1000);
|
||||
db.update(sessions)
|
||||
.set({ expiresAt })
|
||||
.where(eq(sessions.id, sessionId))
|
||||
.run();
|
||||
}
|
||||
|
||||
// ── API Key Management ───────────────────────────────────────────────
|
||||
|
||||
export async function createApiKey(db: Db = prodDb, name: string) {
|
||||
const rawKey = randomBytes(32).toString("hex");
|
||||
const keyHash = await Bun.password.hash(rawKey);
|
||||
const keyPrefix = rawKey.slice(0, 8);
|
||||
|
||||
const record = db
|
||||
.insert(apiKeys)
|
||||
.values({ name, keyHash, keyPrefix })
|
||||
.returning()
|
||||
.get();
|
||||
|
||||
return { ...record, rawKey };
|
||||
}
|
||||
|
||||
export async function verifyApiKey(
|
||||
db: Db = prodDb,
|
||||
rawKey: string,
|
||||
): Promise<boolean> {
|
||||
const prefix = rawKey.slice(0, 8);
|
||||
const candidates = db
|
||||
.select()
|
||||
.from(apiKeys)
|
||||
.where(eq(apiKeys.keyPrefix, prefix))
|
||||
.all();
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const valid = await Bun.password.verify(rawKey, candidate.keyHash);
|
||||
if (valid) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function listApiKeys(db: Db = prodDb) {
|
||||
return db
|
||||
.select({
|
||||
id: apiKeys.id,
|
||||
name: apiKeys.name,
|
||||
keyPrefix: apiKeys.keyPrefix,
|
||||
createdAt: apiKeys.createdAt,
|
||||
})
|
||||
.from(apiKeys)
|
||||
.all();
|
||||
}
|
||||
|
||||
export function deleteApiKey(db: Db = prodDb, id: number) {
|
||||
db.delete(apiKeys).where(eq(apiKeys.id, id)).run();
|
||||
}
|
||||
82
src/server/services/image.service.ts
Normal file
82
src/server/services/image.service.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/webp"];
|
||||
const MAX_SIZE = 5 * 1024 * 1024; // 5MB
|
||||
const FETCH_TIMEOUT = 10_000; // 10 seconds
|
||||
|
||||
interface FetchImageResult {
|
||||
filename: string;
|
||||
sourceUrl: string;
|
||||
}
|
||||
|
||||
export async function fetchImageFromUrl(
|
||||
url: string,
|
||||
uploadsDir = "uploads",
|
||||
): Promise<FetchImageResult> {
|
||||
// Validate URL format
|
||||
let parsedUrl: URL;
|
||||
try {
|
||||
parsedUrl = new URL(url);
|
||||
} catch {
|
||||
throw new Error("Invalid URL format");
|
||||
}
|
||||
|
||||
if (!["http:", "https:"].includes(parsedUrl.protocol)) {
|
||||
throw new Error("URL must use HTTP or HTTPS");
|
||||
}
|
||||
|
||||
// Fetch with timeout
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT);
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(url, { signal: controller.signal });
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === "AbortError") {
|
||||
throw new Error("Request timed out");
|
||||
}
|
||||
throw new Error(`Failed to fetch image: ${(err as Error).message}`);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: Failed to fetch image`);
|
||||
}
|
||||
|
||||
// Validate content type
|
||||
const contentType = response.headers
|
||||
.get("content-type")
|
||||
?.split(";")[0]
|
||||
.trim();
|
||||
if (!contentType || !ALLOWED_TYPES.includes(contentType)) {
|
||||
throw new Error(
|
||||
`Invalid content type: ${contentType ?? "unknown"}. Accepted: jpeg, png, webp`,
|
||||
);
|
||||
}
|
||||
|
||||
// Check content length if available
|
||||
const contentLength = response.headers.get("content-length");
|
||||
if (contentLength && Number.parseInt(contentLength, 10) > MAX_SIZE) {
|
||||
throw new Error("File too large. Maximum size is 5MB");
|
||||
}
|
||||
|
||||
// Read body and check actual size
|
||||
const buffer = await response.arrayBuffer();
|
||||
if (buffer.byteLength > MAX_SIZE) {
|
||||
throw new Error("File too large. Maximum size is 5MB");
|
||||
}
|
||||
|
||||
// Determine extension
|
||||
const ext = contentType === "image/jpeg" ? "jpg" : contentType.split("/")[1];
|
||||
const filename = `${Date.now()}-${randomUUID()}.${ext}`;
|
||||
|
||||
// Ensure directory exists and write
|
||||
await mkdir(uploadsDir, { recursive: true });
|
||||
await Bun.write(join(uploadsDir, filename), buffer);
|
||||
|
||||
return { filename, sourceUrl: url };
|
||||
}
|
||||
@@ -16,6 +16,7 @@ export function getAllItems(db: Db = prodDb) {
|
||||
notes: items.notes,
|
||||
productUrl: items.productUrl,
|
||||
imageFilename: items.imageFilename,
|
||||
imageSourceUrl: items.imageSourceUrl,
|
||||
createdAt: items.createdAt,
|
||||
updatedAt: items.updatedAt,
|
||||
categoryName: categories.name,
|
||||
@@ -38,6 +39,7 @@ export function getItemById(db: Db = prodDb, id: number) {
|
||||
notes: items.notes,
|
||||
productUrl: items.productUrl,
|
||||
imageFilename: items.imageFilename,
|
||||
imageSourceUrl: items.imageSourceUrl,
|
||||
createdAt: items.createdAt,
|
||||
updatedAt: items.updatedAt,
|
||||
})
|
||||
@@ -65,6 +67,7 @@ export function createItem(
|
||||
notes: data.notes ?? null,
|
||||
productUrl: data.productUrl ?? null,
|
||||
imageFilename: data.imageFilename ?? null,
|
||||
imageSourceUrl: data.imageSourceUrl ?? null,
|
||||
})
|
||||
.returning()
|
||||
.get();
|
||||
@@ -81,6 +84,7 @@ export function updateItem(
|
||||
notes: string;
|
||||
productUrl: string;
|
||||
imageFilename: string;
|
||||
imageSourceUrl: string;
|
||||
}>,
|
||||
) {
|
||||
// Check if item exists first
|
||||
|
||||
@@ -76,6 +76,7 @@ export function getThreadWithCandidates(db: Db = prodDb, threadId: number) {
|
||||
notes: threadCandidates.notes,
|
||||
productUrl: threadCandidates.productUrl,
|
||||
imageFilename: threadCandidates.imageFilename,
|
||||
imageSourceUrl: threadCandidates.imageSourceUrl,
|
||||
status: threadCandidates.status,
|
||||
pros: threadCandidates.pros,
|
||||
cons: threadCandidates.cons,
|
||||
@@ -144,6 +145,7 @@ export function createCandidate(
|
||||
name: string;
|
||||
categoryId: number;
|
||||
imageFilename?: string;
|
||||
imageSourceUrl?: string;
|
||||
},
|
||||
) {
|
||||
const maxRow = db
|
||||
@@ -165,6 +167,7 @@ export function createCandidate(
|
||||
notes: data.notes ?? null,
|
||||
productUrl: data.productUrl ?? null,
|
||||
imageFilename: data.imageFilename ?? null,
|
||||
imageSourceUrl: data.imageSourceUrl ?? null,
|
||||
status: data.status ?? "researching",
|
||||
pros: data.pros ?? null,
|
||||
cons: data.cons ?? null,
|
||||
@@ -185,6 +188,7 @@ export function updateCandidate(
|
||||
notes: string;
|
||||
productUrl: string;
|
||||
imageFilename: string;
|
||||
imageSourceUrl: string;
|
||||
status: "researching" | "ordered" | "arrived";
|
||||
pros: string;
|
||||
cons: string;
|
||||
@@ -294,6 +298,7 @@ export function resolveThread(
|
||||
notes: candidate.notes,
|
||||
productUrl: candidate.productUrl,
|
||||
imageFilename: candidate.imageFilename,
|
||||
imageSourceUrl: candidate.imageSourceUrl,
|
||||
})
|
||||
.returning()
|
||||
.get();
|
||||
|
||||
@@ -8,6 +8,7 @@ export const createItemSchema = z.object({
|
||||
notes: z.string().optional(),
|
||||
productUrl: z.string().url().optional().or(z.literal("")),
|
||||
imageFilename: z.string().optional(),
|
||||
imageSourceUrl: z.string().url().optional().or(z.literal("")),
|
||||
});
|
||||
|
||||
export const updateItemSchema = createItemSchema.partial().extend({
|
||||
@@ -52,6 +53,7 @@ export const createCandidateSchema = z.object({
|
||||
notes: z.string().optional(),
|
||||
productUrl: z.string().url().optional().or(z.literal("")),
|
||||
imageFilename: z.string().optional(),
|
||||
imageSourceUrl: z.string().url().optional().or(z.literal("")),
|
||||
status: candidateStatusSchema.optional(),
|
||||
pros: z.string().optional(),
|
||||
cons: z.string().optional(),
|
||||
|
||||
@@ -26,6 +26,7 @@ export function createTestDb() {
|
||||
notes TEXT,
|
||||
product_url TEXT,
|
||||
image_filename TEXT,
|
||||
image_source_url TEXT,
|
||||
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
||||
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
|
||||
)
|
||||
@@ -54,6 +55,7 @@ export function createTestDb() {
|
||||
notes TEXT,
|
||||
product_url TEXT,
|
||||
image_filename TEXT,
|
||||
image_source_url TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'researching',
|
||||
pros TEXT,
|
||||
cons TEXT,
|
||||
@@ -88,6 +90,33 @@ export function createTestDb() {
|
||||
)
|
||||
`);
|
||||
|
||||
sqlite.run(`
|
||||
CREATE TABLE users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL DEFAULT (unixepoch())
|
||||
)
|
||||
`);
|
||||
|
||||
sqlite.run(`
|
||||
CREATE TABLE sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
expires_at INTEGER NOT NULL
|
||||
)
|
||||
`);
|
||||
|
||||
sqlite.run(`
|
||||
CREATE TABLE api_keys (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
key_hash TEXT NOT NULL,
|
||||
key_prefix TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL DEFAULT (unixepoch())
|
||||
)
|
||||
`);
|
||||
|
||||
const db = drizzle(sqlite, { schema });
|
||||
|
||||
// Seed default Uncategorized category
|
||||
|
||||
253
tests/mcp/tools.test.ts
Normal file
253
tests/mcp/tools.test.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { getCollectionSummary } from "../../src/server/mcp/resources/collection.ts";
|
||||
import { registerCategoryTools } from "../../src/server/mcp/tools/categories.ts";
|
||||
import { registerItemTools } from "../../src/server/mcp/tools/items.ts";
|
||||
import { registerSetupTools } from "../../src/server/mcp/tools/setups.ts";
|
||||
import { registerThreadTools } from "../../src/server/mcp/tools/threads.ts";
|
||||
import { createTestDb } from "../helpers/db.ts";
|
||||
|
||||
function parseResult(result: {
|
||||
content: Array<{ type: string; text: string }>;
|
||||
}) {
|
||||
return JSON.parse(result.content[0].text);
|
||||
}
|
||||
|
||||
describe("MCP Item Tools", () => {
|
||||
test("list_items returns array", async () => {
|
||||
const db = createTestDb();
|
||||
const tools = registerItemTools(db);
|
||||
const result = await tools.list_items({});
|
||||
const data = parseResult(result);
|
||||
expect(Array.isArray(data)).toBe(true);
|
||||
});
|
||||
|
||||
test("create_item creates and returns item", async () => {
|
||||
const db = createTestDb();
|
||||
const tools = registerItemTools(db);
|
||||
const result = await tools.create_item({
|
||||
name: "Test Tent",
|
||||
categoryId: 1,
|
||||
weightGrams: 1200,
|
||||
priceCents: 35000,
|
||||
});
|
||||
const data = parseResult(result);
|
||||
expect(data.name).toBe("Test Tent");
|
||||
expect(data.weightGrams).toBe(1200);
|
||||
expect(data.priceCents).toBe(35000);
|
||||
expect(data.id).toBeDefined();
|
||||
});
|
||||
|
||||
test("get_item retrieves by ID", async () => {
|
||||
const db = createTestDb();
|
||||
const tools = registerItemTools(db);
|
||||
const created = parseResult(
|
||||
await tools.create_item({ name: "Sleeping Bag", categoryId: 1 }),
|
||||
);
|
||||
const result = await tools.get_item({ id: created.id });
|
||||
const data = parseResult(result);
|
||||
expect(data.name).toBe("Sleeping Bag");
|
||||
expect(data.id).toBe(created.id);
|
||||
});
|
||||
|
||||
test("get_item returns error for missing item", async () => {
|
||||
const db = createTestDb();
|
||||
const tools = registerItemTools(db);
|
||||
const result = await tools.get_item({ id: 999 });
|
||||
const data = parseResult(result);
|
||||
expect(data.error).toContain("not found");
|
||||
});
|
||||
|
||||
test("delete_item removes item", async () => {
|
||||
const db = createTestDb();
|
||||
const tools = registerItemTools(db);
|
||||
const created = parseResult(
|
||||
await tools.create_item({ name: "To Delete", categoryId: 1 }),
|
||||
);
|
||||
const deleteResult = await tools.delete_item({ id: created.id });
|
||||
const data = parseResult(deleteResult);
|
||||
expect(data.deleted).toBe(true);
|
||||
|
||||
// Verify it's gone
|
||||
const getResult = await tools.get_item({ id: created.id });
|
||||
const getData = parseResult(getResult);
|
||||
expect(getData.error).toContain("not found");
|
||||
});
|
||||
});
|
||||
|
||||
describe("MCP Category Tools", () => {
|
||||
test("list_categories returns array with Uncategorized", async () => {
|
||||
const db = createTestDb();
|
||||
const tools = registerCategoryTools(db);
|
||||
const result = await tools.list_categories();
|
||||
const data = parseResult(result);
|
||||
expect(Array.isArray(data)).toBe(true);
|
||||
expect(data.length).toBeGreaterThanOrEqual(1);
|
||||
expect(data.some((c: any) => c.name === "Uncategorized")).toBe(true);
|
||||
});
|
||||
|
||||
test("create_category creates a new category", async () => {
|
||||
const db = createTestDb();
|
||||
const tools = registerCategoryTools(db);
|
||||
const result = await tools.create_category({
|
||||
name: "Shelter",
|
||||
icon: "tent",
|
||||
});
|
||||
const data = parseResult(result);
|
||||
expect(data.name).toBe("Shelter");
|
||||
expect(data.icon).toBe("tent");
|
||||
});
|
||||
});
|
||||
|
||||
describe("MCP Thread Tools", () => {
|
||||
test("create_thread starts a thread with status active", async () => {
|
||||
const db = createTestDb();
|
||||
const tools = registerThreadTools(db);
|
||||
const result = await tools.create_thread({
|
||||
name: "Handlebar Bag",
|
||||
categoryId: 1,
|
||||
});
|
||||
const data = parseResult(result);
|
||||
expect(data.name).toBe("Handlebar Bag");
|
||||
expect(data.status).toBe("active");
|
||||
});
|
||||
|
||||
test("add_candidate adds to thread", async () => {
|
||||
const db = createTestDb();
|
||||
const tools = registerThreadTools(db);
|
||||
const thread = parseResult(
|
||||
await tools.create_thread({ name: "Saddle Bag", categoryId: 1 }),
|
||||
);
|
||||
const result = await tools.add_candidate({
|
||||
threadId: thread.id,
|
||||
name: "Apidura Racing",
|
||||
categoryId: 1,
|
||||
priceCents: 8500,
|
||||
pros: "Lightweight",
|
||||
cons: "Expensive",
|
||||
});
|
||||
const data = parseResult(result);
|
||||
expect(data.name).toBe("Apidura Racing");
|
||||
expect(data.threadId).toBe(thread.id);
|
||||
expect(data.pros).toBe("Lightweight");
|
||||
});
|
||||
|
||||
test("resolve_thread picks winner and creates item", async () => {
|
||||
const db = createTestDb();
|
||||
const threadTools = registerThreadTools(db);
|
||||
const itemTools = registerItemTools(db);
|
||||
|
||||
// Create thread with two candidates
|
||||
const thread = parseResult(
|
||||
await threadTools.create_thread({ name: "Frame Bag", categoryId: 1 }),
|
||||
);
|
||||
const candidate1 = parseResult(
|
||||
await threadTools.add_candidate({
|
||||
threadId: thread.id,
|
||||
name: "Revelate Tangle",
|
||||
categoryId: 1,
|
||||
priceCents: 12000,
|
||||
}),
|
||||
);
|
||||
await threadTools.add_candidate({
|
||||
threadId: thread.id,
|
||||
name: "Ortlieb Frame Pack",
|
||||
categoryId: 1,
|
||||
priceCents: 9000,
|
||||
});
|
||||
|
||||
// Resolve with first candidate
|
||||
const resolveResult = await threadTools.resolve_thread({
|
||||
threadId: thread.id,
|
||||
candidateId: candidate1.id,
|
||||
});
|
||||
const resolveData = parseResult(resolveResult);
|
||||
expect(resolveData.success).toBe(true);
|
||||
expect(resolveData.item.name).toBe("Revelate Tangle");
|
||||
|
||||
// Check item was added to collection
|
||||
const items = parseResult(await itemTools.list_items({}));
|
||||
expect(items.some((i: any) => i.name === "Revelate Tangle")).toBe(true);
|
||||
|
||||
// Check thread is now resolved
|
||||
const threadList = parseResult(
|
||||
await threadTools.list_threads({ includeResolved: true }),
|
||||
);
|
||||
const resolved = threadList.find((t: any) => t.id === thread.id);
|
||||
expect(resolved.status).toBe("resolved");
|
||||
});
|
||||
});
|
||||
|
||||
describe("MCP Setup Tools", () => {
|
||||
test("create_setup and list_setups", async () => {
|
||||
const db = createTestDb();
|
||||
const tools = registerSetupTools(db);
|
||||
await tools.create_setup({ name: "Weekend Trip" });
|
||||
const result = await tools.list_setups();
|
||||
const data = parseResult(result);
|
||||
expect(data.length).toBe(1);
|
||||
expect(data[0].name).toBe("Weekend Trip");
|
||||
});
|
||||
|
||||
test("get_setup returns setup with items", async () => {
|
||||
const db = createTestDb();
|
||||
const setupTools = registerSetupTools(db);
|
||||
const itemTools = registerItemTools(db);
|
||||
|
||||
const setup = parseResult(
|
||||
await setupTools.create_setup({ name: "Overnighter" }),
|
||||
);
|
||||
const item = parseResult(
|
||||
await itemTools.create_item({ name: "Bivvy", categoryId: 1 }),
|
||||
);
|
||||
await setupTools.update_setup({ id: setup.id, itemIds: [item.id] });
|
||||
|
||||
const result = await setupTools.get_setup({ id: setup.id });
|
||||
const data = parseResult(result);
|
||||
expect(data.name).toBe("Overnighter");
|
||||
expect(data.items.length).toBe(1);
|
||||
expect(data.items[0].name).toBe("Bivvy");
|
||||
});
|
||||
});
|
||||
|
||||
describe("MCP Collection Summary Resource", () => {
|
||||
test("returns overview with correct counts", () => {
|
||||
const db = createTestDb();
|
||||
|
||||
const summary = getCollectionSummary(db);
|
||||
expect(summary.overview).toBeDefined();
|
||||
expect(summary.overview.totalItems).toBe(0);
|
||||
expect(summary.overview.categoryCount).toBe(1); // Uncategorized
|
||||
expect(summary.itemsByCategory).toBeDefined();
|
||||
expect(summary.activeThreads).toBeDefined();
|
||||
expect(Array.isArray(summary.activeThreads)).toBe(true);
|
||||
});
|
||||
|
||||
test("reflects items and threads after creation", async () => {
|
||||
const db = createTestDb();
|
||||
const itemTools = registerItemTools(db);
|
||||
const threadTools = registerThreadTools(db);
|
||||
|
||||
await itemTools.create_item({
|
||||
name: "Tent",
|
||||
categoryId: 1,
|
||||
weightGrams: 1500,
|
||||
});
|
||||
await itemTools.create_item({
|
||||
name: "Sleeping Pad",
|
||||
categoryId: 1,
|
||||
weightGrams: 500,
|
||||
});
|
||||
await threadTools.create_thread({
|
||||
name: "Cook System",
|
||||
categoryId: 1,
|
||||
});
|
||||
|
||||
const summary = getCollectionSummary(db);
|
||||
expect(summary.overview.totalItems).toBe(2);
|
||||
expect(summary.overview.totalWeightGrams).toBe(2000);
|
||||
expect(summary.overview.activeThreadCount).toBe(1);
|
||||
expect(summary.itemsByCategory.Uncategorized).toBe(2);
|
||||
expect(summary.activeThreads.length).toBe(1);
|
||||
expect(summary.activeThreads[0].name).toBe("Cook System");
|
||||
});
|
||||
});
|
||||
86
tests/middleware/auth.test.ts
Normal file
86
tests/middleware/auth.test.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { beforeEach, describe, expect, test } from "bun:test";
|
||||
import { Hono } from "hono";
|
||||
import { requireAuth } from "../../src/server/middleware/auth";
|
||||
import {
|
||||
createApiKey,
|
||||
createSession,
|
||||
createUser,
|
||||
} from "../../src/server/services/auth.service";
|
||||
import { createTestDb } from "../helpers/db";
|
||||
|
||||
let db: ReturnType<typeof createTestDb>;
|
||||
|
||||
beforeEach(() => {
|
||||
db = createTestDb();
|
||||
});
|
||||
|
||||
function createApp() {
|
||||
const app = new Hono<{ Variables: { db?: any } }>();
|
||||
app.use("*", async (c, next) => {
|
||||
c.set("db", db);
|
||||
await next();
|
||||
});
|
||||
|
||||
// Public GET
|
||||
app.get("/items", (c) => c.json({ ok: true }));
|
||||
|
||||
// Protected POST
|
||||
app.post("/items", requireAuth, (c) => c.json({ ok: true }));
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
describe("auth middleware", () => {
|
||||
test("allows GET requests without auth (middleware not applied to GET)", async () => {
|
||||
const app = createApp();
|
||||
const res = await app.request("/items");
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
test("returns 403 setup_required when no users exist", async () => {
|
||||
const app = createApp();
|
||||
const res = await app.request("/items", { method: "POST" });
|
||||
expect(res.status).toBe(403);
|
||||
const body = await res.json();
|
||||
expect(body.error).toBe("setup_required");
|
||||
});
|
||||
|
||||
test("rejects POST without auth when users exist", async () => {
|
||||
const app = createApp();
|
||||
await createUser(db, "admin", "pass");
|
||||
const res = await app.request("/items", { method: "POST" });
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
test("allows POST with valid session cookie", async () => {
|
||||
const app = createApp();
|
||||
const user = await createUser(db, "admin", "pass");
|
||||
const session = createSession(db, user.id);
|
||||
const res = await app.request("/items", {
|
||||
method: "POST",
|
||||
headers: { Cookie: `gearbox_session=${session.id}` },
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
test("allows POST with valid API key", async () => {
|
||||
const app = createApp();
|
||||
await createUser(db, "admin", "pass");
|
||||
const key = await createApiKey(db, "test");
|
||||
const res = await app.request("/items", {
|
||||
method: "POST",
|
||||
headers: { "X-API-Key": key.rawKey },
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
test("rejects POST with invalid API key", async () => {
|
||||
const app = createApp();
|
||||
await createUser(db, "admin", "pass");
|
||||
const res = await app.request("/items", {
|
||||
method: "POST",
|
||||
headers: { "X-API-Key": "invalid" },
|
||||
});
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
128
tests/routes/auth.test.ts
Normal file
128
tests/routes/auth.test.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { beforeEach, describe, expect, it } from "bun:test";
|
||||
import { Hono } from "hono";
|
||||
import { authRoutes } from "../../src/server/routes/auth.ts";
|
||||
import { createTestDb } from "../helpers/db.ts";
|
||||
|
||||
function createTestApp() {
|
||||
const db = createTestDb();
|
||||
const app = new Hono<{ Variables: { db?: any } }>();
|
||||
|
||||
app.use("*", async (c, next) => {
|
||||
c.set("db", db);
|
||||
await next();
|
||||
});
|
||||
|
||||
app.route("/api/auth", authRoutes);
|
||||
return { app, db };
|
||||
}
|
||||
|
||||
describe("Auth Routes", () => {
|
||||
let app: Hono;
|
||||
|
||||
beforeEach(() => {
|
||||
const testApp = createTestApp();
|
||||
app = testApp.app;
|
||||
});
|
||||
|
||||
describe("GET /api/auth/me", () => {
|
||||
it("returns null user and setupRequired true when no users exist", async () => {
|
||||
const res = await app.request("/api/auth/me");
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.user).toBeNull();
|
||||
expect(body.setupRequired).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/auth/setup", () => {
|
||||
it("creates first user and returns 201", async () => {
|
||||
const res = await app.request("/api/auth/setup", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username: "admin", password: "secret123" }),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
const body = await res.json();
|
||||
expect(body.username).toBe("admin");
|
||||
|
||||
// Should set a session cookie
|
||||
const setCookie = res.headers.get("set-cookie");
|
||||
expect(setCookie).toContain("gearbox_session");
|
||||
});
|
||||
|
||||
it("rejects second setup attempt with 403", async () => {
|
||||
// First setup
|
||||
await app.request("/api/auth/setup", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username: "admin", password: "secret123" }),
|
||||
});
|
||||
|
||||
// Second attempt
|
||||
const res = await app.request("/api/auth/setup", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username: "other", password: "secret456" }),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("rejects password shorter than 6 characters", async () => {
|
||||
const res = await app.request("/api/auth/setup", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username: "admin", password: "short" }),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/auth/login", () => {
|
||||
beforeEach(async () => {
|
||||
// Create a user first
|
||||
await app.request("/api/auth/setup", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username: "admin", password: "secret123" }),
|
||||
});
|
||||
});
|
||||
|
||||
it("returns session cookie on valid login", async () => {
|
||||
const res = await app.request("/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username: "admin", password: "secret123" }),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.username).toBe("admin");
|
||||
|
||||
const setCookie = res.headers.get("set-cookie");
|
||||
expect(setCookie).toContain("gearbox_session");
|
||||
});
|
||||
|
||||
it("rejects invalid credentials with 401", async () => {
|
||||
const res = await app.request("/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username: "admin", password: "wrongpassword" }),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/auth/logout", () => {
|
||||
it("clears session cookie", async () => {
|
||||
const res = await app.request("/api/auth/logout", {
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
});
|
||||
26
tests/routes/images.test.ts
Normal file
26
tests/routes/images.test.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { Hono } from "hono";
|
||||
import { imageRoutes } from "../../src/server/routes/images";
|
||||
|
||||
const app = new Hono();
|
||||
app.route("/api/images", imageRoutes);
|
||||
|
||||
describe("POST /api/images/from-url", () => {
|
||||
test("returns 400 for missing URL", async () => {
|
||||
const res = await app.request("/api/images/from-url", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
test("returns 400 for invalid URL", async () => {
|
||||
const res = await app.request("/api/images/from-url", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ url: "not-a-url" }),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
171
tests/services/auth.service.test.ts
Normal file
171
tests/services/auth.service.test.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import { beforeEach, describe, expect, it } from "bun:test";
|
||||
import {
|
||||
changePassword,
|
||||
createApiKey,
|
||||
createSession,
|
||||
createUser,
|
||||
deleteApiKey,
|
||||
deleteSession,
|
||||
getSession,
|
||||
getUserCount,
|
||||
listApiKeys,
|
||||
verifyApiKey,
|
||||
verifyPassword,
|
||||
} from "../../src/server/services/auth.service.ts";
|
||||
import { createTestDb } from "../helpers/db.ts";
|
||||
|
||||
describe("Auth Service", () => {
|
||||
let db: ReturnType<typeof createTestDb>;
|
||||
|
||||
beforeEach(() => {
|
||||
db = createTestDb();
|
||||
});
|
||||
|
||||
describe("User Management", () => {
|
||||
it("creates a user with hashed password (hash !== plaintext)", async () => {
|
||||
const user = await createUser(db, "admin", "secret123");
|
||||
|
||||
expect(user).toBeDefined();
|
||||
expect(user.id).toBeGreaterThan(0);
|
||||
expect(user.username).toBe("admin");
|
||||
expect(user.passwordHash).not.toBe("secret123");
|
||||
expect(user.passwordHash.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("verifies correct password returns user", async () => {
|
||||
await createUser(db, "admin", "secret123");
|
||||
const user = await verifyPassword(db, "admin", "secret123");
|
||||
|
||||
expect(user).not.toBeNull();
|
||||
expect(user!.username).toBe("admin");
|
||||
});
|
||||
|
||||
it("rejects incorrect password returns null", async () => {
|
||||
await createUser(db, "admin", "secret123");
|
||||
const user = await verifyPassword(db, "admin", "wrongpassword");
|
||||
|
||||
expect(user).toBeNull();
|
||||
});
|
||||
|
||||
it("getUserCount returns 0 then 1", async () => {
|
||||
const countBefore = getUserCount(db);
|
||||
expect(countBefore).toBe(0);
|
||||
|
||||
await createUser(db, "admin", "secret123");
|
||||
|
||||
const countAfter = getUserCount(db);
|
||||
expect(countAfter).toBe(1);
|
||||
});
|
||||
|
||||
it("changes password successfully", async () => {
|
||||
await createUser(db, "admin", "oldpass");
|
||||
const changed = await changePassword(db, "admin", "oldpass", "newpass");
|
||||
expect(changed).toBe(true);
|
||||
|
||||
// Verify new password works
|
||||
const user = await verifyPassword(db, "admin", "newpass");
|
||||
expect(user).not.toBeNull();
|
||||
|
||||
// Verify old password no longer works
|
||||
const oldAttempt = await verifyPassword(db, "admin", "oldpass");
|
||||
expect(oldAttempt).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects password change with wrong current password", async () => {
|
||||
await createUser(db, "admin", "secret123");
|
||||
const changed = await changePassword(
|
||||
db,
|
||||
"admin",
|
||||
"wrongcurrent",
|
||||
"newpass",
|
||||
);
|
||||
expect(changed).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Session Management", () => {
|
||||
it("creates and retrieves a session (id length is 64 hex chars)", async () => {
|
||||
const user = await createUser(db, "admin", "secret123");
|
||||
const session = createSession(db, user.id);
|
||||
|
||||
expect(session).toBeDefined();
|
||||
expect(session.id).toHaveLength(64);
|
||||
expect(session.userId).toBe(user.id);
|
||||
expect(session.expiresAt).toBeInstanceOf(Date);
|
||||
|
||||
const retrieved = getSession(db, session.id);
|
||||
expect(retrieved).not.toBeNull();
|
||||
expect(retrieved!.id).toBe(session.id);
|
||||
});
|
||||
|
||||
it("returns null for expired session (expiryDays = -1)", async () => {
|
||||
const user = await createUser(db, "admin", "secret123");
|
||||
const session = createSession(db, user.id, -1);
|
||||
|
||||
const retrieved = getSession(db, session.id);
|
||||
expect(retrieved).toBeNull();
|
||||
});
|
||||
|
||||
it("deletes a session", async () => {
|
||||
const user = await createUser(db, "admin", "secret123");
|
||||
const session = createSession(db, user.id);
|
||||
|
||||
deleteSession(db, session.id);
|
||||
|
||||
const retrieved = getSession(db, session.id);
|
||||
expect(retrieved).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("API Key Management", () => {
|
||||
it("creates key and returns raw key once (length > 16, prefix matches first 8 chars)", async () => {
|
||||
const result = await createApiKey(db, "test-key");
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.rawKey).toBeDefined();
|
||||
expect(result.rawKey.length).toBeGreaterThan(16);
|
||||
expect(result.keyPrefix).toBe(result.rawKey.slice(0, 8));
|
||||
expect(result.name).toBe("test-key");
|
||||
});
|
||||
|
||||
it("verifies valid key returns true", async () => {
|
||||
const result = await createApiKey(db, "test-key");
|
||||
const isValid = await verifyApiKey(db, result.rawKey);
|
||||
|
||||
expect(isValid).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects invalid key returns false", async () => {
|
||||
await createApiKey(db, "test-key");
|
||||
const isValid = await verifyApiKey(db, "invalidkey12345678");
|
||||
|
||||
expect(isValid).toBe(false);
|
||||
});
|
||||
|
||||
it("deletes key so it is no longer valid", async () => {
|
||||
const result = await createApiKey(db, "test-key");
|
||||
deleteApiKey(db, result.id);
|
||||
|
||||
const isValid = await verifyApiKey(db, result.rawKey);
|
||||
expect(isValid).toBe(false);
|
||||
});
|
||||
|
||||
it("listApiKeys returns keys without hashes", async () => {
|
||||
await createApiKey(db, "key-one");
|
||||
await createApiKey(db, "key-two");
|
||||
|
||||
const keys = listApiKeys(db);
|
||||
expect(keys).toHaveLength(2);
|
||||
expect(keys[0].name).toBe("key-one");
|
||||
expect(keys[1].name).toBe("key-two");
|
||||
// Ensure no hash is exposed
|
||||
for (const key of keys) {
|
||||
expect(key).toHaveProperty("id");
|
||||
expect(key).toHaveProperty("name");
|
||||
expect(key).toHaveProperty("keyPrefix");
|
||||
expect(key).toHaveProperty("createdAt");
|
||||
expect(key).not.toHaveProperty("keyHash");
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
89
tests/services/image.service.test.ts
Normal file
89
tests/services/image.service.test.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "bun:test";
|
||||
import { existsSync, rmSync } from "node:fs";
|
||||
import type { Server } from "bun";
|
||||
import { fetchImageFromUrl } from "../../src/server/services/image.service.ts";
|
||||
|
||||
const TEST_UPLOADS_DIR = "test-uploads";
|
||||
|
||||
// 1x1 transparent PNG (smallest valid PNG)
|
||||
const TINY_PNG = new Uint8Array([
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49,
|
||||
0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06,
|
||||
0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00, 0x0a, 0x49, 0x44,
|
||||
0x41, 0x54, 0x78, 0x9c, 0x62, 0x00, 0x00, 0x00, 0x02, 0x00, 0x01, 0xe2, 0x21,
|
||||
0xbc, 0x33, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60,
|
||||
0x82,
|
||||
]);
|
||||
|
||||
let server: Server;
|
||||
let baseUrl: string;
|
||||
|
||||
beforeAll(() => {
|
||||
server = Bun.serve({
|
||||
port: 0,
|
||||
fetch(req) {
|
||||
const url = new URL(req.url);
|
||||
if (url.pathname === "/image.png") {
|
||||
return new Response(TINY_PNG, {
|
||||
headers: { "Content-Type": "image/png" },
|
||||
});
|
||||
}
|
||||
if (url.pathname === "/page.html") {
|
||||
return new Response("<html></html>", {
|
||||
headers: { "Content-Type": "text/html" },
|
||||
});
|
||||
}
|
||||
return new Response("Not found", { status: 404 });
|
||||
},
|
||||
});
|
||||
baseUrl = `http://localhost:${server.port}`;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
server.stop(true);
|
||||
});
|
||||
|
||||
describe("Image Service", () => {
|
||||
afterEach(() => {
|
||||
if (existsSync(TEST_UPLOADS_DIR)) {
|
||||
rmSync(TEST_UPLOADS_DIR, { recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("fetchImageFromUrl", () => {
|
||||
it("fetches a valid image URL and saves to disk", async () => {
|
||||
const imageUrl = `${baseUrl}/image.png`;
|
||||
const result = await fetchImageFromUrl(imageUrl, TEST_UPLOADS_DIR);
|
||||
|
||||
expect(result.filename).toMatch(/^\d+-[\w-]+\.png$/);
|
||||
expect(result.sourceUrl).toBe(imageUrl);
|
||||
|
||||
const filePath = `${TEST_UPLOADS_DIR}/${result.filename}`;
|
||||
expect(existsSync(filePath)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects non-image content type", async () => {
|
||||
await expect(
|
||||
fetchImageFromUrl(`${baseUrl}/page.html`, TEST_UPLOADS_DIR),
|
||||
).rejects.toThrow("Invalid content type");
|
||||
});
|
||||
|
||||
it("rejects invalid URL format", async () => {
|
||||
await expect(
|
||||
fetchImageFromUrl("not-a-url", TEST_UPLOADS_DIR),
|
||||
).rejects.toThrow("Invalid URL format");
|
||||
});
|
||||
|
||||
it("rejects non-HTTP protocols", async () => {
|
||||
await expect(
|
||||
fetchImageFromUrl("ftp://example.com/image.png", TEST_UPLOADS_DIR),
|
||||
).rejects.toThrow("URL must use HTTP or HTTPS");
|
||||
});
|
||||
|
||||
it("rejects 404 responses", async () => {
|
||||
await expect(
|
||||
fetchImageFromUrl(`${baseUrl}/missing.jpg`, TEST_UPLOADS_DIR),
|
||||
).rejects.toThrow("HTTP 404");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -434,7 +434,10 @@ describe("Thread Service", () => {
|
||||
});
|
||||
|
||||
it("returns { success: false, error } when thread status is 'resolved'", () => {
|
||||
const thread = createThread(db, { name: "Resolved Thread", categoryId: 1 });
|
||||
const thread = createThread(db, {
|
||||
name: "Resolved Thread",
|
||||
categoryId: 1,
|
||||
});
|
||||
const candidate = createCandidate(db, thread.id, {
|
||||
name: "Winner",
|
||||
categoryId: 1,
|
||||
|
||||
Reference in New Issue
Block a user