Compare commits
82 Commits
50672cb662
...
v1.3.2
| Author | SHA1 | Date | |
|---|---|---|---|
| bb60168ffb | |||
| 68f6647f76 | |||
| 0a40d7627f | |||
| 3eccbb12fd | |||
| fb925a9dce | |||
| 70e7cd2f0f | |||
| 33f735af67 | |||
| f8a1a00e0a | |||
| 27c36b6b9a | |||
| 684cfd3789 | |||
| 52751ae9d4 | |||
| 3fc737c872 | |||
| b993a0a831 | |||
| a8696c2a85 | |||
| 15f146ee89 | |||
| 8c1fe47a99 | |||
| b9a06dd244 | |||
| 818db73432 | |||
| 1a5e6a303e | |||
| 923a0f66b0 | |||
| 1b492f2ac2 | |||
| 70466a9a1c | |||
| 5e0771d929 | |||
| 70211bdc57 | |||
| 35989f8120 | |||
| b974675b11 | |||
| c4ce96ce4f | |||
| 60db8bd9de | |||
| ecbfbc00e9 | |||
| f7ce380104 | |||
| 0d7c4f476a | |||
| 86a4a747b5 | |||
| e9d33e59e9 | |||
| 5308991123 | |||
| a6e7035aab | |||
| 0eaf401cce | |||
| a3061b22ca | |||
| 1dff6abb3b | |||
| 2dddba9a08 | |||
| 41a2910aeb | |||
| ecff58500e | |||
| 3016eb1a1a | |||
| 4f434f39bf | |||
| 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 |
@@ -2,9 +2,7 @@ name: CI
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [Develop]
|
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [Develop]
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
ci:
|
ci:
|
||||||
@@ -26,3 +24,33 @@ jobs:
|
|||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: bun run build
|
run: bun run build
|
||||||
|
|
||||||
|
e2e:
|
||||||
|
needs: ci
|
||||||
|
runs-on: docker
|
||||||
|
container:
|
||||||
|
image: mcr.microsoft.com/playwright:v1.59.1-noble
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install Bun
|
||||||
|
run: |
|
||||||
|
apt-get update && apt-get install -y unzip
|
||||||
|
curl -fsSL https://bun.sh/install | bash
|
||||||
|
echo "$HOME/.bun/bin" >> $GITHUB_PATH
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
export PATH="$HOME/.bun/bin:$PATH"
|
||||||
|
bun install --frozen-lockfile --ignore-scripts
|
||||||
|
|
||||||
|
- name: Build client
|
||||||
|
run: |
|
||||||
|
export PATH="$HOME/.bun/bin:$PATH"
|
||||||
|
bun run build
|
||||||
|
|
||||||
|
- name: Run E2E tests
|
||||||
|
run: |
|
||||||
|
export PATH="$HOME/.bun/bin:$PATH"
|
||||||
|
CI=true bun run test:e2e
|
||||||
|
|||||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -223,6 +223,14 @@ dist/
|
|||||||
uploads/*
|
uploads/*
|
||||||
!uploads/.gitkeep
|
!uploads/.gitkeep
|
||||||
|
|
||||||
|
# Worktrees
|
||||||
|
.worktrees/
|
||||||
|
|
||||||
|
# Playwright
|
||||||
|
e2e/test.db
|
||||||
|
test-results/
|
||||||
|
playwright-report/
|
||||||
|
|
||||||
# Claude Code
|
# Claude Code
|
||||||
.claude/
|
.claude/
|
||||||
|
|
||||||
|
|||||||
@@ -80,13 +80,13 @@ Replaces spreadsheet-based gear tracking workflow.
|
|||||||
- **Runtime**: Bun — used as package manager and runtime
|
- **Runtime**: Bun — used as package manager and runtime
|
||||||
- **Design**: Light, airy, minimalist — white/light backgrounds, lots of whitespace, no visual clutter
|
- **Design**: Light, airy, minimalist — white/light backgrounds, lots of whitespace, no visual clutter
|
||||||
- **Navigation**: Dashboard-based home page, not sidebar or top-nav tabs
|
- **Navigation**: Dashboard-based home page, not sidebar or top-nav tabs
|
||||||
- **Scope**: No auth, single user for v1
|
- **Scope**: Single user with cookie/API key auth
|
||||||
|
|
||||||
## Key Decisions
|
## Key Decisions
|
||||||
|
|
||||||
| Decision | Rationale | Outcome |
|
| Decision | Rationale | Outcome |
|
||||||
|----------|-----------|---------|
|
|----------|-----------|---------|
|
||||||
| No auth for v1 | Single user, simplicity first | ✓ Good |
|
| Cookie/API key auth | Single user, public read + authenticated write | ✓ Good |
|
||||||
| Generic data model | Support any hobby, not just bikepacking | ✓ Good |
|
| Generic data model | Support any hobby, not just bikepacking | ✓ Good |
|
||||||
| Dashboard navigation | Clean entry point, not persistent nav | ✓ Good |
|
| Dashboard navigation | Clean entry point, not persistent nav | ✓ Good |
|
||||||
| Bun runtime | User preference | ✓ Good |
|
| Bun runtime | User preference | ✓ Good |
|
||||||
|
|||||||
@@ -9,10 +9,10 @@ Requirements for this milestone. Each maps to roadmap phases.
|
|||||||
|
|
||||||
### Comparison View
|
### Comparison View
|
||||||
|
|
||||||
- [ ] **COMP-01**: User can view candidates side-by-side in a tabular comparison layout (weight, price, images, notes, links, status)
|
- [x] **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
|
- [x] **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
|
- [x] **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-04**: Comparison view displays read-only summary for resolved threads
|
||||||
|
|
||||||
### Candidate Ranking
|
### Candidate Ranking
|
||||||
|
|
||||||
@@ -68,10 +68,10 @@ Which phases cover which requirements. Updated during roadmap creation.
|
|||||||
|
|
||||||
| Requirement | Phase | Status |
|
| Requirement | Phase | Status |
|
||||||
|-------------|-------|--------|
|
|-------------|-------|--------|
|
||||||
| COMP-01 | Phase 12 | Pending |
|
| COMP-01 | Phase 12 | Complete |
|
||||||
| COMP-02 | Phase 12 | Pending |
|
| COMP-02 | Phase 12 | Complete |
|
||||||
| COMP-03 | Phase 12 | Pending |
|
| COMP-03 | Phase 12 | Complete |
|
||||||
| COMP-04 | Phase 12 | Pending |
|
| COMP-04 | Phase 12 | Complete |
|
||||||
| RANK-01 | Phase 11 | Complete |
|
| RANK-01 | Phase 11 | Complete |
|
||||||
| RANK-02 | Phase 11 | Complete |
|
| RANK-02 | Phase 11 | Complete |
|
||||||
| RANK-03 | Phase 10 | 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 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)
|
- [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 13: Setup Impact Preview** — Per-candidate weight and cost delta against a selected setup
|
||||||
|
|
||||||
## Phase Details
|
## 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
|
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
|
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
|
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
|
### 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
|
**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
|
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"
|
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
|
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
|
## Progress
|
||||||
|
|
||||||
@@ -111,5 +116,5 @@ Plans:
|
|||||||
| 9. Weight Classification and Visualization | v1.2 | 2/2 | Complete | 2026-03-16 |
|
| 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 |
|
| 10. Schema Foundation + Pros/Cons Fields | v1.3 | 1/1 | Complete | 2026-03-16 |
|
||||||
| 11. Candidate Ranking | 2/2 | Complete | 2026-03-16 | - |
|
| 11. Candidate Ranking | 2/2 | Complete | 2026-03-16 | - |
|
||||||
| 12. Comparison View | v1.3 | 0/TBD | Not started | - |
|
| 12. Comparison View | 1/1 | Complete | 2026-03-17 | - |
|
||||||
| 13. Setup Impact Preview | v1.3 | 0/TBD | Not started | - |
|
| 13. Setup Impact Preview | v1.3 | 0/2 | Not started | - |
|
||||||
|
|||||||
@@ -3,14 +3,14 @@ gsd_state_version: 1.0
|
|||||||
milestone: v1.3
|
milestone: v1.3
|
||||||
milestone_name: Research & Decision Tools
|
milestone_name: Research & Decision Tools
|
||||||
status: planning
|
status: planning
|
||||||
stopped_at: Completed 11-candidate-ranking/11-02-PLAN.md
|
stopped_at: Completed 12-comparison-view/12-01-PLAN.md
|
||||||
last_updated: "2026-03-16T21:39:11.967Z"
|
last_updated: "2026-03-17T14:35:39.075Z"
|
||||||
last_activity: 2026-03-16 — Roadmap created for v1.3 milestone
|
last_activity: 2026-03-16 — Roadmap created for v1.3 milestone
|
||||||
progress:
|
progress:
|
||||||
total_phases: 4
|
total_phases: 4
|
||||||
completed_phases: 2
|
completed_phases: 3
|
||||||
total_plans: 3
|
total_plans: 4
|
||||||
completed_plans: 3
|
completed_plans: 4
|
||||||
percent: 0
|
percent: 0
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -53,6 +53,7 @@ Progress: [░░░░░░░░░░] 0%
|
|||||||
| Phase 10-schema-foundation-pros-cons-fields P01 | 6min | 2 tasks | 9 files |
|
| 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 P01 | 4min | 2 tasks | 8 files |
|
||||||
| Phase 11-candidate-ranking P02 | 4min | 3 tasks | 7 files |
|
| Phase 11-candidate-ranking P02 | 4min | 3 tasks | 7 files |
|
||||||
|
| Phase 12-comparison-view P01 | 2min | 2 tasks | 3 files |
|
||||||
|
|
||||||
## Accumulated Context
|
## 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]: 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]: 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 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
|
### Pending Todos
|
||||||
|
|
||||||
@@ -83,6 +87,6 @@ None active.
|
|||||||
|
|
||||||
## Session Continuity
|
## Session Continuity
|
||||||
|
|
||||||
Last session: 2026-03-16T21:30:15.459Z
|
Last session: 2026-03-17T14:32:04.702Z
|
||||||
Stopped at: Completed 11-candidate-ranking/11-02-PLAN.md
|
Stopped at: Completed 12-comparison-view/12-01-PLAN.md
|
||||||
Resume file: None
|
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
|
||||||
98
CLAUDE.md
98
CLAUDE.md
@@ -9,7 +9,8 @@ GearBox is a single-user web app for managing gear collections (bikepacking, sim
|
|||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
```bash
|
```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:client # Vite dev server on :5173 (proxies /api to :3000)
|
||||||
bun run dev:server # Hono server on :3000 with hot reload
|
bun run dev:server # Hono server on :3000 with hot reload
|
||||||
|
|
||||||
@@ -18,8 +19,10 @@ bun run db:generate # Generate Drizzle migration from schema changes
|
|||||||
bun run db:push # Apply migrations to gearbox.db
|
bun run db:push # Apply migrations to gearbox.db
|
||||||
|
|
||||||
# Testing
|
# Testing
|
||||||
bun test # Run all tests
|
bun test # Run all unit/integration tests
|
||||||
bun test tests/services/item.service.test.ts # Run single test file
|
bun test tests/services/item.service.test.ts # Run single test file
|
||||||
|
bun run test:e2e # Run Playwright E2E tests
|
||||||
|
bun run test:e2e:ui # Playwright UI mode for debugging
|
||||||
|
|
||||||
# Lint & Format
|
# Lint & Format
|
||||||
bun run lint # Biome check (tabs, double quotes, organized imports)
|
bun run lint # Biome check (tabs, double quotes, organized imports)
|
||||||
@@ -52,11 +55,18 @@ bun run build # Vite build → dist/client/
|
|||||||
- **Schema**: `schema.ts` — Drizzle table definitions for SQLite.
|
- **Schema**: `schema.ts` — Drizzle table definitions for SQLite.
|
||||||
- **Prices stored as cents** (`priceCents: integer`) to avoid float rounding.
|
- **Prices stored as cents** (`priceCents: integer`) to avoid float rounding.
|
||||||
- **Timestamps**: stored as integers (unix epoch) with `{ mode: "timestamp" }`.
|
- **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/`)
|
### Testing (`tests/` and `e2e/`)
|
||||||
- Bun test runner. Tests at service level and route level.
|
- **Unit/integration**: Bun test runner (`bun test`). Tests at service level and route level.
|
||||||
- `tests/helpers/db.ts`: `createTestDb()` creates in-memory SQLite with full schema and seeds an "Uncategorized" category. When adding schema columns, update both `src/db/schema.ts` and the test helper's CREATE TABLE statements.
|
- `tests/helpers/db.ts`: `createTestDb()` creates in-memory SQLite via Drizzle migrations and seeds an "Uncategorized" category.
|
||||||
|
- **E2E**: Playwright (`bun run test:e2e`). Tests in `e2e/` run against a seeded SQLite database with the server in production mode. Seed script: `e2e/seed.ts`.
|
||||||
|
|
||||||
|
## Branching
|
||||||
|
|
||||||
|
- **Develop** is the main branch. Keep it clean — don't commit large feature work directly.
|
||||||
|
- For each new brainstorming/implementation session, create a feature branch off Develop (e.g., `feature/setup-impact-preview`, `fix/error-handling`).
|
||||||
|
- Merge back to Develop via PR or fast-forward merge when the work is complete and verified.
|
||||||
|
|
||||||
## Path Alias
|
## Path Alias
|
||||||
|
|
||||||
@@ -67,4 +77,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".
|
- **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).
|
- **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 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.
|
- **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
|
- Research threads for comparing candidates before buying
|
||||||
- Image uploads for items and candidates
|
- Image uploads for items and candidates
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start (Docker)
|
||||||
|
|
||||||
### Docker Compose (recommended)
|
### Docker Compose (recommended)
|
||||||
|
|
||||||
@@ -81,3 +81,42 @@ docker compose up -d
|
|||||||
```
|
```
|
||||||
|
|
||||||
Database migrations run automatically on startup.
|
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": {
|
"files": {
|
||||||
"ignoreUnknown": false,
|
"ignoreUnknown": false,
|
||||||
"includes": ["**", "!src/client/routeTree.gen.ts"]
|
"includes": ["**", "!src/client/routeTree.gen.ts", "!drizzle", "!.planning"]
|
||||||
},
|
},
|
||||||
"formatter": {
|
"formatter": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
|||||||
232
bun.lock
232
bun.lock
@@ -6,11 +6,13 @@
|
|||||||
"name": "gearbox",
|
"name": "gearbox",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hono/zod-validator": "^0.7.6",
|
"@hono/zod-validator": "^0.7.6",
|
||||||
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||||
"@tailwindcss/vite": "^4.2.1",
|
"@tailwindcss/vite": "^4.2.1",
|
||||||
"@tanstack/react-query": "^5.90.21",
|
"@tanstack/react-query": "^5.90.21",
|
||||||
"@tanstack/react-router": "^1.167.0",
|
"@tanstack/react-router": "^1.167.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"drizzle-orm": "^0.45.1",
|
"drizzle-orm": "^0.45.1",
|
||||||
|
"framer-motion": "^12.38.0",
|
||||||
"hono": "^4.12.8",
|
"hono": "^4.12.8",
|
||||||
"lucide-react": "^0.577.0",
|
"lucide-react": "^0.577.0",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
@@ -22,6 +24,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^2.4.7",
|
"@biomejs/biome": "^2.4.7",
|
||||||
|
"@playwright/test": "^1.59.1",
|
||||||
"@tanstack/react-query-devtools": "^5.91.3",
|
"@tanstack/react-query-devtools": "^5.91.3",
|
||||||
"@tanstack/react-router-devtools": "^1.166.7",
|
"@tanstack/react-router-devtools": "^1.166.7",
|
||||||
"@tanstack/router-plugin": "^1.166.9",
|
"@tanstack/router-plugin": "^1.166.9",
|
||||||
@@ -31,6 +34,7 @@
|
|||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^6.0.1",
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
"better-sqlite3": "^12.8.0",
|
"better-sqlite3": "^12.8.0",
|
||||||
|
"concurrently": "^9.1.2",
|
||||||
"drizzle-kit": "^0.31.9",
|
"drizzle-kit": "^0.31.9",
|
||||||
"vite": "^8.0.0",
|
"vite": "^8.0.0",
|
||||||
},
|
},
|
||||||
@@ -160,6 +164,8 @@
|
|||||||
|
|
||||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
|
"@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=="],
|
"@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=="],
|
"@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,12 +178,16 @@
|
|||||||
|
|
||||||
"@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=="],
|
"@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=="],
|
"@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=="],
|
"@oxc-project/runtime": ["@oxc-project/runtime@0.115.0", "", {}, "sha512-Rg8Wlt5dCbXhQnsXPrkOjL1DTSvXLgb2R/KYfnf1/K+R0k6UMLEmbQXPM+kwrWqSmWA2t0B1EtHy2/3zikQpvQ=="],
|
||||||
|
|
||||||
"@oxc-project/types": ["@oxc-project/types@0.115.0", "", {}, "sha512-4n91DKnebUS4yjUHl2g3/b2T+IUdCfmoZGhmwsovZCDaJSs+QkVAM+0AqqTxHSsHfeiMuueT75cZaZcT/m0pSw=="],
|
"@oxc-project/types": ["@oxc-project/types@0.115.0", "", {}, "sha512-4n91DKnebUS4yjUHl2g3/b2T+IUdCfmoZGhmwsovZCDaJSs+QkVAM+0AqqTxHSsHfeiMuueT75cZaZcT/m0pSw=="],
|
||||||
|
|
||||||
|
"@playwright/test": ["@playwright/test@1.59.1", "", { "dependencies": { "playwright": "1.59.1" }, "bin": { "playwright": "cli.js" } }, "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg=="],
|
||||||
|
|
||||||
"@reduxjs/toolkit": ["@reduxjs/toolkit@2.11.2", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@standard-schema/utils": "^0.3.0", "immer": "^11.0.0", "redux": "^5.0.1", "redux-thunk": "^3.1.0", "reselect": "^5.1.0" }, "peerDependencies": { "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" }, "optionalPeers": ["react", "react-redux"] }, "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ=="],
|
"@reduxjs/toolkit": ["@reduxjs/toolkit@2.11.2", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@standard-schema/utils": "^0.3.0", "immer": "^11.0.0", "redux": "^5.0.1", "redux-thunk": "^3.1.0", "reselect": "^5.1.0" }, "peerDependencies": { "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" }, "optionalPeers": ["react", "react-redux"] }, "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ=="],
|
||||||
|
|
||||||
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.9", "", { "os": "android", "cpu": "arm64" }, "sha512-lcJL0bN5hpgJfSIz/8PIf02irmyL43P+j1pTCfbD1DbLkmGRuFIA4DD3B3ZOvGqG0XiVvRznbKtN0COQVaKUTg=="],
|
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.9", "", { "os": "android", "cpu": "arm64" }, "sha512-lcJL0bN5hpgJfSIz/8PIf02irmyL43P+j1pTCfbD1DbLkmGRuFIA4DD3B3ZOvGqG0XiVvRznbKtN0COQVaKUTg=="],
|
||||||
@@ -310,8 +320,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=="],
|
"@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=="],
|
"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=="],
|
"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=="],
|
"anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="],
|
||||||
@@ -332,6 +352,8 @@
|
|||||||
|
|
||||||
"bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="],
|
"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=="],
|
"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=="],
|
"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 +364,46 @@
|
|||||||
|
|
||||||
"bun-types": ["bun-types@1.3.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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-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=="],
|
"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=="],
|
"d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="],
|
||||||
@@ -386,6 +436,8 @@
|
|||||||
|
|
||||||
"deep-extend": ["deep-extend@0.6.0", "", {}, "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA=="],
|
"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=="],
|
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||||
|
|
||||||
"diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="],
|
"diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="],
|
||||||
@@ -394,12 +446,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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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 +474,56 @@
|
|||||||
|
|
||||||
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"github-from-package": ["github-from-package@0.0.0", "", {}, "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw=="],
|
||||||
@@ -434,10 +532,22 @@
|
|||||||
|
|
||||||
"goober": ["goober@2.1.18", "", { "peerDependencies": { "csstype": "^3.0.10" } }, "sha512-2vFqsaDVIT9Gz7N6kAL++pLpp41l3PfDuusHcjnGLfR6+huZkl6ziX+zgVC3ZxpqWhzH6pyDdGrCeDhMIvwaxw=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
|
||||||
|
|
||||||
"immer": ["immer@10.2.0", "", {}, "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw=="],
|
"immer": ["immer@10.2.0", "", {}, "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw=="],
|
||||||
@@ -448,22 +558,38 @@
|
|||||||
|
|
||||||
"internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="],
|
"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-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-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-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-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=="],
|
"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=="],
|
"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=="],
|
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
||||||
|
|
||||||
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
|
"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=="],
|
"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=="],
|
"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 +622,82 @@
|
|||||||
|
|
||||||
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
"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=="],
|
"mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="],
|
||||||
|
|
||||||
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
|
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
|
||||||
|
|
||||||
"mkdirp-classic": ["mkdirp-classic@0.5.3", "", {}, "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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-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=="],
|
"node-releases": ["node-releases@2.0.36", "", {}, "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA=="],
|
||||||
|
|
||||||
"normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],
|
"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=="],
|
"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=="],
|
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
|
||||||
|
|
||||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||||
|
|
||||||
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
|
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
|
||||||
|
|
||||||
|
"pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="],
|
||||||
|
|
||||||
|
"playwright": ["playwright@1.59.1", "", { "dependencies": { "playwright-core": "1.59.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw=="],
|
||||||
|
|
||||||
|
"playwright-core": ["playwright-core@1.59.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg=="],
|
||||||
|
|
||||||
"postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="],
|
||||||
@@ -552,22 +720,52 @@
|
|||||||
|
|
||||||
"redux-thunk": ["redux-thunk@3.1.0", "", { "peerDependencies": { "redux": "^5.0.0" } }, "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw=="],
|
"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=="],
|
"reselect": ["reselect@5.1.1", "", {}, "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w=="],
|
||||||
|
|
||||||
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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": ["seroval@1.5.1", "", {}, "sha512-OwrZRZAfhHww0WEnKHDY8OM0U/Qs8OTfIDWhUD4BLpNJUfXK4cGmjiagGze086m+mhI+V2nD0gfbHEnJjb9STA=="],
|
||||||
|
|
||||||
"seroval-plugins": ["seroval-plugins@1.5.1", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-4FbuZ/TMl02sqv0RTFexu0SP6V+ywaIe5bAWCCEik0fk17BhALgwvUDVF7e3Uvf9pxmwCEJsRPmlkUE6HdzLAw=="],
|
"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-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=="],
|
"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 +776,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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"tailwindcss": ["tailwindcss@4.2.1", "", {}, "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw=="],
|
||||||
|
|
||||||
"tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],
|
"tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],
|
||||||
@@ -598,16 +804,24 @@
|
|||||||
|
|
||||||
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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 +830,32 @@
|
|||||||
|
|
||||||
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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": ["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=="],
|
"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=="],
|
"@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,8 +882,12 @@
|
|||||||
|
|
||||||
"anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
"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=="],
|
"node-abi/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
|
||||||
|
|
||||||
|
"playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
|
||||||
|
|
||||||
"readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
"readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
||||||
|
|
||||||
"recast/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
|
"recast/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
|
||||||
|
|||||||
2
bunfig.toml
Normal file
2
bunfig.toml
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
[test]
|
||||||
|
root = "tests/"
|
||||||
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
661
docs/superpowers/plans/2026-04-03-codebase-improvements.md
Normal file
661
docs/superpowers/plans/2026-04-03-codebase-improvements.md
Normal file
@@ -0,0 +1,661 @@
|
|||||||
|
# Codebase Improvements 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:** Harden the server (explicit DB context, param validation, error handling, rate limiting), add client error boundaries, split the oversized collection route into focused components, and fix stale docs.
|
||||||
|
|
||||||
|
**Architecture:** Server changes are middleware-level (DB context, error handler, rate limiter) plus a small utility for param parsing. Client changes are a TanStack Router error boundary on the root route and extracting three tab components from the 634-line collection route. Docs change is a one-line fix in PROJECT.md.
|
||||||
|
|
||||||
|
**Tech Stack:** Hono middleware, TanStack Router errorComponent, React, TypeScript
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Explicit DB Context Middleware
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/server/index.ts:1-59`
|
||||||
|
- Modify: `src/server/routes/settings.ts:3,12` (remove prodDb fallback)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add DB import and middleware to server index**
|
||||||
|
|
||||||
|
In `src/server/index.ts`, add the import for the production database at the top, alongside existing imports:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { db as prodDb } from "../db/index.ts";
|
||||||
|
```
|
||||||
|
|
||||||
|
Then add a middleware **before** the auth middleware (before line 26) that sets the DB on every API request:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// Inject production database into request context
|
||||||
|
app.use("/api/*", async (c, next) => {
|
||||||
|
c.set("db", prodDb);
|
||||||
|
return next();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Fix auth middleware comment**
|
||||||
|
|
||||||
|
In the same file, update the comment on the auth middleware from:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// Auth middleware for write operations (POST/PUT/DELETE) on non-auth routes
|
||||||
|
```
|
||||||
|
|
||||||
|
to:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// Auth middleware for write operations (POST/PUT/PATCH/DELETE) on non-auth routes
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Remove prodDb fallback from settings route**
|
||||||
|
|
||||||
|
In `src/server/routes/settings.ts`, remove the `prodDb` import and fallback. Change:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { db as prodDb } from "../../db/index.ts";
|
||||||
|
```
|
||||||
|
|
||||||
|
Remove this import entirely.
|
||||||
|
|
||||||
|
Change both occurrences of:
|
||||||
|
```ts
|
||||||
|
const database = c.get("db") ?? prodDb;
|
||||||
|
```
|
||||||
|
to:
|
||||||
|
```ts
|
||||||
|
const database = c.get("db");
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests**
|
||||||
|
|
||||||
|
Run: `bun test`
|
||||||
|
Expected: All 183 tests pass. Tests already set `c.set("db", testDb)` so this change doesn't affect them.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run lint**
|
||||||
|
|
||||||
|
Run: `bun run lint`
|
||||||
|
Expected: No errors.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/server/index.ts src/server/routes/settings.ts
|
||||||
|
git commit -m "fix: add explicit DB context middleware for all API routes"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Route Parameter Validation
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/server/lib/params.ts`
|
||||||
|
- Modify: `src/server/routes/items.ts`
|
||||||
|
- Modify: `src/server/routes/categories.ts`
|
||||||
|
- Modify: `src/server/routes/threads.ts`
|
||||||
|
- Modify: `src/server/routes/setups.ts`
|
||||||
|
- Modify: `src/server/routes/auth.ts:187-189`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create parseId helper**
|
||||||
|
|
||||||
|
Create `src/server/lib/params.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
/**
|
||||||
|
* Parse a route parameter as a positive integer ID.
|
||||||
|
* Returns the number if valid, or null if the string is not a positive integer.
|
||||||
|
*/
|
||||||
|
export function parseId(raw: string): number | null {
|
||||||
|
const id = Number(raw);
|
||||||
|
if (!Number.isInteger(id) || id <= 0) return null;
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update items routes**
|
||||||
|
|
||||||
|
In `src/server/routes/items.ts`, add the import:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { parseId } from "../lib/params.ts";
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace all `Number(c.req.param("id"))` patterns. For each route that uses an ID param, add validation. Example for `GET /:id`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
app.get("/:id", (c) => {
|
||||||
|
const db = c.get("db");
|
||||||
|
const id = parseId(c.req.param("id"));
|
||||||
|
if (!id) return c.json({ error: "Invalid item ID" }, 400);
|
||||||
|
const item = getItemById(db, id);
|
||||||
|
if (!item) return c.json({ error: "Item not found" }, 404);
|
||||||
|
return c.json(item);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Apply the same pattern to `PUT /:id` and `DELETE /:id`. In each case, add `const id = parseId(...)` + the null check returning 400 right after.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Update categories routes**
|
||||||
|
|
||||||
|
In `src/server/routes/categories.ts`, add the import:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { parseId } from "../lib/params.ts";
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace `Number(c.req.param("id"))` with `parseId(c.req.param("id"))` in `PUT /:id` and `DELETE /:id`, adding the null check:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const id = parseId(c.req.param("id"));
|
||||||
|
if (!id) return c.json({ error: "Invalid category ID" }, 400);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Update threads routes**
|
||||||
|
|
||||||
|
In `src/server/routes/threads.ts`, add the import:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { parseId } from "../lib/params.ts";
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace all `Number(c.req.param(...))` calls. There are 8 occurrences across these handlers:
|
||||||
|
- `GET /:id` — `const id = parseId(c.req.param("id"))`
|
||||||
|
- `PUT /:id` — same
|
||||||
|
- `DELETE /:id` — same
|
||||||
|
- `POST /:id/candidates` — `const threadId = parseId(c.req.param("id"))`
|
||||||
|
- `PUT /:threadId/candidates/:candidateId` — `const candidateId = parseId(c.req.param("candidateId"))`
|
||||||
|
- `DELETE /:threadId/candidates/:candidateId` — same
|
||||||
|
- `PATCH /:id/candidates/reorder` — `const threadId = parseId(c.req.param("id"))`
|
||||||
|
- `POST /:id/resolve` — `const threadId = parseId(c.req.param("id"))`
|
||||||
|
|
||||||
|
For each, add the null check returning 400 with a descriptive message like `"Invalid thread ID"` or `"Invalid candidate ID"`.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Update setups routes**
|
||||||
|
|
||||||
|
In `src/server/routes/setups.ts`, add the import:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { parseId } from "../lib/params.ts";
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace all `Number(c.req.param(...))` calls. There are 6 occurrences:
|
||||||
|
- `GET /:id` — `const id = parseId(c.req.param("id"))`
|
||||||
|
- `PUT /:id` — same
|
||||||
|
- `DELETE /:id` — same
|
||||||
|
- `PUT /:id/items` — same
|
||||||
|
- `PATCH /:id/items/:itemId/classification` — both `setupId` and `itemId`
|
||||||
|
- `DELETE /:id/items/:itemId` — both `setupId` and `itemId`
|
||||||
|
|
||||||
|
For the classification and item removal routes with two params:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const setupId = parseId(c.req.param("id"));
|
||||||
|
const itemId = parseId(c.req.param("itemId"));
|
||||||
|
if (!setupId || !itemId) return c.json({ error: "Invalid ID" }, 400);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Update auth routes**
|
||||||
|
|
||||||
|
In `src/server/routes/auth.ts`, add the import:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { parseId } from "../lib/params.ts";
|
||||||
|
```
|
||||||
|
|
||||||
|
Update `DELETE /keys/:id` (line 187-189):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
app.delete("/keys/:id", requireAuth, (c) => {
|
||||||
|
const db = c.get("db");
|
||||||
|
const id = parseId(c.req.param("id"));
|
||||||
|
if (!id) return c.json({ error: "Invalid key ID" }, 400);
|
||||||
|
deleteApiKey(db, id);
|
||||||
|
return c.json({ ok: true });
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 7: Run tests**
|
||||||
|
|
||||||
|
Run: `bun test`
|
||||||
|
Expected: All 183 tests pass. Existing tests use valid integer IDs so no breakage.
|
||||||
|
|
||||||
|
- [ ] **Step 8: Run lint**
|
||||||
|
|
||||||
|
Run: `bun run lint`
|
||||||
|
Expected: No errors.
|
||||||
|
|
||||||
|
- [ ] **Step 9: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/server/lib/params.ts src/server/routes/items.ts src/server/routes/categories.ts src/server/routes/threads.ts src/server/routes/setups.ts src/server/routes/auth.ts
|
||||||
|
git commit -m "fix: validate route ID parameters, return 400 for invalid IDs"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Centralized Error Handler
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/server/index.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add onError handler**
|
||||||
|
|
||||||
|
In `src/server/index.ts`, add the error handler after the app is created (after `const app = new Hono()`) but before any routes:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// Centralized error handler
|
||||||
|
app.onError((err, c) => {
|
||||||
|
console.error(`[${c.req.method}] ${c.req.path}:`, err);
|
||||||
|
const message =
|
||||||
|
process.env.NODE_ENV === "production"
|
||||||
|
? "Internal server error"
|
||||||
|
: err.message || "Internal server error";
|
||||||
|
return c.json({ error: message }, 500);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests**
|
||||||
|
|
||||||
|
Run: `bun test`
|
||||||
|
Expected: All 183 tests pass.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/server/index.ts
|
||||||
|
git commit -m "fix: add centralized error handler for unhandled exceptions"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Rate Limiting on Auth Endpoints
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/server/middleware/rateLimit.ts`
|
||||||
|
- Modify: `src/server/routes/auth.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create rate limiter middleware**
|
||||||
|
|
||||||
|
Create `src/server/middleware/rateLimit.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import type { Context, Next } from "hono";
|
||||||
|
|
||||||
|
interface RateLimitEntry {
|
||||||
|
count: number;
|
||||||
|
resetAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const store = new Map<string, RateLimitEntry>();
|
||||||
|
|
||||||
|
const MAX_ATTEMPTS = 5;
|
||||||
|
const WINDOW_MS = 15 * 60 * 1000; // 15 minutes
|
||||||
|
|
||||||
|
function getClientIp(c: Context): string {
|
||||||
|
return c.req.header("x-forwarded-for")?.split(",")[0]?.trim() || "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanup() {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [key, entry] of store) {
|
||||||
|
if (now >= entry.resetAt) {
|
||||||
|
store.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function rateLimit(c: Context, next: Next) {
|
||||||
|
cleanup();
|
||||||
|
|
||||||
|
const ip = getClientIp(c);
|
||||||
|
const key = `${ip}:${c.req.path}`;
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
const entry = store.get(key);
|
||||||
|
|
||||||
|
if (!entry || now >= entry.resetAt) {
|
||||||
|
store.set(key, { count: 1, resetAt: now + WINDOW_MS });
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.count >= MAX_ATTEMPTS) {
|
||||||
|
const retryAfter = Math.ceil((entry.resetAt - now) / 1000);
|
||||||
|
c.header("Retry-After", String(retryAfter));
|
||||||
|
return c.json({ error: "Too many attempts. Try again later." }, 429);
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.count++;
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Apply rate limiter to auth routes**
|
||||||
|
|
||||||
|
In `src/server/routes/auth.ts`, add the import:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { rateLimit } from "../middleware/rateLimit.ts";
|
||||||
|
```
|
||||||
|
|
||||||
|
Update the `POST /setup` handler to include the rate limiter:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
app.post("/setup", rateLimit, zValidator("json", setupSchema), async (c) => {
|
||||||
|
```
|
||||||
|
|
||||||
|
Update the `POST /login` handler to include the rate limiter:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
app.post("/login", rateLimit, zValidator("json", loginSchema), async (c) => {
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run tests**
|
||||||
|
|
||||||
|
Run: `bun test`
|
||||||
|
Expected: All 183 tests pass. Auth tests make fewer than 5 requests per endpoint so rate limiting won't trigger.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run lint**
|
||||||
|
|
||||||
|
Run: `bun run lint`
|
||||||
|
Expected: No errors.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/server/middleware/rateLimit.ts src/server/routes/auth.ts
|
||||||
|
git commit -m "feat: add rate limiting on login and setup endpoints"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Client Error Boundary
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/client/routes/__root.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add error boundary component and wire it up**
|
||||||
|
|
||||||
|
In `src/client/routes/__root.tsx`, add the import for `useRouter` at the top (add to existing import from `@tanstack/react-router`):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import {
|
||||||
|
createRootRoute,
|
||||||
|
Outlet,
|
||||||
|
useMatchRoute,
|
||||||
|
useNavigate,
|
||||||
|
useRouter,
|
||||||
|
type ErrorComponentProps,
|
||||||
|
} from "@tanstack/react-router";
|
||||||
|
```
|
||||||
|
|
||||||
|
Add the `errorComponent` to the route definition:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export const Route = createRootRoute({
|
||||||
|
component: RootLayout,
|
||||||
|
errorComponent: RootErrorBoundary,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Add the `RootErrorBoundary` function before `RootLayout`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function RootErrorBoundary({ error, reset }: ErrorComponentProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||||
|
<div className="max-w-md mx-auto text-center px-4">
|
||||||
|
<div className="w-12 h-12 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||||
|
<svg
|
||||||
|
className="w-6 h-6 text-red-600"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-xl font-semibold text-gray-900 mb-2">
|
||||||
|
Something went wrong
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-gray-500 mb-6">
|
||||||
|
{error instanceof Error ? error.message : "An unexpected error occurred"}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
reset();
|
||||||
|
router.invalidate();
|
||||||
|
}}
|
||||||
|
className="px-5 py-2.5 bg-gray-700 hover:bg-gray-800 text-white text-sm font-medium rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
Try again
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run lint**
|
||||||
|
|
||||||
|
Run: `bun run lint`
|
||||||
|
Expected: No errors.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run tests**
|
||||||
|
|
||||||
|
Run: `bun test`
|
||||||
|
Expected: All 183 tests pass.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/client/routes/__root.tsx
|
||||||
|
git commit -m "feat: add error boundary to root route for crash resilience"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: Split Collection Route into Tab Components
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/client/components/CollectionView.tsx`
|
||||||
|
- Create: `src/client/components/PlanningView.tsx`
|
||||||
|
- Create: `src/client/components/SetupsView.tsx`
|
||||||
|
- Modify: `src/client/routes/collection/index.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create CollectionView component**
|
||||||
|
|
||||||
|
Create `src/client/components/CollectionView.tsx` with the `CollectionView` function extracted from `collection/index.tsx` (lines 72-334). The component needs these imports:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { CategoryFilterDropdown } from "./CategoryFilterDropdown";
|
||||||
|
import { CategoryHeader } from "./CategoryHeader";
|
||||||
|
import { ItemCard } from "./ItemCard";
|
||||||
|
import { useCategories } from "../hooks/useCategories";
|
||||||
|
import { useCurrency } from "../hooks/useCurrency";
|
||||||
|
import { useItems } from "../hooks/useItems";
|
||||||
|
import { useTotals } from "../hooks/useTotals";
|
||||||
|
import { useWeightUnit } from "../hooks/useWeightUnit";
|
||||||
|
import { formatPrice, formatWeight } from "../lib/formatters";
|
||||||
|
import { LucideIcon } from "../lib/iconData";
|
||||||
|
import { useUIStore } from "../stores/uiStore";
|
||||||
|
|
||||||
|
export function CollectionView() {
|
||||||
|
// ... exact same function body as lines 73-334 of collection/index.tsx
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Copy the entire `CollectionView` function body as-is. No logic changes.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Create PlanningView component**
|
||||||
|
|
||||||
|
Create `src/client/components/PlanningView.tsx` with the `PlanningView` function extracted from `collection/index.tsx` (lines 337-523):
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useState } from "react";
|
||||||
|
import { CategoryFilterDropdown } from "./CategoryFilterDropdown";
|
||||||
|
import { CreateThreadModal } from "./CreateThreadModal";
|
||||||
|
import { ThreadCard } from "./ThreadCard";
|
||||||
|
import { useCategories } from "../hooks/useCategories";
|
||||||
|
import { useThreads } from "../hooks/useThreads";
|
||||||
|
import { useUIStore } from "../stores/uiStore";
|
||||||
|
|
||||||
|
export function PlanningView() {
|
||||||
|
// ... exact same function body as lines 338-523 of collection/index.tsx
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Copy the entire `PlanningView` function body as-is. No logic changes.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Create SetupsView component**
|
||||||
|
|
||||||
|
Create `src/client/components/SetupsView.tsx` with the `SetupsView` function extracted from `collection/index.tsx` (lines 526-633):
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useState } from "react";
|
||||||
|
import { SetupCard } from "./SetupCard";
|
||||||
|
import { useCreateSetup, useSetups } from "../hooks/useSetups";
|
||||||
|
|
||||||
|
export function SetupsView() {
|
||||||
|
// ... exact same function body as lines 527-633 of collection/index.tsx
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Copy the entire `SetupsView` function body as-is. No logic changes.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Update collection/index.tsx**
|
||||||
|
|
||||||
|
Replace the entire file content. Keep only the route definition, tab switching logic, animation constants, and imports from the new components:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
|
import { useRef } from "react";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { CollectionView } from "../../components/CollectionView";
|
||||||
|
import { PlanningView } from "../../components/PlanningView";
|
||||||
|
import { SetupsView } from "../../components/SetupsView";
|
||||||
|
|
||||||
|
const searchSchema = z.object({
|
||||||
|
tab: z.enum(["gear", "planning", "setups"]).catch("gear"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/collection/")({
|
||||||
|
validateSearch: searchSchema,
|
||||||
|
component: CollectionPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
const TAB_ORDER = ["gear", "planning", "setups"] as const;
|
||||||
|
|
||||||
|
const slideVariants = {
|
||||||
|
enter: (dir: number) => ({ x: `${dir * 15}%`, opacity: 0 }),
|
||||||
|
center: { x: 0, opacity: 1 },
|
||||||
|
exit: (dir: number) => ({ x: `${dir * -15}%`, opacity: 0 }),
|
||||||
|
};
|
||||||
|
|
||||||
|
function CollectionPage() {
|
||||||
|
const { tab } = Route.useSearch();
|
||||||
|
const prevTab = useRef(tab);
|
||||||
|
|
||||||
|
const direction =
|
||||||
|
TAB_ORDER.indexOf(tab) >= TAB_ORDER.indexOf(prevTab.current) ? 1 : -1;
|
||||||
|
prevTab.current = tab;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 overflow-x-hidden">
|
||||||
|
<AnimatePresence mode="wait" initial={false} custom={direction}>
|
||||||
|
<motion.div
|
||||||
|
key={tab}
|
||||||
|
custom={direction}
|
||||||
|
variants={slideVariants}
|
||||||
|
initial="enter"
|
||||||
|
animate="center"
|
||||||
|
exit="exit"
|
||||||
|
transition={{ duration: 0.12, ease: "easeInOut" }}
|
||||||
|
>
|
||||||
|
{tab === "gear" ? (
|
||||||
|
<CollectionView />
|
||||||
|
) : tab === "planning" ? (
|
||||||
|
<PlanningView />
|
||||||
|
) : (
|
||||||
|
<SetupsView />
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run lint**
|
||||||
|
|
||||||
|
Run: `bun run lint`
|
||||||
|
Expected: No errors. (Biome may flag import organization — fix if needed.)
|
||||||
|
|
||||||
|
- [ ] **Step 6: Run tests**
|
||||||
|
|
||||||
|
Run: `bun test`
|
||||||
|
Expected: All 183 tests pass.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/client/components/CollectionView.tsx src/client/components/PlanningView.tsx src/client/components/SetupsView.tsx src/client/routes/collection/index.tsx
|
||||||
|
git commit -m "refactor: extract tab views from collection route into separate components"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7: Docs Cleanup
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `.planning/PROJECT.md:84`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Update stale constraint**
|
||||||
|
|
||||||
|
In `.planning/PROJECT.md`, change line 84 from:
|
||||||
|
|
||||||
|
```
|
||||||
|
- **Scope**: No auth, single user for v1
|
||||||
|
```
|
||||||
|
|
||||||
|
to:
|
||||||
|
|
||||||
|
```
|
||||||
|
- **Scope**: Single user with cookie/API key auth
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add .planning/PROJECT.md
|
||||||
|
git commit -m "docs: update PROJECT.md constraints to reflect auth implementation"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 8: Final Verification
|
||||||
|
|
||||||
|
- [ ] **Step 1: Run full test suite**
|
||||||
|
|
||||||
|
Run: `bun test`
|
||||||
|
Expected: All 183 tests pass.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run lint**
|
||||||
|
|
||||||
|
Run: `bun run lint`
|
||||||
|
Expected: No errors.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify dev server starts**
|
||||||
|
|
||||||
|
Run: `bun run dev:server &` then `curl http://localhost:3000/api/health`
|
||||||
|
Expected: `{"status":"ok"}`
|
||||||
|
Then kill the background server.
|
||||||
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
934
docs/superpowers/plans/2026-04-03-testing-improvements.md
Normal file
934
docs/superpowers/plans/2026-04-03-testing-improvements.md
Normal file
@@ -0,0 +1,934 @@
|
|||||||
|
# Testing Improvements 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 unit tests for new server code (parseId, rate limiter, param validation routes), set up Playwright E2E testing with a seeded database, and write E2E tests covering dashboard, collection, threads, auth, and error handling.
|
||||||
|
|
||||||
|
**Architecture:** Unit tests use existing Bun test runner + Hono `app.request()` pattern. E2E tests use Playwright against a real server with a pre-seeded SQLite database. A global-setup script creates the test DB using Drizzle migrations + direct inserts before Playwright runs.
|
||||||
|
|
||||||
|
**Tech Stack:** Bun test runner, Playwright (Chromium only), Drizzle ORM migrations, Hono
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Unit Tests for parseId
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `tests/lib/params.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write tests**
|
||||||
|
|
||||||
|
Create `tests/lib/params.test.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { describe, expect, it } from "bun:test";
|
||||||
|
import { parseId } from "../../src/server/lib/params";
|
||||||
|
|
||||||
|
describe("parseId", () => {
|
||||||
|
it("returns number for valid positive integers", () => {
|
||||||
|
expect(parseId("1")).toBe(1);
|
||||||
|
expect(parseId("42")).toBe(42);
|
||||||
|
expect(parseId("999")).toBe(999);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for zero", () => {
|
||||||
|
expect(parseId("0")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for negative numbers", () => {
|
||||||
|
expect(parseId("-1")).toBeNull();
|
||||||
|
expect(parseId("-100")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for decimals", () => {
|
||||||
|
expect(parseId("1.5")).toBeNull();
|
||||||
|
expect(parseId("3.14")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for non-numeric strings", () => {
|
||||||
|
expect(parseId("abc")).toBeNull();
|
||||||
|
expect(parseId("")).toBeNull();
|
||||||
|
expect(parseId("hello")).toBeNull();
|
||||||
|
expect(parseId("12abc")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for special values", () => {
|
||||||
|
expect(parseId("NaN")).toBeNull();
|
||||||
|
expect(parseId("Infinity")).toBeNull();
|
||||||
|
expect(parseId("-Infinity")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests**
|
||||||
|
|
||||||
|
Run: `bun test tests/lib/params.test.ts`
|
||||||
|
Expected: All tests pass.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add tests/lib/params.test.ts
|
||||||
|
git commit -m "test: add unit tests for parseId helper"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Unit Tests for Rate Limiter
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/server/middleware/rateLimit.ts` (add test reset function)
|
||||||
|
- Create: `tests/middleware/rateLimit.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add test reset function to rate limiter**
|
||||||
|
|
||||||
|
In `src/server/middleware/rateLimit.ts`, add at the end of the file:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
/** @internal — only for testing */
|
||||||
|
export function _resetForTesting() {
|
||||||
|
store.clear();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Write tests**
|
||||||
|
|
||||||
|
Create `tests/middleware/rateLimit.test.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { beforeEach, describe, expect, it } from "bun:test";
|
||||||
|
import { Hono } from "hono";
|
||||||
|
import { _resetForTesting, rateLimit } from "../../src/server/middleware/rateLimit";
|
||||||
|
|
||||||
|
function createApp() {
|
||||||
|
const app = new Hono();
|
||||||
|
app.post("/login", rateLimit, (c) => c.json({ ok: true }));
|
||||||
|
app.post("/setup", rateLimit, (c) => c.json({ ok: true }));
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeRequest(app: Hono, path: string, ip = "127.0.0.1") {
|
||||||
|
return app.request(path, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "x-forwarded-for": ip },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("rateLimit middleware", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
_resetForTesting();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows first request through", async () => {
|
||||||
|
const app = createApp();
|
||||||
|
const res = await makeRequest(app, "/login");
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows up to 5 requests", async () => {
|
||||||
|
const app = createApp();
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
const res = await makeRequest(app, "/login");
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 429 after 5 requests", async () => {
|
||||||
|
const app = createApp();
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
await makeRequest(app, "/login");
|
||||||
|
}
|
||||||
|
const res = await makeRequest(app, "/login");
|
||||||
|
expect(res.status).toBe(429);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.error).toBe("Too many attempts. Try again later.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes Retry-After header on 429", async () => {
|
||||||
|
const app = createApp();
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
await makeRequest(app, "/login");
|
||||||
|
}
|
||||||
|
const res = await makeRequest(app, "/login");
|
||||||
|
expect(res.status).toBe(429);
|
||||||
|
const retryAfter = res.headers.get("Retry-After");
|
||||||
|
expect(retryAfter).toBeTruthy();
|
||||||
|
expect(Number(retryAfter)).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tracks different IPs independently", async () => {
|
||||||
|
const app = createApp();
|
||||||
|
// Fill up IP 1
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
await makeRequest(app, "/login", "10.0.0.1");
|
||||||
|
}
|
||||||
|
// IP 1 is blocked
|
||||||
|
const blocked = await makeRequest(app, "/login", "10.0.0.1");
|
||||||
|
expect(blocked.status).toBe(429);
|
||||||
|
|
||||||
|
// IP 2 still works
|
||||||
|
const allowed = await makeRequest(app, "/login", "10.0.0.2");
|
||||||
|
expect(allowed.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tracks different paths independently", async () => {
|
||||||
|
const app = createApp();
|
||||||
|
// Fill up /login
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
await makeRequest(app, "/login");
|
||||||
|
}
|
||||||
|
const blockedLogin = await makeRequest(app, "/login");
|
||||||
|
expect(blockedLogin.status).toBe(429);
|
||||||
|
|
||||||
|
// /setup still works
|
||||||
|
const allowedSetup = await makeRequest(app, "/setup");
|
||||||
|
expect(allowedSetup.status).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run tests**
|
||||||
|
|
||||||
|
Run: `bun test tests/middleware/rateLimit.test.ts`
|
||||||
|
Expected: All tests pass.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run full test suite**
|
||||||
|
|
||||||
|
Run: `bun test`
|
||||||
|
Expected: All tests pass (previous 183 + new ones).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/server/middleware/rateLimit.ts tests/middleware/rateLimit.test.ts
|
||||||
|
git commit -m "test: add unit tests for rate limiter middleware"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Route-Level Tests for Invalid ID Params
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `tests/routes/params.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write tests**
|
||||||
|
|
||||||
|
Create `tests/routes/params.test.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { beforeEach, describe, expect, it } from "bun:test";
|
||||||
|
import { Hono } from "hono";
|
||||||
|
import { categoryRoutes } from "../../src/server/routes/categories";
|
||||||
|
import { itemRoutes } from "../../src/server/routes/items";
|
||||||
|
import { setupRoutes } from "../../src/server/routes/setups";
|
||||||
|
import { threadRoutes } from "../../src/server/routes/threads";
|
||||||
|
import { createTestDb } from "../helpers/db";
|
||||||
|
|
||||||
|
function createTestApp() {
|
||||||
|
const db = createTestDb();
|
||||||
|
const app = new Hono();
|
||||||
|
app.use("*", async (c, next) => {
|
||||||
|
c.set("db", db);
|
||||||
|
await next();
|
||||||
|
});
|
||||||
|
app.route("/api/items", itemRoutes);
|
||||||
|
app.route("/api/categories", categoryRoutes);
|
||||||
|
app.route("/api/threads", threadRoutes);
|
||||||
|
app.route("/api/setups", setupRoutes);
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Invalid ID parameter handling", () => {
|
||||||
|
let app: Hono;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
app = createTestApp();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("items", () => {
|
||||||
|
it("GET /api/items/abc returns 400", async () => {
|
||||||
|
const res = await app.request("/api/items/abc");
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.error).toContain("Invalid");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("GET /api/items/0 returns 400", async () => {
|
||||||
|
const res = await app.request("/api/items/0");
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("GET /api/items/-1 returns 400", async () => {
|
||||||
|
const res = await app.request("/api/items/-1");
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("categories", () => {
|
||||||
|
it("DELETE /api/categories/abc returns 400", async () => {
|
||||||
|
const res = await app.request("/api/categories/abc", {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("threads", () => {
|
||||||
|
it("GET /api/threads/abc returns 400", async () => {
|
||||||
|
const res = await app.request("/api/threads/abc");
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("GET /api/threads/1.5 returns 400", async () => {
|
||||||
|
const res = await app.request("/api/threads/1.5");
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("setups", () => {
|
||||||
|
it("GET /api/setups/abc returns 400", async () => {
|
||||||
|
const res = await app.request("/api/setups/abc");
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("GET /api/setups/0 returns 400", async () => {
|
||||||
|
const res = await app.request("/api/setups/0");
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests**
|
||||||
|
|
||||||
|
Run: `bun test tests/routes/params.test.ts`
|
||||||
|
Expected: All tests pass.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add tests/routes/params.test.ts
|
||||||
|
git commit -m "test: add route-level tests for invalid ID parameter handling"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Install Playwright and Create Config
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `package.json` (add dep + scripts)
|
||||||
|
- Create: `playwright.config.ts`
|
||||||
|
- Modify: `.gitignore`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Install Playwright**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun add -d @playwright/test
|
||||||
|
bunx playwright install chromium
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Create playwright.config.ts**
|
||||||
|
|
||||||
|
Create `playwright.config.ts` at project root:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { defineConfig, devices } from "@playwright/test";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: "./e2e",
|
||||||
|
fullyParallel: false,
|
||||||
|
retries: 0,
|
||||||
|
workers: 1,
|
||||||
|
reporter: "list",
|
||||||
|
globalSetup: "./e2e/global-setup.ts",
|
||||||
|
use: {
|
||||||
|
baseURL: "http://localhost:3000",
|
||||||
|
trace: "on-first-retry",
|
||||||
|
},
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
name: "chromium",
|
||||||
|
use: { ...devices["Desktop Chrome"] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
webServer: {
|
||||||
|
command: "DATABASE_PATH=./e2e/test.db bun run dev:server",
|
||||||
|
port: 3000,
|
||||||
|
reuseExistingServer: !process.env.CI,
|
||||||
|
timeout: 10000,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add scripts to package.json**
|
||||||
|
|
||||||
|
Add these to the `"scripts"` section in `package.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"test:e2e": "bunx playwright test",
|
||||||
|
"test:e2e:ui": "bunx playwright test --ui"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Update .gitignore**
|
||||||
|
|
||||||
|
Append to `.gitignore`:
|
||||||
|
|
||||||
|
```
|
||||||
|
# Playwright
|
||||||
|
e2e/test.db
|
||||||
|
test-results/
|
||||||
|
playwright-report/
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run lint**
|
||||||
|
|
||||||
|
Run: `bun run lint`
|
||||||
|
Expected: Clean.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add package.json bun.lock playwright.config.ts .gitignore
|
||||||
|
git commit -m "chore: install Playwright and add E2E test configuration"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: E2E Database Seed and Global Setup
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `e2e/seed.ts`
|
||||||
|
- Create: `e2e/global-setup.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create seed script**
|
||||||
|
|
||||||
|
Create `e2e/seed.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { Database } from "bun:sqlite";
|
||||||
|
import { drizzle } from "drizzle-orm/bun-sqlite";
|
||||||
|
import { migrate } from "drizzle-orm/bun-sqlite/migrator";
|
||||||
|
import * as schema from "../src/db/schema";
|
||||||
|
|
||||||
|
const DB_PATH = "./e2e/test.db";
|
||||||
|
|
||||||
|
export async function seedTestDatabase() {
|
||||||
|
// Remove old test DB if it exists
|
||||||
|
try {
|
||||||
|
await Bun.file(DB_PATH).exists() &&
|
||||||
|
(await import("node:fs/promises")).then((fs) => fs.unlink(DB_PATH));
|
||||||
|
} catch {
|
||||||
|
// File doesn't exist, that's fine
|
||||||
|
}
|
||||||
|
|
||||||
|
const sqlite = new Database(DB_PATH);
|
||||||
|
sqlite.run("PRAGMA journal_mode = WAL");
|
||||||
|
sqlite.run("PRAGMA foreign_keys = ON");
|
||||||
|
|
||||||
|
const db = drizzle(sqlite, { schema });
|
||||||
|
|
||||||
|
// Apply all migrations
|
||||||
|
migrate(db, { migrationsFolder: "./drizzle" });
|
||||||
|
|
||||||
|
// ── Seed Categories ──
|
||||||
|
const [uncategorized] = db
|
||||||
|
.insert(schema.categories)
|
||||||
|
.values({ name: "Uncategorized", icon: "package" })
|
||||||
|
.returning()
|
||||||
|
.all();
|
||||||
|
|
||||||
|
const [shelter] = db
|
||||||
|
.insert(schema.categories)
|
||||||
|
.values({ name: "Shelter", icon: "tent" })
|
||||||
|
.returning()
|
||||||
|
.all();
|
||||||
|
|
||||||
|
const [sleep] = db
|
||||||
|
.insert(schema.categories)
|
||||||
|
.values({ name: "Sleep System", icon: "moon" })
|
||||||
|
.returning()
|
||||||
|
.all();
|
||||||
|
|
||||||
|
const [cook] = db
|
||||||
|
.insert(schema.categories)
|
||||||
|
.values({ name: "Cook Kit", icon: "flame" })
|
||||||
|
.returning()
|
||||||
|
.all();
|
||||||
|
|
||||||
|
// ── Seed Items ──
|
||||||
|
const tent = db
|
||||||
|
.insert(schema.items)
|
||||||
|
.values({
|
||||||
|
name: "Zpacks Duplex",
|
||||||
|
weightGrams: 539,
|
||||||
|
priceCents: 67900,
|
||||||
|
categoryId: shelter.id,
|
||||||
|
notes: "DCF shelter, 2-person",
|
||||||
|
})
|
||||||
|
.returning()
|
||||||
|
.get();
|
||||||
|
|
||||||
|
const tarp = db
|
||||||
|
.insert(schema.items)
|
||||||
|
.values({
|
||||||
|
name: "Borah Gear Tarp",
|
||||||
|
weightGrams: 156,
|
||||||
|
priceCents: 11000,
|
||||||
|
categoryId: shelter.id,
|
||||||
|
})
|
||||||
|
.returning()
|
||||||
|
.get();
|
||||||
|
|
||||||
|
const quilt = db
|
||||||
|
.insert(schema.items)
|
||||||
|
.values({
|
||||||
|
name: "Enlightened Equipment Enigma 20",
|
||||||
|
weightGrams: 595,
|
||||||
|
priceCents: 34000,
|
||||||
|
categoryId: sleep.id,
|
||||||
|
notes: "20F quilt",
|
||||||
|
})
|
||||||
|
.returning()
|
||||||
|
.get();
|
||||||
|
|
||||||
|
const pad = db
|
||||||
|
.insert(schema.items)
|
||||||
|
.values({
|
||||||
|
name: "Therm-a-Rest NeoAir XLite",
|
||||||
|
weightGrams: 354,
|
||||||
|
priceCents: 20999,
|
||||||
|
categoryId: sleep.id,
|
||||||
|
})
|
||||||
|
.returning()
|
||||||
|
.get();
|
||||||
|
|
||||||
|
const stove = db
|
||||||
|
.insert(schema.items)
|
||||||
|
.values({
|
||||||
|
name: "BRS-3000T Stove",
|
||||||
|
weightGrams: 25,
|
||||||
|
priceCents: 2000,
|
||||||
|
categoryId: cook.id,
|
||||||
|
})
|
||||||
|
.returning()
|
||||||
|
.get();
|
||||||
|
|
||||||
|
const pot = db
|
||||||
|
.insert(schema.items)
|
||||||
|
.values({
|
||||||
|
name: "Toaks 750ml Pot",
|
||||||
|
weightGrams: 103,
|
||||||
|
priceCents: 3000,
|
||||||
|
categoryId: cook.id,
|
||||||
|
})
|
||||||
|
.returning()
|
||||||
|
.get();
|
||||||
|
|
||||||
|
// ── Seed Active Thread with 3 Candidates ──
|
||||||
|
const activeThread = db
|
||||||
|
.insert(schema.threads)
|
||||||
|
.values({
|
||||||
|
name: "New Backpack",
|
||||||
|
status: "active",
|
||||||
|
categoryId: uncategorized.id,
|
||||||
|
})
|
||||||
|
.returning()
|
||||||
|
.get();
|
||||||
|
|
||||||
|
db.insert(schema.threadCandidates)
|
||||||
|
.values({
|
||||||
|
threadId: activeThread.id,
|
||||||
|
name: "ULA Circuit",
|
||||||
|
weightGrams: 1077,
|
||||||
|
priceCents: 27500,
|
||||||
|
categoryId: uncategorized.id,
|
||||||
|
pros: "Great hip belt\nLarge capacity",
|
||||||
|
cons: "Heavier than competitors",
|
||||||
|
sortOrder: 1000,
|
||||||
|
status: "researching",
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
|
||||||
|
db.insert(schema.threadCandidates)
|
||||||
|
.values({
|
||||||
|
threadId: activeThread.id,
|
||||||
|
name: "Gossamer Gear Mariposa",
|
||||||
|
weightGrams: 737,
|
||||||
|
priceCents: 28500,
|
||||||
|
categoryId: uncategorized.id,
|
||||||
|
pros: "Very lightweight\nGood ventilation",
|
||||||
|
cons: "Smaller hip belt pockets",
|
||||||
|
sortOrder: 2000,
|
||||||
|
status: "researching",
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
|
||||||
|
db.insert(schema.threadCandidates)
|
||||||
|
.values({
|
||||||
|
threadId: activeThread.id,
|
||||||
|
name: "Granite Gear Crown2 38",
|
||||||
|
weightGrams: 850,
|
||||||
|
priceCents: 18000,
|
||||||
|
categoryId: uncategorized.id,
|
||||||
|
sortOrder: 3000,
|
||||||
|
status: "ordered",
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
|
||||||
|
// ── Seed Resolved Thread ──
|
||||||
|
const resolvedThread = db
|
||||||
|
.insert(schema.threads)
|
||||||
|
.values({
|
||||||
|
name: "Camp Stove",
|
||||||
|
status: "resolved",
|
||||||
|
categoryId: cook.id,
|
||||||
|
resolvedCandidateId: 1,
|
||||||
|
})
|
||||||
|
.returning()
|
||||||
|
.get();
|
||||||
|
|
||||||
|
db.insert(schema.threadCandidates)
|
||||||
|
.values({
|
||||||
|
threadId: resolvedThread.id,
|
||||||
|
name: "BRS-3000T",
|
||||||
|
weightGrams: 25,
|
||||||
|
priceCents: 2000,
|
||||||
|
categoryId: cook.id,
|
||||||
|
sortOrder: 1000,
|
||||||
|
status: "arrived",
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
|
||||||
|
// ── Seed Setup with Items ──
|
||||||
|
const setup = db
|
||||||
|
.insert(schema.setups)
|
||||||
|
.values({ name: "Weekend Overnighter" })
|
||||||
|
.returning()
|
||||||
|
.get();
|
||||||
|
|
||||||
|
db.insert(schema.setupItems)
|
||||||
|
.values([
|
||||||
|
{ setupId: setup.id, itemId: tent.id, classification: "base" },
|
||||||
|
{ setupId: setup.id, itemId: quilt.id, classification: "base" },
|
||||||
|
{ setupId: setup.id, itemId: pad.id, classification: "base" },
|
||||||
|
{ setupId: setup.id, itemId: stove.id, classification: "consumable" },
|
||||||
|
])
|
||||||
|
.run();
|
||||||
|
|
||||||
|
// ── Seed User ──
|
||||||
|
const passwordHash = await Bun.password.hash("password123");
|
||||||
|
db.insert(schema.users)
|
||||||
|
.values({ username: "admin", passwordHash })
|
||||||
|
.run();
|
||||||
|
|
||||||
|
// ── Seed Settings ──
|
||||||
|
db.insert(schema.settings)
|
||||||
|
.values([
|
||||||
|
{ key: "weightUnit", value: "g" },
|
||||||
|
{ key: "currency", value: "USD" },
|
||||||
|
{ key: "onboardingComplete", value: "true" },
|
||||||
|
])
|
||||||
|
.run();
|
||||||
|
|
||||||
|
sqlite.close();
|
||||||
|
console.log("E2E test database seeded at", DB_PATH);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Create global-setup**
|
||||||
|
|
||||||
|
Create `e2e/global-setup.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { seedTestDatabase } from "./seed";
|
||||||
|
|
||||||
|
export default async function globalSetup() {
|
||||||
|
await seedTestDatabase();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify seed works**
|
||||||
|
|
||||||
|
Run: `bun run e2e/global-setup.ts`
|
||||||
|
Expected: Prints "E2E test database seeded at ./e2e/test.db" and the file exists.
|
||||||
|
|
||||||
|
Then clean up: `rm -f e2e/test.db`
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add e2e/seed.ts e2e/global-setup.ts
|
||||||
|
git commit -m "test: add E2E database seed and Playwright global setup"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: E2E Tests — Dashboard and Collection
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `e2e/dashboard.spec.ts`
|
||||||
|
- Create: `e2e/collection.spec.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create dashboard tests**
|
||||||
|
|
||||||
|
Create `e2e/dashboard.spec.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
test.describe("Dashboard", () => {
|
||||||
|
test("loads and shows summary cards", async ({ page }) => {
|
||||||
|
await page.goto("/");
|
||||||
|
await expect(page.locator("text=GearBox")).toBeVisible();
|
||||||
|
// Should show item count (we seeded 6 items)
|
||||||
|
await expect(page.locator("text=6")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("has navigation to collection", async ({ page }) => {
|
||||||
|
await page.goto("/");
|
||||||
|
// Click on a dashboard card or link that goes to collection
|
||||||
|
const collectionLink = page.locator('a[href*="collection"]').first();
|
||||||
|
if (await collectionLink.isVisible()) {
|
||||||
|
await collectionLink.click();
|
||||||
|
await expect(page).toHaveURL(/collection/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Create collection tests**
|
||||||
|
|
||||||
|
Create `e2e/collection.spec.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
test.describe("Collection", () => {
|
||||||
|
test("gear tab shows items grouped by category", async ({ page }) => {
|
||||||
|
await page.goto("/collection?tab=gear");
|
||||||
|
// Should see seeded items
|
||||||
|
await expect(page.locator("text=Zpacks Duplex")).toBeVisible();
|
||||||
|
await expect(page.locator("text=BRS-3000T Stove")).toBeVisible();
|
||||||
|
// Should see category headers
|
||||||
|
await expect(page.locator("text=Shelter")).toBeVisible();
|
||||||
|
await expect(page.locator("text=Cook Kit")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("search filters items by name", async ({ page }) => {
|
||||||
|
await page.goto("/collection?tab=gear");
|
||||||
|
const searchInput = page.locator('input[placeholder*="Search"]');
|
||||||
|
await searchInput.fill("Zpacks");
|
||||||
|
// Should show matching item
|
||||||
|
await expect(page.locator("text=Zpacks Duplex")).toBeVisible();
|
||||||
|
// Should hide non-matching items
|
||||||
|
await expect(page.locator("text=BRS-3000T Stove")).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("tab switching works", async ({ page }) => {
|
||||||
|
await page.goto("/collection?tab=gear");
|
||||||
|
await expect(page.locator("text=Zpacks Duplex")).toBeVisible();
|
||||||
|
|
||||||
|
// Switch to planning tab
|
||||||
|
await page.goto("/collection?tab=planning");
|
||||||
|
await expect(page.locator("text=Planning Threads")).toBeVisible();
|
||||||
|
await expect(page.locator("text=New Backpack")).toBeVisible();
|
||||||
|
|
||||||
|
// Switch to setups tab
|
||||||
|
await page.goto("/collection?tab=setups");
|
||||||
|
await expect(page.locator("text=Weekend Overnighter")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("category filter dropdown works", async ({ page }) => {
|
||||||
|
await page.goto("/collection?tab=gear");
|
||||||
|
// Open category filter
|
||||||
|
const filterButton = page.locator("text=All categories");
|
||||||
|
await filterButton.click();
|
||||||
|
// Select "Shelter"
|
||||||
|
await page.locator("li").filter({ hasText: "Shelter" }).click();
|
||||||
|
// Should show only shelter items
|
||||||
|
await expect(page.locator("text=Zpacks Duplex")).toBeVisible();
|
||||||
|
await expect(page.locator("text=BRS-3000T Stove")).not.toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run E2E tests**
|
||||||
|
|
||||||
|
Run: `bun run test:e2e`
|
||||||
|
Expected: All tests pass. If any fail due to selector issues, adjust selectors based on actual DOM.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add e2e/dashboard.spec.ts e2e/collection.spec.ts
|
||||||
|
git commit -m "test: add E2E tests for dashboard and collection views"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7: E2E Tests — Threads, Auth, Error Handling
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `e2e/threads.spec.ts`
|
||||||
|
- Create: `e2e/auth.spec.ts`
|
||||||
|
- Create: `e2e/error-handling.spec.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create threads tests**
|
||||||
|
|
||||||
|
Create `e2e/threads.spec.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
test.describe("Threads", () => {
|
||||||
|
test("thread detail page shows candidates", async ({ page }) => {
|
||||||
|
// Navigate to the active thread
|
||||||
|
await page.goto("/collection?tab=planning");
|
||||||
|
await page.locator("text=New Backpack").click();
|
||||||
|
// Should see candidates
|
||||||
|
await expect(page.locator("text=ULA Circuit")).toBeVisible();
|
||||||
|
await expect(page.locator("text=Gossamer Gear Mariposa")).toBeVisible();
|
||||||
|
await expect(page.locator("text=Granite Gear Crown2 38")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rank badges are visible on candidates", async ({ page }) => {
|
||||||
|
await page.goto("/collection?tab=planning");
|
||||||
|
await page.locator("text=New Backpack").click();
|
||||||
|
// Should see rank badges (gold, silver, bronze for top 3)
|
||||||
|
// The rank badges use specific colors: #D4AF37 (gold), #C0C0C0 (silver), #CD7F32 (bronze)
|
||||||
|
await expect(page.locator("text=#1").first()).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("comparison view toggles on", async ({ page }) => {
|
||||||
|
await page.goto("/collection?tab=planning");
|
||||||
|
await page.locator("text=New Backpack").click();
|
||||||
|
// Find and click the compare toggle
|
||||||
|
const compareButton = page.locator("button", { hasText: /compare/i });
|
||||||
|
if (await compareButton.isVisible()) {
|
||||||
|
await compareButton.click();
|
||||||
|
// Comparison table should appear with attribute rows
|
||||||
|
await expect(page.locator("text=Weight")).toBeVisible();
|
||||||
|
await expect(page.locator("text=Price")).toBeVisible();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resolved thread shows winner", async ({ page }) => {
|
||||||
|
await page.goto("/collection?tab=planning");
|
||||||
|
// Switch to resolved tab
|
||||||
|
await page.locator("button", { hasText: "Resolved" }).click();
|
||||||
|
await page.locator("text=Camp Stove").click();
|
||||||
|
// Should indicate resolved state
|
||||||
|
await expect(page.locator("text=BRS-3000T")).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Create auth tests**
|
||||||
|
|
||||||
|
Create `e2e/auth.spec.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
test.describe("Auth", () => {
|
||||||
|
test("login page renders", async ({ page }) => {
|
||||||
|
await page.goto("/login");
|
||||||
|
await expect(page.locator("text=Log in")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("login with valid credentials succeeds", async ({ page }) => {
|
||||||
|
await page.goto("/login");
|
||||||
|
await page.locator('input[name="username"], input[placeholder*="sername"]').fill("admin");
|
||||||
|
await page.locator('input[type="password"]').fill("password123");
|
||||||
|
await page.locator('button[type="submit"]').click();
|
||||||
|
// Should redirect away from login
|
||||||
|
await page.waitForURL((url) => !url.pathname.includes("/login"), {
|
||||||
|
timeout: 5000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("login with wrong password shows error", async ({ page }) => {
|
||||||
|
await page.goto("/login");
|
||||||
|
await page.locator('input[name="username"], input[placeholder*="sername"]').fill("admin");
|
||||||
|
await page.locator('input[type="password"]').fill("wrongpassword");
|
||||||
|
await page.locator('button[type="submit"]').click();
|
||||||
|
// Should show error message
|
||||||
|
await expect(page.locator("text=Invalid credentials").or(page.locator('[role="alert"]'))).toBeVisible({
|
||||||
|
timeout: 3000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Create error handling tests**
|
||||||
|
|
||||||
|
Create `e2e/error-handling.spec.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
test.describe("Error handling", () => {
|
||||||
|
test("non-existent thread shows not found or error", async ({ page }) => {
|
||||||
|
await page.goto("/threads/99999");
|
||||||
|
// Should not white-screen — should show some content
|
||||||
|
const body = page.locator("body");
|
||||||
|
await expect(body).not.toBeEmpty();
|
||||||
|
// Either shows error boundary or "not found" text
|
||||||
|
const hasContent = await page
|
||||||
|
.locator("text=Something went wrong")
|
||||||
|
.or(page.locator("text=not found"))
|
||||||
|
.or(page.locator("text=Not Found"))
|
||||||
|
.isVisible()
|
||||||
|
.catch(() => false);
|
||||||
|
// At minimum, the page should not be blank
|
||||||
|
const bodyText = await body.textContent();
|
||||||
|
expect(bodyText?.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("non-existent setup shows not found or error", async ({ page }) => {
|
||||||
|
await page.goto("/setups/99999");
|
||||||
|
const body = page.locator("body");
|
||||||
|
await expect(body).not.toBeEmpty();
|
||||||
|
const bodyText = await body.textContent();
|
||||||
|
expect(bodyText?.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("app recovers from navigation errors", async ({ page }) => {
|
||||||
|
// Navigate to a bad route, then back to a good one
|
||||||
|
await page.goto("/threads/99999");
|
||||||
|
await page.goto("/");
|
||||||
|
// Dashboard should load fine
|
||||||
|
await expect(page.locator("text=GearBox")).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run all E2E tests**
|
||||||
|
|
||||||
|
Run: `bun run test:e2e`
|
||||||
|
Expected: All tests pass.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add e2e/threads.spec.ts e2e/auth.spec.ts e2e/error-handling.spec.ts
|
||||||
|
git commit -m "test: add E2E tests for threads, auth, and error handling"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 8: Final Verification
|
||||||
|
|
||||||
|
- [ ] **Step 1: Run unit tests**
|
||||||
|
|
||||||
|
Run: `bun test`
|
||||||
|
Expected: All tests pass (previous 183 + new parseId + rate limiter + param routes).
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run E2E tests**
|
||||||
|
|
||||||
|
Run: `bun run test:e2e`
|
||||||
|
Expected: All E2E tests pass.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run lint**
|
||||||
|
|
||||||
|
Run: `bun run lint`
|
||||||
|
Expected: Clean.
|
||||||
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,74 @@
|
|||||||
|
# Code Quality Improvements (Round 2) Design
|
||||||
|
|
||||||
|
**Date:** 2026-04-03
|
||||||
|
**Scope:** Combined formatters hook, test helper schema generation, stale todo cleanup
|
||||||
|
|
||||||
|
## 1. useFormatters Combined Hook
|
||||||
|
|
||||||
|
**Problem:** 14 component files import the same 3-4 lines: `useWeightUnit`, `useCurrency`, `formatWeight`, `formatPrice`. This is repetitive boilerplate.
|
||||||
|
|
||||||
|
**Solution:** Create `src/client/hooks/useFormatters.ts` that returns pre-bound formatting functions:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export function useFormatters() {
|
||||||
|
const unit = useWeightUnit();
|
||||||
|
const currency = useCurrency();
|
||||||
|
return {
|
||||||
|
weight: (grams: number | null) => formatWeight(grams, unit),
|
||||||
|
price: (cents: number | null) => formatPrice(cents, currency),
|
||||||
|
unit,
|
||||||
|
currency,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Consumer files to update (14):**
|
||||||
|
- CollectionView.tsx
|
||||||
|
- setups/$setupId.tsx
|
||||||
|
- routes/index.tsx
|
||||||
|
- WeightSummaryCard.tsx
|
||||||
|
- TotalsBar.tsx
|
||||||
|
- settings.tsx
|
||||||
|
- ThreadCard.tsx
|
||||||
|
- SetupCard.tsx
|
||||||
|
- ItemPicker.tsx
|
||||||
|
- ItemCard.tsx
|
||||||
|
- ComparisonTable.tsx
|
||||||
|
- CandidateCard.tsx
|
||||||
|
- CandidateListItem.tsx
|
||||||
|
- CategoryHeader.tsx
|
||||||
|
|
||||||
|
Each file replaces 3-4 imports + 2 hook calls with 1 import + 1 destructured hook call. Components that need raw `unit` or `currency` (e.g., WeightSummaryCard uses `unit` as a type, TotalsBar has a unit toggle) get them from the return object.
|
||||||
|
|
||||||
|
## 2. Test Helper Schema Generation
|
||||||
|
|
||||||
|
**Problem:** `tests/helpers/db.ts` has 120 lines of hand-written CREATE TABLE SQL that must manually mirror `src/db/schema.ts`. Any schema change requires updating both files — a known source of `SqliteError: no such column` failures.
|
||||||
|
|
||||||
|
**Solution:** Replace hand-written SQL with Drizzle's migration runner:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { migrate } from "drizzle-orm/bun-sqlite/migrator";
|
||||||
|
|
||||||
|
export function createTestDb() {
|
||||||
|
const sqlite = new Database(":memory:");
|
||||||
|
sqlite.run("PRAGMA foreign_keys = ON");
|
||||||
|
const db = drizzle(sqlite, { schema });
|
||||||
|
migrate(db, { migrationsFolder: "./drizzle" });
|
||||||
|
db.insert(schema.categories).values({ name: "Uncategorized", icon: "package" }).run();
|
||||||
|
return db;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This reduces the file from ~128 lines to ~15 lines and eliminates all future manual sync.
|
||||||
|
|
||||||
|
## 3. Stale Todo Cleanup
|
||||||
|
|
||||||
|
**Problem:** Pending todo "Replace planning category filter select with icon-aware dropdown" from 2026-03-15 is already resolved — `PlanningView.tsx` uses `<CategoryFilterDropdown>` which renders Lucide icons.
|
||||||
|
|
||||||
|
**Solution:** Move the todo file from `pending/` to `done/`.
|
||||||
|
|
||||||
|
## Commit Strategy
|
||||||
|
|
||||||
|
1. **useFormatters hook** — create hook + update all 14 consumer files
|
||||||
|
2. **Test helper migration** — replace hand-written SQL with migrate()
|
||||||
|
3. **Todo cleanup** — move stale todo to done
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
# Codebase Improvements Design
|
||||||
|
|
||||||
|
**Date:** 2026-04--03
|
||||||
|
**Scope:** General code quality, error handling, resilience, and maintainability improvements
|
||||||
|
|
||||||
|
## 1. Server Hardening
|
||||||
|
|
||||||
|
### 1a. Explicit DB Context Middleware
|
||||||
|
|
||||||
|
**File:** `src/server/index.ts`
|
||||||
|
|
||||||
|
Add middleware that explicitly sets `c.set("db", prodDb)` for all API routes. Currently routes call `c.get("db")` but nothing sets it in production — services silently fall back to `prodDb` via default parameters. This makes production behavior match the test pattern.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { db as prodDb } from "../db/index.ts";
|
||||||
|
|
||||||
|
app.use("/api/*", async (c, next) => {
|
||||||
|
c.set("db", prodDb);
|
||||||
|
return next();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Place this **before** the auth middleware so `db` is available when auth checks run.
|
||||||
|
|
||||||
|
### 1b. Route Parameter Validation
|
||||||
|
|
||||||
|
**New file:** `src/server/lib/params.ts`
|
||||||
|
|
||||||
|
Create a helper that validates numeric route params:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export function parseId(raw: string): number | null {
|
||||||
|
const id = Number(raw);
|
||||||
|
if (!Number.isInteger(id) || id <= 0) return null;
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Update all route files (`items.ts`, `threads.ts`, `categories.ts`, `setups.ts`) to replace `Number(c.req.param("id"))` with `parseId()`, returning 400 for invalid IDs.
|
||||||
|
|
||||||
|
### 1c. Centralized Error Handling
|
||||||
|
|
||||||
|
**File:** `src/server/index.ts`
|
||||||
|
|
||||||
|
Add Hono's `onError` handler:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
app.onError((err, c) => {
|
||||||
|
console.error(`[${c.req.method}] ${c.req.path}:`, err);
|
||||||
|
const status = err instanceof HTTPException ? err.status : 500;
|
||||||
|
const message = process.env.NODE_ENV === "production"
|
||||||
|
? "Internal server error"
|
||||||
|
: err.message;
|
||||||
|
return c.json({ error: message }, status);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1d. Auth Comment Fix
|
||||||
|
|
||||||
|
**File:** `src/server/index.ts`
|
||||||
|
|
||||||
|
Change comment from:
|
||||||
|
```
|
||||||
|
// Auth middleware for write operations (POST/PUT/DELETE) on non-auth routes
|
||||||
|
```
|
||||||
|
To:
|
||||||
|
```
|
||||||
|
// Auth middleware for write operations (POST/PUT/PATCH/DELETE) on non-auth routes
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1e. Rate Limiting on Auth Endpoints
|
||||||
|
|
||||||
|
**New file:** `src/server/middleware/rateLimit.ts`
|
||||||
|
|
||||||
|
In-memory rate limiter using a `Map<string, { count: number; resetAt: number }>`:
|
||||||
|
|
||||||
|
- Tracks by IP (`c.req.header("x-forwarded-for") || "unknown"`)
|
||||||
|
- 5 attempts per 15-minute window
|
||||||
|
- Returns 429 with `{ error: "Too many attempts. Try again later." }` and `Retry-After` header
|
||||||
|
- Stale entries cleaned on each check
|
||||||
|
- Applied to `POST /api/auth/login` and `POST /api/auth/setup`
|
||||||
|
|
||||||
|
## 2. Client Resilience
|
||||||
|
|
||||||
|
### Error Boundary
|
||||||
|
|
||||||
|
**File:** `src/client/routes/__root.tsx`
|
||||||
|
|
||||||
|
Add `errorComponent` to the root route definition:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export const Route = createRootRoute({
|
||||||
|
component: RootLayout,
|
||||||
|
errorComponent: RootErrorBoundary,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
`RootErrorBoundary` renders a centered error message with:
|
||||||
|
- "Something went wrong" heading
|
||||||
|
- Error message in dev mode
|
||||||
|
- "Try again" button that calls `router.invalidate()` + `reset()`
|
||||||
|
|
||||||
|
Uses TanStack Router's `ErrorComponentProps` which provides `error` and `reset`.
|
||||||
|
|
||||||
|
## 3. Client Refactor
|
||||||
|
|
||||||
|
### Split collection/index.tsx
|
||||||
|
|
||||||
|
Extract the three tab-level functions into separate component files:
|
||||||
|
|
||||||
|
| Source function | New file | Approx lines |
|
||||||
|
|----------------|----------|-------------|
|
||||||
|
| `CollectionView()` | `src/client/components/CollectionView.tsx` | ~260 |
|
||||||
|
| `PlanningView()` | `src/client/components/PlanningView.tsx` | ~190 |
|
||||||
|
| `SetupsView()` | `src/client/components/SetupsView.tsx` | ~110 |
|
||||||
|
|
||||||
|
`collection/index.tsx` keeps:
|
||||||
|
- Route definition with `searchSchema` and `validateSearch`
|
||||||
|
- `CollectionPage` function (tab switcher + AnimatePresence)
|
||||||
|
- `TAB_ORDER` and `slideVariants` constants
|
||||||
|
- Imports from the three new component files
|
||||||
|
|
||||||
|
Each extracted component is a named export, self-contained with its own hooks and local state.
|
||||||
|
|
||||||
|
## 4. Docs Cleanup
|
||||||
|
|
||||||
|
### PROJECT.md
|
||||||
|
|
||||||
|
**File:** `.planning/PROJECT.md`
|
||||||
|
|
||||||
|
Update Constraints section line:
|
||||||
|
```
|
||||||
|
- **Scope**: No auth, single user for v1
|
||||||
|
```
|
||||||
|
To:
|
||||||
|
```
|
||||||
|
- **Scope**: Single user with cookie/API key auth
|
||||||
|
```
|
||||||
|
|
||||||
|
## Commit Strategy
|
||||||
|
|
||||||
|
Group into 3-4 commits by area:
|
||||||
|
1. **Server hardening**: DB middleware, param validation, error handler, rate limiter, comment fix
|
||||||
|
2. **Client resilience + refactor**: Error boundary, split collection route
|
||||||
|
3. **Docs cleanup**: PROJECT.md update
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- All 183 existing tests must continue to pass
|
||||||
|
- Rate limiter: manual verification (no automated test needed for in-memory rate limiting in a single-user app)
|
||||||
|
- Error boundary: manual verification by triggering a render error
|
||||||
|
- Param validation: existing route tests cover happy paths; invalid IDs are a new edge case but won't break existing tests
|
||||||
@@ -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
|
||||||
128
docs/superpowers/specs/2026-04-03-testing-improvements-design.md
Normal file
128
docs/superpowers/specs/2026-04-03-testing-improvements-design.md
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
# Testing Improvements Design
|
||||||
|
|
||||||
|
**Date:** 2026-04-03
|
||||||
|
**Scope:** Unit tests for new server code + Playwright E2E test setup with seeded database
|
||||||
|
|
||||||
|
## Part 1: Unit/Integration Tests (Bun test runner)
|
||||||
|
|
||||||
|
### tests/lib/params.test.ts
|
||||||
|
|
||||||
|
Tests for `parseId` helper in `src/server/lib/params.ts`:
|
||||||
|
- Valid positive integers (1, 42, 999) return the number
|
||||||
|
- Zero returns null
|
||||||
|
- Negative numbers (-1, -100) return null
|
||||||
|
- Decimals (1.5, 3.14) return null
|
||||||
|
- Non-numeric strings ("abc", "", "hello") return null
|
||||||
|
- NaN-producing values return null
|
||||||
|
|
||||||
|
### tests/middleware/rateLimit.test.ts
|
||||||
|
|
||||||
|
Tests for rate limiter in `src/server/middleware/rateLimit.ts`:
|
||||||
|
- First request passes through (200)
|
||||||
|
- 5 requests succeed, 6th returns 429
|
||||||
|
- 429 response includes `Retry-After` header
|
||||||
|
- Different IPs tracked independently
|
||||||
|
- After window expires, requests succeed again
|
||||||
|
|
||||||
|
Since the rate limiter uses a module-level `Map`, tests need to either:
|
||||||
|
- Reset the store between tests (export a `resetStore` for testing), OR
|
||||||
|
- Use unique paths/IPs per test to avoid interference
|
||||||
|
|
||||||
|
Recommended: export a `_resetForTesting()` function from rateLimit.ts that clears the store. Only used in tests.
|
||||||
|
|
||||||
|
### tests/routes/params.test.ts
|
||||||
|
|
||||||
|
Route-level integration tests verifying 400 responses for invalid IDs:
|
||||||
|
- `GET /api/items/abc` → 400
|
||||||
|
- `GET /api/items/-1` → 400
|
||||||
|
- `GET /api/items/0` → 400
|
||||||
|
- `DELETE /api/categories/notanumber` → 400
|
||||||
|
- `GET /api/threads/abc` → 400
|
||||||
|
- `GET /api/setups/abc` → 400
|
||||||
|
|
||||||
|
Uses existing test app pattern with in-memory DB.
|
||||||
|
|
||||||
|
## Part 2: Playwright E2E Setup
|
||||||
|
|
||||||
|
### Installation
|
||||||
|
|
||||||
|
- `bun add -d @playwright/test`
|
||||||
|
- `bunx playwright install chromium` (only Chromium needed)
|
||||||
|
|
||||||
|
### Configuration: playwright.config.ts
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: "./e2e",
|
||||||
|
webServer: {
|
||||||
|
command: "DATABASE_PATH=./e2e/test.db bun run dev:server",
|
||||||
|
port: 3000,
|
||||||
|
reuseExistingServer: !process.env.CI,
|
||||||
|
},
|
||||||
|
use: {
|
||||||
|
baseURL: "http://localhost:3000",
|
||||||
|
},
|
||||||
|
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Database Seeding: e2e/seed.ts
|
||||||
|
|
||||||
|
Script that creates `e2e/test.db` with:
|
||||||
|
- Run Drizzle migrations against the file
|
||||||
|
- Seed data:
|
||||||
|
- 1 user (username: "admin", password: "password123")
|
||||||
|
- 3 categories: Shelter, Sleep System, Cook Kit
|
||||||
|
- 6 items across categories with realistic weights/prices
|
||||||
|
- 1 active thread with 3 candidates (with pros/cons, sort_order)
|
||||||
|
- 1 resolved thread
|
||||||
|
- 1 setup with 4 items (mixed classifications)
|
||||||
|
- Settings: weightUnit=g, currency=USD, onboardingComplete=true
|
||||||
|
|
||||||
|
Run before E2E tests via `e2e/global-setup.ts` (Playwright globalSetup).
|
||||||
|
|
||||||
|
### E2E Test Files
|
||||||
|
|
||||||
|
**e2e/dashboard.spec.ts**
|
||||||
|
- Dashboard page loads
|
||||||
|
- Summary cards show item count, weight, cost
|
||||||
|
- Navigation links to collection work
|
||||||
|
|
||||||
|
**e2e/collection.spec.ts**
|
||||||
|
- Gear tab renders items grouped by category
|
||||||
|
- Search input filters items by name
|
||||||
|
- Category filter dropdown works
|
||||||
|
- Tab switching between gear/planning/setups
|
||||||
|
|
||||||
|
**e2e/threads.spec.ts**
|
||||||
|
- Thread detail page loads with candidates
|
||||||
|
- Comparison view toggle works (shows table)
|
||||||
|
- Rank badges visible on candidates
|
||||||
|
|
||||||
|
**e2e/auth.spec.ts**
|
||||||
|
- Login page renders
|
||||||
|
- Login with valid credentials succeeds
|
||||||
|
- Login with wrong password shows error
|
||||||
|
- Rate limiting returns error after 5 attempts
|
||||||
|
|
||||||
|
**e2e/error-boundary.spec.ts**
|
||||||
|
- App doesn't white-screen on unknown routes
|
||||||
|
- Navigating to a non-existent thread/setup shows appropriate error
|
||||||
|
|
||||||
|
### Scripts
|
||||||
|
|
||||||
|
Add to package.json:
|
||||||
|
- `"test:e2e": "bunx playwright test"`
|
||||||
|
- `"test:e2e:ui": "bunx playwright test --ui"` (for debugging)
|
||||||
|
|
||||||
|
### Files to .gitignore
|
||||||
|
|
||||||
|
- `e2e/test.db`
|
||||||
|
- `test-results/`
|
||||||
|
- `playwright-report/`
|
||||||
|
|
||||||
|
## Commit Strategy
|
||||||
|
|
||||||
|
1. Unit tests for parseId, rate limiter, route params
|
||||||
|
2. Playwright setup (install, config, seed, global-setup)
|
||||||
|
3. Playwright E2E test files
|
||||||
35
docs/superpowers/specs/2026-04-03-user-menu-design.md
Normal file
35
docs/superpowers/specs/2026-04-03-user-menu-design.md
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# User Menu Design
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Replace the plain "Sign out" button in the header with a user icon that opens a dropdown menu containing Settings and Sign out options. This provides a way to navigate to the Settings page from the header.
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
### UserMenu (`src/client/components/UserMenu.tsx`)
|
||||||
|
|
||||||
|
New component rendered by `TotalsBar` when authenticated.
|
||||||
|
|
||||||
|
**Trigger:** Circular `CircleUser` icon button (Lucide). Styled consistently with surrounding header elements.
|
||||||
|
|
||||||
|
**Dropdown:** Absolutely-positioned popover anchored to the right edge, appearing below the icon:
|
||||||
|
|
||||||
|
1. **Settings** — `Settings` (gear) icon + "Settings" label, `<Link to="/settings">`
|
||||||
|
2. **Divider** — thin horizontal line
|
||||||
|
3. **Sign out** — `LogOut` icon + "Sign out" label, calls `logout.mutate()` from `useLogout()`
|
||||||
|
|
||||||
|
**Behavior:**
|
||||||
|
- Click icon toggles open/close
|
||||||
|
- Click outside closes (via `useEffect` with document click listener)
|
||||||
|
- Clicking a menu item closes the dropdown
|
||||||
|
- Dropdown anchored right so it doesn't overflow viewport
|
||||||
|
|
||||||
|
### TotalsBar Changes (`src/client/components/TotalsBar.tsx`)
|
||||||
|
|
||||||
|
- When `isAuthenticated`: render `<UserMenu />` in place of the current "Sign out" button
|
||||||
|
- When not authenticated: keep the existing "Sign in" link unchanged
|
||||||
|
- Remove the `useLogout` hook usage from TotalsBar (moved into UserMenu)
|
||||||
|
|
||||||
|
## No Backend Changes
|
||||||
|
|
||||||
|
The existing `/api/auth/me` endpoint and `useAuth` hook are sufficient. No username display needed — using a generic user icon.
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
# v1.4 Collection Tools Design
|
||||||
|
|
||||||
|
**Date:** 2026-04-03
|
||||||
|
**Milestone:** v1.4 Collection Tools
|
||||||
|
**Scope:** Setup impact preview, item quantity, CSV import/export, item duplication
|
||||||
|
|
||||||
|
## Feature 1: Setup Impact Preview
|
||||||
|
|
||||||
|
Already fully designed in `.planning/phases/13-setup-impact-preview/`. Two plans exist:
|
||||||
|
- **13-01**: Pure `computeImpactDeltas` function + `useImpactDeltas` hook + uiStore state (TDD)
|
||||||
|
- **13-02**: `SetupImpactSelector` + `ImpactDeltaBadge` components wired into thread detail
|
||||||
|
|
||||||
|
Execute the existing plans as-is. No design changes needed.
|
||||||
|
|
||||||
|
## Feature 2: Item Quantity
|
||||||
|
|
||||||
|
### Schema
|
||||||
|
|
||||||
|
Add `quantity INTEGER NOT NULL DEFAULT 1` to `items` table via Drizzle migration.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
quantity: integer("quantity").notNull().default(1),
|
||||||
|
```
|
||||||
|
|
||||||
|
### Validation
|
||||||
|
|
||||||
|
Add to `createItemSchema` in `src/shared/schemas.ts`:
|
||||||
|
```ts
|
||||||
|
quantity: z.number().int().positive().optional(),
|
||||||
|
```
|
||||||
|
|
||||||
|
Flows to `updateItemSchema` via `.partial()` automatically.
|
||||||
|
|
||||||
|
### Service Layer
|
||||||
|
|
||||||
|
No special business logic — quantity is a stored field.
|
||||||
|
|
||||||
|
**Totals computation changes:**
|
||||||
|
- `totals.service.ts`: `getCategoryTotals()` and `getGlobalTotals()` must multiply `weightGrams * quantity` and `priceCents * quantity` in their SQL SUM aggregations.
|
||||||
|
- `setup.service.ts`: `getSetupWithItems()` and `getAllSetups()` — when computing setup totals, multiply item weight/price by the item's quantity.
|
||||||
|
|
||||||
|
### UI
|
||||||
|
|
||||||
|
- **ItemForm**: Number input for quantity (min=1), placed below price field. Defaults to 1.
|
||||||
|
- **ItemCard**: Show "x2" badge next to item name when quantity > 1. No badge when quantity is 1.
|
||||||
|
- **Totals**: Already computed server-side with the quantity multiplication. No client-side changes for totals.
|
||||||
|
- **Setup weight/cost**: The item's quantity determines its weight/cost contribution when included in a setup (one `setup_items` row, but totals reflect quantity).
|
||||||
|
|
||||||
|
### Thread Resolution
|
||||||
|
|
||||||
|
When a thread is resolved and a candidate is copied to an item, the new item gets `quantity: 1` (default). No special handling needed.
|
||||||
|
|
||||||
|
## Feature 3: CSV Import/Export
|
||||||
|
|
||||||
|
### Export
|
||||||
|
|
||||||
|
**Endpoint:** `GET /api/items/export`
|
||||||
|
- Returns CSV with headers: `name,quantity,weightGrams,priceCents,category,notes,productUrl`
|
||||||
|
- `Content-Type: text/csv`
|
||||||
|
- `Content-Disposition: attachment; filename="gearbox-export.csv"`
|
||||||
|
- Weight in grams, price in cents (raw values, no formatting)
|
||||||
|
- Category column contains category name (not ID)
|
||||||
|
|
||||||
|
**Service:** `exportItemsCsv(db)` returns a CSV string. Joins items with categories for name lookup.
|
||||||
|
|
||||||
|
### Import
|
||||||
|
|
||||||
|
**Endpoint:** `POST /api/items/import`
|
||||||
|
- Accepts multipart form upload (CSV file)
|
||||||
|
- Parses rows, validates required fields (name is required, others optional)
|
||||||
|
- Category matching: looks up by name (case-insensitive). Creates new category if not found.
|
||||||
|
- Quantity defaults to 1 if not present in CSV
|
||||||
|
- Returns `{ imported: number, created_categories: string[], errors: string[] }`
|
||||||
|
- Skips rows with errors, continues processing remaining rows
|
||||||
|
|
||||||
|
**Service:** `importItemsCsv(db, csvContent: string)` parses and inserts items.
|
||||||
|
|
||||||
|
### UI
|
||||||
|
|
||||||
|
Settings page gets an "Import/Export" section:
|
||||||
|
- "Export CSV" button — triggers download via `GET /api/items/export`
|
||||||
|
- "Import CSV" file input — accepts .csv files, shows count of parsed rows, confirm button to upload
|
||||||
|
- Success/error feedback after import completes
|
||||||
|
|
||||||
|
## Feature 4: Item Duplication
|
||||||
|
|
||||||
|
### API
|
||||||
|
|
||||||
|
**Endpoint:** `POST /api/items/:id/duplicate`
|
||||||
|
- Copies all fields from source item: name, weightGrams, priceCents, categoryId, notes, productUrl, imageFilename, imageSourceUrl, quantity
|
||||||
|
- Appends " (copy)" to the name
|
||||||
|
- New `createdAt`/`updatedAt` timestamps
|
||||||
|
- Returns the new item
|
||||||
|
|
||||||
|
**Service:** `duplicateItem(db, id)` — fetches source item, inserts copy, returns new item.
|
||||||
|
|
||||||
|
### UI
|
||||||
|
|
||||||
|
- Add "Duplicate" action to ItemCard (alongside existing edit/delete actions)
|
||||||
|
- Duplicating opens the edit panel pre-filled with the new item so the user can rename or adjust
|
||||||
|
|
||||||
|
## Phase Ordering
|
||||||
|
|
||||||
|
1. **Item Quantity** — schema change first since CSV import/export and totals depend on it
|
||||||
|
2. **Setup Impact Preview** — execute existing Phase 13 plans
|
||||||
|
3. **Item Duplication** — small, self-contained
|
||||||
|
4. **CSV Import/Export** — depends on quantity field existing in schema
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- Quantity per setup (setup_items.quantity) — items table quantity is sufficient for v1.4
|
||||||
|
- CSV export with formatted weights/prices — raw values are more portable
|
||||||
|
- Image export/import via CSV — images are local files, not CSV-compatible
|
||||||
|
- Bulk edit from CSV preview — import creates, doesn't update existing items
|
||||||
2
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`);
|
||||||
1
drizzle/0008_loving_colossus.sql
Normal file
1
drizzle/0008_loving_colossus.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE `items` ADD `quantity` integer DEFAULT 1 NOT NULL;
|
||||||
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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
663
drizzle/meta/0008_snapshot.json
Normal file
663
drizzle/meta/0008_snapshot.json
Normal file
@@ -0,0 +1,663 @@
|
|||||||
|
{
|
||||||
|
"version": "6",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"id": "ede9f482-7af0-42bc-9672-43f5fba289d0",
|
||||||
|
"prevId": "738e67c5-ebad-46c1-9261-6ab60ec4bdb1",
|
||||||
|
"tables": {
|
||||||
|
"api_keys": {
|
||||||
|
"name": "api_keys",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"key_hash": {
|
||||||
|
"name": "key_hash",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"key_prefix": {
|
||||||
|
"name": "key_prefix",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"categories": {
|
||||||
|
"name": "categories",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"icon": {
|
||||||
|
"name": "icon",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'package'"
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"categories_name_unique": {
|
||||||
|
"name": "categories_name_unique",
|
||||||
|
"columns": [
|
||||||
|
"name"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"items": {
|
||||||
|
"name": "items",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"weight_grams": {
|
||||||
|
"name": "weight_grams",
|
||||||
|
"type": "real",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"price_cents": {
|
||||||
|
"name": "price_cents",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"category_id": {
|
||||||
|
"name": "category_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"notes": {
|
||||||
|
"name": "notes",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"product_url": {
|
||||||
|
"name": "product_url",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"image_filename": {
|
||||||
|
"name": "image_filename",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"image_source_url": {
|
||||||
|
"name": "image_source_url",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"quantity": {
|
||||||
|
"name": "quantity",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 1
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"items_category_id_categories_id_fk": {
|
||||||
|
"name": "items_category_id_categories_id_fk",
|
||||||
|
"tableFrom": "items",
|
||||||
|
"tableTo": "categories",
|
||||||
|
"columnsFrom": [
|
||||||
|
"category_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"sessions": {
|
||||||
|
"name": "sessions",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"sessions_user_id_users_id_fk": {
|
||||||
|
"name": "sessions_user_id_users_id_fk",
|
||||||
|
"tableFrom": "sessions",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"name": "settings",
|
||||||
|
"columns": {
|
||||||
|
"key": {
|
||||||
|
"name": "key",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"value": {
|
||||||
|
"name": "value",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"setup_items": {
|
||||||
|
"name": "setup_items",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"setup_id": {
|
||||||
|
"name": "setup_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"item_id": {
|
||||||
|
"name": "item_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"classification": {
|
||||||
|
"name": "classification",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'base'"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"setup_items_setup_id_setups_id_fk": {
|
||||||
|
"name": "setup_items_setup_id_setups_id_fk",
|
||||||
|
"tableFrom": "setup_items",
|
||||||
|
"tableTo": "setups",
|
||||||
|
"columnsFrom": [
|
||||||
|
"setup_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"setup_items_item_id_items_id_fk": {
|
||||||
|
"name": "setup_items_item_id_items_id_fk",
|
||||||
|
"tableFrom": "setup_items",
|
||||||
|
"tableTo": "items",
|
||||||
|
"columnsFrom": [
|
||||||
|
"item_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"setups": {
|
||||||
|
"name": "setups",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"thread_candidates": {
|
||||||
|
"name": "thread_candidates",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"thread_id": {
|
||||||
|
"name": "thread_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"weight_grams": {
|
||||||
|
"name": "weight_grams",
|
||||||
|
"type": "real",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"price_cents": {
|
||||||
|
"name": "price_cents",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"category_id": {
|
||||||
|
"name": "category_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"notes": {
|
||||||
|
"name": "notes",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"product_url": {
|
||||||
|
"name": "product_url",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"image_filename": {
|
||||||
|
"name": "image_filename",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"image_source_url": {
|
||||||
|
"name": "image_source_url",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"name": "status",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'researching'"
|
||||||
|
},
|
||||||
|
"pros": {
|
||||||
|
"name": "pros",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"cons": {
|
||||||
|
"name": "cons",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"sort_order": {
|
||||||
|
"name": "sort_order",
|
||||||
|
"type": "real",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"thread_candidates_thread_id_threads_id_fk": {
|
||||||
|
"name": "thread_candidates_thread_id_threads_id_fk",
|
||||||
|
"tableFrom": "thread_candidates",
|
||||||
|
"tableTo": "threads",
|
||||||
|
"columnsFrom": [
|
||||||
|
"thread_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"thread_candidates_category_id_categories_id_fk": {
|
||||||
|
"name": "thread_candidates_category_id_categories_id_fk",
|
||||||
|
"tableFrom": "thread_candidates",
|
||||||
|
"tableTo": "categories",
|
||||||
|
"columnsFrom": [
|
||||||
|
"category_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"threads": {
|
||||||
|
"name": "threads",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"name": "status",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'active'"
|
||||||
|
},
|
||||||
|
"resolved_candidate_id": {
|
||||||
|
"name": "resolved_candidate_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"category_id": {
|
||||||
|
"name": "category_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"threads_category_id_categories_id_fk": {
|
||||||
|
"name": "threads_category_id_categories_id_fk",
|
||||||
|
"tableFrom": "threads",
|
||||||
|
"tableTo": "categories",
|
||||||
|
"columnsFrom": [
|
||||||
|
"category_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"users": {
|
||||||
|
"name": "users",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"username": {
|
||||||
|
"name": "username",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"password_hash": {
|
||||||
|
"name": "password_hash",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"users_username_unique": {
|
||||||
|
"name": "users_username_unique",
|
||||||
|
"columns": [
|
||||||
|
"username"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"views": {},
|
||||||
|
"enums": {},
|
||||||
|
"_meta": {
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {},
|
||||||
|
"columns": {}
|
||||||
|
},
|
||||||
|
"internal": {
|
||||||
|
"indexes": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -43,6 +43,27 @@
|
|||||||
"when": 1773696058992,
|
"when": 1773696058992,
|
||||||
"tag": "0005_clear_micromax",
|
"tag": "0005_clear_micromax",
|
||||||
"breakpoints": true
|
"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
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 8,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1775232090363,
|
||||||
|
"tag": "0008_loving_colossus",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
47
e2e/auth.spec.ts
Normal file
47
e2e/auth.spec.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
test.describe("Authentication", () => {
|
||||||
|
test("login page renders at /login", async ({ page }) => {
|
||||||
|
await page.goto("/login");
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
|
||||||
|
// Should show the Sign In heading
|
||||||
|
await expect(page.getByRole("heading", { name: "Sign In" })).toBeVisible({
|
||||||
|
timeout: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should have username and password inputs
|
||||||
|
await expect(page.locator("#username")).toBeVisible({ timeout: 5000 });
|
||||||
|
await expect(page.locator("#password")).toBeVisible({ timeout: 5000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("login with valid credentials succeeds and redirects away from /login", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto("/login");
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
|
||||||
|
await page.locator("#username").fill("admin");
|
||||||
|
await page.locator("#password").fill("password123");
|
||||||
|
await page.getByRole("button", { name: "Sign In" }).click();
|
||||||
|
|
||||||
|
// After successful login, should redirect to / (dashboard)
|
||||||
|
await page.waitForURL("/", { timeout: 5000 });
|
||||||
|
await expect(page).not.toHaveURL(/\/login/);
|
||||||
|
await expect(page.getByText("GearBox")).toBeVisible({ timeout: 5000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("login with wrong password shows error", async ({ page }) => {
|
||||||
|
await page.goto("/login");
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
|
||||||
|
await page.locator("#username").fill("admin");
|
||||||
|
await page.locator("#password").fill("wrongpassword");
|
||||||
|
await page.getByRole("button", { name: "Sign In" }).click();
|
||||||
|
|
||||||
|
// Should stay on the login page and show an error message
|
||||||
|
await expect(page).toHaveURL(/\/login/, { timeout: 5000 });
|
||||||
|
// The error paragraph should be visible (login.tsx renders <p className="text-sm text-red-600">{error}</p>)
|
||||||
|
await expect(page.locator(".text-red-600")).toBeVisible({ timeout: 5000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
89
e2e/collection.spec.ts
Normal file
89
e2e/collection.spec.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
test.describe("Collection page", () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.goto("/collection");
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("Gear tab", () => {
|
||||||
|
test("shows seeded items", async ({ page }) => {
|
||||||
|
await expect(page.getByText("Zpacks Duplex")).toBeVisible();
|
||||||
|
await expect(page.getByText("BRS-3000T Stove")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("search filters items by name", async ({ page }) => {
|
||||||
|
const searchInput = page.getByPlaceholder("Search items...");
|
||||||
|
await searchInput.fill("Zpacks");
|
||||||
|
await expect(page.getByText("Zpacks Duplex")).toBeVisible();
|
||||||
|
// Other items should not be visible
|
||||||
|
await expect(page.getByText("BRS-3000T Stove")).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("clearing search restores all items", async ({ page }) => {
|
||||||
|
const searchInput = page.getByPlaceholder("Search items...");
|
||||||
|
await searchInput.fill("Zpacks");
|
||||||
|
await expect(page.getByText("BRS-3000T Stove")).not.toBeVisible();
|
||||||
|
// Clear the search
|
||||||
|
await searchInput.clear();
|
||||||
|
await expect(page.getByText("BRS-3000T Stove")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("category filter dropdown opens and lists categories", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const filterButton = page.getByRole("button", {
|
||||||
|
name: /all categories/i,
|
||||||
|
});
|
||||||
|
await filterButton.click();
|
||||||
|
|
||||||
|
// Dropdown list (ul) contains the category options
|
||||||
|
const dropdown = page.locator("ul");
|
||||||
|
await expect(
|
||||||
|
dropdown.getByRole("button", { name: "Shelter" }),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
dropdown.getByRole("button", { name: "Cook Kit" }),
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("category filter shows only items in selected category", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
// Open filter dropdown
|
||||||
|
const filterButton = page.getByRole("button", {
|
||||||
|
name: /all categories/i,
|
||||||
|
});
|
||||||
|
await filterButton.click();
|
||||||
|
|
||||||
|
// Select "Shelter" from the dropdown list
|
||||||
|
const dropdown = page.locator("ul");
|
||||||
|
await dropdown.getByRole("button", { name: "Shelter" }).click();
|
||||||
|
|
||||||
|
await expect(page.getByText("Zpacks Duplex")).toBeVisible();
|
||||||
|
// Items from other categories should not be visible
|
||||||
|
await expect(page.getByText("BRS-3000T Stove")).not.toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("Tab switching", () => {
|
||||||
|
test("navigates to planning tab", async ({ page }) => {
|
||||||
|
await page.goto("/collection?tab=planning");
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
// Planning tab shows the active thread
|
||||||
|
await expect(page.getByText("New Backpack")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("navigates to setups tab", async ({ page }) => {
|
||||||
|
await page.goto("/collection?tab=setups");
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
// Setups tab shows the seeded setup
|
||||||
|
await expect(page.getByText("Weekend Overnighter")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("gear tab is default and shows items", async ({ page }) => {
|
||||||
|
// Default tab (no ?tab param) shows gear
|
||||||
|
await expect(page.getByText("Zpacks Duplex")).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
56
e2e/dashboard.spec.ts
Normal file
56
e2e/dashboard.spec.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
test.describe("Dashboard", () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.goto("/");
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shows GearBox heading", async ({ page }) => {
|
||||||
|
await expect(page.getByText("GearBox")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shows collection card with item count of 6", async ({ page }) => {
|
||||||
|
// The Collection card link contains "Items" label and value "6"
|
||||||
|
const collectionCard = page
|
||||||
|
.getByRole("link", { name: /collection/i })
|
||||||
|
.first();
|
||||||
|
await expect(collectionCard).toBeVisible();
|
||||||
|
await expect(collectionCard.getByText("6")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shows Collection, Planning, and Setups card headings", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await expect(
|
||||||
|
page.getByRole("heading", { name: "Collection" }),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(page.getByRole("heading", { name: "Planning" })).toBeVisible();
|
||||||
|
await expect(page.getByRole("heading", { name: "Setups" })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Collection card links to /collection", async ({ page }) => {
|
||||||
|
const collectionLink = page
|
||||||
|
.getByRole("link", { name: /collection/i })
|
||||||
|
.first();
|
||||||
|
await collectionLink.click();
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
await expect(page).toHaveURL(/\/collection/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shows active thread count on Planning card", async ({ page }) => {
|
||||||
|
// The Planning card is a link containing "Active threads"
|
||||||
|
const planningCard = page.getByRole("link", { name: /planning/i });
|
||||||
|
await expect(planningCard.getByText("Active threads")).toBeVisible();
|
||||||
|
// Seed has 1 active thread
|
||||||
|
await expect(planningCard.getByText("1")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shows setup count on Setups card", async ({ page }) => {
|
||||||
|
// The Setups card has a heading "Setups"
|
||||||
|
await expect(page.getByRole("heading", { name: "Setups" })).toBeVisible();
|
||||||
|
// Seed has 1 setup
|
||||||
|
const setupsCard = page.getByRole("link", { name: /setups/i }).last();
|
||||||
|
await expect(setupsCard.getByText("1")).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
59
e2e/error-handling.spec.ts
Normal file
59
e2e/error-handling.spec.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
test.describe("Error handling — non-existent routes", () => {
|
||||||
|
test("non-existent thread does not white-screen", async ({ page }) => {
|
||||||
|
await page.goto("/threads/99999");
|
||||||
|
|
||||||
|
// React Query retries failed requests (default 3 times with backoff) before
|
||||||
|
// setting isError=true. Wait for the page to leave loading state and show content.
|
||||||
|
// The thread detail page renders "Thread not found" + "Back to planning" link on error.
|
||||||
|
await expect(page.getByText("Back to planning")).toBeVisible({
|
||||||
|
timeout: 30000,
|
||||||
|
});
|
||||||
|
await expect(page.getByText("Thread not found")).toBeVisible({
|
||||||
|
timeout: 5000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("non-existent setup does not white-screen", async ({ page }) => {
|
||||||
|
await page.goto("/setups/99999");
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
|
||||||
|
// Setup detail shows "Setup not found." when data is null (no retry — isLoading=false, data=undefined)
|
||||||
|
// The setup query resolves with undefined rather than throwing for missing items.
|
||||||
|
// Check that the page has content (not a blank screen).
|
||||||
|
const body = page.locator("body");
|
||||||
|
await expect(body).not.toBeEmpty();
|
||||||
|
|
||||||
|
// Navigation header should be visible (app did not crash)
|
||||||
|
await expect(page.getByText("GearBox")).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
|
// Wait for setup data to load; it will show "Setup not found." when done
|
||||||
|
await expect(page.getByText("Setup not found.")).toBeVisible({
|
||||||
|
timeout: 30000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("app recovers after bad route — dashboard loads fine", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
// Navigate to a non-existent thread first
|
||||||
|
await page.goto("/threads/99999");
|
||||||
|
|
||||||
|
// The page should render without crashing — wait for the error state
|
||||||
|
// (React Query retries before showing isError state, so use a long timeout)
|
||||||
|
await expect(page.getByText("Back to planning")).toBeVisible({
|
||||||
|
timeout: 30000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Now navigate to the dashboard
|
||||||
|
await page.goto("/");
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
|
||||||
|
// Dashboard should load normally
|
||||||
|
await expect(page.getByText("GearBox")).toBeVisible({ timeout: 5000 });
|
||||||
|
await expect(page.getByRole("heading", { name: "Collection" })).toBeVisible(
|
||||||
|
{ timeout: 5000 },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
10
e2e/global-setup.ts
Normal file
10
e2e/global-setup.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { seedTestDatabase } from "./seed";
|
||||||
|
|
||||||
|
export default async function globalSetup() {
|
||||||
|
await seedTestDatabase();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allow direct invocation: bun run e2e/global-setup.ts
|
||||||
|
if (import.meta.main) {
|
||||||
|
await globalSetup();
|
||||||
|
}
|
||||||
220
e2e/seed.ts
Normal file
220
e2e/seed.ts
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
import { Database } from "bun:sqlite";
|
||||||
|
import { unlink } from "node:fs/promises";
|
||||||
|
import { drizzle } from "drizzle-orm/bun-sqlite";
|
||||||
|
import { migrate } from "drizzle-orm/bun-sqlite/migrator";
|
||||||
|
import * as schema from "../src/db/schema";
|
||||||
|
|
||||||
|
const DB_PATH = "./e2e/test.db";
|
||||||
|
|
||||||
|
export async function seedTestDatabase() {
|
||||||
|
// Remove old test DB if it exists
|
||||||
|
try {
|
||||||
|
await unlink(DB_PATH);
|
||||||
|
} catch {
|
||||||
|
// File doesn't exist, that's fine
|
||||||
|
}
|
||||||
|
|
||||||
|
const sqlite = new Database(DB_PATH);
|
||||||
|
sqlite.run("PRAGMA journal_mode = WAL");
|
||||||
|
sqlite.run("PRAGMA foreign_keys = ON");
|
||||||
|
|
||||||
|
const db = drizzle(sqlite, { schema });
|
||||||
|
migrate(db, { migrationsFolder: "./drizzle" });
|
||||||
|
|
||||||
|
// ── Categories ──
|
||||||
|
const [uncategorized] = db
|
||||||
|
.insert(schema.categories)
|
||||||
|
.values({ name: "Uncategorized", icon: "package" })
|
||||||
|
.returning()
|
||||||
|
.all();
|
||||||
|
|
||||||
|
const [shelter] = db
|
||||||
|
.insert(schema.categories)
|
||||||
|
.values({ name: "Shelter", icon: "tent" })
|
||||||
|
.returning()
|
||||||
|
.all();
|
||||||
|
|
||||||
|
const [sleep] = db
|
||||||
|
.insert(schema.categories)
|
||||||
|
.values({ name: "Sleep System", icon: "moon" })
|
||||||
|
.returning()
|
||||||
|
.all();
|
||||||
|
|
||||||
|
const [cook] = db
|
||||||
|
.insert(schema.categories)
|
||||||
|
.values({ name: "Cook Kit", icon: "flame" })
|
||||||
|
.returning()
|
||||||
|
.all();
|
||||||
|
|
||||||
|
// ── Items ──
|
||||||
|
const tent = db
|
||||||
|
.insert(schema.items)
|
||||||
|
.values({
|
||||||
|
name: "Zpacks Duplex",
|
||||||
|
weightGrams: 539,
|
||||||
|
priceCents: 67900,
|
||||||
|
categoryId: shelter.id,
|
||||||
|
notes: "DCF shelter, 2-person",
|
||||||
|
})
|
||||||
|
.returning()
|
||||||
|
.get();
|
||||||
|
|
||||||
|
db.insert(schema.items)
|
||||||
|
.values({
|
||||||
|
name: "Borah Gear Tarp",
|
||||||
|
weightGrams: 156,
|
||||||
|
priceCents: 11000,
|
||||||
|
categoryId: shelter.id,
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
|
||||||
|
const quilt = db
|
||||||
|
.insert(schema.items)
|
||||||
|
.values({
|
||||||
|
name: "Enlightened Equipment Enigma 20",
|
||||||
|
weightGrams: 595,
|
||||||
|
priceCents: 34000,
|
||||||
|
categoryId: sleep.id,
|
||||||
|
notes: "20F quilt",
|
||||||
|
})
|
||||||
|
.returning()
|
||||||
|
.get();
|
||||||
|
|
||||||
|
const pad = db
|
||||||
|
.insert(schema.items)
|
||||||
|
.values({
|
||||||
|
name: "Therm-a-Rest NeoAir XLite",
|
||||||
|
weightGrams: 354,
|
||||||
|
priceCents: 20999,
|
||||||
|
categoryId: sleep.id,
|
||||||
|
})
|
||||||
|
.returning()
|
||||||
|
.get();
|
||||||
|
|
||||||
|
const stove = db
|
||||||
|
.insert(schema.items)
|
||||||
|
.values({
|
||||||
|
name: "BRS-3000T Stove",
|
||||||
|
weightGrams: 25,
|
||||||
|
priceCents: 2000,
|
||||||
|
categoryId: cook.id,
|
||||||
|
})
|
||||||
|
.returning()
|
||||||
|
.get();
|
||||||
|
|
||||||
|
db.insert(schema.items)
|
||||||
|
.values({
|
||||||
|
name: "Toaks 750ml Pot",
|
||||||
|
weightGrams: 103,
|
||||||
|
priceCents: 3000,
|
||||||
|
categoryId: cook.id,
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
|
||||||
|
// ── Active Thread with 3 Candidates ──
|
||||||
|
const activeThread = db
|
||||||
|
.insert(schema.threads)
|
||||||
|
.values({
|
||||||
|
name: "New Backpack",
|
||||||
|
status: "active",
|
||||||
|
categoryId: uncategorized.id,
|
||||||
|
})
|
||||||
|
.returning()
|
||||||
|
.get();
|
||||||
|
|
||||||
|
db.insert(schema.threadCandidates)
|
||||||
|
.values({
|
||||||
|
threadId: activeThread.id,
|
||||||
|
name: "ULA Circuit",
|
||||||
|
weightGrams: 1077,
|
||||||
|
priceCents: 27500,
|
||||||
|
categoryId: uncategorized.id,
|
||||||
|
pros: "Great hip belt\nLarge capacity",
|
||||||
|
cons: "Heavier than competitors",
|
||||||
|
sortOrder: 1000,
|
||||||
|
status: "researching",
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
|
||||||
|
db.insert(schema.threadCandidates)
|
||||||
|
.values({
|
||||||
|
threadId: activeThread.id,
|
||||||
|
name: "Gossamer Gear Mariposa",
|
||||||
|
weightGrams: 737,
|
||||||
|
priceCents: 28500,
|
||||||
|
categoryId: uncategorized.id,
|
||||||
|
pros: "Very lightweight\nGood ventilation",
|
||||||
|
cons: "Smaller hip belt pockets",
|
||||||
|
sortOrder: 2000,
|
||||||
|
status: "researching",
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
|
||||||
|
db.insert(schema.threadCandidates)
|
||||||
|
.values({
|
||||||
|
threadId: activeThread.id,
|
||||||
|
name: "Granite Gear Crown2 38",
|
||||||
|
weightGrams: 850,
|
||||||
|
priceCents: 18000,
|
||||||
|
categoryId: uncategorized.id,
|
||||||
|
sortOrder: 3000,
|
||||||
|
status: "ordered",
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
|
||||||
|
// ── Resolved Thread ──
|
||||||
|
const resolvedThread = db
|
||||||
|
.insert(schema.threads)
|
||||||
|
.values({
|
||||||
|
name: "Camp Stove",
|
||||||
|
status: "resolved",
|
||||||
|
categoryId: cook.id,
|
||||||
|
resolvedCandidateId: 1,
|
||||||
|
})
|
||||||
|
.returning()
|
||||||
|
.get();
|
||||||
|
|
||||||
|
db.insert(schema.threadCandidates)
|
||||||
|
.values({
|
||||||
|
threadId: resolvedThread.id,
|
||||||
|
name: "BRS-3000T",
|
||||||
|
weightGrams: 25,
|
||||||
|
priceCents: 2000,
|
||||||
|
categoryId: cook.id,
|
||||||
|
sortOrder: 1000,
|
||||||
|
status: "arrived",
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
|
||||||
|
// ── Setup with Items ──
|
||||||
|
const setup = db
|
||||||
|
.insert(schema.setups)
|
||||||
|
.values({ name: "Weekend Overnighter" })
|
||||||
|
.returning()
|
||||||
|
.get();
|
||||||
|
|
||||||
|
db.insert(schema.setupItems)
|
||||||
|
.values([
|
||||||
|
{ setupId: setup.id, itemId: tent.id, classification: "base" },
|
||||||
|
{ setupId: setup.id, itemId: quilt.id, classification: "base" },
|
||||||
|
{ setupId: setup.id, itemId: pad.id, classification: "base" },
|
||||||
|
{ setupId: setup.id, itemId: stove.id, classification: "consumable" },
|
||||||
|
])
|
||||||
|
.run();
|
||||||
|
|
||||||
|
// ── User ──
|
||||||
|
const passwordHash = await Bun.password.hash("password123");
|
||||||
|
db.insert(schema.users).values({ username: "admin", passwordHash }).run();
|
||||||
|
|
||||||
|
// ── Settings ──
|
||||||
|
db.insert(schema.settings)
|
||||||
|
.values([
|
||||||
|
{ key: "weightUnit", value: "g" },
|
||||||
|
{ key: "currency", value: "USD" },
|
||||||
|
{ key: "onboardingComplete", value: "true" },
|
||||||
|
])
|
||||||
|
.run();
|
||||||
|
|
||||||
|
sqlite.close();
|
||||||
|
console.log("E2E test database seeded at", DB_PATH);
|
||||||
|
}
|
||||||
4
e2e/start-test-server.sh
Executable file
4
e2e/start-test-server.sh
Executable file
@@ -0,0 +1,4 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Seed the test database, then start the production server
|
||||||
|
bun run e2e/global-setup.ts
|
||||||
|
NODE_ENV=production DATABASE_PATH=./e2e/test.db bun run src/server/index.ts
|
||||||
113
e2e/threads.spec.ts
Normal file
113
e2e/threads.spec.ts
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
test.describe("Thread detail page", () => {
|
||||||
|
test("loads with candidates visible", async ({ page }) => {
|
||||||
|
await page.goto("/collection?tab=planning");
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
|
||||||
|
// Click the "New Backpack" thread card
|
||||||
|
await page.getByText("New Backpack").click();
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
|
||||||
|
// Thread detail page should show the thread name
|
||||||
|
await expect(
|
||||||
|
page.getByRole("heading", { name: "New Backpack" }),
|
||||||
|
).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
|
// Candidates should be visible
|
||||||
|
await expect(page.getByText("ULA Circuit")).toBeVisible({ timeout: 5000 });
|
||||||
|
await expect(page.getByText("Gossamer Gear Mariposa")).toBeVisible({
|
||||||
|
timeout: 5000,
|
||||||
|
});
|
||||||
|
await expect(page.getByText("Granite Gear Crown2 38")).toBeVisible({
|
||||||
|
timeout: 5000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rank badges are visible for top 3 candidates", async ({ page }) => {
|
||||||
|
await page.goto("/collection?tab=planning");
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
|
||||||
|
await page.getByText("New Backpack").click();
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
|
||||||
|
// Rank badges are medal icons — the component renders LucideIcon with name="medal"
|
||||||
|
// for ranks 1, 2, 3. We can verify via SVG elements or by checking the page has
|
||||||
|
// the expected number of medal icons (one per top candidate).
|
||||||
|
// The list view is default, which renders CandidateListItem with RankBadge.
|
||||||
|
// With 3 candidates all in top 3, all 3 get medal icons.
|
||||||
|
await expect(page.locator("text=ULA Circuit")).toBeVisible({
|
||||||
|
timeout: 5000,
|
||||||
|
});
|
||||||
|
await expect(page.locator("text=Gossamer Gear Mariposa")).toBeVisible({
|
||||||
|
timeout: 5000,
|
||||||
|
});
|
||||||
|
await expect(page.locator("text=Granite Gear Crown2 38")).toBeVisible({
|
||||||
|
timeout: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify candidates are rendered (rank badges are SVG medals, not text)
|
||||||
|
// Check that at least the 3 candidates are present in the list
|
||||||
|
const candidateRows = page.locator(".bg-white.rounded-xl.border");
|
||||||
|
await expect(candidateRows).toHaveCount(3, { timeout: 5000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("comparison view toggle works", async ({ page }) => {
|
||||||
|
await page.goto("/collection?tab=planning");
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
|
||||||
|
await page.getByText("New Backpack").click();
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
|
||||||
|
await expect(page.getByText("ULA Circuit")).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
|
// The compare button is a button with title="Compare view" (icon button with columns-3 icon)
|
||||||
|
const compareButton = page.getByRole("button", { name: "Compare view" });
|
||||||
|
await expect(compareButton).toBeVisible({ timeout: 5000 });
|
||||||
|
await compareButton.click();
|
||||||
|
|
||||||
|
// After clicking, a table should appear with Weight and Price row labels
|
||||||
|
await expect(page.locator("table")).toBeVisible({ timeout: 5000 });
|
||||||
|
// The comparison table renders row labels in sticky <td> cells (exact match to avoid
|
||||||
|
// matching candidate notes that contain the word "weight" or "price")
|
||||||
|
await expect(
|
||||||
|
page.getByRole("cell", { name: "Weight", exact: true }),
|
||||||
|
).toBeVisible({ timeout: 5000 });
|
||||||
|
await expect(
|
||||||
|
page.getByRole("cell", { name: "Price", exact: true }),
|
||||||
|
).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
|
// All 3 candidates should appear as table column headers (in <thead>)
|
||||||
|
await expect(page.locator("thead").getByText("ULA Circuit")).toBeVisible({
|
||||||
|
timeout: 5000,
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
page.locator("thead").getByText("Gossamer Gear Mariposa"),
|
||||||
|
).toBeVisible({ timeout: 5000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resolved thread shows winner banner", async ({ page }) => {
|
||||||
|
await page.goto("/collection?tab=planning");
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
|
||||||
|
// Click the "Resolved" tab pill button in the planning view
|
||||||
|
await page.getByRole("button", { name: "Resolved" }).click();
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
|
||||||
|
// Camp Stove resolved thread should appear
|
||||||
|
await expect(page.getByText("Camp Stove")).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
|
// Click the Camp Stove thread card and wait for URL to change to thread detail
|
||||||
|
await page.getByText("Camp Stove").click();
|
||||||
|
await page.waitForURL(/\/threads\/\d+/, { timeout: 5000 });
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
|
||||||
|
// Should show the resolved thread heading
|
||||||
|
await expect(page.getByRole("heading", { name: "Camp Stove" })).toBeVisible(
|
||||||
|
{ timeout: 5000 },
|
||||||
|
);
|
||||||
|
|
||||||
|
// The winner candidate (BRS-3000T) should be visible in the candidate list
|
||||||
|
await expect(page.getByText("BRS-3000T")).toBeVisible({ timeout: 5000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,16 +4,20 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"dev": "concurrently -k -c \"blue,green\" -n \"server,client\" \"bun run dev:server\" \"bun run dev:client\"",
|
||||||
"dev:client": "vite",
|
"dev:client": "vite",
|
||||||
"dev:server": "bun --hot src/server/index.ts",
|
"dev:server": "bun --hot src/server/index.ts",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"db:generate": "bunx drizzle-kit generate",
|
"db:generate": "bunx drizzle-kit generate",
|
||||||
"db:push": "bunx drizzle-kit push",
|
"db:push": "bunx drizzle-kit push",
|
||||||
"test": "bun test",
|
"test": "bun test tests/",
|
||||||
|
"test:e2e": "bunx playwright test",
|
||||||
|
"test:e2e:ui": "bunx playwright test --ui",
|
||||||
"lint": "bunx @biomejs/biome check ."
|
"lint": "bunx @biomejs/biome check ."
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^2.4.7",
|
"@biomejs/biome": "^2.4.7",
|
||||||
|
"@playwright/test": "^1.59.1",
|
||||||
"@tanstack/react-query-devtools": "^5.91.3",
|
"@tanstack/react-query-devtools": "^5.91.3",
|
||||||
"@tanstack/react-router-devtools": "^1.166.7",
|
"@tanstack/react-router-devtools": "^1.166.7",
|
||||||
"@tanstack/router-plugin": "^1.166.9",
|
"@tanstack/router-plugin": "^1.166.9",
|
||||||
@@ -23,6 +27,7 @@
|
|||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^6.0.1",
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
"better-sqlite3": "^12.8.0",
|
"better-sqlite3": "^12.8.0",
|
||||||
|
"concurrently": "^9.1.2",
|
||||||
"drizzle-kit": "^0.31.9",
|
"drizzle-kit": "^0.31.9",
|
||||||
"vite": "^8.0.0"
|
"vite": "^8.0.0"
|
||||||
},
|
},
|
||||||
@@ -31,11 +36,13 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hono/zod-validator": "^0.7.6",
|
"@hono/zod-validator": "^0.7.6",
|
||||||
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||||
"@tailwindcss/vite": "^4.2.1",
|
"@tailwindcss/vite": "^4.2.1",
|
||||||
"@tanstack/react-query": "^5.90.21",
|
"@tanstack/react-query": "^5.90.21",
|
||||||
"@tanstack/react-router": "^1.167.0",
|
"@tanstack/react-router": "^1.167.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"drizzle-orm": "^0.45.1",
|
"drizzle-orm": "^0.45.1",
|
||||||
|
"framer-motion": "^12.38.0",
|
||||||
"hono": "^4.12.8",
|
"hono": "^4.12.8",
|
||||||
"lucide-react": "^0.577.0",
|
"lucide-react": "^0.577.0",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
|
|||||||
25
playwright.config.ts
Normal file
25
playwright.config.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { defineConfig, devices } from "@playwright/test";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: "./e2e",
|
||||||
|
fullyParallel: false,
|
||||||
|
retries: 0,
|
||||||
|
workers: 1,
|
||||||
|
reporter: "list",
|
||||||
|
use: {
|
||||||
|
baseURL: "http://localhost:3000",
|
||||||
|
trace: "on-first-retry",
|
||||||
|
},
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
name: "chromium",
|
||||||
|
use: { ...devices["Desktop Chrome"] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
webServer: {
|
||||||
|
command: "sh e2e/start-test-server.sh",
|
||||||
|
port: 3000,
|
||||||
|
reuseExistingServer: !process.env.CI,
|
||||||
|
timeout: 30000,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import { useCurrency } from "../hooks/useCurrency";
|
import { useFormatters } from "../hooks/useFormatters";
|
||||||
import { useWeightUnit } from "../hooks/useWeightUnit";
|
import type { CandidateDelta } from "../hooks/useImpactDeltas";
|
||||||
import { formatPrice, formatWeight } from "../lib/formatters";
|
|
||||||
import { LucideIcon } from "../lib/iconData";
|
import { LucideIcon } from "../lib/iconData";
|
||||||
import { useUIStore } from "../stores/uiStore";
|
import { useUIStore } from "../stores/uiStore";
|
||||||
import { RankBadge } from "./CandidateListItem";
|
import { RankBadge } from "./CandidateListItem";
|
||||||
|
import { ImpactDeltaBadge } from "./ImpactDeltaBadge";
|
||||||
import { StatusBadge } from "./StatusBadge";
|
import { StatusBadge } from "./StatusBadge";
|
||||||
|
|
||||||
interface CandidateCardProps {
|
interface CandidateCardProps {
|
||||||
@@ -22,6 +22,7 @@ interface CandidateCardProps {
|
|||||||
pros?: string | null;
|
pros?: string | null;
|
||||||
cons?: string | null;
|
cons?: string | null;
|
||||||
rank?: number;
|
rank?: number;
|
||||||
|
delta?: CandidateDelta;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CandidateCard({
|
export function CandidateCard({
|
||||||
@@ -40,9 +41,9 @@ export function CandidateCard({
|
|||||||
pros,
|
pros,
|
||||||
cons,
|
cons,
|
||||||
rank,
|
rank,
|
||||||
|
delta,
|
||||||
}: CandidateCardProps) {
|
}: CandidateCardProps) {
|
||||||
const unit = useWeightUnit();
|
const { weight, price } = useFormatters();
|
||||||
const currency = useCurrency();
|
|
||||||
const openCandidateEditPanel = useUIStore((s) => s.openCandidateEditPanel);
|
const openCandidateEditPanel = useUIStore((s) => s.openCandidateEditPanel);
|
||||||
const openConfirmDeleteCandidate = useUIStore(
|
const openConfirmDeleteCandidate = useUIStore(
|
||||||
(s) => s.openConfirmDeleteCandidate,
|
(s) => s.openConfirmDeleteCandidate,
|
||||||
@@ -165,14 +166,16 @@ export function CandidateCard({
|
|||||||
{rank != null && <RankBadge rank={rank} />}
|
{rank != null && <RankBadge rank={rank} />}
|
||||||
{weightGrams != null && (
|
{weightGrams != null && (
|
||||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-50 text-blue-400">
|
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-50 text-blue-400">
|
||||||
{formatWeight(weightGrams, unit)}
|
{weight(weightGrams)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
<ImpactDeltaBadge delta={delta} type="weight" formatFn={weight} />
|
||||||
{priceCents != null && (
|
{priceCents != null && (
|
||||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-50 text-green-500">
|
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-50 text-green-500">
|
||||||
{formatPrice(priceCents, currency)}
|
{price(priceCents)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
<ImpactDeltaBadge delta={delta} type="price" formatFn={price} />
|
||||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-50 text-gray-600">
|
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-50 text-gray-600">
|
||||||
<LucideIcon
|
<LucideIcon
|
||||||
name={categoryIcon}
|
name={categoryIcon}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { Reorder, useDragControls } from "framer-motion";
|
import { Reorder } from "framer-motion";
|
||||||
import { useCurrency } from "../hooks/useCurrency";
|
import { useRef } from "react";
|
||||||
import { useWeightUnit } from "../hooks/useWeightUnit";
|
import { useFormatters } from "../hooks/useFormatters";
|
||||||
import { formatPrice, formatWeight } from "../lib/formatters";
|
import type { CandidateDelta } from "../hooks/useImpactDeltas";
|
||||||
import { LucideIcon } from "../lib/iconData";
|
import { LucideIcon } from "../lib/iconData";
|
||||||
import { useUIStore } from "../stores/uiStore";
|
import { useUIStore } from "../stores/uiStore";
|
||||||
|
import { ImpactDeltaBadge } from "./ImpactDeltaBadge";
|
||||||
import { StatusBadge } from "./StatusBadge";
|
import { StatusBadge } from "./StatusBadge";
|
||||||
|
|
||||||
interface CandidateWithCategory {
|
interface CandidateWithCategory {
|
||||||
@@ -30,6 +31,8 @@ interface CandidateListItemProps {
|
|||||||
rank: number;
|
rank: number;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
onStatusChange: (status: "researching" | "ordered" | "arrived") => void;
|
onStatusChange: (status: "researching" | "ordered" | "arrived") => void;
|
||||||
|
delta?: CandidateDelta;
|
||||||
|
onDragEnd?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const RANK_COLORS = ["#D4AF37", "#C0C0C0", "#CD7F32"]; // gold, silver, bronze
|
const RANK_COLORS = ["#D4AF37", "#C0C0C0", "#CD7F32"]; // gold, silver, bronze
|
||||||
@@ -51,10 +54,11 @@ export function CandidateListItem({
|
|||||||
rank,
|
rank,
|
||||||
isActive,
|
isActive,
|
||||||
onStatusChange,
|
onStatusChange,
|
||||||
|
delta,
|
||||||
|
onDragEnd,
|
||||||
}: CandidateListItemProps) {
|
}: CandidateListItemProps) {
|
||||||
const controls = useDragControls();
|
const isDragging = useRef(false);
|
||||||
const unit = useWeightUnit();
|
const { weight, price } = useFormatters();
|
||||||
const currency = useCurrency();
|
|
||||||
const openCandidateEditPanel = useUIStore((s) => s.openCandidateEditPanel);
|
const openCandidateEditPanel = useUIStore((s) => s.openCandidateEditPanel);
|
||||||
const openConfirmDeleteCandidate = useUIStore(
|
const openConfirmDeleteCandidate = useUIStore(
|
||||||
(s) => s.openConfirmDeleteCandidate,
|
(s) => s.openConfirmDeleteCandidate,
|
||||||
@@ -62,23 +66,16 @@ export function CandidateListItem({
|
|||||||
const openResolveDialog = useUIStore((s) => s.openResolveDialog);
|
const openResolveDialog = useUIStore((s) => s.openResolveDialog);
|
||||||
const openExternalLink = useUIStore((s) => s.openExternalLink);
|
const openExternalLink = useUIStore((s) => s.openExternalLink);
|
||||||
|
|
||||||
return (
|
const sharedClassName =
|
||||||
<Reorder.Item
|
"flex items-center gap-3 bg-white rounded-xl border border-gray-100 p-3 hover:border-gray-200 hover:shadow-sm group cursor-default";
|
||||||
value={candidate}
|
|
||||||
dragControls={controls}
|
const innerContent = (
|
||||||
dragListener={false}
|
<>
|
||||||
className="flex items-center gap-3 bg-white rounded-xl border border-gray-100 p-3 hover:border-gray-200 hover:shadow-sm transition-all group cursor-default"
|
{/* Drag handle indicator */}
|
||||||
>
|
|
||||||
{/* Drag handle */}
|
|
||||||
{isActive && (
|
{isActive && (
|
||||||
<button
|
<span className="text-gray-300 shrink-0">
|
||||||
type="button"
|
|
||||||
onPointerDown={(e) => controls.start(e)}
|
|
||||||
className="cursor-grab active:cursor-grabbing text-gray-300 hover:text-gray-500 touch-none shrink-0"
|
|
||||||
title="Drag to reorder"
|
|
||||||
>
|
|
||||||
<LucideIcon name="grip-vertical" size={16} />
|
<LucideIcon name="grip-vertical" size={16} />
|
||||||
</button>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Rank badge */}
|
{/* Rank badge */}
|
||||||
@@ -104,7 +101,10 @@ export function CandidateListItem({
|
|||||||
{/* Name + badges */}
|
{/* Name + badges */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => openCandidateEditPanel(candidate.id)}
|
onClick={() => {
|
||||||
|
if (isDragging.current) return;
|
||||||
|
openCandidateEditPanel(candidate.id);
|
||||||
|
}}
|
||||||
className="flex-1 min-w-0 text-left"
|
className="flex-1 min-w-0 text-left"
|
||||||
>
|
>
|
||||||
<p className="text-sm font-semibold text-gray-900 truncate">
|
<p className="text-sm font-semibold text-gray-900 truncate">
|
||||||
@@ -113,14 +113,16 @@ export function CandidateListItem({
|
|||||||
<div className="flex flex-wrap gap-1.5 mt-1">
|
<div className="flex flex-wrap gap-1.5 mt-1">
|
||||||
{candidate.weightGrams != null && (
|
{candidate.weightGrams != null && (
|
||||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-50 text-blue-400">
|
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-50 text-blue-400">
|
||||||
{formatWeight(candidate.weightGrams, unit)}
|
{weight(candidate.weightGrams)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
<ImpactDeltaBadge delta={delta} type="weight" formatFn={weight} />
|
||||||
{candidate.priceCents != null && (
|
{candidate.priceCents != null && (
|
||||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-50 text-green-500">
|
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-50 text-green-500">
|
||||||
{formatPrice(candidate.priceCents, currency)}
|
{price(candidate.priceCents)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
<ImpactDeltaBadge delta={delta} type="price" formatFn={price} />
|
||||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-50 text-gray-600">
|
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-50 text-gray-600">
|
||||||
<LucideIcon
|
<LucideIcon
|
||||||
name={candidate.categoryIcon}
|
name={candidate.categoryIcon}
|
||||||
@@ -206,6 +208,31 @@ export function CandidateListItem({
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</Reorder.Item>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Reorder.Item requires a Reorder.Group parent — only use it in active threads
|
||||||
|
if (isActive) {
|
||||||
|
return (
|
||||||
|
<Reorder.Item
|
||||||
|
value={candidate}
|
||||||
|
onDragStart={() => {
|
||||||
|
isDragging.current = true;
|
||||||
|
}}
|
||||||
|
onDragEnd={() => {
|
||||||
|
setTimeout(() => {
|
||||||
|
isDragging.current = false;
|
||||||
|
}, 0);
|
||||||
|
onDragEnd?.();
|
||||||
|
}}
|
||||||
|
whileDrag={{ cursor: "grabbing" }}
|
||||||
|
style={{ marginBottom: 8, cursor: "grab" }}
|
||||||
|
className={sharedClassName}
|
||||||
|
>
|
||||||
|
{innerContent}
|
||||||
|
</Reorder.Item>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div className={sharedClassName}>{innerContent}</div>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useDeleteCategory, useUpdateCategory } from "../hooks/useCategories";
|
import { useDeleteCategory, useUpdateCategory } from "../hooks/useCategories";
|
||||||
import { useCurrency } from "../hooks/useCurrency";
|
import { useFormatters } from "../hooks/useFormatters";
|
||||||
import { useWeightUnit } from "../hooks/useWeightUnit";
|
|
||||||
import { formatPrice, formatWeight } from "../lib/formatters";
|
|
||||||
import { LucideIcon } from "../lib/iconData";
|
import { LucideIcon } from "../lib/iconData";
|
||||||
import { IconPicker } from "./IconPicker";
|
import { IconPicker } from "./IconPicker";
|
||||||
|
|
||||||
@@ -23,8 +21,7 @@ export function CategoryHeader({
|
|||||||
totalCost,
|
totalCost,
|
||||||
itemCount,
|
itemCount,
|
||||||
}: CategoryHeaderProps) {
|
}: CategoryHeaderProps) {
|
||||||
const unit = useWeightUnit();
|
const { weight, price } = useFormatters();
|
||||||
const currency = useCurrency();
|
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
const [editName, setEditName] = useState(name);
|
const [editName, setEditName] = useState(name);
|
||||||
const [editIcon, setEditIcon] = useState(icon);
|
const [editIcon, setEditIcon] = useState(icon);
|
||||||
@@ -88,8 +85,8 @@ export function CategoryHeader({
|
|||||||
<LucideIcon name={icon} size={22} className="text-gray-500" />
|
<LucideIcon name={icon} size={22} className="text-gray-500" />
|
||||||
<h2 className="text-lg font-semibold text-gray-900">{name}</h2>
|
<h2 className="text-lg font-semibold text-gray-900">{name}</h2>
|
||||||
<span className="text-sm text-gray-400">
|
<span className="text-sm text-gray-400">
|
||||||
{itemCount} {itemCount === 1 ? "item" : "items"} ·{" "}
|
{itemCount} {itemCount === 1 ? "item" : "items"} · {weight(totalWeight)}{" "}
|
||||||
{formatWeight(totalWeight, unit)} · {formatPrice(totalCost, currency)}
|
· {price(totalCost)}
|
||||||
</span>
|
</span>
|
||||||
{!isUncategorized && (
|
{!isUncategorized && (
|
||||||
<div className="ml-auto flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
<div className="ml-auto flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
|||||||
276
src/client/components/CollectionView.tsx
Normal file
276
src/client/components/CollectionView.tsx
Normal file
@@ -0,0 +1,276 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { useCategories } from "../hooks/useCategories";
|
||||||
|
import { useFormatters } from "../hooks/useFormatters";
|
||||||
|
import { useItems } from "../hooks/useItems";
|
||||||
|
import { useTotals } from "../hooks/useTotals";
|
||||||
|
import { LucideIcon } from "../lib/iconData";
|
||||||
|
import { useUIStore } from "../stores/uiStore";
|
||||||
|
import { CategoryFilterDropdown } from "./CategoryFilterDropdown";
|
||||||
|
import { CategoryHeader } from "./CategoryHeader";
|
||||||
|
import { ItemCard } from "./ItemCard";
|
||||||
|
|
||||||
|
export function CollectionView() {
|
||||||
|
const { data: items, isLoading: itemsLoading } = useItems();
|
||||||
|
const { data: totals } = useTotals();
|
||||||
|
const { data: categories } = useCategories();
|
||||||
|
const { weight, price } = useFormatters();
|
||||||
|
const openAddPanel = useUIStore((s) => s.openAddPanel);
|
||||||
|
|
||||||
|
const [searchText, setSearchText] = useState("");
|
||||||
|
const [categoryFilter, setCategoryFilter] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const filteredItems = useMemo(() => {
|
||||||
|
if (!items) return [];
|
||||||
|
return items.filter((item) => {
|
||||||
|
const matchesSearch =
|
||||||
|
searchText === "" ||
|
||||||
|
item.name.toLowerCase().includes(searchText.toLowerCase());
|
||||||
|
const matchesCategory =
|
||||||
|
categoryFilter === null || item.categoryId === categoryFilter;
|
||||||
|
return matchesSearch && matchesCategory;
|
||||||
|
});
|
||||||
|
}, [items, searchText, categoryFilter]);
|
||||||
|
|
||||||
|
const hasActiveFilters = searchText !== "" || categoryFilter !== null;
|
||||||
|
|
||||||
|
if (itemsLoading) {
|
||||||
|
return (
|
||||||
|
<div className="animate-pulse space-y-6">
|
||||||
|
<div className="h-6 bg-gray-200 rounded w-48" />
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<div key={i} className="h-40 bg-gray-200 rounded-xl" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!items || items.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="py-16 text-center">
|
||||||
|
<div className="max-w-md mx-auto">
|
||||||
|
<div className="mb-4">
|
||||||
|
<LucideIcon
|
||||||
|
name="backpack"
|
||||||
|
size={48}
|
||||||
|
className="text-gray-400 mx-auto"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900 mb-2">
|
||||||
|
Your collection is empty
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-gray-500 mb-6">
|
||||||
|
Start cataloging your gear by adding your first item. Track weight,
|
||||||
|
price, and organize by category.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={openAddPanel}
|
||||||
|
className="inline-flex items-center gap-2 px-5 py-2.5 bg-gray-700 hover:bg-gray-800 text-white text-sm font-medium rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
className="w-4 h-4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M12 4v16m8-8H4"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Add your first item
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build category totals lookup
|
||||||
|
const categoryTotalsMap = new Map<
|
||||||
|
number,
|
||||||
|
{ totalWeight: number; totalCost: number; itemCount: number }
|
||||||
|
>();
|
||||||
|
if (totals?.categories) {
|
||||||
|
for (const ct of totals.categories) {
|
||||||
|
categoryTotalsMap.set(ct.categoryId, {
|
||||||
|
totalWeight: ct.totalWeight,
|
||||||
|
totalCost: ct.totalCost,
|
||||||
|
itemCount: ct.itemCount,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group filtered items by categoryId (used when no active filters)
|
||||||
|
const groupedItems = new Map<
|
||||||
|
number,
|
||||||
|
{
|
||||||
|
items: typeof filteredItems;
|
||||||
|
categoryName: string;
|
||||||
|
categoryIcon: string;
|
||||||
|
}
|
||||||
|
>();
|
||||||
|
|
||||||
|
for (const item of filteredItems) {
|
||||||
|
const group = groupedItems.get(item.categoryId);
|
||||||
|
if (group) {
|
||||||
|
group.items.push(item);
|
||||||
|
} else {
|
||||||
|
groupedItems.set(item.categoryId, {
|
||||||
|
items: [item],
|
||||||
|
categoryName: item.categoryName,
|
||||||
|
categoryIcon: item.categoryIcon,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Collection stats card */}
|
||||||
|
{totals?.global && (
|
||||||
|
<div className="bg-white rounded-xl border border-gray-100 p-5 mb-6">
|
||||||
|
<div className="flex items-center gap-8">
|
||||||
|
<div className="flex flex-col items-center gap-1">
|
||||||
|
<LucideIcon name="layers" size={14} className="text-gray-400" />
|
||||||
|
<span className="text-xs text-gray-500">Items</span>
|
||||||
|
<span className="text-sm font-semibold text-gray-900">
|
||||||
|
{totals.global.itemCount}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-center gap-1">
|
||||||
|
<LucideIcon name="weight" size={14} className="text-gray-400" />
|
||||||
|
<span className="text-xs text-gray-500">Total Weight</span>
|
||||||
|
<span className="text-sm font-semibold text-gray-900">
|
||||||
|
{weight(totals.global.totalWeight)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-center gap-1">
|
||||||
|
<LucideIcon
|
||||||
|
name="credit-card"
|
||||||
|
size={14}
|
||||||
|
className="text-gray-400"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-gray-500">Total Spent</span>
|
||||||
|
<span className="text-sm font-semibold text-gray-900">
|
||||||
|
{price(totals.global.totalCost)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Search/filter toolbar */}
|
||||||
|
<div className="sticky top-0 z-10 bg-gray-50/95 backdrop-blur-sm border-b border-gray-100 -mx-4 px-4 py-3 sm:-mx-6 sm:px-6 lg:-mx-8 lg:px-8 mb-6">
|
||||||
|
<div className="flex gap-3 items-center">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search items..."
|
||||||
|
value={searchText}
|
||||||
|
onChange={(e) => setSearchText(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
{searchText && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSearchText("")}
|
||||||
|
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="w-4 h-4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M6 18L18 6M6 6l12 12"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<CategoryFilterDropdown
|
||||||
|
value={categoryFilter}
|
||||||
|
onChange={setCategoryFilter}
|
||||||
|
categories={categories ?? []}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{hasActiveFilters && (
|
||||||
|
<p className="text-xs text-gray-500 mt-2">
|
||||||
|
Showing {filteredItems.length} of {items.length} items
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filtered results */}
|
||||||
|
{hasActiveFilters ? (
|
||||||
|
filteredItems.length === 0 ? (
|
||||||
|
<div className="py-12 text-center">
|
||||||
|
<p className="text-sm text-gray-500">No items match your search</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{filteredItems.map((item) => (
|
||||||
|
<ItemCard
|
||||||
|
key={item.id}
|
||||||
|
id={item.id}
|
||||||
|
name={item.name}
|
||||||
|
weightGrams={item.weightGrams}
|
||||||
|
priceCents={item.priceCents}
|
||||||
|
quantity={item.quantity}
|
||||||
|
categoryName={item.categoryName}
|
||||||
|
categoryIcon={item.categoryIcon}
|
||||||
|
imageFilename={item.imageFilename}
|
||||||
|
productUrl={item.productUrl}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
Array.from(groupedItems.entries()).map(
|
||||||
|
([
|
||||||
|
categoryId,
|
||||||
|
{ items: categoryItems, categoryName, categoryIcon },
|
||||||
|
]) => {
|
||||||
|
const catTotals = categoryTotalsMap.get(categoryId);
|
||||||
|
return (
|
||||||
|
<div key={categoryId} className="mb-8">
|
||||||
|
<CategoryHeader
|
||||||
|
categoryId={categoryId}
|
||||||
|
name={categoryName}
|
||||||
|
icon={categoryIcon}
|
||||||
|
totalWeight={catTotals?.totalWeight ?? 0}
|
||||||
|
totalCost={catTotals?.totalCost ?? 0}
|
||||||
|
itemCount={catTotals?.itemCount ?? categoryItems.length}
|
||||||
|
/>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{categoryItems.map((item) => (
|
||||||
|
<ItemCard
|
||||||
|
key={item.id}
|
||||||
|
id={item.id}
|
||||||
|
name={item.name}
|
||||||
|
weightGrams={item.weightGrams}
|
||||||
|
priceCents={item.priceCents}
|
||||||
|
quantity={item.quantity}
|
||||||
|
categoryName={categoryName}
|
||||||
|
categoryIcon={categoryIcon}
|
||||||
|
imageFilename={item.imageFilename}
|
||||||
|
productUrl={item.productUrl}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
383
src/client/components/ComparisonTable.tsx
Normal file
383
src/client/components/ComparisonTable.tsx
Normal file
@@ -0,0 +1,383 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import { useFormatters } from "../hooks/useFormatters";
|
||||||
|
import type { CandidateDelta } from "../hooks/useImpactDeltas";
|
||||||
|
import { LucideIcon } from "../lib/iconData";
|
||||||
|
import { useUIStore } from "../stores/uiStore";
|
||||||
|
import { RankBadge } from "./CandidateListItem";
|
||||||
|
import { ImpactDeltaBadge } from "./ImpactDeltaBadge";
|
||||||
|
|
||||||
|
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;
|
||||||
|
deltas?: Record<number, CandidateDelta>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_LABELS: Record<"researching" | "ordered" | "arrived", string> = {
|
||||||
|
researching: "Researching",
|
||||||
|
ordered: "Ordered",
|
||||||
|
arrived: "Arrived",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ComparisonTable({
|
||||||
|
candidates,
|
||||||
|
resolvedCandidateId,
|
||||||
|
deltas,
|
||||||
|
}: ComparisonTableProps) {
|
||||||
|
const { weight, price } = useFormatters();
|
||||||
|
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 : `+${weight(delta)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} 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 : `+${price(delta)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (const c of candidates) {
|
||||||
|
priceDeltas[c.id] = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { bestWeightId, bestPriceId, weightDeltas, priceDeltas };
|
||||||
|
}, [candidates, weight, price]);
|
||||||
|
|
||||||
|
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">
|
||||||
|
{weight(c.weightGrams)}
|
||||||
|
</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">
|
||||||
|
{price(c.priceCents)}
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Determine if impact rows should be shown
|
||||||
|
const firstDelta = deltas ? Object.values(deltas)[0] : undefined;
|
||||||
|
const showImpact = !!deltas && !!firstDelta && firstDelta.mode !== "none";
|
||||||
|
|
||||||
|
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>
|
||||||
|
))}
|
||||||
|
{showImpact && (
|
||||||
|
<>
|
||||||
|
<tr className="border-b border-gray-50">
|
||||||
|
<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">
|
||||||
|
Weight Impact
|
||||||
|
</td>
|
||||||
|
{candidates.map((candidate) => {
|
||||||
|
const isWinner = candidate.id === resolvedCandidateId;
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
key={candidate.id}
|
||||||
|
className={`px-4 py-3 min-w-[160px] ${isWinner ? "bg-amber-50/50" : ""}`}
|
||||||
|
>
|
||||||
|
<ImpactDeltaBadge
|
||||||
|
delta={deltas?.[candidate.id]}
|
||||||
|
type="weight"
|
||||||
|
formatFn={weight}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
<tr className="border-b border-gray-50">
|
||||||
|
<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">
|
||||||
|
Price Impact
|
||||||
|
</td>
|
||||||
|
{candidates.map((candidate) => {
|
||||||
|
const isWinner = candidate.id === resolvedCandidateId;
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
key={candidate.id}
|
||||||
|
className={`px-4 py-3 min-w-[160px] ${isWinner ? "bg-amber-50/50" : ""}`}
|
||||||
|
>
|
||||||
|
<ImpactDeltaBadge
|
||||||
|
delta={deltas?.[candidate.id]}
|
||||||
|
type="price"
|
||||||
|
formatFn={price}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
39
src/client/components/ImpactDeltaBadge.tsx
Normal file
39
src/client/components/ImpactDeltaBadge.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import type { CandidateDelta } from "../hooks/useImpactDeltas";
|
||||||
|
|
||||||
|
interface ImpactDeltaBadgeProps {
|
||||||
|
delta: CandidateDelta | undefined;
|
||||||
|
type: "weight" | "price";
|
||||||
|
formatFn: (value: number) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ImpactDeltaBadge({
|
||||||
|
delta,
|
||||||
|
type,
|
||||||
|
formatFn,
|
||||||
|
}: ImpactDeltaBadgeProps) {
|
||||||
|
if (!delta || delta.mode === "none") return null;
|
||||||
|
|
||||||
|
const value = type === "weight" ? delta.weightDelta : delta.priceDelta;
|
||||||
|
|
||||||
|
if (value === null) {
|
||||||
|
return <span className="text-xs text-gray-400">—</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value === 0) {
|
||||||
|
return <span className="text-xs text-gray-400">±0</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value > 0) {
|
||||||
|
return (
|
||||||
|
<span className="text-xs text-green-600">
|
||||||
|
+{formatFn(value)}
|
||||||
|
{delta.mode === "add" && (
|
||||||
|
<span className="ml-0.5 text-green-500">(add)</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// value < 0
|
||||||
|
return <span className="text-xs text-red-500">{formatFn(value)}</span>;
|
||||||
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useCurrency } from "../hooks/useCurrency";
|
import { useFormatters } from "../hooks/useFormatters";
|
||||||
import { useWeightUnit } from "../hooks/useWeightUnit";
|
import { useDuplicateItem } from "../hooks/useItems";
|
||||||
import { formatPrice, formatWeight } from "../lib/formatters";
|
|
||||||
import { LucideIcon } from "../lib/iconData";
|
import { LucideIcon } from "../lib/iconData";
|
||||||
import { useUIStore } from "../stores/uiStore";
|
import { useUIStore } from "../stores/uiStore";
|
||||||
import { ClassificationBadge } from "./ClassificationBadge";
|
import { ClassificationBadge } from "./ClassificationBadge";
|
||||||
@@ -10,6 +9,7 @@ interface ItemCardProps {
|
|||||||
name: string;
|
name: string;
|
||||||
weightGrams: number | null;
|
weightGrams: number | null;
|
||||||
priceCents: number | null;
|
priceCents: number | null;
|
||||||
|
quantity?: number;
|
||||||
categoryName: string;
|
categoryName: string;
|
||||||
categoryIcon: string;
|
categoryIcon: string;
|
||||||
imageFilename: string | null;
|
imageFilename: string | null;
|
||||||
@@ -24,6 +24,7 @@ export function ItemCard({
|
|||||||
name,
|
name,
|
||||||
weightGrams,
|
weightGrams,
|
||||||
priceCents,
|
priceCents,
|
||||||
|
quantity,
|
||||||
categoryName,
|
categoryName,
|
||||||
categoryIcon,
|
categoryIcon,
|
||||||
imageFilename,
|
imageFilename,
|
||||||
@@ -32,10 +33,10 @@ export function ItemCard({
|
|||||||
classification,
|
classification,
|
||||||
onClassificationCycle,
|
onClassificationCycle,
|
||||||
}: ItemCardProps) {
|
}: ItemCardProps) {
|
||||||
const unit = useWeightUnit();
|
const { weight, price } = useFormatters();
|
||||||
const currency = useCurrency();
|
|
||||||
const openEditPanel = useUIStore((s) => s.openEditPanel);
|
const openEditPanel = useUIStore((s) => s.openEditPanel);
|
||||||
const openExternalLink = useUIStore((s) => s.openExternalLink);
|
const openExternalLink = useUIStore((s) => s.openExternalLink);
|
||||||
|
const duplicateItem = useDuplicateItem();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -43,6 +44,46 @@ export function ItemCard({
|
|||||||
onClick={() => openEditPanel(id)}
|
onClick={() => openEditPanel(id)}
|
||||||
className="relative w-full text-left bg-white rounded-xl border border-gray-100 hover:border-gray-200 hover:shadow-sm transition-all overflow-hidden group"
|
className="relative w-full text-left bg-white rounded-xl border border-gray-100 hover:border-gray-200 hover:shadow-sm transition-all overflow-hidden group"
|
||||||
>
|
>
|
||||||
|
{!onRemove && (
|
||||||
|
<span
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
duplicateItem.mutate(id, {
|
||||||
|
onSuccess: (newItem) => {
|
||||||
|
openEditPanel(newItem.id);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
e.stopPropagation();
|
||||||
|
duplicateItem.mutate(id, {
|
||||||
|
onSuccess: (newItem) => {
|
||||||
|
openEditPanel(newItem.id);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={`absolute top-2 ${productUrl ? "right-10" : "right-2"} z-10 w-6 h-6 flex items-center justify-center rounded-full bg-gray-100/80 text-gray-400 hover:bg-blue-100 hover:text-blue-500 opacity-0 group-hover:opacity-100 transition-all cursor-pointer`}
|
||||||
|
title="Duplicate item"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="w-3.5 h-3.5"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{productUrl && (
|
{productUrl && (
|
||||||
<span
|
<span
|
||||||
role="button"
|
role="button"
|
||||||
@@ -125,18 +166,25 @@ export function ItemCard({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<h3 className="text-sm font-semibold text-gray-900 mb-2 truncate">
|
<div className="flex items-center gap-1.5 mb-2">
|
||||||
{name}
|
<h3 className="text-sm font-semibold text-gray-900 truncate min-w-0">
|
||||||
</h3>
|
{name}
|
||||||
|
</h3>
|
||||||
|
{quantity != null && quantity > 1 && (
|
||||||
|
<span className="shrink-0 inline-flex items-center px-1.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-600">
|
||||||
|
×{quantity}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{weightGrams != null && (
|
{weightGrams != null && (
|
||||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-50 text-blue-400">
|
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-50 text-blue-400">
|
||||||
{formatWeight(weightGrams, unit)}
|
{weight(weightGrams)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{priceCents != null && (
|
{priceCents != null && (
|
||||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-50 text-green-500">
|
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-50 text-green-500">
|
||||||
{formatPrice(priceCents, currency)}
|
{price(priceCents)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-50 text-gray-600">
|
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-50 text-gray-600">
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ interface FormData {
|
|||||||
name: string;
|
name: string;
|
||||||
weightGrams: string;
|
weightGrams: string;
|
||||||
priceDollars: string;
|
priceDollars: string;
|
||||||
|
quantity: number;
|
||||||
categoryId: number;
|
categoryId: number;
|
||||||
notes: string;
|
notes: string;
|
||||||
productUrl: string;
|
productUrl: string;
|
||||||
@@ -23,6 +24,7 @@ const INITIAL_FORM: FormData = {
|
|||||||
name: "",
|
name: "",
|
||||||
weightGrams: "",
|
weightGrams: "",
|
||||||
priceDollars: "",
|
priceDollars: "",
|
||||||
|
quantity: 1,
|
||||||
categoryId: 1,
|
categoryId: 1,
|
||||||
notes: "",
|
notes: "",
|
||||||
productUrl: "",
|
productUrl: "",
|
||||||
@@ -49,6 +51,7 @@ export function ItemForm({ mode, itemId }: ItemFormProps) {
|
|||||||
weightGrams: item.weightGrams != null ? String(item.weightGrams) : "",
|
weightGrams: item.weightGrams != null ? String(item.weightGrams) : "",
|
||||||
priceDollars:
|
priceDollars:
|
||||||
item.priceCents != null ? (item.priceCents / 100).toFixed(2) : "",
|
item.priceCents != null ? (item.priceCents / 100).toFixed(2) : "",
|
||||||
|
quantity: item.quantity ?? 1,
|
||||||
categoryId: item.categoryId,
|
categoryId: item.categoryId,
|
||||||
notes: item.notes ?? "",
|
notes: item.notes ?? "",
|
||||||
productUrl: item.productUrl ?? "",
|
productUrl: item.productUrl ?? "",
|
||||||
@@ -98,6 +101,7 @@ export function ItemForm({ mode, itemId }: ItemFormProps) {
|
|||||||
priceCents: form.priceDollars
|
priceCents: form.priceDollars
|
||||||
? Math.round(Number(form.priceDollars) * 100)
|
? Math.round(Number(form.priceDollars) * 100)
|
||||||
: undefined,
|
: undefined,
|
||||||
|
quantity: form.quantity,
|
||||||
categoryId: form.categoryId,
|
categoryId: form.categoryId,
|
||||||
notes: form.notes.trim() || undefined,
|
notes: form.notes.trim() || undefined,
|
||||||
productUrl: form.productUrl.trim() || undefined,
|
productUrl: form.productUrl.trim() || undefined,
|
||||||
@@ -202,6 +206,30 @@ export function ItemForm({ mode, itemId }: ItemFormProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Quantity */}
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="item-quantity"
|
||||||
|
className="block text-sm font-medium text-gray-700 mb-1"
|
||||||
|
>
|
||||||
|
Quantity
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="item-quantity"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
step="1"
|
||||||
|
value={form.quantity}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm((f) => ({
|
||||||
|
...f,
|
||||||
|
quantity: Math.max(1, Number(e.target.value) || 1),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Category */}
|
{/* Category */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useCurrency } from "../hooks/useCurrency";
|
import { useFormatters } from "../hooks/useFormatters";
|
||||||
import { useItems } from "../hooks/useItems";
|
import { useItems } from "../hooks/useItems";
|
||||||
import { useSyncSetupItems } from "../hooks/useSetups";
|
import { useSyncSetupItems } from "../hooks/useSetups";
|
||||||
import { useWeightUnit } from "../hooks/useWeightUnit";
|
|
||||||
import { formatPrice, formatWeight } from "../lib/formatters";
|
|
||||||
import { LucideIcon } from "../lib/iconData";
|
import { LucideIcon } from "../lib/iconData";
|
||||||
import { SlideOutPanel } from "./SlideOutPanel";
|
import { SlideOutPanel } from "./SlideOutPanel";
|
||||||
|
|
||||||
@@ -22,8 +20,7 @@ export function ItemPicker({
|
|||||||
}: ItemPickerProps) {
|
}: ItemPickerProps) {
|
||||||
const { data: items } = useItems();
|
const { data: items } = useItems();
|
||||||
const syncItems = useSyncSetupItems(setupId);
|
const syncItems = useSyncSetupItems(setupId);
|
||||||
const unit = useWeightUnit();
|
const { weight, price } = useFormatters();
|
||||||
const currency = useCurrency();
|
|
||||||
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
|
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
|
||||||
|
|
||||||
// Reset selected IDs when panel opens
|
// Reset selected IDs when panel opens
|
||||||
@@ -117,13 +114,11 @@ export function ItemPicker({
|
|||||||
{item.name}
|
{item.name}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-gray-400 shrink-0">
|
<span className="text-xs text-gray-400 shrink-0">
|
||||||
{item.weightGrams != null &&
|
{item.weightGrams != null && weight(item.weightGrams)}
|
||||||
formatWeight(item.weightGrams, unit)}
|
|
||||||
{item.weightGrams != null &&
|
{item.weightGrams != null &&
|
||||||
item.priceCents != null &&
|
item.priceCents != null &&
|
||||||
" · "}
|
" · "}
|
||||||
{item.priceCents != null &&
|
{item.priceCents != null && price(item.priceCents)}
|
||||||
formatPrice(item.priceCents, currency)}
|
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
|
|||||||
196
src/client/components/PlanningView.tsx
Normal file
196
src/client/components/PlanningView.tsx
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useCategories } from "../hooks/useCategories";
|
||||||
|
import { useThreads } from "../hooks/useThreads";
|
||||||
|
import { useUIStore } from "../stores/uiStore";
|
||||||
|
import { CategoryFilterDropdown } from "./CategoryFilterDropdown";
|
||||||
|
import { CreateThreadModal } from "./CreateThreadModal";
|
||||||
|
import { ThreadCard } from "./ThreadCard";
|
||||||
|
|
||||||
|
export function PlanningView() {
|
||||||
|
const [activeTab, setActiveTab] = useState<"active" | "resolved">("active");
|
||||||
|
const [categoryFilter, setCategoryFilter] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const openCreateThreadModal = useUIStore((s) => s.openCreateThreadModal);
|
||||||
|
const { data: categories } = useCategories();
|
||||||
|
const { data: threads, isLoading } = useThreads(activeTab === "resolved");
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="animate-pulse space-y-4">
|
||||||
|
{[1, 2].map((i) => (
|
||||||
|
<div key={i} className="h-24 bg-gray-200 rounded-xl" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter threads by active tab and category
|
||||||
|
const filteredThreads = (threads ?? [])
|
||||||
|
.filter((t) => t.status === activeTab)
|
||||||
|
.filter((t) => (categoryFilter ? t.categoryId === categoryFilter : true));
|
||||||
|
|
||||||
|
// Determine if we should show the educational empty state
|
||||||
|
const isEmptyNoFilters =
|
||||||
|
filteredThreads.length === 0 &&
|
||||||
|
activeTab === "active" &&
|
||||||
|
categoryFilter === null &&
|
||||||
|
(!threads || threads.length === 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* Header row */}
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900">
|
||||||
|
Planning Threads
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={openCreateThreadModal}
|
||||||
|
className="inline-flex items-center gap-2 px-4 py-2 bg-gray-700 hover:bg-gray-800 text-white text-sm font-medium rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
className="w-4 h-4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M12 4v16m8-8H4"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
New Thread
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filter row */}
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
{/* Pill tabs */}
|
||||||
|
<div className="flex bg-gray-100 rounded-full p-0.5 gap-0.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveTab("active")}
|
||||||
|
className={`px-4 py-1.5 text-sm font-medium rounded-full transition-colors ${
|
||||||
|
activeTab === "active"
|
||||||
|
? "bg-gray-700 text-white"
|
||||||
|
: "text-gray-600 hover:bg-gray-200"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Active
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveTab("resolved")}
|
||||||
|
className={`px-4 py-1.5 text-sm font-medium rounded-full transition-colors ${
|
||||||
|
activeTab === "resolved"
|
||||||
|
? "bg-gray-700 text-white"
|
||||||
|
: "text-gray-600 hover:bg-gray-200"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Resolved
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Category filter */}
|
||||||
|
<CategoryFilterDropdown
|
||||||
|
value={categoryFilter}
|
||||||
|
onChange={setCategoryFilter}
|
||||||
|
categories={categories ?? []}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content: empty state or thread grid */}
|
||||||
|
{isEmptyNoFilters ? (
|
||||||
|
<div className="py-16">
|
||||||
|
<div className="max-w-lg mx-auto text-center">
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900 mb-8">
|
||||||
|
Plan your next purchase
|
||||||
|
</h2>
|
||||||
|
<div className="space-y-6 text-left mb-10">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-gray-200 text-gray-700 font-bold text-sm shrink-0">
|
||||||
|
1
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-gray-900">Create a thread</p>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
Start a research thread for gear you're considering
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-gray-200 text-gray-700 font-bold text-sm shrink-0">
|
||||||
|
2
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-gray-900">Add candidates</p>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
Add products you're comparing with prices and weights
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-gray-200 text-gray-700 font-bold text-sm shrink-0">
|
||||||
|
3
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-gray-900">Pick a winner</p>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
Resolve the thread and the winner joins your collection
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={openCreateThreadModal}
|
||||||
|
className="inline-flex items-center gap-2 px-5 py-2.5 bg-gray-700 hover:bg-gray-800 text-white text-sm font-medium rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
className="w-4 h-4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M12 4v16m8-8H4"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Create your first thread
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : filteredThreads.length === 0 ? (
|
||||||
|
<div className="py-12 text-center">
|
||||||
|
<p className="text-sm text-gray-500">No threads found</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{filteredThreads.map((thread) => (
|
||||||
|
<ThreadCard
|
||||||
|
key={thread.id}
|
||||||
|
id={thread.id}
|
||||||
|
name={thread.name}
|
||||||
|
candidateCount={thread.candidateCount}
|
||||||
|
minPriceCents={thread.minPriceCents}
|
||||||
|
maxPriceCents={thread.maxPriceCents}
|
||||||
|
createdAt={thread.createdAt}
|
||||||
|
status={thread.status}
|
||||||
|
categoryName={thread.categoryName}
|
||||||
|
categoryIcon={thread.categoryIcon}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<CreateThreadModal />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,5 @@
|
|||||||
import { Link } from "@tanstack/react-router";
|
import { Link } from "@tanstack/react-router";
|
||||||
import { useCurrency } from "../hooks/useCurrency";
|
import { useFormatters } from "../hooks/useFormatters";
|
||||||
import { useWeightUnit } from "../hooks/useWeightUnit";
|
|
||||||
import { formatPrice, formatWeight } from "../lib/formatters";
|
|
||||||
|
|
||||||
interface SetupCardProps {
|
interface SetupCardProps {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -18,8 +16,7 @@ export function SetupCard({
|
|||||||
totalWeight,
|
totalWeight,
|
||||||
totalCost,
|
totalCost,
|
||||||
}: SetupCardProps) {
|
}: SetupCardProps) {
|
||||||
const unit = useWeightUnit();
|
const { weight, price } = useFormatters();
|
||||||
const currency = useCurrency();
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
to="/setups/$setupId"
|
to="/setups/$setupId"
|
||||||
@@ -34,10 +31,10 @@ export function SetupCard({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-50 text-blue-400">
|
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-50 text-blue-400">
|
||||||
{formatWeight(totalWeight, unit)}
|
{weight(totalWeight)}
|
||||||
</span>
|
</span>
|
||||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-50 text-green-500">
|
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-50 text-green-500">
|
||||||
{formatPrice(totalCost, currency)}
|
{price(totalCost)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
34
src/client/components/SetupImpactSelector.tsx
Normal file
34
src/client/components/SetupImpactSelector.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { useSetups } from "../hooks/useSetups";
|
||||||
|
import { useUIStore } from "../stores/uiStore";
|
||||||
|
|
||||||
|
interface SetupImpactSelectorProps {
|
||||||
|
threadStatus: "active" | "resolved";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SetupImpactSelector({
|
||||||
|
threadStatus,
|
||||||
|
}: SetupImpactSelectorProps) {
|
||||||
|
const { data: setups } = useSetups();
|
||||||
|
const selectedSetupId = useUIStore((s) => s.selectedSetupId);
|
||||||
|
const setSelectedSetupId = useUIStore((s) => s.setSelectedSetupId);
|
||||||
|
|
||||||
|
if (threadStatus !== "active") return null;
|
||||||
|
if (!setups || setups.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<select
|
||||||
|
value={selectedSetupId ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSelectedSetupId(e.target.value ? Number(e.target.value) : null)
|
||||||
|
}
|
||||||
|
className="border border-gray-200 rounded-lg text-sm px-3 py-1.5 text-gray-700 bg-white focus:outline-none focus:ring-2 focus:ring-gray-300"
|
||||||
|
>
|
||||||
|
<option value="">Compare with setup...</option>
|
||||||
|
{setups.map((setup) => (
|
||||||
|
<option key={setup.id} value={setup.id}>
|
||||||
|
{setup.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
);
|
||||||
|
}
|
||||||
112
src/client/components/SetupsView.tsx
Normal file
112
src/client/components/SetupsView.tsx
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useCreateSetup, useSetups } from "../hooks/useSetups";
|
||||||
|
import { SetupCard } from "./SetupCard";
|
||||||
|
|
||||||
|
export function SetupsView() {
|
||||||
|
const [newSetupName, setNewSetupName] = useState("");
|
||||||
|
const { data: setups, isLoading } = useSetups();
|
||||||
|
const createSetup = useCreateSetup();
|
||||||
|
|
||||||
|
function handleCreateSetup(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
const name = newSetupName.trim();
|
||||||
|
if (!name) return;
|
||||||
|
createSetup.mutate({ name }, { onSuccess: () => setNewSetupName("") });
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* Create setup form */}
|
||||||
|
<form onSubmit={handleCreateSetup} className="flex gap-2 mb-6">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newSetupName}
|
||||||
|
onChange={(e) => setNewSetupName(e.target.value)}
|
||||||
|
placeholder="New setup name..."
|
||||||
|
className="flex-1 px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!newSetupName.trim() || createSetup.isPending}
|
||||||
|
className="px-4 py-2 bg-gray-700 hover:bg-gray-800 disabled:opacity-50 text-white text-sm font-medium rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
{createSetup.isPending ? "Creating..." : "Create"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* Loading skeleton */}
|
||||||
|
{isLoading && (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{[1, 2].map((i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="h-24 bg-gray-200 rounded-xl animate-pulse"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Empty state */}
|
||||||
|
{!isLoading && (!setups || setups.length === 0) && (
|
||||||
|
<div className="py-16">
|
||||||
|
<div className="max-w-lg mx-auto text-center">
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900 mb-8">
|
||||||
|
Build your perfect loadout
|
||||||
|
</h2>
|
||||||
|
<div className="space-y-6 text-left mb-10">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-gray-200 text-gray-700 font-bold text-sm shrink-0">
|
||||||
|
1
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-gray-900">Create a setup</p>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
Name your loadout for a specific trip or activity
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-gray-200 text-gray-700 font-bold text-sm shrink-0">
|
||||||
|
2
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-gray-900">Add items</p>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
Pick gear from your collection to include in the setup
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-gray-200 text-gray-700 font-bold text-sm shrink-0">
|
||||||
|
3
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-gray-900">Track weight</p>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
See weight breakdown and optimize your pack
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Setup grid */}
|
||||||
|
{!isLoading && setups && setups.length > 0 && (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{setups.map((setup) => (
|
||||||
|
<SetupCard
|
||||||
|
key={setup.id}
|
||||||
|
id={setup.id}
|
||||||
|
name={setup.name}
|
||||||
|
itemCount={setup.itemCount}
|
||||||
|
totalWeight={setup.totalWeight}
|
||||||
|
totalCost={setup.totalCost}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useNavigate } from "@tanstack/react-router";
|
import { useNavigate } from "@tanstack/react-router";
|
||||||
import { useCurrency } from "../hooks/useCurrency";
|
import { useFormatters } from "../hooks/useFormatters";
|
||||||
import { formatPrice } from "../lib/formatters";
|
|
||||||
import { LucideIcon } from "../lib/iconData";
|
import { LucideIcon } from "../lib/iconData";
|
||||||
|
|
||||||
interface ThreadCardProps {
|
interface ThreadCardProps {
|
||||||
@@ -20,16 +19,6 @@ function formatDate(iso: string): string {
|
|||||||
return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatPriceRange(
|
|
||||||
min: number | null,
|
|
||||||
max: number | null,
|
|
||||||
currency: Parameters<typeof formatPrice>[1],
|
|
||||||
): string | null {
|
|
||||||
if (min == null && max == null) return null;
|
|
||||||
if (min === max) return formatPrice(min, currency);
|
|
||||||
return `${formatPrice(min, currency)} - ${formatPrice(max, currency)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ThreadCard({
|
export function ThreadCard({
|
||||||
id,
|
id,
|
||||||
name,
|
name,
|
||||||
@@ -42,10 +31,19 @@ export function ThreadCard({
|
|||||||
categoryIcon,
|
categoryIcon,
|
||||||
}: ThreadCardProps) {
|
}: ThreadCardProps) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const currency = useCurrency();
|
const { price } = useFormatters();
|
||||||
|
|
||||||
|
function formatPriceRange(
|
||||||
|
min: number | null,
|
||||||
|
max: number | null,
|
||||||
|
): string | null {
|
||||||
|
if (min == null && max == null) return null;
|
||||||
|
if (min === max) return price(min);
|
||||||
|
return `${price(min)} - ${price(max)}`;
|
||||||
|
}
|
||||||
|
|
||||||
const isResolved = status === "resolved";
|
const isResolved = status === "resolved";
|
||||||
const priceRange = formatPriceRange(minPriceCents, maxPriceCents, currency);
|
const priceRange = formatPriceRange(minPriceCents, maxPriceCents);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { Link } from "@tanstack/react-router";
|
import { Link } from "@tanstack/react-router";
|
||||||
|
import { useAuth } from "../hooks/useAuth";
|
||||||
|
import { useFormatters } from "../hooks/useFormatters";
|
||||||
import { useUpdateSetting } from "../hooks/useSettings";
|
import { useUpdateSetting } from "../hooks/useSettings";
|
||||||
import { useTotals } from "../hooks/useTotals";
|
import { useTotals } from "../hooks/useTotals";
|
||||||
import { useWeightUnit } from "../hooks/useWeightUnit";
|
import type { WeightUnit } from "../lib/formatters";
|
||||||
import { formatPrice, formatWeight, type WeightUnit } from "../lib/formatters";
|
|
||||||
import { LucideIcon } from "../lib/iconData";
|
import { LucideIcon } from "../lib/iconData";
|
||||||
|
import { UserMenu } from "./UserMenu";
|
||||||
|
|
||||||
const UNITS: WeightUnit[] = ["g", "oz", "lb", "kg"];
|
const UNITS: WeightUnit[] = ["g", "oz", "lb", "kg"];
|
||||||
|
|
||||||
@@ -19,7 +21,9 @@ export function TotalsBar({
|
|||||||
linkTo,
|
linkTo,
|
||||||
}: TotalsBarProps) {
|
}: TotalsBarProps) {
|
||||||
const { data } = useTotals();
|
const { data } = useTotals();
|
||||||
const unit = useWeightUnit();
|
const { data: auth } = useAuth();
|
||||||
|
const isAuthenticated = !!auth?.user;
|
||||||
|
const { weight, price, unit } = useFormatters();
|
||||||
const updateSetting = useUpdateSetting();
|
const updateSetting = useUpdateSetting();
|
||||||
|
|
||||||
// When no stats provided, use global totals (backward compatible)
|
// When no stats provided, use global totals (backward compatible)
|
||||||
@@ -30,14 +34,14 @@ export function TotalsBar({
|
|||||||
{ label: "items", value: String(data.global.itemCount) },
|
{ label: "items", value: String(data.global.itemCount) },
|
||||||
{
|
{
|
||||||
label: "total",
|
label: "total",
|
||||||
value: formatWeight(data.global.totalWeight, unit),
|
value: weight(data.global.totalWeight),
|
||||||
},
|
},
|
||||||
{ label: "spent", value: formatPrice(data.global.totalCost) },
|
{ label: "spent", value: price(data.global.totalCost) },
|
||||||
]
|
]
|
||||||
: [
|
: [
|
||||||
{ label: "items", value: "0" },
|
{ label: "items", value: "0" },
|
||||||
{ label: "total", value: formatWeight(null, unit) },
|
{ label: "total", value: weight(null) },
|
||||||
{ label: "spent", value: formatPrice(null) },
|
{ label: "spent", value: price(null) },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const titleContent = (
|
const titleContent = (
|
||||||
@@ -100,6 +104,16 @@ export function TotalsBar({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{isAuthenticated ? (
|
||||||
|
<UserMenu />
|
||||||
|
) : (
|
||||||
|
<Link
|
||||||
|
to="/login"
|
||||||
|
className="text-xs text-gray-500 hover:text-gray-700 transition-colors"
|
||||||
|
>
|
||||||
|
Sign in
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
57
src/client/components/UserMenu.tsx
Normal file
57
src/client/components/UserMenu.tsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { Link } from "@tanstack/react-router";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { useLogout } from "../hooks/useAuth";
|
||||||
|
import { LucideIcon } from "../lib/iconData";
|
||||||
|
|
||||||
|
export function UserMenu() {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
|
const logout = useLogout();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
function handleClick(e: MouseEvent) {
|
||||||
|
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener("mousedown", handleClick);
|
||||||
|
return () => document.removeEventListener("mousedown", handleClick);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={menuRef} className="relative">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen((prev) => !prev)}
|
||||||
|
className="flex items-center justify-center w-8 h-8 rounded-full text-gray-500 hover:text-gray-700 hover:bg-gray-100 transition-colors"
|
||||||
|
>
|
||||||
|
<LucideIcon name="circle-user" size={22} />
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div className="absolute right-0 mt-1 w-40 bg-white rounded-lg shadow-lg border border-gray-200 py-1 z-50">
|
||||||
|
<Link
|
||||||
|
to="/settings"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
className="flex items-center gap-2 px-3 py-2 text-sm text-gray-700 hover:bg-gray-50 transition-colors"
|
||||||
|
>
|
||||||
|
<LucideIcon name="settings" size={16} className="text-gray-400" />
|
||||||
|
Settings
|
||||||
|
</Link>
|
||||||
|
<div className="border-t border-gray-100 my-1" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setOpen(false);
|
||||||
|
logout.mutate();
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-2 w-full px-3 py-2 text-sm text-gray-700 hover:bg-gray-50 transition-colors"
|
||||||
|
>
|
||||||
|
<LucideIcon name="log-out" size={16} className="text-gray-400" />
|
||||||
|
Sign out
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,8 +7,8 @@ import {
|
|||||||
ResponsiveContainer,
|
ResponsiveContainer,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
} from "recharts";
|
} from "recharts";
|
||||||
|
import { useFormatters } from "../hooks/useFormatters";
|
||||||
import type { SetupItemWithCategory } from "../hooks/useSetups";
|
import type { SetupItemWithCategory } from "../hooks/useSetups";
|
||||||
import { useWeightUnit } from "../hooks/useWeightUnit";
|
|
||||||
import { formatWeight, type WeightUnit } from "../lib/formatters";
|
import { formatWeight, type WeightUnit } from "../lib/formatters";
|
||||||
import { LucideIcon } from "../lib/iconData";
|
import { LucideIcon } from "../lib/iconData";
|
||||||
|
|
||||||
@@ -55,9 +55,15 @@ function buildCategoryChartData(items: SetupItemWithCategory[]): ChartDatum[] {
|
|||||||
const groups = new Map<string, number>();
|
const groups = new Map<string, number>();
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
const current = groups.get(item.categoryName) ?? 0;
|
const current = groups.get(item.categoryName) ?? 0;
|
||||||
groups.set(item.categoryName, current + (item.weightGrams ?? 0));
|
groups.set(
|
||||||
|
item.categoryName,
|
||||||
|
current + (item.weightGrams ?? 0) * (item.quantity ?? 1),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
const total = items.reduce((sum, i) => sum + (i.weightGrams ?? 0), 0);
|
const total = items.reduce(
|
||||||
|
(sum, i) => sum + (i.weightGrams ?? 0) * (i.quantity ?? 1),
|
||||||
|
0,
|
||||||
|
);
|
||||||
return Array.from(groups.entries())
|
return Array.from(groups.entries())
|
||||||
.filter(([, weight]) => weight > 0)
|
.filter(([, weight]) => weight > 0)
|
||||||
.map(([name, weight]) => ({
|
.map(([name, weight]) => ({
|
||||||
@@ -76,7 +82,8 @@ function buildClassificationChartData(
|
|||||||
consumable: 0,
|
consumable: 0,
|
||||||
};
|
};
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
groups[item.classification] += item.weightGrams ?? 0;
|
groups[item.classification] +=
|
||||||
|
(item.weightGrams ?? 0) * (item.quantity ?? 1);
|
||||||
}
|
}
|
||||||
const total = Object.values(groups).reduce((a, b) => a + b, 0);
|
const total = Object.values(groups).reduce((a, b) => a + b, 0);
|
||||||
return Object.entries(groups)
|
return Object.entries(groups)
|
||||||
@@ -143,22 +150,28 @@ function LegendRow({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function WeightSummaryCard({ items }: WeightSummaryCardProps) {
|
export function WeightSummaryCard({ items }: WeightSummaryCardProps) {
|
||||||
const unit = useWeightUnit();
|
const { unit } = useFormatters();
|
||||||
const [viewMode, setViewMode] = useState<ViewMode>("category");
|
const [viewMode, setViewMode] = useState<ViewMode>("category");
|
||||||
|
|
||||||
const baseWeight = items.reduce(
|
const baseWeight = items.reduce(
|
||||||
(sum, i) =>
|
(sum, i) =>
|
||||||
i.classification === "base" ? sum + (i.weightGrams ?? 0) : sum,
|
i.classification === "base"
|
||||||
|
? sum + (i.weightGrams ?? 0) * (i.quantity ?? 1)
|
||||||
|
: sum,
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
const wornWeight = items.reduce(
|
const wornWeight = items.reduce(
|
||||||
(sum, i) =>
|
(sum, i) =>
|
||||||
i.classification === "worn" ? sum + (i.weightGrams ?? 0) : sum,
|
i.classification === "worn"
|
||||||
|
? sum + (i.weightGrams ?? 0) * (i.quantity ?? 1)
|
||||||
|
: sum,
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
const consumableWeight = items.reduce(
|
const consumableWeight = items.reduce(
|
||||||
(sum, i) =>
|
(sum, i) =>
|
||||||
i.classification === "consumable" ? sum + (i.weightGrams ?? 0) : sum,
|
i.classification === "consumable"
|
||||||
|
? sum + (i.weightGrams ?? 0) * (i.quantity ?? 1)
|
||||||
|
: sum,
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
const totalWeight = baseWeight + wornWeight + consumableWeight;
|
const totalWeight = baseWeight + wornWeight + consumableWeight;
|
||||||
@@ -264,12 +277,20 @@ export function WeightSummaryCard({ items }: WeightSummaryCardProps) {
|
|||||||
label="Consumable"
|
label="Consumable"
|
||||||
weight={consumableWeight}
|
weight={consumableWeight}
|
||||||
unit={unit}
|
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="border-t border-gray-200 mt-1.5 pt-1.5">
|
||||||
<div className="flex items-center gap-3 py-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" />
|
<LucideIcon
|
||||||
<span className="text-sm font-medium text-gray-700 flex-1">Total</span>
|
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">
|
<span className="text-sm font-bold text-gray-900 tabular-nums">
|
||||||
{formatWeight(totalWeight, unit)}
|
{formatWeight(totalWeight, unit)}
|
||||||
</span>
|
</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"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
14
src/client/hooks/useFormatters.ts
Normal file
14
src/client/hooks/useFormatters.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { formatPrice, formatWeight } from "../lib/formatters";
|
||||||
|
import { useCurrency } from "./useCurrency";
|
||||||
|
import { useWeightUnit } from "./useWeightUnit";
|
||||||
|
|
||||||
|
export function useFormatters() {
|
||||||
|
const unit = useWeightUnit();
|
||||||
|
const currency = useCurrency();
|
||||||
|
return {
|
||||||
|
weight: (grams: number | null) => formatWeight(grams, unit),
|
||||||
|
price: (cents: number | null) => formatPrice(cents, currency),
|
||||||
|
unit,
|
||||||
|
currency,
|
||||||
|
};
|
||||||
|
}
|
||||||
22
src/client/hooks/useImpactDeltas.ts
Normal file
22
src/client/hooks/useImpactDeltas.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import {
|
||||||
|
type CandidateDelta,
|
||||||
|
type CandidateInput,
|
||||||
|
computeImpactDeltas,
|
||||||
|
type DeltaMode,
|
||||||
|
type ImpactDeltas,
|
||||||
|
type SetupItemInput,
|
||||||
|
} from "../lib/impactDeltas";
|
||||||
|
|
||||||
|
export type { CandidateDelta, DeltaMode, ImpactDeltas };
|
||||||
|
|
||||||
|
export function useImpactDeltas(
|
||||||
|
candidates: CandidateInput[],
|
||||||
|
setupItems: SetupItemInput[] | undefined,
|
||||||
|
threadCategoryId: number,
|
||||||
|
): ImpactDeltas {
|
||||||
|
return useMemo(
|
||||||
|
() => computeImpactDeltas(candidates, setupItems, threadCategoryId),
|
||||||
|
[candidates, setupItems, threadCategoryId],
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,12 +1,35 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import type { CreateItem } from "../../shared/types";
|
import type { CreateItem } from "../../shared/types";
|
||||||
import { apiDelete, apiGet, apiPost, apiPut } from "../lib/api";
|
import {
|
||||||
|
ApiError,
|
||||||
|
apiDelete,
|
||||||
|
apiGet,
|
||||||
|
apiPost,
|
||||||
|
apiPut,
|
||||||
|
apiUpload,
|
||||||
|
} from "../lib/api";
|
||||||
|
|
||||||
|
interface Item {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
weightGrams: number | null;
|
||||||
|
priceCents: number | null;
|
||||||
|
quantity: number;
|
||||||
|
categoryId: number;
|
||||||
|
notes: string | null;
|
||||||
|
productUrl: string | null;
|
||||||
|
imageFilename: string | null;
|
||||||
|
imageSourceUrl: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface ItemWithCategory {
|
interface ItemWithCategory {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
weightGrams: number | null;
|
weightGrams: number | null;
|
||||||
priceCents: number | null;
|
priceCents: number | null;
|
||||||
|
quantity: number;
|
||||||
categoryId: number;
|
categoryId: number;
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
productUrl: string | null;
|
productUrl: string | null;
|
||||||
@@ -29,6 +52,8 @@ export function useItem(id: number | null) {
|
|||||||
queryKey: ["items", id],
|
queryKey: ["items", id],
|
||||||
queryFn: () => apiGet<ItemWithCategory>(`/api/items/${id}`),
|
queryFn: () => apiGet<ItemWithCategory>(`/api/items/${id}`),
|
||||||
enabled: id != null,
|
enabled: id != null,
|
||||||
|
retry: (count, error) =>
|
||||||
|
error instanceof ApiError && error.status === 404 ? false : count < 3,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,3 +94,38 @@ export function useDeleteItem() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useDuplicateItem() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: number) => apiPost<Item>(`/api/items/${id}/duplicate`, {}),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["items"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["totals"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useExportItems() {
|
||||||
|
return function exportItems() {
|
||||||
|
window.location.href = "/api/items/export";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportResult {
|
||||||
|
imported: number;
|
||||||
|
createdCategories: string[];
|
||||||
|
errors: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useImportItems() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (file: File) =>
|
||||||
|
apiUpload<ImportResult>("/api/items/import", file),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["items"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["totals"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { apiDelete, apiGet, apiPatch, apiPost, apiPut } from "../lib/api";
|
import {
|
||||||
|
ApiError,
|
||||||
|
apiDelete,
|
||||||
|
apiGet,
|
||||||
|
apiPatch,
|
||||||
|
apiPost,
|
||||||
|
apiPut,
|
||||||
|
} from "../lib/api";
|
||||||
|
|
||||||
interface SetupListItem {
|
interface SetupListItem {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -16,6 +23,7 @@ interface SetupItemWithCategory {
|
|||||||
name: string;
|
name: string;
|
||||||
weightGrams: number | null;
|
weightGrams: number | null;
|
||||||
priceCents: number | null;
|
priceCents: number | null;
|
||||||
|
quantity: number;
|
||||||
categoryId: number;
|
categoryId: number;
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
productUrl: string | null;
|
productUrl: string | null;
|
||||||
@@ -49,6 +57,8 @@ export function useSetup(setupId: number | null) {
|
|||||||
queryKey: ["setups", setupId],
|
queryKey: ["setups", setupId],
|
||||||
queryFn: () => apiGet<SetupWithItems>(`/api/setups/${setupId}`),
|
queryFn: () => apiGet<SetupWithItems>(`/api/setups/${setupId}`),
|
||||||
enabled: setupId != null,
|
enabled: setupId != null,
|
||||||
|
retry: (count, error) =>
|
||||||
|
error instanceof ApiError && error.status === 404 ? false : count < 3,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,7 +123,10 @@ export function useUpdateItemClassification(setupId: number) {
|
|||||||
mutationFn: ({
|
mutationFn: ({
|
||||||
itemId,
|
itemId,
|
||||||
classification,
|
classification,
|
||||||
}: { itemId: number; classification: string }) =>
|
}: {
|
||||||
|
itemId: number;
|
||||||
|
classification: string;
|
||||||
|
}) =>
|
||||||
apiPatch<{ success: boolean }>(
|
apiPatch<{ success: boolean }>(
|
||||||
`/api/setups/${setupId}/items/${itemId}/classification`,
|
`/api/setups/${setupId}/items/${itemId}/classification`,
|
||||||
{ classification },
|
{ classification },
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { apiDelete, apiGet, apiPost, apiPut } from "../lib/api";
|
import { ApiError, apiDelete, apiGet, apiPost, apiPut } from "../lib/api";
|
||||||
|
|
||||||
interface ThreadListItem {
|
interface ThreadListItem {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -40,6 +40,7 @@ interface ThreadWithCandidates {
|
|||||||
name: string;
|
name: string;
|
||||||
status: "active" | "resolved";
|
status: "active" | "resolved";
|
||||||
resolvedCandidateId: number | null;
|
resolvedCandidateId: number | null;
|
||||||
|
categoryId: number;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
candidates: CandidateWithCategory[];
|
candidates: CandidateWithCategory[];
|
||||||
@@ -60,6 +61,8 @@ export function useThread(threadId: number | null) {
|
|||||||
queryKey: ["threads", threadId],
|
queryKey: ["threads", threadId],
|
||||||
queryFn: () => apiGet<ThreadWithCandidates>(`/api/threads/${threadId}`),
|
queryFn: () => apiGet<ThreadWithCandidates>(`/api/threads/${threadId}`),
|
||||||
enabled: threadId != null,
|
enabled: threadId != null,
|
||||||
|
retry: (count, error) =>
|
||||||
|
error instanceof ApiError && error.status === 404 ? false : count < 3,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
class ApiError extends Error {
|
export class ApiError extends Error {
|
||||||
constructor(
|
constructor(
|
||||||
message: string,
|
message: string,
|
||||||
public status: number,
|
public status: number,
|
||||||
|
|||||||
69
src/client/lib/impactDeltas.ts
Normal file
69
src/client/lib/impactDeltas.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
export interface CandidateInput {
|
||||||
|
id: number;
|
||||||
|
weightGrams: number | null;
|
||||||
|
priceCents: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SetupItemInput {
|
||||||
|
categoryId: number;
|
||||||
|
weightGrams: number | null;
|
||||||
|
priceCents: number | null;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
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>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeImpactDeltas(
|
||||||
|
candidates: CandidateInput[],
|
||||||
|
setupItems: SetupItemInput[] | undefined,
|
||||||
|
threadCategoryId: number,
|
||||||
|
): ImpactDeltas {
|
||||||
|
if (!setupItems) return { mode: "none", deltas: {} };
|
||||||
|
|
||||||
|
const replacedItem =
|
||||||
|
setupItems.find((item) => item.categoryId === threadCategoryId) ?? null;
|
||||||
|
const mode: DeltaMode = replacedItem ? "replace" : "add";
|
||||||
|
const deltas: Record<number, CandidateDelta> = {};
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
let weightDelta: number | null = null;
|
||||||
|
let priceDelta: number | null = null;
|
||||||
|
|
||||||
|
if (candidate.weightGrams != null) {
|
||||||
|
weightDelta =
|
||||||
|
replacedItem?.weightGrams != null
|
||||||
|
? candidate.weightGrams - replacedItem.weightGrams
|
||||||
|
: candidate.weightGrams;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (candidate.priceCents != null) {
|
||||||
|
priceDelta =
|
||||||
|
replacedItem?.priceCents != null
|
||||||
|
? candidate.priceCents - replacedItem.priceCents
|
||||||
|
: candidate.priceCents;
|
||||||
|
}
|
||||||
|
|
||||||
|
deltas[candidate.id] = {
|
||||||
|
candidateId: candidate.id,
|
||||||
|
mode,
|
||||||
|
weightDelta,
|
||||||
|
priceDelta,
|
||||||
|
replacedItemName: replacedItem?.name ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { mode, deltas };
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
|
|
||||||
import { Route as rootRouteImport } from './routes/__root'
|
import { Route as rootRouteImport } from './routes/__root'
|
||||||
import { Route as SettingsRouteImport } from './routes/settings'
|
import { Route as SettingsRouteImport } from './routes/settings'
|
||||||
|
import { Route as LoginRouteImport } from './routes/login'
|
||||||
import { Route as IndexRouteImport } from './routes/index'
|
import { Route as IndexRouteImport } from './routes/index'
|
||||||
import { Route as CollectionIndexRouteImport } from './routes/collection/index'
|
import { Route as CollectionIndexRouteImport } from './routes/collection/index'
|
||||||
import { Route as ThreadsThreadIdRouteImport } from './routes/threads/$threadId'
|
import { Route as ThreadsThreadIdRouteImport } from './routes/threads/$threadId'
|
||||||
@@ -20,6 +21,11 @@ const SettingsRoute = SettingsRouteImport.update({
|
|||||||
path: '/settings',
|
path: '/settings',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const LoginRoute = LoginRouteImport.update({
|
||||||
|
id: '/login',
|
||||||
|
path: '/login',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
const IndexRoute = IndexRouteImport.update({
|
const IndexRoute = IndexRouteImport.update({
|
||||||
id: '/',
|
id: '/',
|
||||||
path: '/',
|
path: '/',
|
||||||
@@ -43,6 +49,7 @@ const SetupsSetupIdRoute = SetupsSetupIdRouteImport.update({
|
|||||||
|
|
||||||
export interface FileRoutesByFullPath {
|
export interface FileRoutesByFullPath {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
|
'/login': typeof LoginRoute
|
||||||
'/settings': typeof SettingsRoute
|
'/settings': typeof SettingsRoute
|
||||||
'/setups/$setupId': typeof SetupsSetupIdRoute
|
'/setups/$setupId': typeof SetupsSetupIdRoute
|
||||||
'/threads/$threadId': typeof ThreadsThreadIdRoute
|
'/threads/$threadId': typeof ThreadsThreadIdRoute
|
||||||
@@ -50,6 +57,7 @@ export interface FileRoutesByFullPath {
|
|||||||
}
|
}
|
||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
|
'/login': typeof LoginRoute
|
||||||
'/settings': typeof SettingsRoute
|
'/settings': typeof SettingsRoute
|
||||||
'/setups/$setupId': typeof SetupsSetupIdRoute
|
'/setups/$setupId': typeof SetupsSetupIdRoute
|
||||||
'/threads/$threadId': typeof ThreadsThreadIdRoute
|
'/threads/$threadId': typeof ThreadsThreadIdRoute
|
||||||
@@ -58,6 +66,7 @@ export interface FileRoutesByTo {
|
|||||||
export interface FileRoutesById {
|
export interface FileRoutesById {
|
||||||
__root__: typeof rootRouteImport
|
__root__: typeof rootRouteImport
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
|
'/login': typeof LoginRoute
|
||||||
'/settings': typeof SettingsRoute
|
'/settings': typeof SettingsRoute
|
||||||
'/setups/$setupId': typeof SetupsSetupIdRoute
|
'/setups/$setupId': typeof SetupsSetupIdRoute
|
||||||
'/threads/$threadId': typeof ThreadsThreadIdRoute
|
'/threads/$threadId': typeof ThreadsThreadIdRoute
|
||||||
@@ -67,6 +76,7 @@ export interface FileRouteTypes {
|
|||||||
fileRoutesByFullPath: FileRoutesByFullPath
|
fileRoutesByFullPath: FileRoutesByFullPath
|
||||||
fullPaths:
|
fullPaths:
|
||||||
| '/'
|
| '/'
|
||||||
|
| '/login'
|
||||||
| '/settings'
|
| '/settings'
|
||||||
| '/setups/$setupId'
|
| '/setups/$setupId'
|
||||||
| '/threads/$threadId'
|
| '/threads/$threadId'
|
||||||
@@ -74,6 +84,7 @@ export interface FileRouteTypes {
|
|||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
to:
|
to:
|
||||||
| '/'
|
| '/'
|
||||||
|
| '/login'
|
||||||
| '/settings'
|
| '/settings'
|
||||||
| '/setups/$setupId'
|
| '/setups/$setupId'
|
||||||
| '/threads/$threadId'
|
| '/threads/$threadId'
|
||||||
@@ -81,6 +92,7 @@ export interface FileRouteTypes {
|
|||||||
id:
|
id:
|
||||||
| '__root__'
|
| '__root__'
|
||||||
| '/'
|
| '/'
|
||||||
|
| '/login'
|
||||||
| '/settings'
|
| '/settings'
|
||||||
| '/setups/$setupId'
|
| '/setups/$setupId'
|
||||||
| '/threads/$threadId'
|
| '/threads/$threadId'
|
||||||
@@ -89,6 +101,7 @@ export interface FileRouteTypes {
|
|||||||
}
|
}
|
||||||
export interface RootRouteChildren {
|
export interface RootRouteChildren {
|
||||||
IndexRoute: typeof IndexRoute
|
IndexRoute: typeof IndexRoute
|
||||||
|
LoginRoute: typeof LoginRoute
|
||||||
SettingsRoute: typeof SettingsRoute
|
SettingsRoute: typeof SettingsRoute
|
||||||
SetupsSetupIdRoute: typeof SetupsSetupIdRoute
|
SetupsSetupIdRoute: typeof SetupsSetupIdRoute
|
||||||
ThreadsThreadIdRoute: typeof ThreadsThreadIdRoute
|
ThreadsThreadIdRoute: typeof ThreadsThreadIdRoute
|
||||||
@@ -104,6 +117,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof SettingsRouteImport
|
preLoaderRoute: typeof SettingsRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
|
'/login': {
|
||||||
|
id: '/login'
|
||||||
|
path: '/login'
|
||||||
|
fullPath: '/login'
|
||||||
|
preLoaderRoute: typeof LoginRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
'/': {
|
'/': {
|
||||||
id: '/'
|
id: '/'
|
||||||
path: '/'
|
path: '/'
|
||||||
@@ -137,6 +157,7 @@ declare module '@tanstack/react-router' {
|
|||||||
|
|
||||||
const rootRouteChildren: RootRouteChildren = {
|
const rootRouteChildren: RootRouteChildren = {
|
||||||
IndexRoute: IndexRoute,
|
IndexRoute: IndexRoute,
|
||||||
|
LoginRoute: LoginRoute,
|
||||||
SettingsRoute: SettingsRoute,
|
SettingsRoute: SettingsRoute,
|
||||||
SetupsSetupIdRoute: SetupsSetupIdRoute,
|
SetupsSetupIdRoute: SetupsSetupIdRoute,
|
||||||
ThreadsThreadIdRoute: ThreadsThreadIdRoute,
|
ThreadsThreadIdRoute: ThreadsThreadIdRoute,
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import {
|
import {
|
||||||
createRootRoute,
|
createRootRoute,
|
||||||
|
type ErrorComponentProps,
|
||||||
Outlet,
|
Outlet,
|
||||||
useMatchRoute,
|
useMatchRoute,
|
||||||
useNavigate,
|
useNavigate,
|
||||||
|
useRouter,
|
||||||
} from "@tanstack/react-router";
|
} from "@tanstack/react-router";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import "../app.css";
|
import "../app.css";
|
||||||
@@ -13,6 +15,7 @@ import { ItemForm } from "../components/ItemForm";
|
|||||||
import { OnboardingWizard } from "../components/OnboardingWizard";
|
import { OnboardingWizard } from "../components/OnboardingWizard";
|
||||||
import { SlideOutPanel } from "../components/SlideOutPanel";
|
import { SlideOutPanel } from "../components/SlideOutPanel";
|
||||||
import { TotalsBar } from "../components/TotalsBar";
|
import { TotalsBar } from "../components/TotalsBar";
|
||||||
|
import { useAuth } from "../hooks/useAuth";
|
||||||
import { useDeleteCandidate } from "../hooks/useCandidates";
|
import { useDeleteCandidate } from "../hooks/useCandidates";
|
||||||
import { useOnboardingComplete } from "../hooks/useSettings";
|
import { useOnboardingComplete } from "../hooks/useSettings";
|
||||||
import { useResolveThread, useThread } from "../hooks/useThreads";
|
import { useResolveThread, useThread } from "../hooks/useThreads";
|
||||||
@@ -20,10 +23,57 @@ import { useUIStore } from "../stores/uiStore";
|
|||||||
|
|
||||||
export const Route = createRootRoute({
|
export const Route = createRootRoute({
|
||||||
component: RootLayout,
|
component: RootLayout,
|
||||||
|
errorComponent: RootErrorBoundary,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function RootErrorBoundary({ error, reset }: ErrorComponentProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||||
|
<div className="max-w-md mx-auto text-center px-4">
|
||||||
|
<div className="w-12 h-12 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||||
|
<svg
|
||||||
|
className="w-6 h-6 text-red-600"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-xl font-semibold text-gray-900 mb-2">
|
||||||
|
Something went wrong
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-gray-500 mb-6">
|
||||||
|
{error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "An unexpected error occurred"}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
reset();
|
||||||
|
router.invalidate();
|
||||||
|
}}
|
||||||
|
className="px-5 py-2.5 bg-gray-700 hover:bg-gray-800 text-white text-sm font-medium rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
Try again
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function RootLayout() {
|
function RootLayout() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { data: auth } = useAuth();
|
||||||
|
const isAuthenticated = !!auth?.user;
|
||||||
|
|
||||||
// Item panel state
|
// Item panel state
|
||||||
const panelMode = useUIStore((s) => s.panelMode);
|
const panelMode = useUIStore((s) => s.panelMode);
|
||||||
@@ -54,8 +104,12 @@ function RootLayout() {
|
|||||||
useOnboardingComplete();
|
useOnboardingComplete();
|
||||||
const [wizardDismissed, setWizardDismissed] = useState(false);
|
const [wizardDismissed, setWizardDismissed] = useState(false);
|
||||||
|
|
||||||
|
// Don't show onboarding wizard until user has created an account
|
||||||
const showWizard =
|
const showWizard =
|
||||||
!onboardingLoading && onboardingComplete !== "true" && !wizardDismissed;
|
!onboardingLoading &&
|
||||||
|
onboardingComplete !== "true" &&
|
||||||
|
!wizardDismissed &&
|
||||||
|
isAuthenticated;
|
||||||
|
|
||||||
const isItemPanelOpen = panelMode !== "closed";
|
const isItemPanelOpen = panelMode !== "closed";
|
||||||
const isCandidatePanelOpen = candidatePanelMode !== "closed";
|
const isCandidatePanelOpen = candidatePanelMode !== "closed";
|
||||||
@@ -175,7 +229,7 @@ function RootLayout() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Floating Add Button - only on collection gear tab */}
|
{/* Floating Add Button - only on collection gear tab */}
|
||||||
{showFab && (
|
{showFab && isAuthenticated && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={openAddPanel}
|
onClick={openAddPanel}
|
||||||
|
|||||||
@@ -1,23 +1,10 @@
|
|||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||||
import { AnimatePresence, motion } from "framer-motion";
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
import { useMemo, useRef, useState } from "react";
|
import { useRef } from "react";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { CategoryFilterDropdown } from "../../components/CategoryFilterDropdown";
|
import { CollectionView } from "../../components/CollectionView";
|
||||||
import { CategoryHeader } from "../../components/CategoryHeader";
|
import { PlanningView } from "../../components/PlanningView";
|
||||||
import { CreateThreadModal } from "../../components/CreateThreadModal";
|
import { SetupsView } from "../../components/SetupsView";
|
||||||
import { ItemCard } from "../../components/ItemCard";
|
|
||||||
import { SetupCard } from "../../components/SetupCard";
|
|
||||||
import { ThreadCard } from "../../components/ThreadCard";
|
|
||||||
import { useCategories } from "../../hooks/useCategories";
|
|
||||||
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";
|
|
||||||
import { useUIStore } from "../../stores/uiStore";
|
|
||||||
|
|
||||||
const searchSchema = z.object({
|
const searchSchema = z.object({
|
||||||
tab: z.enum(["gear", "planning", "setups"]).catch("gear"),
|
tab: z.enum(["gear", "planning", "setups"]).catch("gear"),
|
||||||
@@ -29,6 +16,11 @@ export const Route = createFileRoute("/collection/")({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const TAB_ORDER = ["gear", "planning", "setups"] as const;
|
const TAB_ORDER = ["gear", "planning", "setups"] as const;
|
||||||
|
const TAB_LABELS: Record<(typeof TAB_ORDER)[number], string> = {
|
||||||
|
gear: "Gear",
|
||||||
|
planning: "Planning",
|
||||||
|
setups: "Setups",
|
||||||
|
};
|
||||||
|
|
||||||
const slideVariants = {
|
const slideVariants = {
|
||||||
enter: (dir: number) => ({ x: `${dir * 15}%`, opacity: 0 }),
|
enter: (dir: number) => ({ x: `${dir * 15}%`, opacity: 0 }),
|
||||||
@@ -46,6 +38,26 @@ function CollectionPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 overflow-x-hidden">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 overflow-x-hidden">
|
||||||
|
{/* Tab navigation */}
|
||||||
|
<div className="flex justify-center mb-6">
|
||||||
|
<div className="flex bg-gray-100 rounded-full p-0.5 gap-0.5">
|
||||||
|
{TAB_ORDER.map((t) => (
|
||||||
|
<Link
|
||||||
|
key={t}
|
||||||
|
to="/collection"
|
||||||
|
search={{ tab: t }}
|
||||||
|
className={`px-4 py-1.5 text-sm font-medium rounded-full transition-colors ${
|
||||||
|
tab === t
|
||||||
|
? "bg-gray-700 text-white"
|
||||||
|
: "text-gray-600 hover:bg-gray-200"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{TAB_LABELS[t]}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<AnimatePresence mode="wait" initial={false} custom={direction}>
|
<AnimatePresence mode="wait" initial={false} custom={direction}>
|
||||||
<motion.div
|
<motion.div
|
||||||
key={tab}
|
key={tab}
|
||||||
@@ -68,566 +80,3 @@ function CollectionPage() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CollectionView() {
|
|
||||||
const { data: items, isLoading: itemsLoading } = useItems();
|
|
||||||
const { data: totals } = useTotals();
|
|
||||||
const { data: categories } = useCategories();
|
|
||||||
const unit = useWeightUnit();
|
|
||||||
const currency = useCurrency();
|
|
||||||
const openAddPanel = useUIStore((s) => s.openAddPanel);
|
|
||||||
|
|
||||||
const [searchText, setSearchText] = useState("");
|
|
||||||
const [categoryFilter, setCategoryFilter] = useState<number | null>(null);
|
|
||||||
|
|
||||||
const filteredItems = useMemo(() => {
|
|
||||||
if (!items) return [];
|
|
||||||
return items.filter((item) => {
|
|
||||||
const matchesSearch =
|
|
||||||
searchText === "" ||
|
|
||||||
item.name.toLowerCase().includes(searchText.toLowerCase());
|
|
||||||
const matchesCategory =
|
|
||||||
categoryFilter === null || item.categoryId === categoryFilter;
|
|
||||||
return matchesSearch && matchesCategory;
|
|
||||||
});
|
|
||||||
}, [items, searchText, categoryFilter]);
|
|
||||||
|
|
||||||
const hasActiveFilters = searchText !== "" || categoryFilter !== null;
|
|
||||||
|
|
||||||
if (itemsLoading) {
|
|
||||||
return (
|
|
||||||
<div className="animate-pulse space-y-6">
|
|
||||||
<div className="h-6 bg-gray-200 rounded w-48" />
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
||||||
{[1, 2, 3].map((i) => (
|
|
||||||
<div key={i} className="h-40 bg-gray-200 rounded-xl" />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!items || items.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className="py-16 text-center">
|
|
||||||
<div className="max-w-md mx-auto">
|
|
||||||
<div className="mb-4">
|
|
||||||
<LucideIcon
|
|
||||||
name="backpack"
|
|
||||||
size={48}
|
|
||||||
className="text-gray-400 mx-auto"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<h2 className="text-xl font-semibold text-gray-900 mb-2">
|
|
||||||
Your collection is empty
|
|
||||||
</h2>
|
|
||||||
<p className="text-sm text-gray-500 mb-6">
|
|
||||||
Start cataloging your gear by adding your first item. Track weight,
|
|
||||||
price, and organize by category.
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={openAddPanel}
|
|
||||||
className="inline-flex items-center gap-2 px-5 py-2.5 bg-gray-700 hover:bg-gray-800 text-white text-sm font-medium rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
aria-hidden="true"
|
|
||||||
className="w-4 h-4"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M12 4v16m8-8H4"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
Add your first item
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build category totals lookup
|
|
||||||
const categoryTotalsMap = new Map<
|
|
||||||
number,
|
|
||||||
{ totalWeight: number; totalCost: number; itemCount: number }
|
|
||||||
>();
|
|
||||||
if (totals?.categories) {
|
|
||||||
for (const ct of totals.categories) {
|
|
||||||
categoryTotalsMap.set(ct.categoryId, {
|
|
||||||
totalWeight: ct.totalWeight,
|
|
||||||
totalCost: ct.totalCost,
|
|
||||||
itemCount: ct.itemCount,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Group filtered items by categoryId (used when no active filters)
|
|
||||||
const groupedItems = new Map<
|
|
||||||
number,
|
|
||||||
{
|
|
||||||
items: typeof filteredItems;
|
|
||||||
categoryName: string;
|
|
||||||
categoryIcon: string;
|
|
||||||
}
|
|
||||||
>();
|
|
||||||
|
|
||||||
for (const item of filteredItems) {
|
|
||||||
const group = groupedItems.get(item.categoryId);
|
|
||||||
if (group) {
|
|
||||||
group.items.push(item);
|
|
||||||
} else {
|
|
||||||
groupedItems.set(item.categoryId, {
|
|
||||||
items: [item],
|
|
||||||
categoryName: item.categoryName,
|
|
||||||
categoryIcon: item.categoryIcon,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{/* Collection stats card */}
|
|
||||||
{totals?.global && (
|
|
||||||
<div className="bg-white rounded-xl border border-gray-100 p-5 mb-6">
|
|
||||||
<div className="flex items-center gap-8">
|
|
||||||
<div className="flex flex-col items-center gap-1">
|
|
||||||
<LucideIcon name="layers" size={14} className="text-gray-400" />
|
|
||||||
<span className="text-xs text-gray-500">Items</span>
|
|
||||||
<span className="text-sm font-semibold text-gray-900">
|
|
||||||
{totals.global.itemCount}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col items-center gap-1">
|
|
||||||
<LucideIcon name="weight" size={14} className="text-gray-400" />
|
|
||||||
<span className="text-xs text-gray-500">Total Weight</span>
|
|
||||||
<span className="text-sm font-semibold text-gray-900">
|
|
||||||
{formatWeight(totals.global.totalWeight, unit)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col items-center gap-1">
|
|
||||||
<LucideIcon
|
|
||||||
name="credit-card"
|
|
||||||
size={14}
|
|
||||||
className="text-gray-400"
|
|
||||||
/>
|
|
||||||
<span className="text-xs text-gray-500">Total Spent</span>
|
|
||||||
<span className="text-sm font-semibold text-gray-900">
|
|
||||||
{formatPrice(totals.global.totalCost, currency)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Search/filter toolbar */}
|
|
||||||
<div className="sticky top-0 z-10 bg-gray-50/95 backdrop-blur-sm border-b border-gray-100 -mx-4 px-4 py-3 sm:-mx-6 sm:px-6 lg:-mx-8 lg:px-8 mb-6">
|
|
||||||
<div className="flex gap-3 items-center">
|
|
||||||
<div className="relative flex-1">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Search items..."
|
|
||||||
value={searchText}
|
|
||||||
onChange={(e) => setSearchText(e.target.value)}
|
|
||||||
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent"
|
|
||||||
/>
|
|
||||||
{searchText && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setSearchText("")}
|
|
||||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
className="w-4 h-4"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M6 18L18 6M6 6l12 12"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<CategoryFilterDropdown
|
|
||||||
value={categoryFilter}
|
|
||||||
onChange={setCategoryFilter}
|
|
||||||
categories={categories ?? []}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{hasActiveFilters && (
|
|
||||||
<p className="text-xs text-gray-500 mt-2">
|
|
||||||
Showing {filteredItems.length} of {items.length} items
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Filtered results */}
|
|
||||||
{hasActiveFilters ? (
|
|
||||||
filteredItems.length === 0 ? (
|
|
||||||
<div className="py-12 text-center">
|
|
||||||
<p className="text-sm text-gray-500">No items match your search</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
||||||
{filteredItems.map((item) => (
|
|
||||||
<ItemCard
|
|
||||||
key={item.id}
|
|
||||||
id={item.id}
|
|
||||||
name={item.name}
|
|
||||||
weightGrams={item.weightGrams}
|
|
||||||
priceCents={item.priceCents}
|
|
||||||
categoryName={item.categoryName}
|
|
||||||
categoryIcon={item.categoryIcon}
|
|
||||||
imageFilename={item.imageFilename}
|
|
||||||
productUrl={item.productUrl}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
Array.from(groupedItems.entries()).map(
|
|
||||||
([
|
|
||||||
categoryId,
|
|
||||||
{ items: categoryItems, categoryName, categoryIcon },
|
|
||||||
]) => {
|
|
||||||
const catTotals = categoryTotalsMap.get(categoryId);
|
|
||||||
return (
|
|
||||||
<div key={categoryId} className="mb-8">
|
|
||||||
<CategoryHeader
|
|
||||||
categoryId={categoryId}
|
|
||||||
name={categoryName}
|
|
||||||
icon={categoryIcon}
|
|
||||||
totalWeight={catTotals?.totalWeight ?? 0}
|
|
||||||
totalCost={catTotals?.totalCost ?? 0}
|
|
||||||
itemCount={catTotals?.itemCount ?? categoryItems.length}
|
|
||||||
/>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
||||||
{categoryItems.map((item) => (
|
|
||||||
<ItemCard
|
|
||||||
key={item.id}
|
|
||||||
id={item.id}
|
|
||||||
name={item.name}
|
|
||||||
weightGrams={item.weightGrams}
|
|
||||||
priceCents={item.priceCents}
|
|
||||||
categoryName={categoryName}
|
|
||||||
categoryIcon={categoryIcon}
|
|
||||||
imageFilename={item.imageFilename}
|
|
||||||
productUrl={item.productUrl}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function PlanningView() {
|
|
||||||
const [activeTab, setActiveTab] = useState<"active" | "resolved">("active");
|
|
||||||
const [categoryFilter, setCategoryFilter] = useState<number | null>(null);
|
|
||||||
|
|
||||||
const openCreateThreadModal = useUIStore((s) => s.openCreateThreadModal);
|
|
||||||
const { data: categories } = useCategories();
|
|
||||||
const { data: threads, isLoading } = useThreads(activeTab === "resolved");
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="animate-pulse space-y-4">
|
|
||||||
{[1, 2].map((i) => (
|
|
||||||
<div key={i} className="h-24 bg-gray-200 rounded-xl" />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter threads by active tab and category
|
|
||||||
const filteredThreads = (threads ?? [])
|
|
||||||
.filter((t) => t.status === activeTab)
|
|
||||||
.filter((t) => (categoryFilter ? t.categoryId === categoryFilter : true));
|
|
||||||
|
|
||||||
// Determine if we should show the educational empty state
|
|
||||||
const isEmptyNoFilters =
|
|
||||||
filteredThreads.length === 0 &&
|
|
||||||
activeTab === "active" &&
|
|
||||||
categoryFilter === null &&
|
|
||||||
(!threads || threads.length === 0);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
{/* Header row */}
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<h2 className="text-lg font-semibold text-gray-900">
|
|
||||||
Planning Threads
|
|
||||||
</h2>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={openCreateThreadModal}
|
|
||||||
className="inline-flex items-center gap-2 px-4 py-2 bg-gray-700 hover:bg-gray-800 text-white text-sm font-medium rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
aria-hidden="true"
|
|
||||||
className="w-4 h-4"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M12 4v16m8-8H4"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
New Thread
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Filter row */}
|
|
||||||
<div className="flex items-center justify-between mb-6">
|
|
||||||
{/* Pill tabs */}
|
|
||||||
<div className="flex bg-gray-100 rounded-full p-0.5 gap-0.5">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setActiveTab("active")}
|
|
||||||
className={`px-4 py-1.5 text-sm font-medium rounded-full transition-colors ${
|
|
||||||
activeTab === "active"
|
|
||||||
? "bg-gray-700 text-white"
|
|
||||||
: "text-gray-600 hover:bg-gray-200"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Active
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setActiveTab("resolved")}
|
|
||||||
className={`px-4 py-1.5 text-sm font-medium rounded-full transition-colors ${
|
|
||||||
activeTab === "resolved"
|
|
||||||
? "bg-gray-700 text-white"
|
|
||||||
: "text-gray-600 hover:bg-gray-200"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Resolved
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Category filter */}
|
|
||||||
<CategoryFilterDropdown
|
|
||||||
value={categoryFilter}
|
|
||||||
onChange={setCategoryFilter}
|
|
||||||
categories={categories ?? []}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content: empty state or thread grid */}
|
|
||||||
{isEmptyNoFilters ? (
|
|
||||||
<div className="py-16">
|
|
||||||
<div className="max-w-lg mx-auto text-center">
|
|
||||||
<h2 className="text-xl font-semibold text-gray-900 mb-8">
|
|
||||||
Plan your next purchase
|
|
||||||
</h2>
|
|
||||||
<div className="space-y-6 text-left mb-10">
|
|
||||||
<div className="flex items-start gap-4">
|
|
||||||
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-gray-200 text-gray-700 font-bold text-sm shrink-0">
|
|
||||||
1
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="font-medium text-gray-900">Create a thread</p>
|
|
||||||
<p className="text-sm text-gray-500">
|
|
||||||
Start a research thread for gear you're considering
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-start gap-4">
|
|
||||||
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-gray-200 text-gray-700 font-bold text-sm shrink-0">
|
|
||||||
2
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="font-medium text-gray-900">Add candidates</p>
|
|
||||||
<p className="text-sm text-gray-500">
|
|
||||||
Add products you're comparing with prices and weights
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-start gap-4">
|
|
||||||
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-gray-200 text-gray-700 font-bold text-sm shrink-0">
|
|
||||||
3
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="font-medium text-gray-900">Pick a winner</p>
|
|
||||||
<p className="text-sm text-gray-500">
|
|
||||||
Resolve the thread and the winner joins your collection
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={openCreateThreadModal}
|
|
||||||
className="inline-flex items-center gap-2 px-5 py-2.5 bg-gray-700 hover:bg-gray-800 text-white text-sm font-medium rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
aria-hidden="true"
|
|
||||||
className="w-4 h-4"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M12 4v16m8-8H4"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
Create your first thread
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : filteredThreads.length === 0 ? (
|
|
||||||
<div className="py-12 text-center">
|
|
||||||
<p className="text-sm text-gray-500">No threads found</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
||||||
{filteredThreads.map((thread) => (
|
|
||||||
<ThreadCard
|
|
||||||
key={thread.id}
|
|
||||||
id={thread.id}
|
|
||||||
name={thread.name}
|
|
||||||
candidateCount={thread.candidateCount}
|
|
||||||
minPriceCents={thread.minPriceCents}
|
|
||||||
maxPriceCents={thread.maxPriceCents}
|
|
||||||
createdAt={thread.createdAt}
|
|
||||||
status={thread.status}
|
|
||||||
categoryName={thread.categoryName}
|
|
||||||
categoryIcon={thread.categoryIcon}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<CreateThreadModal />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SetupsView() {
|
|
||||||
const [newSetupName, setNewSetupName] = useState("");
|
|
||||||
const { data: setups, isLoading } = useSetups();
|
|
||||||
const createSetup = useCreateSetup();
|
|
||||||
|
|
||||||
function handleCreateSetup(e: React.FormEvent) {
|
|
||||||
e.preventDefault();
|
|
||||||
const name = newSetupName.trim();
|
|
||||||
if (!name) return;
|
|
||||||
createSetup.mutate({ name }, { onSuccess: () => setNewSetupName("") });
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
{/* Create setup form */}
|
|
||||||
<form onSubmit={handleCreateSetup} className="flex gap-2 mb-6">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={newSetupName}
|
|
||||||
onChange={(e) => setNewSetupName(e.target.value)}
|
|
||||||
placeholder="New setup name..."
|
|
||||||
className="flex-1 px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={!newSetupName.trim() || createSetup.isPending}
|
|
||||||
className="px-4 py-2 bg-gray-700 hover:bg-gray-800 disabled:opacity-50 text-white text-sm font-medium rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
{createSetup.isPending ? "Creating..." : "Create"}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{/* Loading skeleton */}
|
|
||||||
{isLoading && (
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
||||||
{[1, 2].map((i) => (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
className="h-24 bg-gray-200 rounded-xl animate-pulse"
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Empty state */}
|
|
||||||
{!isLoading && (!setups || setups.length === 0) && (
|
|
||||||
<div className="py-16">
|
|
||||||
<div className="max-w-lg mx-auto text-center">
|
|
||||||
<h2 className="text-xl font-semibold text-gray-900 mb-8">
|
|
||||||
Build your perfect loadout
|
|
||||||
</h2>
|
|
||||||
<div className="space-y-6 text-left mb-10">
|
|
||||||
<div className="flex items-start gap-4">
|
|
||||||
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-gray-200 text-gray-700 font-bold text-sm shrink-0">
|
|
||||||
1
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="font-medium text-gray-900">Create a setup</p>
|
|
||||||
<p className="text-sm text-gray-500">
|
|
||||||
Name your loadout for a specific trip or activity
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-start gap-4">
|
|
||||||
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-gray-200 text-gray-700 font-bold text-sm shrink-0">
|
|
||||||
2
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="font-medium text-gray-900">Add items</p>
|
|
||||||
<p className="text-sm text-gray-500">
|
|
||||||
Pick gear from your collection to include in the setup
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-start gap-4">
|
|
||||||
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-gray-200 text-gray-700 font-bold text-sm shrink-0">
|
|
||||||
3
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="font-medium text-gray-900">Track weight</p>
|
|
||||||
<p className="text-sm text-gray-500">
|
|
||||||
See weight breakdown and optimize your pack
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Setup grid */}
|
|
||||||
{!isLoading && setups && setups.length > 0 && (
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
||||||
{setups.map((setup) => (
|
|
||||||
<SetupCard
|
|
||||||
key={setup.id}
|
|
||||||
id={setup.id}
|
|
||||||
name={setup.name}
|
|
||||||
itemCount={setup.itemCount}
|
|
||||||
totalWeight={setup.totalWeight}
|
|
||||||
totalCost={setup.totalCost}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { DashboardCard } from "../components/DashboardCard";
|
import { DashboardCard } from "../components/DashboardCard";
|
||||||
|
import { useFormatters } from "../hooks/useFormatters";
|
||||||
import { useSetups } from "../hooks/useSetups";
|
import { useSetups } from "../hooks/useSetups";
|
||||||
import { useThreads } from "../hooks/useThreads";
|
import { useThreads } from "../hooks/useThreads";
|
||||||
import { useTotals } from "../hooks/useTotals";
|
import { useTotals } from "../hooks/useTotals";
|
||||||
import { useWeightUnit } from "../hooks/useWeightUnit";
|
|
||||||
import { useCurrency } from "../hooks/useCurrency";
|
|
||||||
import { formatPrice, formatWeight } from "../lib/formatters";
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/")({
|
export const Route = createFileRoute("/")({
|
||||||
component: DashboardPage,
|
component: DashboardPage,
|
||||||
@@ -15,8 +13,7 @@ function DashboardPage() {
|
|||||||
const { data: totals } = useTotals();
|
const { data: totals } = useTotals();
|
||||||
const { data: threads } = useThreads(false);
|
const { data: threads } = useThreads(false);
|
||||||
const { data: setups } = useSetups();
|
const { data: setups } = useSetups();
|
||||||
const unit = useWeightUnit();
|
const { weight, price } = useFormatters();
|
||||||
const currency = useCurrency();
|
|
||||||
|
|
||||||
const global = totals?.global;
|
const global = totals?.global;
|
||||||
const activeThreadCount = threads?.length ?? 0;
|
const activeThreadCount = threads?.length ?? 0;
|
||||||
@@ -33,9 +30,12 @@ function DashboardPage() {
|
|||||||
{ label: "Items", value: String(global?.itemCount ?? 0) },
|
{ label: "Items", value: String(global?.itemCount ?? 0) },
|
||||||
{
|
{
|
||||||
label: "Weight",
|
label: "Weight",
|
||||||
value: formatWeight(global?.totalWeight ?? null, unit),
|
value: weight(global?.totalWeight ?? null),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Cost",
|
||||||
|
value: price(global?.totalCost ?? null),
|
||||||
},
|
},
|
||||||
{ label: "Cost", value: formatPrice(global?.totalCost ?? null, currency) },
|
|
||||||
]}
|
]}
|
||||||
emptyText="Get started"
|
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,5 +1,14 @@
|
|||||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||||
|
import { useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
useApiKeys,
|
||||||
|
useAuth,
|
||||||
|
useChangePassword,
|
||||||
|
useCreateApiKey,
|
||||||
|
useDeleteApiKey,
|
||||||
|
} from "../hooks/useAuth";
|
||||||
import { useCurrency } from "../hooks/useCurrency";
|
import { useCurrency } from "../hooks/useCurrency";
|
||||||
|
import { useExportItems, useImportItems } from "../hooks/useItems";
|
||||||
import { useUpdateSetting } from "../hooks/useSettings";
|
import { useUpdateSetting } from "../hooks/useSettings";
|
||||||
import { useWeightUnit } from "../hooks/useWeightUnit";
|
import { useWeightUnit } from "../hooks/useWeightUnit";
|
||||||
import type { Currency, WeightUnit } from "../lib/formatters";
|
import type { Currency, WeightUnit } from "../lib/formatters";
|
||||||
@@ -18,10 +27,246 @@ export const Route = createFileRoute("/settings")({
|
|||||||
component: SettingsPage,
|
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 ImportExportSection() {
|
||||||
|
const exportItems = useExportItems();
|
||||||
|
const importItems = useImportItems();
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [importResult, setImportResult] = useState<{
|
||||||
|
imported: number;
|
||||||
|
createdCategories: string[];
|
||||||
|
errors: string[];
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
async function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
setImportResult(null);
|
||||||
|
try {
|
||||||
|
const result = await importItems.mutateAsync(file);
|
||||||
|
setImportResult(result);
|
||||||
|
} catch (err) {
|
||||||
|
setImportResult({
|
||||||
|
imported: 0,
|
||||||
|
createdCategories: [],
|
||||||
|
errors: [(err as Error).message],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Reset so the same file can be imported again if needed
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h3 className="text-sm font-medium text-gray-900">Import / Export</h3>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
Export your gear collection as a CSV file, or import items from a CSV.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={exportItems}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-white bg-gray-700 hover:bg-gray-800 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
Export CSV
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<label className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-200 hover:bg-gray-50 rounded-lg transition-colors cursor-pointer">
|
||||||
|
{importItems.isPending ? "Importing..." : "Import CSV"}
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".csv"
|
||||||
|
className="hidden"
|
||||||
|
disabled={importItems.isPending}
|
||||||
|
onChange={handleFileChange}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{importResult && (
|
||||||
|
<div
|
||||||
|
className={`rounded-lg p-3 text-xs space-y-1 ${
|
||||||
|
importResult.errors.length > 0 && importResult.imported === 0
|
||||||
|
? "bg-red-50 border border-red-200 text-red-700"
|
||||||
|
: "bg-green-50 border border-green-200 text-green-700"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{importResult.imported > 0 && (
|
||||||
|
<p className="font-medium">
|
||||||
|
{importResult.imported} item
|
||||||
|
{importResult.imported !== 1 ? "s" : ""} imported.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{importResult.createdCategories.length > 0 && (
|
||||||
|
<p>New categories: {importResult.createdCategories.join(", ")}</p>
|
||||||
|
)}
|
||||||
|
{importResult.errors.map((err, i) => (
|
||||||
|
// biome-ignore lint/suspicious/noArrayIndexKey: static error list
|
||||||
|
<p key={i} className="text-red-600">
|
||||||
|
{err}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
{importResult.imported === 0 && importResult.errors.length === 0 && (
|
||||||
|
<p>No items found in the CSV.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function SettingsPage() {
|
function SettingsPage() {
|
||||||
const unit = useWeightUnit();
|
const unit = useWeightUnit();
|
||||||
const currency = useCurrency();
|
const currency = useCurrency();
|
||||||
const updateSetting = useUpdateSetting();
|
const updateSetting = useUpdateSetting();
|
||||||
|
const { data: auth } = useAuth();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-2xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
<div className="max-w-2xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||||
@@ -99,6 +344,18 @@ function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-xl border border-gray-100 p-5 space-y-6 mt-4">
|
||||||
|
<ImportExportSection />
|
||||||
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,15 +4,13 @@ import { CategoryHeader } from "../../components/CategoryHeader";
|
|||||||
import { ItemCard } from "../../components/ItemCard";
|
import { ItemCard } from "../../components/ItemCard";
|
||||||
import { ItemPicker } from "../../components/ItemPicker";
|
import { ItemPicker } from "../../components/ItemPicker";
|
||||||
import { WeightSummaryCard } from "../../components/WeightSummaryCard";
|
import { WeightSummaryCard } from "../../components/WeightSummaryCard";
|
||||||
|
import { useFormatters } from "../../hooks/useFormatters";
|
||||||
import {
|
import {
|
||||||
useDeleteSetup,
|
useDeleteSetup,
|
||||||
useRemoveSetupItem,
|
useRemoveSetupItem,
|
||||||
useSetup,
|
useSetup,
|
||||||
useUpdateItemClassification,
|
useUpdateItemClassification,
|
||||||
} from "../../hooks/useSetups";
|
} from "../../hooks/useSetups";
|
||||||
import { useCurrency } from "../../hooks/useCurrency";
|
|
||||||
import { useWeightUnit } from "../../hooks/useWeightUnit";
|
|
||||||
import { formatPrice, formatWeight } from "../../lib/formatters";
|
|
||||||
import { LucideIcon } from "../../lib/iconData";
|
import { LucideIcon } from "../../lib/iconData";
|
||||||
|
|
||||||
export const Route = createFileRoute("/setups/$setupId")({
|
export const Route = createFileRoute("/setups/$setupId")({
|
||||||
@@ -21,8 +19,7 @@ export const Route = createFileRoute("/setups/$setupId")({
|
|||||||
|
|
||||||
function SetupDetailPage() {
|
function SetupDetailPage() {
|
||||||
const { setupId } = Route.useParams();
|
const { setupId } = Route.useParams();
|
||||||
const unit = useWeightUnit();
|
const { weight, price } = useFormatters();
|
||||||
const currency = useCurrency();
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const numericId = Number(setupId);
|
const numericId = Number(setupId);
|
||||||
const { data: setup, isLoading } = useSetup(numericId);
|
const { data: setup, isLoading } = useSetup(numericId);
|
||||||
@@ -56,13 +53,13 @@ function SetupDetailPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compute totals from items
|
// Compute totals from items (multiply by quantity)
|
||||||
const totalWeight = setup.items.reduce(
|
const totalWeight = setup.items.reduce(
|
||||||
(sum, item) => sum + (item.weightGrams ?? 0),
|
(sum, item) => sum + (item.weightGrams ?? 0) * (item.quantity ?? 1),
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
const totalCost = setup.items.reduce(
|
const totalCost = setup.items.reduce(
|
||||||
(sum, item) => sum + (item.priceCents ?? 0),
|
(sum, item) => sum + (item.priceCents ?? 0) * (item.quantity ?? 1),
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
const itemCount = setup.items.length;
|
const itemCount = setup.items.length;
|
||||||
@@ -127,13 +124,13 @@ function SetupDetailPage() {
|
|||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
<span className="font-medium text-gray-700">
|
<span className="font-medium text-gray-700">
|
||||||
{formatWeight(totalWeight, unit)}
|
{weight(totalWeight)}
|
||||||
</span>{" "}
|
</span>{" "}
|
||||||
total
|
total
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
<span className="font-medium text-gray-700">
|
<span className="font-medium text-gray-700">
|
||||||
{formatPrice(totalCost, currency)}
|
{price(totalCost)}
|
||||||
</span>{" "}
|
</span>{" "}
|
||||||
cost
|
cost
|
||||||
</span>
|
</span>
|
||||||
@@ -210,11 +207,13 @@ function SetupDetailPage() {
|
|||||||
{ items: categoryItems, categoryName, categoryIcon },
|
{ items: categoryItems, categoryName, categoryIcon },
|
||||||
]) => {
|
]) => {
|
||||||
const catWeight = categoryItems.reduce(
|
const catWeight = categoryItems.reduce(
|
||||||
(sum, item) => sum + (item.weightGrams ?? 0),
|
(sum, item) =>
|
||||||
|
sum + (item.weightGrams ?? 0) * (item.quantity ?? 1),
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
const catCost = categoryItems.reduce(
|
const catCost = categoryItems.reduce(
|
||||||
(sum, item) => sum + (item.priceCents ?? 0),
|
(sum, item) =>
|
||||||
|
sum + (item.priceCents ?? 0) * (item.quantity ?? 1),
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
@@ -235,6 +234,7 @@ function SetupDetailPage() {
|
|||||||
name={item.name}
|
name={item.name}
|
||||||
weightGrams={item.weightGrams}
|
weightGrams={item.weightGrams}
|
||||||
priceCents={item.priceCents}
|
priceCents={item.priceCents}
|
||||||
|
quantity={item.quantity}
|
||||||
categoryName={categoryName}
|
categoryName={categoryName}
|
||||||
categoryIcon={categoryIcon}
|
categoryIcon={categoryIcon}
|
||||||
imageFilename={item.imageFilename}
|
imageFilename={item.imageFilename}
|
||||||
|
|||||||
@@ -3,10 +3,14 @@ import { Reorder } from "framer-motion";
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { CandidateCard } from "../../components/CandidateCard";
|
import { CandidateCard } from "../../components/CandidateCard";
|
||||||
import { CandidateListItem } from "../../components/CandidateListItem";
|
import { CandidateListItem } from "../../components/CandidateListItem";
|
||||||
|
import { ComparisonTable } from "../../components/ComparisonTable";
|
||||||
|
import { SetupImpactSelector } from "../../components/SetupImpactSelector";
|
||||||
import {
|
import {
|
||||||
useReorderCandidates,
|
useReorderCandidates,
|
||||||
useUpdateCandidate,
|
useUpdateCandidate,
|
||||||
} from "../../hooks/useCandidates";
|
} from "../../hooks/useCandidates";
|
||||||
|
import { useImpactDeltas } from "../../hooks/useImpactDeltas";
|
||||||
|
import { useSetup } from "../../hooks/useSetups";
|
||||||
import { useThread } from "../../hooks/useThreads";
|
import { useThread } from "../../hooks/useThreads";
|
||||||
import { LucideIcon } from "../../lib/iconData";
|
import { LucideIcon } from "../../lib/iconData";
|
||||||
import { useUIStore } from "../../stores/uiStore";
|
import { useUIStore } from "../../stores/uiStore";
|
||||||
@@ -22,15 +26,21 @@ function ThreadDetailPage() {
|
|||||||
const openCandidateAddPanel = useUIStore((s) => s.openCandidateAddPanel);
|
const openCandidateAddPanel = useUIStore((s) => s.openCandidateAddPanel);
|
||||||
const candidateViewMode = useUIStore((s) => s.candidateViewMode);
|
const candidateViewMode = useUIStore((s) => s.candidateViewMode);
|
||||||
const setCandidateViewMode = useUIStore((s) => s.setCandidateViewMode);
|
const setCandidateViewMode = useUIStore((s) => s.setCandidateViewMode);
|
||||||
|
const selectedSetupId = useUIStore((s) => s.selectedSetupId);
|
||||||
const updateCandidate = useUpdateCandidate(threadId);
|
const updateCandidate = useUpdateCandidate(threadId);
|
||||||
const reorderMutation = useReorderCandidates(threadId);
|
const reorderMutation = useReorderCandidates(threadId);
|
||||||
|
const { data: setupData } = useSetup(selectedSetupId);
|
||||||
|
const { deltas } = useImpactDeltas(
|
||||||
|
thread?.candidates ?? [],
|
||||||
|
setupData?.items,
|
||||||
|
thread?.categoryId ?? 0,
|
||||||
|
);
|
||||||
|
|
||||||
const [tempItems, setTempItems] =
|
const [tempItems, setTempItems] = useState<
|
||||||
useState<typeof thread extends { candidates: infer C } ? C : never | null>(
|
NonNullable<typeof thread>["candidates"] | null
|
||||||
null,
|
>(null);
|
||||||
);
|
|
||||||
|
|
||||||
// Clear tempItems when server data changes (biome-ignore: thread?.candidates is intentional dep)
|
// Clear tempItems when server data changes
|
||||||
// biome-ignore lint/correctness/useExhaustiveDependencies: thread?.candidates is the intended trigger
|
// biome-ignore lint/correctness/useExhaustiveDependencies: thread?.candidates is the intended trigger
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setTempItems(null);
|
setTempItems(null);
|
||||||
@@ -77,10 +87,9 @@ function ThreadDetailPage() {
|
|||||||
|
|
||||||
function handleDragEnd() {
|
function handleDragEnd() {
|
||||||
if (!tempItems) return;
|
if (!tempItems) return;
|
||||||
reorderMutation.mutate(
|
reorderMutation.mutate({
|
||||||
{ orderedIds: tempItems.map((c) => c.id) },
|
orderedIds: tempItems.map((c) => c.id),
|
||||||
{ onSettled: () => setTempItems(null) },
|
});
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -119,7 +128,7 @@ function ThreadDetailPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Toolbar: Add candidate + view toggle */}
|
{/* Toolbar: Add candidate + view toggle */}
|
||||||
<div className="mb-6 flex items-center gap-3">
|
<div className="mb-6 flex items-center gap-3 flex-wrap">
|
||||||
{isActive && (
|
{isActive && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -168,8 +177,23 @@ function ThreadDetailPage() {
|
|||||||
>
|
>
|
||||||
<LucideIcon name="layout-grid" size={16} />
|
<LucideIcon name="layout-grid" size={16} />
|
||||||
</button>
|
</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>
|
||||||
)}
|
)}
|
||||||
|
<SetupImpactSelector threadStatus={thread.status} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Candidates */}
|
{/* Candidates */}
|
||||||
@@ -189,14 +213,19 @@ function ThreadDetailPage() {
|
|||||||
Add your first candidate to start comparing.
|
Add your first candidate to start comparing.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
) : candidateViewMode === "compare" ? (
|
||||||
|
<ComparisonTable
|
||||||
|
candidates={displayItems}
|
||||||
|
resolvedCandidateId={thread.resolvedCandidateId}
|
||||||
|
deltas={deltas}
|
||||||
|
/>
|
||||||
) : candidateViewMode === "list" ? (
|
) : candidateViewMode === "list" ? (
|
||||||
isActive ? (
|
isActive ? (
|
||||||
<Reorder.Group
|
<Reorder.Group
|
||||||
axis="y"
|
axis="y"
|
||||||
values={displayItems}
|
values={displayItems}
|
||||||
onReorder={setTempItems}
|
onReorder={setTempItems}
|
||||||
onPointerUp={handleDragEnd}
|
className="flex flex-col"
|
||||||
className="flex flex-col gap-2"
|
|
||||||
>
|
>
|
||||||
{displayItems.map((candidate, index) => (
|
{displayItems.map((candidate, index) => (
|
||||||
<CandidateListItem
|
<CandidateListItem
|
||||||
@@ -210,6 +239,8 @@ function ThreadDetailPage() {
|
|||||||
status: newStatus,
|
status: newStatus,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
delta={deltas[candidate.id]}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Reorder.Group>
|
</Reorder.Group>
|
||||||
@@ -227,6 +258,7 @@ function ThreadDetailPage() {
|
|||||||
status: newStatus,
|
status: newStatus,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
delta={deltas[candidate.id]}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -256,6 +288,7 @@ function ThreadDetailPage() {
|
|||||||
pros={candidate.pros}
|
pros={candidate.pros}
|
||||||
cons={candidate.cons}
|
cons={candidate.cons}
|
||||||
rank={index + 1}
|
rank={index + 1}
|
||||||
|
delta={deltas[candidate.id]}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -50,8 +50,12 @@ interface UIState {
|
|||||||
closeExternalLink: () => void;
|
closeExternalLink: () => void;
|
||||||
|
|
||||||
// Candidate view mode
|
// Candidate view mode
|
||||||
candidateViewMode: "list" | "grid";
|
candidateViewMode: "list" | "grid" | "compare";
|
||||||
setCandidateViewMode: (mode: "list" | "grid") => void;
|
setCandidateViewMode: (mode: "list" | "grid" | "compare") => void;
|
||||||
|
|
||||||
|
// Setup impact preview
|
||||||
|
selectedSetupId: number | null;
|
||||||
|
setSelectedSetupId: (id: number | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useUIStore = create<UIState>((set) => ({
|
export const useUIStore = create<UIState>((set) => ({
|
||||||
@@ -111,4 +115,8 @@ export const useUIStore = create<UIState>((set) => ({
|
|||||||
// Candidate view mode
|
// Candidate view mode
|
||||||
candidateViewMode: "list",
|
candidateViewMode: "list",
|
||||||
setCandidateViewMode: (mode) => set({ candidateViewMode: mode }),
|
setCandidateViewMode: (mode) => set({ candidateViewMode: mode }),
|
||||||
|
|
||||||
|
// Setup impact preview
|
||||||
|
selectedSetupId: null,
|
||||||
|
setSelectedSetupId: (id) => set({ selectedSetupId: id }),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ export const items = sqliteTable("items", {
|
|||||||
notes: text("notes"),
|
notes: text("notes"),
|
||||||
productUrl: text("product_url"),
|
productUrl: text("product_url"),
|
||||||
imageFilename: text("image_filename"),
|
imageFilename: text("image_filename"),
|
||||||
|
imageSourceUrl: text("image_source_url"),
|
||||||
|
quantity: integer("quantity").notNull().default(1),
|
||||||
createdAt: integer("created_at", { mode: "timestamp" })
|
createdAt: integer("created_at", { mode: "timestamp" })
|
||||||
.notNull()
|
.notNull()
|
||||||
.$defaultFn(() => new Date()),
|
.$defaultFn(() => new Date()),
|
||||||
@@ -58,6 +60,7 @@ export const threadCandidates = sqliteTable("thread_candidates", {
|
|||||||
notes: text("notes"),
|
notes: text("notes"),
|
||||||
productUrl: text("product_url"),
|
productUrl: text("product_url"),
|
||||||
imageFilename: text("image_filename"),
|
imageFilename: text("image_filename"),
|
||||||
|
imageSourceUrl: text("image_source_url"),
|
||||||
status: text("status").notNull().default("researching"),
|
status: text("status").notNull().default("researching"),
|
||||||
pros: text("pros"),
|
pros: text("pros"),
|
||||||
cons: text("cons"),
|
cons: text("cons"),
|
||||||
@@ -96,3 +99,30 @@ export const settings = sqliteTable("settings", {
|
|||||||
key: text("key").primaryKey(),
|
key: text("key").primaryKey(),
|
||||||
value: text("value").notNull(),
|
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,10 @@
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { serveStatic } from "hono/bun";
|
import { serveStatic } from "hono/bun";
|
||||||
|
import { db as prodDb } from "../db/index.ts";
|
||||||
import { seedDefaults } from "../db/seed.ts";
|
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 { categoryRoutes } from "./routes/categories.ts";
|
||||||
import { imageRoutes } from "./routes/images.ts";
|
import { imageRoutes } from "./routes/images.ts";
|
||||||
import { itemRoutes } from "./routes/items.ts";
|
import { itemRoutes } from "./routes/items.ts";
|
||||||
@@ -14,12 +18,39 @@ seedDefaults();
|
|||||||
|
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
|
|
||||||
|
// Centralized error handler
|
||||||
|
app.onError((err, c) => {
|
||||||
|
console.error(`[${c.req.method}] ${c.req.path}:`, err);
|
||||||
|
const message =
|
||||||
|
process.env.NODE_ENV === "production"
|
||||||
|
? "Internal server error"
|
||||||
|
: err.message || "Internal server error";
|
||||||
|
return c.json({ error: message }, 500);
|
||||||
|
});
|
||||||
|
|
||||||
// Health check
|
// Health check
|
||||||
app.get("/api/health", (c) => {
|
app.get("/api/health", (c) => {
|
||||||
return c.json({ status: "ok" });
|
return c.json({ status: "ok" });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Inject production database into request context
|
||||||
|
app.use("/api/*", async (c, next) => {
|
||||||
|
c.set("db", prodDb);
|
||||||
|
return next();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auth middleware for write operations (POST/PUT/PATCH/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
|
// API routes
|
||||||
|
app.route("/api/auth", authRoutes);
|
||||||
app.route("/api/items", itemRoutes);
|
app.route("/api/items", itemRoutes);
|
||||||
app.route("/api/categories", categoryRoutes);
|
app.route("/api/categories", categoryRoutes);
|
||||||
app.route("/api/totals", totalRoutes);
|
app.route("/api/totals", totalRoutes);
|
||||||
@@ -28,6 +59,11 @@ app.route("/api/settings", settingsRoutes);
|
|||||||
app.route("/api/threads", threadRoutes);
|
app.route("/api/threads", threadRoutes);
|
||||||
app.route("/api/setups", setupRoutes);
|
app.route("/api/setups", setupRoutes);
|
||||||
|
|
||||||
|
// MCP server (conditionally mounted)
|
||||||
|
if (process.env.GEARBOX_MCP !== "false") {
|
||||||
|
app.route("/mcp", mcpRoutes);
|
||||||
|
}
|
||||||
|
|
||||||
// Serve uploaded images
|
// Serve uploaded images
|
||||||
app.use("/uploads/*", serveStatic({ root: "./" }));
|
app.use("/uploads/*", serveStatic({ root: "./" }));
|
||||||
|
|
||||||
|
|||||||
9
src/server/lib/params.ts
Normal file
9
src/server/lib/params.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* Parse a route parameter as a positive integer ID.
|
||||||
|
* Returns the number if valid, or null if the string is not a positive integer.
|
||||||
|
*/
|
||||||
|
export function parseId(raw: string): number | null {
|
||||||
|
const id = Number(raw);
|
||||||
|
if (!Number.isInteger(id) || id <= 0) return null;
|
||||||
|
return id;
|
||||||
|
}
|
||||||
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,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
63
src/server/mcp/tools/categories.ts
Normal file
63
src/server/mcp/tools/categories.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
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: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "create_category",
|
||||||
|
description: "Create a new gear category.",
|
||||||
|
inputSchema: {
|
||||||
|
name: z.string().describe("Category name"),
|
||||||
|
icon: z.string().optional().describe("Icon name (defaults to 'package')"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
44
src/server/mcp/tools/images.ts
Normal file
44
src/server/mcp/tools/images.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
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: {
|
||||||
|
url: z
|
||||||
|
.string()
|
||||||
|
.describe("URL of the image to fetch (jpeg, png, or webp)"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
169
src/server/mcp/tools/items.ts
Normal file
169
src/server/mcp/tools/items.ts
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
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: {
|
||||||
|
categoryId: z.number().optional().describe("Filter items by category ID"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "get_item",
|
||||||
|
description: "Get a single item by its ID, including all details.",
|
||||||
|
inputSchema: {
|
||||||
|
id: z.number().describe("The item 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: {
|
||||||
|
name: z.string().describe("Item name"),
|
||||||
|
categoryId: z.number().describe("Category ID"),
|
||||||
|
weightGrams: z.number().optional().describe("Weight in grams"),
|
||||||
|
priceCents: z.number().optional().describe("Price in cents"),
|
||||||
|
notes: z.string().optional().describe("Notes about the item"),
|
||||||
|
productUrl: z.string().optional().describe("URL to the product page"),
|
||||||
|
imageFilename: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe("Filename of an uploaded image"),
|
||||||
|
imageSourceUrl: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe("Original URL the image was fetched from"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "update_item",
|
||||||
|
description: "Update an existing item's fields.",
|
||||||
|
inputSchema: {
|
||||||
|
id: z.number().describe("The item ID to update"),
|
||||||
|
name: z.string().optional().describe("Item name"),
|
||||||
|
categoryId: z.number().optional().describe("Category ID"),
|
||||||
|
weightGrams: z.number().optional().describe("Weight in grams"),
|
||||||
|
priceCents: z.number().optional().describe("Price in cents"),
|
||||||
|
notes: z.string().optional().describe("Notes about the item"),
|
||||||
|
productUrl: z.string().optional().describe("URL to the product page"),
|
||||||
|
imageFilename: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe("Filename of an uploaded image"),
|
||||||
|
imageSourceUrl: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe("Original URL the image was fetched from"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "delete_item",
|
||||||
|
description: "Delete an item from the gear collection by ID.",
|
||||||
|
inputSchema: {
|
||||||
|
id: z.number().describe("The item ID to delete"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
116
src/server/mcp/tools/setups.ts
Normal file
116
src/server/mcp/tools/setups.ts
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
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: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "get_setup",
|
||||||
|
description: "Get a setup with all its items and details.",
|
||||||
|
inputSchema: {
|
||||||
|
id: z.number().describe("Setup ID"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "create_setup",
|
||||||
|
description: "Create a new gear setup (e.g. 'Bikepacking weekend').",
|
||||||
|
inputSchema: {
|
||||||
|
name: z.string().describe("Setup 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: {
|
||||||
|
id: z.number().describe("Setup ID"),
|
||||||
|
name: z.string().optional().describe("New setup name"),
|
||||||
|
itemIds: z
|
||||||
|
.array(z.number())
|
||||||
|
.optional()
|
||||||
|
.describe("Array of item IDs to include in the setup"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
221
src/server/mcp/tools/threads.ts
Normal file
221
src/server/mcp/tools/threads.ts
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
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: {
|
||||||
|
includeResolved: z
|
||||||
|
.boolean()
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
"Include resolved threads (default: false, only active threads)",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "get_thread",
|
||||||
|
description:
|
||||||
|
"Get a thread with all its candidates for detailed comparison.",
|
||||||
|
inputSchema: {
|
||||||
|
id: z.number().describe("Thread 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: {
|
||||||
|
name: z.string().describe("Thread name (e.g. 'Handlebar bag')"),
|
||||||
|
categoryId: z.number().describe("Category ID"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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: {
|
||||||
|
threadId: z.number().describe("Thread ID"),
|
||||||
|
candidateId: z.number().describe("ID of the winning candidate"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "add_candidate",
|
||||||
|
description: "Add a candidate option to a research thread for comparison.",
|
||||||
|
inputSchema: {
|
||||||
|
threadId: z.number().describe("Thread ID"),
|
||||||
|
name: z.string().describe("Candidate name"),
|
||||||
|
categoryId: z.number().describe("Category ID"),
|
||||||
|
weightGrams: z.number().optional().describe("Weight in grams"),
|
||||||
|
priceCents: z.number().optional().describe("Price in cents"),
|
||||||
|
notes: z.string().optional().describe("Notes"),
|
||||||
|
productUrl: z.string().optional().describe("Product URL"),
|
||||||
|
imageFilename: z.string().optional().describe("Image filename"),
|
||||||
|
pros: z.string().optional().describe("Pros of this candidate"),
|
||||||
|
cons: z.string().optional().describe("Cons of this candidate"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "update_candidate",
|
||||||
|
description:
|
||||||
|
"Update a candidate's details (name, price, pros, cons, etc.).",
|
||||||
|
inputSchema: {
|
||||||
|
id: z.number().describe("Candidate ID"),
|
||||||
|
name: z.string().optional().describe("Candidate name"),
|
||||||
|
weightGrams: z.number().optional().describe("Weight in grams"),
|
||||||
|
priceCents: z.number().optional().describe("Price in cents"),
|
||||||
|
categoryId: z.number().optional().describe("Category ID"),
|
||||||
|
notes: z.string().optional().describe("Notes"),
|
||||||
|
productUrl: z.string().optional().describe("Product URL"),
|
||||||
|
imageFilename: z.string().optional().describe("Image filename"),
|
||||||
|
imageSourceUrl: z.string().optional().describe("Image source URL"),
|
||||||
|
status: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe("Status: researching, ordered, or arrived"),
|
||||||
|
pros: z.string().optional().describe("Pros"),
|
||||||
|
cons: z.string().optional().describe("Cons"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "remove_candidate",
|
||||||
|
description: "Remove a candidate from a research thread.",
|
||||||
|
inputSchema: {
|
||||||
|
id: z.number().describe("Candidate ID to remove"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user