Merge branch 'worktree-agent-adbc35a5' into Develop

# Conflicts:
#	.planning/STATE.md
#	drizzle-pg/meta/0002_snapshot.json
#	drizzle-pg/meta/_journal.json
#	src/db/schema.ts
This commit is contained in:
2026-04-06 08:00:04 +02:00
18 changed files with 866 additions and 32 deletions

View File

@@ -2,16 +2,16 @@
gsd_state_version: 1.0 gsd_state_version: 1.0
milestone: v1.3 milestone: v1.3
milestone_name: Research & Decision Tools milestone_name: Research & Decision Tools
status: executing status: planning
stopped_at: Phase 20 context gathered stopped_at: Completed 20-01-PLAN.md
last_updated: "2026-04-06T05:52:12.097Z" last_updated: "2026-04-06T05:59:26.689Z"
last_activity: 2026-04-06 -- Phase 20 execution started last_activity: 2026-04-05
progress: progress:
total_phases: 14 total_phases: 13
completed_phases: 12 completed_phases: 11
total_plans: 38 total_plans: 33
completed_plans: 34 completed_plans: 32
percent: 3 percent: 0
--- ---
# Project State # Project State
@@ -21,16 +21,16 @@ progress:
See: .planning/PROJECT.md (updated 2026-04-03) See: .planning/PROJECT.md (updated 2026-04-03)
**Core value:** Help people make better gear decisions — discover what others use, compare real-world data, and see how a potential buy affects your setup before committing. **Core value:** Help people make better gear decisions — discover what others use, compare real-world data, and see how a potential buy affects your setup before committing.
**Current focus:** Phase 20 — fab-full-screen-catalog-search **Current focus:** v2.0 Platform Foundation — Phase 14 (PostgreSQL Migration)
## Current Position ## Current Position
Phase: 20 (fab-full-screen-catalog-search) — EXECUTING Phase: 18 of 18 (PostgreSQL Migration)
Plan: 1 of 2 Plan: Not started
Status: Executing Phase 20 Status: Ready to plan
Last activity: 2026-04-06 -- Phase 20 execution started Last activity: 2026-04-05
Progress: [#---------] 3% (v2.0 milestone) Progress: [----------] 0% (v2.0 milestone)
## Performance Metrics ## Performance Metrics
@@ -55,11 +55,7 @@ Key decisions made during v2.0 planning:
- Separate globalItems table — not a flag on user items table - Separate globalItems table — not a flag on user items table
- Single-user SQLite mode diverges at v2.0 boundary - Single-user SQLite mode diverges at v2.0 boundary
- [Phase 18]: Profile data loaded via usePublicProfile(userId) not /auth/me extension - [Phase 18]: Profile data loaded via usePublicProfile(userId) not /auth/me extension
- [Phase 19]: Direct globalItemId FK on items replaces itemGlobalLinks junction table - [Phase 20]: Created tags table in schema (was missing, needed for GET /api/tags endpoint)
- [Phase 19]: Data migration SQL: UPDATE items before DROP TABLE item_global_links
- [Phase 19]: Flat tags system without type categorization per D-16
- [Phase 19-reference-item-model-tags-schema]: COALESCE merge pattern for transparent reference item data in item/thread services
- [Phase 19-reference-item-model-tags-schema]: COALESCE merge pattern propagated to all secondary services (setup, totals, profile, CSV)
### Pending Todos ### Pending Todos
@@ -72,6 +68,6 @@ None active.
## Session Continuity ## Session Continuity
Last session: 2026-04-06T05:38:18.950Z Last session: 2026-04-06T05:59:26.687Z
Stopped at: Phase 20 context gathered Stopped at: Completed 20-01-PLAN.md
Resume file: .planning/phases/20-fab-full-screen-catalog-search/20-CONTEXT.md Resume file: None

View File

@@ -0,0 +1,125 @@
---
phase: 20-fab-full-screen-catalog-search
plan: 01
subsystem: api, ui
tags: [hono, zustand, tanstack-query, drizzle, tags, global-items]
requires:
- phase: 19-reference-item-model-tags-schema
provides: global-items service and route, schema foundation
provides:
- GET /api/tags endpoint returning all tags
- GET /api/global-items route registration in index.ts
- UIStore FAB menu and catalog search state slices
- useTags hook with 5-min stale cache
- useGlobalItems hook with optional tags parameter
affects: [20-02-PLAN, phase-21]
tech-stack:
added: []
patterns: [public-read auth skip for new GET endpoints]
key-files:
created:
- src/server/services/tag.service.ts
- src/server/routes/tags.ts
- src/client/hooks/useTags.ts
- tests/services/tag.service.test.ts
- tests/routes/tags.test.ts
- drizzle-pg/0002_square_pyro.sql
modified:
- src/db/schema.ts
- src/server/index.ts
- src/client/stores/uiStore.ts
- src/client/hooks/useGlobalItems.ts
key-decisions:
- "Created tags table in schema (was missing, needed for GET /api/tags)"
- "Tags endpoint is public-read (no auth), consistent with global-items"
patterns-established:
- "Tag service pattern: select specific columns (id, name) not full row"
requirements-completed: [CATFLOW-01, CATFLOW-02]
duration: 5min
completed: 2026-04-06
---
# Phase 20 Plan 01: Tags API, Route Registration, and UI State Summary
**Tags endpoint with alphabetical ordering, global-items route registration, UIStore FAB/catalog-search state, and tag-aware useGlobalItems hook**
## Performance
- **Duration:** 5 min
- **Started:** 2026-04-06T05:53:35Z
- **Completed:** 2026-04-06T05:58:27Z
- **Tasks:** 2
- **Files modified:** 10
## Accomplishments
- Created tags table, service, and route with full test coverage (4 tests)
- Registered previously unregistered global-items route in index.ts
- Added public-read auth skips for both /api/tags and /api/global-items
- Extended UIStore with FAB menu state (open/close) and catalog search overlay state (open with mode, close)
- Created useTags hook with 5-minute staleTime caching
- Updated useGlobalItems hook to accept optional tags array for filtering
## Task Commits
Each task was committed atomically:
1. **Task 1 (RED): Tag service and route tests** - `6f07e87` (test)
2. **Task 1 (GREEN): Tags table, service, route, registrations** - `2ec1276` (feat)
3. **Task 2: UIStore extension, useTags hook, useGlobalItems update** - `67facea` (feat)
## Files Created/Modified
- `src/db/schema.ts` - Added tags table definition
- `drizzle-pg/0002_square_pyro.sql` - Migration for tags table
- `src/server/services/tag.service.ts` - getAllTags function (id+name, alphabetical)
- `src/server/routes/tags.ts` - GET / handler returning all tags
- `src/server/index.ts` - Registered global-items and tags routes, added auth skips
- `src/client/stores/uiStore.ts` - Added FAB menu and catalog search state slices
- `src/client/hooks/useTags.ts` - Tag fetching hook with staleTime cache
- `src/client/hooks/useGlobalItems.ts` - Added optional tags parameter
- `tests/services/tag.service.test.ts` - Service-level tests for getAllTags
- `tests/routes/tags.test.ts` - Route-level tests for GET /api/tags
## Decisions Made
- Created tags table in schema since it was referenced by the plan but didn't exist yet (Rule 3 deviation)
- Made both /api/tags and /api/global-items public-read (GET requests skip auth middleware)
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 3 - Blocking] Created missing tags table in schema**
- **Found during:** Task 1 (Tag service implementation)
- **Issue:** Plan referenced `tags` table from schema.ts but no such table existed in the database schema
- **Fix:** Added tags table definition to schema.ts and generated migration (0002_square_pyro.sql)
- **Files modified:** src/db/schema.ts, drizzle-pg/0002_square_pyro.sql, drizzle-pg/meta/
- **Verification:** Migration generated successfully, tests pass with PGlite
- **Committed in:** 2ec1276 (Task 1 GREEN commit)
---
**Total deviations:** 1 auto-fixed (1 blocking)
**Impact on plan:** Essential for task completion. Tags table is required by the entire phase. No scope creep.
## Issues Encountered
- Pre-existing global-items route test failures (9 of 10 tests fail) due to async/sync mismatch in test helper usage. Out of scope for this plan.
## User Setup Required
None - no external service configuration required.
## Known Stubs
None - all functionality is fully wired.
## Next Phase Readiness
- Tags endpoint and UIStore state ready for Plan 02's UI components (FabMenu, CatalogSearchOverlay, TagChips)
- useTags and useGlobalItems hooks ready for consumption by overlay components
---
*Phase: 20-fab-full-screen-catalog-search*
*Completed: 2026-04-06*

View File

@@ -0,0 +1,145 @@
<h2>Catalog Browse & Search Experience</h2>
<p class="subtitle">How should users find gear in the global catalog?</p>
<div class="options">
<div class="option" data-choice="a" onclick="toggleSelect(this)">
<div class="letter">A</div>
<div class="content">
<h3>Search-First</h3>
<p style="margin-bottom:12px;">Big search bar dominates. Tags below as quick filters. Type naturally: "waterproof handlebar bag under 200g". Results appear as you type.</p>
<div class="mockup">
<div class="mockup-header">Global Catalog — Search-First</div>
<div class="mockup-body" style="padding: 20px;">
<div style="display:flex; gap:8px; margin-bottom:16px;">
<input class="mock-input" placeholder="Search gear... e.g. 'waterproof handlebar bag under 200g'" style="flex:1; font-size:15px; padding:10px 14px;">
</div>
<div style="display:flex; gap:8px; margin-bottom:20px; flex-wrap:wrap;">
<span style="padding:4px 12px; border-radius:99px; background:#f3f4f6; font-size:13px; color:#6b7280; cursor:pointer;">Bikepacking</span>
<span style="padding:4px 12px; border-radius:99px; background:#f3f4f6; font-size:13px; color:#6b7280; cursor:pointer;">Bags</span>
<span style="padding:4px 12px; border-radius:99px; background:#f3f4f6; font-size:13px; color:#6b7280; cursor:pointer;">Waterproof</span>
<span style="padding:4px 12px; border-radius:99px; background:#f3f4f6; font-size:13px; color:#6b7280; cursor:pointer;">Ultralight</span>
<span style="padding:4px 12px; border-radius:99px; background:#f3f4f6; font-size:13px; color:#6b7280; cursor:pointer;">Shelter</span>
<span style="padding:4px 12px; border-radius:99px; background:#f3f4f6; font-size:13px; color:#6b7280; cursor:pointer;">Cooking</span>
</div>
<div style="color:#9ca3af; font-size:13px; margin-bottom:16px;">Type to search or click tags to filter</div>
<div style="display:grid; grid-template-columns: 1fr 1fr; gap:12px;">
<div style="border:1px solid #e5e7eb; border-radius:12px; padding:14px;">
<div style="display:flex; justify-content:space-between; align-items:start;">
<div>
<div style="font-weight:600; font-size:14px;">Revelate Designs Pika</div>
<div style="color:#6b7280; font-size:12px;">Handlebar Bag</div>
</div>
<div style="background:#fef3c7; color:#92400e; font-size:11px; padding:2px 8px; border-radius:99px;">★ 4.6</div>
</div>
<div style="display:flex; gap:16px; margin-top:10px; font-size:12px; color:#6b7280;">
<span>128g</span>
<span>$85</span>
<span>24 owners</span>
</div>
<div style="display:flex; gap:4px; margin-top:8px; flex-wrap:wrap;">
<span style="padding:2px 8px; border-radius:99px; background:#eff6ff; font-size:11px; color:#3b82f6;">bikepacking</span>
<span style="padding:2px 8px; border-radius:99px; background:#eff6ff; font-size:11px; color:#3b82f6;">handlebar</span>
<span style="padding:2px 8px; border-radius:99px; background:#eff6ff; font-size:11px; color:#3b82f6;">waterproof</span>
</div>
</div>
<div style="border:1px solid #e5e7eb; border-radius:12px; padding:14px;">
<div style="display:flex; justify-content:space-between; align-items:start;">
<div>
<div style="font-weight:600; font-size:14px;">Apidura Racing HB Pack</div>
<div style="color:#6b7280; font-size:12px;">Handlebar Bag</div>
</div>
<div style="background:#fef3c7; color:#92400e; font-size:11px; padding:2px 8px; border-radius:99px;">★ 4.4</div>
</div>
<div style="display:flex; gap:16px; margin-top:10px; font-size:12px; color:#6b7280;">
<span>105g</span>
<span>$72</span>
<span>18 owners</span>
</div>
<div style="display:flex; gap:4px; margin-top:8px; flex-wrap:wrap;">
<span style="padding:2px 8px; border-radius:99px; background:#eff6ff; font-size:11px; color:#3b82f6;">bikepacking</span>
<span style="padding:2px 8px; border-radius:99px; background:#eff6ff; font-size:11px; color:#3b82f6;">handlebar</span>
<span style="padding:2px 8px; border-radius:99px; background:#eff6ff; font-size:11px; color:#3b82f6;">ultralight</span>
</div>
</div>
</div>
</div>
</div>
<div class="pros-cons" style="margin-top:12px;">
<div class="pros"><h4>Pros</h4><ul><li>Natural language friendly</li><li>Fast for users who know what they want</li><li>Tags as quick filters below search</li></ul></div>
<div class="cons"><h4>Cons</h4><ul><li>Empty state before typing</li><li>Browsing requires typing first</li></ul></div>
</div>
</div>
</div>
<div class="option" data-choice="b" onclick="toggleSelect(this)">
<div class="letter">B</div>
<div class="content">
<h3>Browse-First with Search</h3>
<p style="margin-bottom:12px;">Category tiles for discovery with trending items. Search bar prominent but browsing is the default. Great for "I don't know exactly what I need yet."</p>
<div class="mockup">
<div class="mockup-header">Global Catalog — Browse-First</div>
<div class="mockup-body" style="padding: 20px;">
<div style="display:flex; gap:8px; margin-bottom:16px;">
<input class="mock-input" placeholder="Search gear..." style="flex:1; font-size:15px; padding:10px 14px;">
</div>
<div style="margin-bottom:20px;">
<div style="font-size:13px; font-weight:600; color:#374151; margin-bottom:8px;">Popular Categories</div>
<div style="display:grid; grid-template-columns: repeat(3, 1fr); gap:8px;">
<div style="border:1px solid #e5e7eb; border-radius:10px; padding:12px; text-align:center; cursor:pointer;">
<div style="font-size:20px; margin-bottom:4px;">🎒</div>
<div style="font-size:13px; font-weight:500;">Bags</div>
<div style="font-size:11px; color:#9ca3af;">342 items</div>
</div>
<div style="border:1px solid #e5e7eb; border-radius:10px; padding:12px; text-align:center; cursor:pointer;">
<div style="font-size:20px; margin-bottom:4px;">🏕️</div>
<div style="font-size:13px; font-weight:500;">Shelter</div>
<div style="font-size:11px; color:#9ca3af;">128 items</div>
</div>
<div style="border:1px solid #e5e7eb; border-radius:10px; padding:12px; text-align:center; cursor:pointer;">
<div style="font-size:20px; margin-bottom:4px;">🍳</div>
<div style="font-size:13px; font-weight:500;">Cooking</div>
<div style="font-size:11px; color:#9ca3af;">94 items</div>
</div>
<div style="border:1px solid #e5e7eb; border-radius:10px; padding:12px; text-align:center; cursor:pointer;">
<div style="font-size:20px; margin-bottom:4px;">🚲</div>
<div style="font-size:13px; font-weight:500;">Bike Parts</div>
<div style="font-size:11px; color:#9ca3af;">215 items</div>
</div>
<div style="border:1px solid #e5e7eb; border-radius:10px; padding:12px; text-align:center; cursor:pointer;">
<div style="font-size:20px; margin-bottom:4px;">💡</div>
<div style="font-size:13px; font-weight:500;">Lights</div>
<div style="font-size:11px; color:#9ca3af;">67 items</div>
</div>
<div style="border:1px solid #e5e7eb; border-radius:10px; padding:12px; text-align:center; cursor:pointer;">
<div style="font-size:20px; margin-bottom:4px;">🧭</div>
<div style="font-size:13px; font-weight:500;">Navigation</div>
<div style="font-size:11px; color:#9ca3af;">43 items</div>
</div>
</div>
</div>
<div>
<div style="font-size:13px; font-weight:600; color:#374151; margin-bottom:8px;">Trending</div>
<div style="border:1px solid #e5e7eb; border-radius:12px; padding:12px; display:flex; justify-content:space-between; align-items:center; margin-bottom:6px;">
<div>
<div style="font-weight:600; font-size:14px;">Revelate Designs Pika</div>
<div style="font-size:12px; color:#6b7280;">128g · $85 · ★ 4.6</div>
</div>
<div style="font-size:12px; color:#6b7280;">24 owners</div>
</div>
<div style="border:1px solid #e5e7eb; border-radius:12px; padding:12px; display:flex; justify-content:space-between; align-items:center;">
<div>
<div style="font-weight:600; font-size:14px;">Ortlieb Fork-Pack</div>
<div style="font-size:12px; color:#6b7280;">310g · $55 · ★ 4.3</div>
</div>
<div style="font-size:12px; color:#6b7280;">19 owners</div>
</div>
</div>
</div>
</div>
<div class="pros-cons" style="margin-top:12px;">
<div class="pros"><h4>Pros</h4><ul><li>Great for discovery and browsing</li><li>Never an empty state</li><li>Shows what's popular in the community</li></ul></div>
<div class="cons"><h4>Cons</h4><ul><li>Categories need curation</li><li>Slower for targeted searches</li></ul></div>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,128 @@
<h2>Catalog Browse & Search Experience</h2>
<p class="subtitle">How should users find gear in the global catalog?</p>
<div class="options">
<div class="option" data-choice="a" onclick="toggleSelect(this)">
<div class="letter">A</div>
<div class="content">
<h3>Search-First</h3>
<div class="mockup" style="margin-top: 12px;">
<div class="mockup-header">Global Catalog</div>
<div class="mockup-body" style="padding: 20px;">
<div style="display:flex; gap:8px; margin-bottom:16px;">
<input class="mock-input" placeholder="Search gear... e.g. 'waterproof handlebar bag under 200g'" style="flex:1; font-size:15px; padding:10px 14px;">
</div>
<div style="display:flex; gap:8px; margin-bottom:20px; flex-wrap:wrap;">
<span style="padding:4px 12px; border-radius:99px; background:#f3f4f6; font-size:13px; color:#6b7280;">Bikepacking</span>
<span style="padding:4px 12px; border-radius:99px; background:#f3f4f6; font-size:13px; color:#6b7280;">Bags</span>
<span style="padding:4px 12px; border-radius:99px; background:#f3f4f6; font-size:13px; color:#6b7280;">Waterproof</span>
<span style="padding:4px 12px; border-radius:99px; background:#f3f4f6; font-size:13px; color:#6b7280;">Ultralight</span>
<span style="padding:4px 12px; border-radius:99px; background:#f3f4f6; font-size:13px; color:#6b7280;">Sim Racing</span>
<span style="padding:4px 12px; border-radius:99px; background:#f3f4f6; font-size:13px; color:#6b7280;">Wheels</span>
</div>
<div style="color:#9ca3af; font-size:13px; margin-bottom:12px;">Type to search or click tags to filter</div>
<div style="display:grid; grid-template-columns: 1fr 1fr; gap:12px;">
<div style="border:1px solid #e5e7eb; border-radius:12px; padding:14px;">
<div style="display:flex; justify-content:space-between; align-items:start;">
<div>
<div style="font-weight:600; font-size:14px;">Revelate Designs Pika</div>
<div style="color:#6b7280; font-size:12px;">Handlebar Bag</div>
</div>
<div style="background:#fef3c7; color:#92400e; font-size:11px; padding:2px 8px; border-radius:99px;">★ 4.6</div>
</div>
<div style="display:flex; gap:16px; margin-top:10px; font-size:12px; color:#6b7280;">
<span>128g</span>
<span>$85</span>
<span>24 owners</span>
</div>
<div style="display:flex; gap:4px; margin-top:8px; flex-wrap:wrap;">
<span style="padding:2px 8px; border-radius:99px; background:#eff6ff; font-size:11px; color:#3b82f6;">bikepacking</span>
<span style="padding:2px 8px; border-radius:99px; background:#eff6ff; font-size:11px; color:#3b82f6;">handlebar</span>
<span style="padding:2px 8px; border-radius:99px; background:#eff6ff; font-size:11px; color:#3b82f6;">waterproof</span>
</div>
</div>
<div style="border:1px solid #e5e7eb; border-radius:12px; padding:14px;">
<div style="display:flex; justify-content:space-between; align-items:start;">
<div>
<div style="font-weight:600; font-size:14px;">Apidura Racing HB Pack</div>
<div style="color:#6b7280; font-size:12px;">Handlebar Bag</div>
</div>
<div style="background:#fef3c7; color:#92400e; font-size:11px; padding:2px 8px; border-radius:99px;">★ 4.4</div>
</div>
<div style="display:flex; gap:16px; margin-top:10px; font-size:12px; color:#6b7280;">
<span>105g</span>
<span>$72</span>
<span>18 owners</span>
</div>
<div style="display:flex; gap:4px; margin-top:8px; flex-wrap:wrap;">
<span style="padding:2px 8px; border-radius:99px; background:#eff6ff; font-size:11px; color:#3b82f6;">bikepacking</span>
<span style="padding:2px 8px; border-radius:99px; background:#eff6ff; font-size:11px; color:#3b82f6;">handlebar</span>
<span style="padding:2px 8px; border-radius:99px; background:#eff6ff; font-size:11px; color:#3b82f6;">ultralight</span>
</div>
</div>
</div>
</div>
</div>
<div class="pros-cons" style="margin-top:12px;">
<div class="pros"><h4>Pros</h4><ul><li>Natural language friendly</li><li>Fast for users who know what they want</li><li>Tags as quick filters below search</li></ul></div>
<div class="cons"><h4>Cons</h4><ul><li>Empty state can feel blank</li><li>Browsing requires typing first</li></ul></div>
</div>
</div>
</div>
<div class="option" data-choice="b" onclick="toggleSelect(this)">
<div class="letter">B</div>
<div class="content">
<h3>Browse-First with Search</h3>
<div class="mockup" style="margin-top: 12px;">
<div class="mockup-header">Global Catalog</div>
<div class="mockup-body" style="padding: 20px;">
<div style="display:flex; gap:8px; margin-bottom:16px;">
<input class="mock-input" placeholder="Search gear..." style="flex:1; font-size:15px; padding:10px 14px;">
</div>
<div style="margin-bottom:20px;">
<div style="font-size:13px; font-weight:600; color:#374151; margin-bottom:8px;">Popular Categories</div>
<div style="display:grid; grid-template-columns: repeat(3, 1fr); gap:8px;">
<div style="border:1px solid #e5e7eb; border-radius:10px; padding:12px; text-align:center; cursor:pointer;">
<div style="font-size:20px; margin-bottom:4px;">🎒</div>
<div style="font-size:13px; font-weight:500;">Bags</div>
<div style="font-size:11px; color:#9ca3af;">342 items</div>
</div>
<div style="border:1px solid #e5e7eb; border-radius:10px; padding:12px; text-align:center; cursor:pointer;">
<div style="font-size:20px; margin-bottom:4px;">🏕️</div>
<div style="font-size:13px; font-weight:500;">Shelter</div>
<div style="font-size:11px; color:#9ca3af;">128 items</div>
</div>
<div style="border:1px solid #e5e7eb; border-radius:10px; padding:12px; text-align:center; cursor:pointer;">
<div style="font-size:20px; margin-bottom:4px;">🎮</div>
<div style="font-size:13px; font-weight:500;">Sim Gear</div>
<div style="font-size:11px; color:#9ca3af;">89 items</div>
</div>
</div>
</div>
<div>
<div style="font-size:13px; font-weight:600; color:#374151; margin-bottom:8px;">Trending</div>
<div style="border:1px solid #e5e7eb; border-radius:12px; padding:12px; display:flex; justify-content:space-between; align-items:center; margin-bottom:6px;">
<div>
<div style="font-weight:600; font-size:14px;">Revelate Designs Pika</div>
<div style="font-size:12px; color:#6b7280;">128g · $85 · ★ 4.6</div>
</div>
<div style="font-size:12px; color:#6b7280;">24 owners</div>
</div>
<div style="border:1px solid #e5e7eb; border-radius:12px; padding:12px; display:flex; justify-content:space-between; align-items:center;">
<div>
<div style="font-weight:600; font-size:14px;">Fanatec DD Pro</div>
<div style="font-size:12px; color:#6b7280;">2.4kg · $350 · ★ 4.8</div>
</div>
<div style="font-size:12px; color:#6b7280;">31 owners</div>
</div>
</div>
</div>
</div>
<div class="pros-cons" style="margin-top:12px;">
<div class="pros"><h4>Pros</h4><ul><li>Great for discovery — see what's popular</li><li>Categories give structure without complexity</li><li>Never an empty state</li></ul></div>
<div class="cons"><h4>Cons</h4><ul><li>More complex landing page</li><li>Categories need curation</li></ul></div>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,172 @@
<h2>Add Item Flow — From FAB to Search to Results</h2>
<p class="subtitle">The journey from clicking + to finding gear</p>
<div class="section">
<h3>Step 1: FAB Menu</h3>
<p class="subtitle">Click + → two options appear above the button</p>
<div class="mockup">
<div class="mockup-header">Collection View</div>
<div class="mockup-body" style="padding:20px; min-height:280px; position:relative; background:#f9fafb;">
<!-- Fake collection content -->
<div style="opacity:0.4;">
<div style="border:1px solid #e5e7eb; border-radius:12px; padding:12px; margin-bottom:8px; background:white;">
<div style="font-weight:600; font-size:14px;">Revelate Designs Pika</div>
<div style="font-size:12px; color:#6b7280;">128g · Bags</div>
</div>
<div style="border:1px solid #e5e7eb; border-radius:12px; padding:12px; margin-bottom:8px; background:white;">
<div style="font-weight:600; font-size:14px;">MSR Hubba NX</div>
<div style="font-size:12px; color:#6b7280;">1,540g · Shelter</div>
</div>
</div>
<!-- FAB with popup menu -->
<div style="position:absolute; bottom:20px; right:20px; text-align:right;">
<div style="background:white; border:1px solid #e5e7eb; border-radius:12px; padding:6px; margin-bottom:10px; box-shadow:0 4px 12px rgba(0,0,0,0.1); display:inline-block;">
<div style="padding:10px 16px; border-radius:8px; cursor:pointer; font-size:14px; font-weight:500; display:flex; align-items:center; gap:8px; color:#374151;">
<span style="font-size:16px;">📦</span> Add to Collection
</div>
<div style="height:1px; background:#e5e7eb; margin:2px 0;"></div>
<div style="padding:10px 16px; border-radius:8px; cursor:pointer; font-size:14px; font-weight:500; display:flex; align-items:center; gap:8px; color:#374151;">
<span style="font-size:16px;">🔍</span> Start New Thread
</div>
</div>
<div style="width:56px; height:56px; background:#374151; border-radius:50%; display:flex; align-items:center; justify-content:center; color:white; font-size:24px; box-shadow:0 2px 8px rgba(0,0,0,0.2); margin-left:auto;">+</div>
</div>
</div>
</div>
</div>
<div class="section">
<h3>Step 2: Search Overlay</h3>
<p class="subtitle">After choosing an action, a search overlay appears</p>
<div class="options">
<div class="option" data-choice="a" onclick="toggleSelect(this)">
<div class="letter">A</div>
<div class="content">
<h3>Floating Search Bar</h3>
<p style="margin-bottom:12px;">Compact overlay centered on screen. Feels quick and light.</p>
<div class="mockup">
<div class="mockup-header">Search Overlay — Floating</div>
<div class="mockup-body" style="padding:20px; min-height:220px; position:relative; background:#f9fafb;">
<!-- Dimmed background -->
<div style="position:absolute; inset:0; background:rgba(0,0,0,0.3); border-radius:0 0 8px 8px;"></div>
<!-- Floating search -->
<div style="position:relative; max-width:480px; margin:40px auto 0; background:white; border-radius:16px; padding:20px; box-shadow:0 8px 30px rgba(0,0,0,0.15);">
<div style="font-size:13px; font-weight:600; color:#6b7280; margin-bottom:10px;">Adding to Collection</div>
<div style="display:flex; gap:8px;">
<input class="mock-input" placeholder="Search gear..." style="flex:1; font-size:15px; padding:10px 14px;">
<button class="mock-button" style="padding:10px 20px;">Search</button>
</div>
<div style="margin-top:12px; font-size:12px; color:#9ca3af;">Search the catalog or press Enter to see results</div>
<div style="margin-top:10px; display:flex; gap:6px; flex-wrap:wrap;">
<span style="padding:3px 10px; border-radius:99px; background:#f3f4f6; font-size:12px; color:#6b7280; cursor:pointer;">Bags</span>
<span style="padding:3px 10px; border-radius:99px; background:#f3f4f6; font-size:12px; color:#6b7280; cursor:pointer;">Shelter</span>
<span style="padding:3px 10px; border-radius:99px; background:#f3f4f6; font-size:12px; color:#6b7280; cursor:pointer;">Lights</span>
<span style="padding:3px 10px; border-radius:99px; background:#f3f4f6; font-size:12px; color:#6b7280; cursor:pointer;">Cooking</span>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="option" data-choice="b" onclick="toggleSelect(this)">
<div class="letter">B</div>
<div class="content">
<h3>Full-Screen Search</h3>
<p style="margin-bottom:12px;">Takes over the screen. More room for instant suggestions and recent searches.</p>
<div class="mockup">
<div class="mockup-header">Search Overlay — Full Screen</div>
<div class="mockup-body" style="padding:0; min-height:280px; background:white;">
<div style="padding:16px 20px; border-bottom:1px solid #e5e7eb; display:flex; align-items:center; gap:12px;">
<span style="color:#9ca3af; cursor:pointer; font-size:18px;"></span>
<input class="mock-input" placeholder="Search gear..." style="flex:1; font-size:16px; padding:10px 14px; border:none; background:#f9fafb;">
</div>
<div style="padding:16px 20px;">
<div style="font-size:12px; font-weight:600; color:#9ca3af; text-transform:uppercase; letter-spacing:0.5px; margin-bottom:10px;">Quick Tags</div>
<div style="display:flex; gap:6px; flex-wrap:wrap; margin-bottom:20px;">
<span style="padding:6px 14px; border-radius:99px; background:#f3f4f6; font-size:13px; color:#6b7280; cursor:pointer;">Bags</span>
<span style="padding:6px 14px; border-radius:99px; background:#f3f4f6; font-size:13px; color:#6b7280; cursor:pointer;">Shelter</span>
<span style="padding:6px 14px; border-radius:99px; background:#f3f4f6; font-size:13px; color:#6b7280; cursor:pointer;">Lights</span>
<span style="padding:6px 14px; border-radius:99px; background:#f3f4f6; font-size:13px; color:#6b7280; cursor:pointer;">Cooking</span>
<span style="padding:6px 14px; border-radius:99px; background:#f3f4f6; font-size:13px; color:#6b7280; cursor:pointer;">Bike Parts</span>
<span style="padding:6px 14px; border-radius:99px; background:#f3f4f6; font-size:13px; color:#6b7280; cursor:pointer;">Waterproof</span>
</div>
<div style="font-size:12px; font-weight:600; color:#9ca3af; text-transform:uppercase; letter-spacing:0.5px; margin-bottom:10px;">Adding to Collection</div>
<div style="font-size:13px; color:#6b7280;">Type to search the gear catalog, or <span style="color:#3b82f6; cursor:pointer;">add manually</span> if your item isn't listed.</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="section">
<h3>Step 3: Results (always full screen)</h3>
<p class="subtitle">After hitting Enter/Search, full results page with actions</p>
<div class="mockup">
<div class="mockup-header">Search Results — "handlebar bag"</div>
<div class="mockup-body" style="padding:0; background:white;">
<div style="padding:12px 20px; border-bottom:1px solid #e5e7eb; display:flex; align-items:center; gap:12px;">
<span style="color:#9ca3af; cursor:pointer; font-size:18px;"></span>
<div style="flex:1; background:#f3f4f6; border-radius:8px; padding:8px 14px; font-size:14px; color:#374151;">handlebar bag</div>
</div>
<div style="padding:12px 20px;">
<div style="display:flex; gap:6px; flex-wrap:wrap; margin-bottom:16px;">
<span style="padding:4px 12px; border-radius:99px; background:#dbeafe; font-size:12px; color:#2563eb;">handlebar</span>
<span style="padding:4px 12px; border-radius:99px; background:#f3f4f6; font-size:12px; color:#6b7280; cursor:pointer;">+ waterproof</span>
<span style="padding:4px 12px; border-radius:99px; background:#f3f4f6; font-size:12px; color:#6b7280; cursor:pointer;">+ ultralight</span>
</div>
<div style="font-size:12px; color:#9ca3af; margin-bottom:12px;">12 results</div>
<!-- Result cards -->
<div style="border:1px solid #e5e7eb; border-radius:12px; padding:14px; margin-bottom:8px; display:flex; justify-content:space-between; align-items:center;">
<div style="flex:1;">
<div style="display:flex; align-items:center; gap:8px;">
<div style="font-weight:600; font-size:14px;">Revelate Designs Pika</div>
<div style="background:#fef3c7; color:#92400e; font-size:11px; padding:2px 8px; border-radius:99px;">★ 4.6</div>
</div>
<div style="display:flex; gap:16px; margin-top:4px; font-size:12px; color:#6b7280;">
<span>128g</span><span>$85</span><span>24 owners</span>
</div>
</div>
<button class="mock-button" style="padding:6px 14px; font-size:13px; background:#374151; color:white;">+ Add</button>
</div>
<div style="border:1px solid #e5e7eb; border-radius:12px; padding:14px; margin-bottom:8px; display:flex; justify-content:space-between; align-items:center;">
<div style="flex:1;">
<div style="display:flex; align-items:center; gap:8px;">
<div style="font-weight:600; font-size:14px;">Apidura Racing HB Pack</div>
<div style="background:#fef3c7; color:#92400e; font-size:11px; padding:2px 8px; border-radius:99px;">★ 4.4</div>
</div>
<div style="display:flex; gap:16px; margin-top:4px; font-size:12px; color:#6b7280;">
<span>105g</span><span>$72</span><span>18 owners</span>
</div>
</div>
<button class="mock-button" style="padding:6px 14px; font-size:13px; background:#374151; color:white;">+ Add</button>
</div>
<div style="border:1px solid #e5e7eb; border-radius:12px; padding:14px; margin-bottom:8px; display:flex; justify-content:space-between; align-items:center;">
<div style="flex:1;">
<div style="display:flex; align-items:center; gap:8px;">
<div style="font-weight:600; font-size:14px;">Ortlieb Handlebar-Pack QR</div>
<div style="background:#fef3c7; color:#92400e; font-size:11px; padding:2px 8px; border-radius:99px;">★ 4.2</div>
</div>
<div style="display:flex; gap:16px; margin-top:4px; font-size:12px; color:#6b7280;">
<span>340g</span><span>$95</span><span>31 owners</span>
</div>
</div>
<button class="mock-button" style="padding:6px 14px; font-size:13px; background:#374151; color:white;">+ Add</button>
</div>
<!-- Not found option -->
<div style="border:2px dashed #d1d5db; border-radius:12px; padding:16px; margin-top:16px; text-align:center;">
<div style="font-size:14px; font-weight:500; color:#6b7280;">Can't find what you're looking for?</div>
<div style="font-size:13px; color:#9ca3af; margin-top:4px;">Add it manually and submit it to the catalog for review</div>
<button class="mock-button" style="margin-top:10px; padding:8px 20px; font-size:13px;">Add Manually</button>
</div>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,3 @@
<div style="display:flex;align-items:center;justify-content:center;min-height:60vh">
<p class="subtitle">Continuing in terminal...</p>
</div>

View File

@@ -0,0 +1,3 @@
<div style="display:flex;align-items:center;justify-content:center;min-height:60vh">
<p class="subtitle">Continuing in terminal...</p>
</div>

View File

@@ -0,0 +1 @@
{"reason":"idle timeout","timestamp":1775407587614}

View File

@@ -0,0 +1,6 @@
CREATE TABLE "tags" (
"id" serial PRIMARY KEY NOT NULL,
"name" text NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "tags_name_unique" UNIQUE("name")
);

View File

@@ -1,5 +1,9 @@
{ {
<<<<<<< HEAD
"id": "1c8fbda2-e486-4f57-a6d5-1a2e7042e413", "id": "1c8fbda2-e486-4f57-a6d5-1a2e7042e413",
=======
"id": "4b01f839-a5ff-416c-826c-1e37e76d0a78",
>>>>>>> worktree-agent-adbc35a5
"prevId": "8fb47390-ff75-41f7-aa35-fad97b1a097e", "prevId": "8fb47390-ff75-41f7-aa35-fad97b1a097e",
"version": "7", "version": "7",
"dialect": "postgresql", "dialect": "postgresql",
@@ -136,6 +140,7 @@
"checkConstraints": {}, "checkConstraints": {},
"isRLSEnabled": false "isRLSEnabled": false
}, },
<<<<<<< HEAD
"public.global_item_tags": { "public.global_item_tags": {
"name": "global_item_tags", "name": "global_item_tags",
"schema": "", "schema": "",
@@ -196,6 +201,8 @@
"checkConstraints": {}, "checkConstraints": {},
"isRLSEnabled": false "isRLSEnabled": false
}, },
=======
>>>>>>> worktree-agent-adbc35a5
"public.global_items": { "public.global_items": {
"name": "global_items", "name": "global_items",
"schema": "", "schema": "",
@@ -264,6 +271,75 @@
"checkConstraints": {}, "checkConstraints": {},
"isRLSEnabled": false "isRLSEnabled": false
}, },
<<<<<<< HEAD
=======
"public.item_global_links": {
"name": "item_global_links",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"item_id": {
"name": "item_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"global_item_id": {
"name": "global_item_id",
"type": "integer",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"item_global_links_item_id_items_id_fk": {
"name": "item_global_links_item_id_items_id_fk",
"tableFrom": "item_global_links",
"tableTo": "items",
"columnsFrom": [
"item_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"item_global_links_global_item_id_global_items_id_fk": {
"name": "item_global_links_global_item_id_global_items_id_fk",
"tableFrom": "item_global_links",
"tableTo": "global_items",
"columnsFrom": [
"global_item_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"item_global_links_item_id_unique": {
"name": "item_global_links_item_id_unique",
"nullsNotDistinct": false,
"columns": [
"item_id"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
>>>>>>> worktree-agent-adbc35a5
"public.items": { "public.items": {
"name": "items", "name": "items",
"schema": "", "schema": "",
@@ -335,6 +411,7 @@
"notNull": true, "notNull": true,
"default": 1 "default": 1
}, },
<<<<<<< HEAD
"global_item_id": { "global_item_id": {
"name": "global_item_id", "name": "global_item_id",
"type": "integer", "type": "integer",
@@ -347,6 +424,8 @@
"primaryKey": false, "primaryKey": false,
"notNull": false "notNull": false
}, },
=======
>>>>>>> worktree-agent-adbc35a5
"created_at": { "created_at": {
"name": "created_at", "name": "created_at",
"type": "timestamp", "type": "timestamp",
@@ -389,6 +468,7 @@
], ],
"onDelete": "no action", "onDelete": "no action",
"onUpdate": "no action" "onUpdate": "no action"
<<<<<<< HEAD
}, },
"items_global_item_id_global_items_id_fk": { "items_global_item_id_global_items_id_fk": {
"name": "items_global_item_id_global_items_id_fk", "name": "items_global_item_id_global_items_id_fk",
@@ -402,6 +482,8 @@
], ],
"onDelete": "no action", "onDelete": "no action",
"onUpdate": "no action" "onUpdate": "no action"
=======
>>>>>>> worktree-agent-adbc35a5
} }
}, },
"compositePrimaryKeys": {}, "compositePrimaryKeys": {},
@@ -938,12 +1020,15 @@
"notNull": true, "notNull": true,
"default": 0 "default": 0
}, },
<<<<<<< HEAD
"global_item_id": { "global_item_id": {
"name": "global_item_id", "name": "global_item_id",
"type": "integer", "type": "integer",
"primaryKey": false, "primaryKey": false,
"notNull": false "notNull": false
}, },
=======
>>>>>>> worktree-agent-adbc35a5
"created_at": { "created_at": {
"name": "created_at", "name": "created_at",
"type": "timestamp", "type": "timestamp",
@@ -986,6 +1071,7 @@
], ],
"onDelete": "no action", "onDelete": "no action",
"onUpdate": "no action" "onUpdate": "no action"
<<<<<<< HEAD
}, },
"thread_candidates_global_item_id_global_items_id_fk": { "thread_candidates_global_item_id_global_items_id_fk": {
"name": "thread_candidates_global_item_id_global_items_id_fk", "name": "thread_candidates_global_item_id_global_items_id_fk",
@@ -999,6 +1085,8 @@
], ],
"onDelete": "no action", "onDelete": "no action",
"onUpdate": "no action" "onUpdate": "no action"
=======
>>>>>>> worktree-agent-adbc35a5
} }
}, },
"compositePrimaryKeys": {}, "compositePrimaryKeys": {},

View File

@@ -23,13 +23,16 @@ interface ItemGlobalLink {
globalItemId: number; globalItemId: number;
} }
export function useGlobalItems(query?: string) { export function useGlobalItems(query?: string, tags?: string[]) {
const params = new URLSearchParams();
if (query) params.set("q", query);
if (tags && tags.length > 0) params.set("tags", tags.join(","));
const qs = params.toString();
return useQuery({ return useQuery({
queryKey: ["global-items", query ?? ""], queryKey: ["global-items", query ?? "", tags ?? []],
queryFn: () => queryFn: () =>
apiGet<GlobalItem[]>( apiGet<GlobalItem[]>(`/api/global-items${qs ? `?${qs}` : ""}`),
`/api/global-items${query ? `?q=${encodeURIComponent(query)}` : ""}`,
),
}); });
} }

View File

@@ -0,0 +1,15 @@
import { useQuery } from "@tanstack/react-query";
import { apiGet } from "../lib/api";
export interface Tag {
id: number;
name: string;
}
export function useTags() {
return useQuery({
queryKey: ["tags"],
queryFn: () => apiGet<Tag[]>("/api/tags"),
staleTime: 5 * 60 * 1000,
});
}

View File

@@ -56,6 +56,17 @@ interface UIState {
// Setup impact preview // Setup impact preview
selectedSetupId: number | null; selectedSetupId: number | null;
setSelectedSetupId: (id: number | null) => void; setSelectedSetupId: (id: number | null) => void;
// FAB menu
fabMenuOpen: boolean;
openFabMenu: () => void;
closeFabMenu: () => void;
// Catalog search overlay
catalogSearchOpen: boolean;
catalogSearchMode: "collection" | "thread" | null;
openCatalogSearch: (mode: "collection" | "thread") => void;
closeCatalogSearch: () => void;
} }
export const useUIStore = create<UIState>((set) => ({ export const useUIStore = create<UIState>((set) => ({
@@ -119,4 +130,21 @@ export const useUIStore = create<UIState>((set) => ({
// Setup impact preview // Setup impact preview
selectedSetupId: null, selectedSetupId: null,
setSelectedSetupId: (id) => set({ selectedSetupId: id }), setSelectedSetupId: (id) => set({ selectedSetupId: id }),
// FAB menu
fabMenuOpen: false,
openFabMenu: () => set({ fabMenuOpen: true }),
closeFabMenu: () => set({ fabMenuOpen: false }),
// Catalog search overlay
catalogSearchOpen: false,
catalogSearchMode: null,
openCatalogSearch: (mode) =>
set({
catalogSearchOpen: true,
catalogSearchMode: mode,
fabMenuOpen: false,
}),
closeCatalogSearch: () =>
set({ catalogSearchOpen: false, catalogSearchMode: null }),
})); }));

View File

@@ -12,12 +12,14 @@ import { mcpRoutes } from "./mcp/index.ts";
import { requireAuth } from "./middleware/auth.ts"; import { requireAuth } from "./middleware/auth.ts";
import { authRoutes } from "./routes/auth.ts"; import { authRoutes } from "./routes/auth.ts";
import { categoryRoutes } from "./routes/categories.ts"; import { categoryRoutes } from "./routes/categories.ts";
import { globalItemRoutes } from "./routes/global-items.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";
import { oauthRoutes, wellKnownRoute } from "./routes/oauth.ts"; import { oauthRoutes, wellKnownRoute } from "./routes/oauth.ts";
import { profileRoutes } from "./routes/profiles.ts"; import { profileRoutes } from "./routes/profiles.ts";
import { settingsRoutes } from "./routes/settings.ts"; import { settingsRoutes } from "./routes/settings.ts";
import { setupRoutes } from "./routes/setups.ts"; import { setupRoutes } from "./routes/setups.ts";
import { tagRoutes } from "./routes/tags.ts";
import { threadRoutes } from "./routes/threads.ts"; import { threadRoutes } from "./routes/threads.ts";
import { totalRoutes } from "./routes/totals.ts"; import { totalRoutes } from "./routes/totals.ts";
@@ -51,10 +53,7 @@ if (process.env.NODE_ENV !== "production") {
if (setCookies.length > 0) { if (setCookies.length > 0) {
c.res.headers.delete("Set-Cookie"); c.res.headers.delete("Set-Cookie");
for (const cookie of setCookies) { for (const cookie of setCookies) {
c.res.headers.append( c.res.headers.append("Set-Cookie", cookie.replace(/;\s*Secure/gi, ""));
"Set-Cookie",
cookie.replace(/;\s*Secure/gi, ""),
);
} }
} }
}); });
@@ -98,6 +97,12 @@ app.use("/api/*", async (c, next) => {
// Skip public setup view (GET /api/setups/:id/public) // Skip public setup view (GET /api/setups/:id/public)
if (/^\/api\/setups\/\d+\/public$/.test(c.req.path) && c.req.method === "GET") if (/^\/api\/setups\/\d+\/public$/.test(c.req.path) && c.req.method === "GET")
return next(); return next();
// Skip public tags endpoint (GET /api/tags)
if (c.req.path.startsWith("/api/tags") && c.req.method === "GET")
return next();
// Skip public global-items endpoint (GET /api/global-items)
if (c.req.path.startsWith("/api/global-items") && c.req.method === "GET")
return next();
// All other methods require auth for userId resolution // All other methods require auth for userId resolution
return requireAuth(c, next); return requireAuth(c, next);
}); });
@@ -112,6 +117,8 @@ app.route("/api/settings", settingsRoutes);
app.route("/api/threads", threadRoutes); app.route("/api/threads", threadRoutes);
app.route("/api/users", profileRoutes); app.route("/api/users", profileRoutes);
app.route("/api/setups", setupRoutes); app.route("/api/setups", setupRoutes);
app.route("/api/global-items", globalItemRoutes);
app.route("/api/tags", tagRoutes);
// MCP server (conditionally mounted) // MCP server (conditionally mounted)
if (process.env.GEARBOX_MCP !== "false") { if (process.env.GEARBOX_MCP !== "false") {

14
src/server/routes/tags.ts Normal file
View File

@@ -0,0 +1,14 @@
import { Hono } from "hono";
import { getAllTags } from "../services/tag.service.ts";
type Env = { Variables: { db?: any } };
const app = new Hono<Env>();
app.get("/", async (c) => {
const db = c.get("db");
const allTags = await getAllTags(db);
return c.json(allTags);
});
export { app as tagRoutes };

View File

@@ -0,0 +1,12 @@
import { asc } from "drizzle-orm";
import { db as prodDb } from "../../db/index.ts";
import { tags } from "../../db/schema.ts";
type Db = typeof prodDb;
export async function getAllTags(db: Db = prodDb) {
return db
.select({ id: tags.id, name: tags.name })
.from(tags)
.orderBy(asc(tags.name));
}

52
tests/routes/tags.test.ts Normal file
View File

@@ -0,0 +1,52 @@
import { beforeEach, describe, expect, it } from "bun:test";
import { Hono } from "hono";
import { tags } from "../../src/db/schema.ts";
import { tagRoutes } from "../../src/server/routes/tags.ts";
import { createTestDb } from "../helpers/db.ts";
function createTestApp(db: any) {
const app = new Hono();
app.use("*", async (c, next) => {
c.set("db", db);
await next();
});
app.route("/api/tags", tagRoutes);
return app;
}
describe("Tag Routes", () => {
let app: Hono;
let db: Awaited<ReturnType<typeof createTestDb>>["db"];
beforeEach(async () => {
const testDb = await createTestDb();
db = testDb.db;
app = createTestApp(db);
});
describe("GET /api/tags", () => {
it("returns 200 with empty array when no tags", async () => {
const res = await app.request("/api/tags");
expect(res.status).toBe(200);
const body = await res.json();
expect(body).toEqual([]);
});
it("returns 200 with tag objects after seeding", async () => {
await db
.insert(tags)
.values([{ name: "bikepacking" }, { name: "ultralight" }]);
const res = await app.request("/api/tags");
expect(res.status).toBe(200);
const body = await res.json();
expect(body).toHaveLength(2);
expect(body[0]).toHaveProperty("id");
expect(body[0]).toHaveProperty("name");
});
});
});

View File

@@ -0,0 +1,36 @@
import { beforeEach, describe, expect, it } from "bun:test";
import { tags } from "../../src/db/schema.ts";
import { getAllTags } from "../../src/server/services/tag.service.ts";
import { createTestDb } from "../helpers/db.ts";
describe("Tag Service", () => {
let db: Awaited<ReturnType<typeof createTestDb>>["db"];
beforeEach(async () => {
const testDb = await createTestDb();
db = testDb.db;
});
it("returns empty array when no tags exist", async () => {
const result = await getAllTags(db);
expect(result).toEqual([]);
});
it("returns all tags as { id, name } ordered alphabetically", async () => {
await db
.insert(tags)
.values([
{ name: "bikepacking" },
{ name: "ultralight" },
{ name: "accessories" },
]);
const result = await getAllTags(db);
expect(result).toHaveLength(3);
expect(result[0].name).toBe("accessories");
expect(result[1].name).toBe("bikepacking");
expect(result[2].name).toBe("ultralight");
// Should NOT include createdAt
expect(result[0]).toEqual({ id: expect.any(Number), name: "accessories" });
});
});