37 Commits

Author SHA1 Message Date
2853477a75 chore: archive v2.2 User Experience Polish milestone
All checks were successful
CI / ci (push) Successful in 1m15s
CI / e2e (push) Has been skipped
CI / deploy (push) Has been skipped
Phases 28-31 archived to milestones/v2.2-phases/
Requirements and roadmap snapshots archived to milestones/

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 16:00:35 +02:00
92b84d2cd6 docs: capture todo - Auth prompt sign-in should redirect directly to Logto 2026-04-13 15:53:07 +02:00
ebf031a62c fix: cap onboarding to 5 categories with 4 items each
All checks were successful
CI / ci (push) Successful in 1m15s
CI / e2e (push) Has been skipped
CI / deploy (push) Successful in 14s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 15:23:10 +02:00
03e0fe99fa feat: group onboarding items by category
All checks were successful
CI / ci (push) Successful in 1m17s
CI / e2e (push) Has been skipped
CI / deploy (push) Successful in 14s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 15:19:02 +02:00
adbc13eb15 fix: SelectableItemCard used wrong formatter names, globalItems imageFilename→imageUrl
All checks were successful
CI / ci (push) Successful in 1m12s
CI / e2e (push) Has been skipped
CI / deploy (push) Successful in 14s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 15:13:23 +02:00
2beabe88f9 fix: popular-items query referenced non-existent imageFilename on globalItems
All checks were successful
CI / ci (push) Successful in 1m17s
CI / e2e (push) Has been skipped
CI / deploy (push) Successful in 16s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 15:07:38 +02:00
29f925027c chore: one-liner fixing script for docker exec
All checks were successful
CI / ci (push) Successful in 1m15s
CI / e2e (push) Has been skipped
CI / deploy (push) Successful in 15s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 14:58:19 +02:00
32fa261ec2 chore: diagnostic and fix script for catalog seeding
Some checks failed
CI / deploy (push) Has been cancelled
CI / e2e (push) Has been cancelled
CI / ci (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 14:57:27 +02:00
9864a09fc1 chore: shell script for nuke-and-reseed (runs inside container)
All checks were successful
CI / ci (push) Successful in 1m16s
CI / e2e (push) Has been skipped
CI / deploy (push) Successful in 14s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 14:20:03 +02:00
c3874d031a chore: temp script to nuke dev seed user
All checks were successful
CI / ci (push) Successful in 1m13s
CI / e2e (push) Has been skipped
CI / deploy (push) Successful in 14s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 14:15:54 +02:00
cd55f3c282 fix: seedTags inserts missing tags instead of skipping when any exist
All checks were successful
CI / ci (push) Successful in 1m15s
CI / e2e (push) Has been skipped
CI / deploy (push) Successful in 15s
2026-04-13 14:09:28 +02:00
80f4d1d9ae fix: lint formatting for seed data and item detail page
All checks were successful
CI / ci (push) Successful in 1m14s
CI / e2e (push) Has been skipped
CI / deploy (push) Successful in 17s
2026-04-13 14:03:25 +02:00
ba13fa8ded docs: update roadmap, config, and UAT state
Some checks failed
CI / ci (push) Failing after 20s
CI / deploy (push) Has been skipped
CI / e2e (push) Has been skipped
2026-04-13 13:56:56 +02:00
13883ea14d fix: add hobby tags to catalog seed data for onboarding discovery 2026-04-13 13:48:40 +02:00
bedef04581 test(30): re-test UAT - 3 passed, 1 cosmetic, 3 blocked (catalog seed) 2026-04-13 13:45:47 +02:00
c1177764ef docs(29-05): add execution summary 2026-04-13 13:42:20 +02:00
ded6bf521e fix(29-05): add local crop state to ImageUpload for immediate preview 2026-04-13 13:42:06 +02:00
d91d32deaf docs: capture todo - Fix Add Candidate button shows wrong modal on thread page 2026-04-13 13:39:58 +02:00
c98ac6e46f docs(29): create gap closure plan for crop preview state 2026-04-13 13:37:57 +02:00
e536f68bd1 docs(29): diagnose crop preview gap - ImageUpload missing local crop state 2026-04-13 13:36:50 +02:00
80cb313b08 test(29): re-test UAT - 4 passed, 2 issues (crop conflicts) 2026-04-13 13:34:00 +02:00
159ff824b2 fix: position crop button as overlay next to trash icon on image
All checks were successful
CI / ci (push) Successful in 1m11s
CI / e2e (push) Has been skipped
CI / deploy (push) Successful in 13s
Moved the crop button from below the image into the ImageUpload
component as an absolute-positioned overlay next to the trash icon,
matching the visual pattern.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 23:49:10 +02:00
09952e37b4 fix: move crop button into edit mode so it's reachable
All checks were successful
CI / ci (push) Successful in 1m12s
CI / e2e (push) Has been skipped
CI / deploy (push) Successful in 14s
The crop icon button was in the view-mode branch but conditioned on
isEditing, making it unreachable. Moved it below ImageUpload in the
edit-mode branch where it belongs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 23:44:56 +02:00
fe5bd49b75 fix: save dominant color from image upload to item record
All checks were successful
CI / ci (push) Successful in 1m12s
CI / e2e (push) Has been skipped
CI / deploy (push) Successful in 13s
ImageUpload was discarding the dominantColor returned by the upload
API. Now it passes the color through onChange and the item detail
page saves it to the item record immediately after upload.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 23:39:13 +02:00
ef531f79b2 fix: update email display in UI after email change
All checks were successful
CI / ci (push) Successful in 1m11s
CI / e2e (push) Has been skipped
CI / deploy (push) Successful in 14s
The OIDC session token retains the old email after a Logto email
change. Now the server returns the new email in the response and
the frontend optimistically updates the auth cache.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 23:31:24 +02:00
6108db3dab debug: add detailed error logging for Logto M2M token request failures
All checks were successful
CI / ci (push) Successful in 1m10s
CI / e2e (push) Has been skipped
CI / deploy (push) Successful in 13s
Logs the URL, resource, app ID prefix, and response body when the
token request fails — helps diagnose 400 errors from Logto.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 23:18:05 +02:00
af58145fe1 feat: show avatar image in top nav when user has one
All checks were successful
CI / ci (push) Successful in 1m13s
CI / e2e (push) Has been skipped
CI / deploy (push) Successful in 14s
UserMenu now fetches the user's profile and displays their avatar
image in the nav button instead of the default circle-user icon.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 23:07:28 +02:00
b647e23f91 fix: use presigned S3 URLs for avatar images instead of /uploads/ paths
All checks were successful
CI / ci (push) Successful in 1m11s
CI / e2e (push) Has been skipped
CI / deploy (push) Successful in 14s
Avatar images were rendered via /uploads/ which doesn't exist since
the S3 migration. Now the server enriches profile responses with
avatarImageUrl (presigned S3 URL) and the frontend uses it directly.
Also fixed the public profile page at /users/:id.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 23:02:45 +02:00
62916a8397 fix(F-08): stronger selected state on hobby picker cards + biome formatting
All checks were successful
CI / ci (push) Successful in 1m13s
CI / e2e (push) Has been skipped
CI / deploy (push) Successful in 13s
Selected hobby cards now use dark gray fill with inverted white
text/icon for clear visual distinction. Also fixes biome formatting
across all changed files.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 22:37:19 +02:00
596872d942 fix(F-05): use icon button for crop trigger and trash icon for image removal
Changed "Adjust framing" text to a crop icon button visible only in
edit mode. Replaced the X icon on the image remove button with a
trash icon for clearer semantics.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 22:36:40 +02:00
da5ce7da1d fix(F-06): auto-open crop editor after image upload on item detail
Added onCropChange and dominantColor props to ImageUpload in the item
detail page, so the crop editor opens automatically after uploading
a new image.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 22:35:59 +02:00
452928760a fix(F-01): fix avatar upload persistence on profile page
Replaced the one-shot initialized flag with a dirty flag that allows
the useEffect to re-sync local state from server data after a
successful save. Previously, once initialized was set to true, the
effect never ran again so avatar changes were lost on refetch.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 22:35:35 +02:00
957d661567 fix(F-03): pass imageUrl and crop/color props to ItemCard in CollectionView
The flat list was missing dominantColor/crop props, and the grouped
view was also missing imageUrl entirely — causing images not to render
on collection cards.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 22:34:47 +02:00
e3124e49c9 fix(F-04): include crop/color fields in item queries and use dominantColor in GearImage
getAllItems and getItemById were not selecting dominantColor, cropZoom,
cropX, cropY from the database. GearImage was ignoring the dominantColor
prop. Now the fields flow end-to-end from DB to UI background fill.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 22:34:19 +02:00
581872b534 fix(F-07): add crop/color fields to updateItem service type
The updateItem function's TypeScript type was missing dominantColor,
cropZoom, cropX, and cropY fields, causing crop settings to silently
fail to save despite the Zod schema and DB schema supporting them.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 22:33:28 +02:00
ce48121b2b test(31): complete UAT - 2 passed, 0 issues 2026-04-12 22:23:46 +02:00
2948cc5848 test(30): complete UAT - 3 passed, 1 cosmetic, 3 blocked 2026-04-12 22:22:03 +02:00
90 changed files with 1586 additions and 398 deletions

View File

@@ -1,5 +1,68 @@
# Milestones
## v2.2 User Experience Polish (Shipped: 2026-04-13)
**Phases completed:** 36 phases, 68 plans, 120 tasks
**Key accomplishments:**
- Parameterized formatWeight with g/oz/lb/kg conversion and useWeightUnit settings hook, backed by 21 TDD tests
- Segmented g/oz/lb/kg toggle in TotalsBar with all 8 weight display call sites wired to user-selected unit
- Candidate status tracking (researching/ordered/arrived) with schema migration, service/Zod updates, 5 TDD tests, and clickable StatusBadge popup on CandidateCard
- Sticky search/filter toolbar on gear tab with text+category filtering, and shared icon-aware CategoryFilterDropdown on both gear and planning tabs
- Per-setup item classification (base/worn/consumable) with click-to-cycle badge, classification-preserving sync, and full test coverage
- Recharts donut chart with category/classification toggle, weight subtotals card, and hover tooltips inside setup detail page
- Nullable pros/cons TEXT columns added to thread_candidates from SQLite schema through Drizzle migration, service layer, Zod validation, React form inputs, and CandidateCard visual badge
- sortOrder REAL column, reorderCandidates transaction service, and PATCH /api/threads/:id/candidates/reorder endpoint with active-thread guard
- 1. [Rule 2 - Missing] Added pros/cons fields to CandidateWithCategory in useThreads.ts
- Side-by-side candidate comparison table with sticky labels, weight/price delta highlighting, and resolved-thread winner marking via a new "compare" candidateViewMode
- PostgreSQL schema with 13 pgTable definitions, postgres.js connection, PGlite test infrastructure, and initial migration
- PostgreSQL 16 Docker Compose for dev and production, lean Dockerfile without native SQLite build dependencies
- All 9 service files (30 functions) converted from synchronous SQLite to async PostgreSQL operations with PGlite smoke test validation
- All 9 route files and auth middleware converted to properly await async service/DB calls, preventing Promise-as-JSON responses
- One-time data migration script converting all 13 tables from SQLite to PostgreSQL with timestamp/boolean type conversions and serial sequence reset
- All 18 test files converted to async PGlite with 161 tests passing across service, route, and MCP layers
- Logto OIDC provider added to Docker Compose with Postgres init script, users/sessions tables removed from schema
- Three-way auth middleware with @hono/oidc-auth for browser sessions, API keys for programmatic access, and MCP OAuth consent flow
- OIDC login redirect page, cleaned auth hooks (string user id, no credential forms), API-key E2E seed, and three-way auth test coverage
- pgTable schema with users table, userId FK on 6 entity tables, composite constraints, and auth middleware resolving userId for all auth methods
- All 7 service files accept userId parameter with and(eq) isolation on every query — no unscoped reads or writes remain
- Complete userId propagation chain from auth middleware through routes and MCP tools to service layer
- Route tests, MCP tests, and cross-user isolation tests updated with userId context for multi-user data model
- S3 storage abstraction with uploadImage/deleteImage/getImageUrl using @aws-sdk/client-s3, plus MinIO in Docker Compose with automatic bucket creation
- Replaced all local filesystem image operations with S3 storage service calls across routes, services, and MCP tools
- Replaced all client /uploads/ path references with presigned S3 URLs and created one-time image migration script
- Global items table, item-global links, user profile columns, setup visibility, Zod schemas, and 18-item bikepacking seed catalog
- Global item catalog backend with LIKE search, owner count aggregation, item linking, idempotent seeding, and full test coverage
- Profile service with CRUD and public profile data, public setup viewing, setup visibility toggle, and auth middleware bypass for public endpoints
- Global catalog browse/search page, item detail with owner count, and link-to-catalog component using TanStack Router and Query
- Profile edit UI in settings with avatar upload, public profile page with setup listing, and setup visibility toggle with globe icon
- Database schema updated with direct globalItemId FK on items/candidates, tags system tables, and data migration from itemGlobalLinks
- COALESCE merge pattern in item/thread services for transparent reference item data, branched thread resolution, and link/unlink endpoint removal
- Tag-filtered global item search with AND logic, owner count via direct FK, and COALESCE merge propagated to setup/totals/profile/CSV services
- Tags endpoint with alphabetical ordering, global-items route registration, UIStore FAB/catalog-search state, and tag-aware useGlobalItems hook
- Global FAB with animated mini menu and full-screen catalog search overlay with debounced search, tag chip AND-filtering, and result card grid
- Private item detail page with edit mode toggle at /items/:id, and enhanced catalog detail page with Add to Collection stub button
- Candidate detail page with edit mode toggle at /threads/:threadId/candidates/:candidateId, thread route directory restructured for nested routes, add-candidate modal replacing slide-out panel
- All card components rewired from slide-out panels to detail page navigation, panels removed from root layout, UIStore cleaned of panel state
- AddToCollectionModal with category/notes/price fields, sonner toasts, and wired catalog search + detail page entry points
- AddToThreadModal with existing thread picker, new thread + candidate creation, and session thread memory for catalog search flow
- ManualEntryForm component with CategoryPicker, ImageUpload, and cents conversion wired into CatalogSearchOverlay as inline mode with entry points, success card, and context-sensitive navigation
- createRateLimit(max, windowMs) factory with browse (120/min) and detail (60/min) tiers applied to all public GET endpoints before auth middleware
- globalItems attribution columns (sourceUrl, imageCredit, imageSourceUrl) with unique(brand, model) constraint, upsertGlobalItem/bulkUpsertGlobalItems service functions, and Zod schemas — 21 tests passing
- POST /api/global-items, POST /api/global-items/bulk, upsert_catalog_item and bulk_upsert_catalog MCP tools, and catalog detail page attribution display — 61 tests passing, lint clean, build succeeds
- One-liner:
- One-liner:
- Discovery landing page replacing personal dashboard — hero search trigger, popular setups feed, recent catalog items, trending categories, with auth-conditional CTA and PublicSetupCard enhanced with item counts and creator names
- 1. [Rule 1 - Bug] Used 'house' icon instead of plan-specified 'home'
- One-liner:
- TopNav replaces TotalsBar across all pages, BottomTabBar wired for mobile, hero removed from landing page, and /setups added as a public route
- Shared hobby config, popular-items-by-tags endpoint with owner count ordering, and batch onboarding completion service with auto-category creation
- 5-step catalog-driven onboarding with hobby cards, selectable item grid, review list, and CSS step transitions following UI-SPEC design contract
- Replaced old OnboardingWizard with new OnboardingFlow in root route, deleted old component, verified build and no stale references
---
## v2.0 Platform Foundation (Shipped: 2026-04-08)
**Phases completed:** 10 phases, 32 plans

View File

@@ -60,19 +60,21 @@ Help people make better gear decisions — discover what others use, compare rea
- ✓ MCP catalog tools (upsert_catalog_item, bulk_upsert_catalog) for agent seeding — v2.1
- ✓ Discovery landing page with catalog search, popular setups feed, recent items, trending categories — v2.1
- ✓ Profile page with Logto-powered account management (display name, bio, avatar, email, password, delete) — v2.2
- ✓ Image fit-within framing with dominant color background fill and crop editor — v2.2
- ✓ Catalog-driven onboarding flow with hobby picker, category-grouped item browser, and batch collection creation — v2.2
- ✓ Mobile icon-based action buttons on detail pages — v2.2
### Active
## Current Milestone: v2.2 User Experience Polish
## Current Milestone: v2.3 Global & Social Ready
**Goal:** Fix broken user-facing features and polish the experience for real users — working profiles, better image handling, refreshed onboarding, and mobile refinements.
**Goal:** Make GearBox work for a global audience with setup sharing, multi-currency support, and localization infrastructure.
**Target features:**
- Profile page with Logto integration for account management, branded login screens, email verification
- Image fit-within framing (letterbox/pillarbox) instead of hard crops
- Catalog-driven onboarding flow with visual refresh
- Mobile UX improvements (icon actions, touch refinements)
**Next milestone:** v2.3 Global & Social Ready — setup sharing system, multi-currency, i18n
- Setup sharing system with visibility toggle (private/link/public)
- Multi-currency support (USD/EUR/GBP) with user preference
- i18n foundation with translation framework and locale-aware formatting
### Future
@@ -96,19 +98,19 @@ Help people make better gear decisions — discover what others use, compare rea
## Context
Shipped through v2.0 with 23,970 LOC TypeScript across 210+ files. All milestones v1.0-v2.0 complete.
Shipped through v2.2 with 31 phases across 6 milestones. All milestones v1.0-v2.2 complete.
Tech stack: React 19, Hono, Drizzle ORM, PostgreSQL, TanStack Router/Query, Tailwind CSS v4, Lucide React, Recharts, framer-motion, all on Bun.
Primary use case is bikepacking gear but data model is hobby-agnostic.
Auth: External OIDC via Logto (browser sessions) + API keys (programmatic) + MCP OAuth (Claude).
Infrastructure: PostgreSQL, MinIO (S3-compatible image storage), Docker Compose for dev/prod.
Features: MCP server (21 tools), global item catalog with attribution and bulk import, user profiles, public setup sharing, catalog-driven gear flow, item/candidate detail pages, candidate ranking/comparison/impact preview. Public discovery landing page with catalog search, popular setups feed, recent items, and trending categories.
Features: MCP server (21 tools), global item catalog with attribution and bulk import, user profiles with Logto account management, public setup sharing, catalog-driven onboarding, fit-within image framing with crop editor, item/candidate detail pages, candidate ranking/comparison/impact preview. Public discovery landing page with catalog search, popular setups feed, recent items, and trending categories. Top nav + mobile bottom tab bar.
20+ test files (service-level, route-level integration, MCP). E2E tests pending rewrite for OIDC auth (backlog 999.1).
## Constraints
- **Runtime**: Bun — used as package manager and runtime
- **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**: Top nav bar (desktop) + bottom tab bar (mobile), discovery landing page for unauthenticated users
- **Auth**: External self-hosted provider — no in-house auth maintenance
- **Database**: PostgreSQL with Drizzle ORM
- **UGC**: Structured input only (ratings, predefined fields) — no freeform text until moderation exists

View File

@@ -8,8 +8,8 @@
-**v1.3 Research & Decision Tools** — Phases 10-13 (shipped 2026-04-08)
-**v2.0 Platform Foundation** — Phases 14-23 (shipped 2026-04-08)
-**v2.1 Public Discovery** — Phases 24-27 (shipped 2026-04-12)
- 🚧 **v2.2 User Experience Polish** — Phases 28-31 (in progress)
- 📋 **v2.3 Global & Social Ready** — Phases 32-34 (planned)
- **v2.2 User Experience Polish** — Phases 28-31 (shipped 2026-04-13)
- 🚧 **v2.3 Global & Social Ready** — Phases 32-34 (planned)
## Phases
@@ -76,14 +76,15 @@
</details>
### v2.2 User Experience Polish (In Progress)
<details>
<summary>✅ v2.2 User Experience Polish (Phases 28-31) — SHIPPED 2026-04-13</summary>
**Milestone Goal:** Fix broken user-facing features and polish the experience for real users — working profiles, better image handling, refreshed onboarding, and mobile refinements.
- [x] Phase 28: Profile & Logto Integration (3/3 plans) — completed 2026-04-12
- [x] Phase 29: Image Presentation (5/5 plans) — completed 2026-04-12
- [x] Phase 30: Onboarding Redesign (3/3 plans) — completed 2026-04-12
- [x] Phase 31: Mobile Polish (2/2 plans) — completed 2026-04-12
- [x] **Phase 28: Profile & Logto Integration** — Fix profile page, integrate Logto for profile management, customize login branding, configure email verification (completed 2026-04-12)
- [x] **Phase 29: Image Presentation** — Fit-within framing with letterbox/pillarbox instead of hard crops, optional crop positioning (completed 2026-04-12)
- [x] **Phase 30: Onboarding Redesign** — Catalog-driven onboarding replacing manual entry, visual refresh to match current UI (promotes 999.2) (completed 2026-04-12)
- [x] **Phase 31: Mobile Polish** — Icon-based action buttons on item views, small UX improvements (completed 2026-04-12)
</details>
### v2.3 Global & Social Ready (Planned)
@@ -166,40 +167,6 @@ Plans:
- [x] 27-03-PLAN.md — Root layout wiring, hero removal, and visual verification
**UI hint**: yes
### Phase 28: Profile & Logto Integration
**Goal**: Users have a working profile page with account management powered by Logto, branded login screens, and email verification
**Depends on**: Phase 27 (v2.1 complete)
**Requirements**: TBD (discuss phase)
**Success Criteria** (what must be TRUE):
TBD (discuss phase)
**Plans**: TBD
### Phase 29: Image Presentation
**Goal**: Images display within the fixed aspect ratio using fit-within framing (letterbox/pillarbox) instead of hard crops, preserving the full image
**Depends on**: Phase 28
**Requirements**: TBD (discuss phase)
**Success Criteria** (what must be TRUE):
TBD (discuss phase)
**Plans**: TBD
### Phase 30: Onboarding Redesign
**Goal**: New users experience a polished, catalog-driven onboarding flow that matches the current UI style and guides them through their first setup
**Depends on**: Phase 28
**Requirements**: TBD (discuss phase)
**Success Criteria** (what must be TRUE):
TBD (discuss phase)
**Plans**: TBD
**UI hint**: yes
### Phase 31: Mobile Polish
**Goal**: Mobile item views use icon-based action buttons instead of text labels, with small UX refinements across touch interactions
**Depends on**: None
**Requirements**: TBD (discuss phase)
**Success Criteria** (what must be TRUE):
TBD (discuss phase)
**Plans**: TBD
**UI hint**: yes
### Phase 32: Setup Sharing System
**Goal**: Setup owners can toggle visibility between private, link-shared, and public, with schema designed for future likes, friends, and collaborative editing
**Depends on**: Phase 28 (profiles working)
@@ -257,7 +224,7 @@ Plans:
| 26. Discovery Landing Page | v2.1 | 3/3 | Complete | 2026-04-10 |
| 27. Top Nav Restructure & Search Bar Rethink | v2.1 | 4/4 | Complete | 2026-04-12 |
| 28. Profile & Logto Integration | v2.2 | 3/3 | Complete | 2026-04-12 |
| 29. Image Presentation | v2.2 | 4/4 | Complete | 2026-04-12 |
| 29. Image Presentation | v2.2 | 5/5 | Complete | 2026-04-13 |
| 30. Onboarding Redesign | v2.2 | 3/3 | Complete | 2026-04-12 |
| 31. Mobile Polish | v2.2 | 2/2 | Complete | 2026-04-12 |
| 32. Setup Sharing System | v2.3 | TBD | Pending | — |

View File

@@ -4,13 +4,13 @@ milestone: v2.2
milestone_name: User Experience Polish
status: executing
stopped_at: Phase 31 context gathered
last_updated: "2026-04-12T18:50:04.872Z"
last_activity: 2026-04-12
last_updated: "2026-04-13T13:55:33.612Z"
last_activity: 2026-04-13
progress:
total_phases: 36
completed_phases: 24
total_plans: 67
completed_plans: 65
total_plans: 68
completed_plans: 66
percent: 97
---
@@ -28,7 +28,7 @@ See: .planning/PROJECT.md (updated 2026-04-09)
Phase: 31
Plan: Not started
Status: Executing Phase 30
Last activity: 2026-04-12
Last activity: 2026-04-13
Progress: [░░░░░░░░░░] 0%
@@ -80,7 +80,7 @@ v2.1 decisions:
### Pending Todos
None active.
- Fix Add Candidate button shows wrong modal on thread page (ui)
### Blockers/Concerns

View File

@@ -9,6 +9,6 @@
"plan_check": true,
"verifier": true,
"nyquist_validation": true,
"_auto_chain_active": true
"_auto_chain_active": false
}
}

View File

@@ -0,0 +1,55 @@
---
status: diagnosed
trigger: "crop editor opens on upload correctly, but after cropping the cropped image isn't shown in the edit state always — after clicking save it is shown correctly"
created: 2026-04-13T12:30:00Z
updated: 2026-04-13T12:35:00Z
---
## Current Focus
hypothesis: GearImage in ImageUpload receives no crop props after cropping — crop values are sent to server via onCropChange but never stored locally or passed to the preview GearImage
test: trace data flow from ImageCropEditor.onSave through ImageUpload to GearImage rendering
expecting: GearImage in ImageUpload has no cropZoom/cropX/cropY props
next_action: return diagnosis
## Symptoms
expected: After cropping in the crop editor, the image preview in edit mode should immediately reflect the crop
actual: Cropped image not shown in edit state after cropping; shows correctly only after Save
errors: None
reproduction: Upload image to item -> crop editor opens -> adjust crop -> close editor -> preview shows uncropped image -> Save item -> page re-renders with crop applied
started: Since Phase 29 implementation
## Eliminated
(none needed — root cause found on first hypothesis)
## Evidence
- timestamp: 2026-04-13T12:32:00Z
checked: ImageUpload.tsx lines 83-95 — ImageCropEditor onSave handler
found: onSave calls onCropChange(result) then setShowCropEditor(false). The crop values are passed up to the parent but NOT stored in any local state within ImageUpload.
implication: After crop editor closes, ImageUpload has no memory of what crop was applied.
- timestamp: 2026-04-13T12:33:00Z
checked: ImageUpload.tsx lines 109-114 — GearImage rendering after crop editor closes
found: GearImage is rendered with only src, alt, and dominantColor props. NO cropZoom, cropX, or cropY props are passed. The component never receives crop values.
implication: GearImage renders uncropped because it literally has no crop data to apply.
- timestamp: 2026-04-13T12:34:00Z
checked: $itemId.tsx lines 277-294 — onCropChange callback in item detail page
found: onCropChange triggers updateItem.mutate() which sends crop values to the server immediately. This is a fire-and-forget mutation — it does NOT update local state or the React Query cache synchronously.
implication: Crop values reach the server, but the local component tree has no access to them until the query is invalidated/refetched.
- timestamp: 2026-04-13T12:34:30Z
checked: $itemId.tsx lines 326-335 — GearImage in non-edit view mode
found: Non-edit view reads cropZoom, cropX, cropY from item (React Query cache data). After Save, the mutation invalidates the query, item refetches with crop values, and GearImage renders correctly.
implication: Confirms the "works after save" behavior — the query refetch provides the crop data.
## Resolution
root_cause: ImageUpload component does not track crop values locally after the crop editor closes. When the crop editor's onSave fires, the crop values are forwarded to the parent ($itemId.tsx) which sends them to the server via updateItem.mutate(), but no local state is updated. The GearImage rendered inside ImageUpload receives zero crop-related props (cropZoom, cropX, cropY are never passed). So the preview always shows the uncropped/default image. After the user clicks Save on the item form, the React Query cache is invalidated, the item refetches with server-side crop values, and the page re-renders in view mode with the correct crop applied.
fix: (not applied — diagnosis only)
verification: (not applied — diagnosis only)
files_changed: []

View File

@@ -0,0 +1,156 @@
# Requirements Archive: v2.2 User Experience Polish
**Archived:** 2026-04-13
**Status:** SHIPPED
For current requirements, see `.planning/REQUIREMENTS.md`.
---
# Requirements: GearBox v2.1 Public Discovery
**Defined:** 2026-04-09
**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.
## v2.1 Requirements
Requirements for Public Discovery milestone. Each maps to roadmap phases.
### Public Access
- [x] **PUBL-01**: User can browse the global item catalog without logging in
- [x] **PUBL-02**: User can view public setups without logging in
- [x] **PUBL-03**: User can view user profiles without logging in
- [x] **PUBL-04**: Anonymous visitors see the landing page without auth spinner or redirect
- [x] **PUBL-05**: Login is only required when user attempts to create/edit/delete their own data
### Discovery
- [x] **DISC-01**: Landing page displays an always-visible catalog search bar at the top
- [x] **DISC-02**: Landing page shows a feed of popular setups below the search
- [x] **DISC-03**: Landing page shows recently added catalog items
- [x] **DISC-04**: Landing page shows trending categories
- [x] **DISC-05**: Authenticated users see a "Go to Collection" entry point from the landing page
### Catalog Enrichment
- [x] **CATL-01**: Global items have attribution fields (sourceUrl, manufacturer, imageCredit, imageSourceUrl)
- [x] **CATL-02**: Global items have a unique constraint on (brand, model) preventing duplicates
- [x] **CATL-03**: Catalog detail pages display image attribution with credit and source link
- [x] **CATL-04**: Bulk import API endpoint accepts multiple catalog items in one request
- [x] **CATL-05**: Bulk import uses upsert semantics (ON CONFLICT update, not fail)
### Agent Seeding Tools
- [x] **SEED-01**: MCP server has a dedicated `upsert_catalog_item` tool that writes to globalItems (not user-scoped)
- [x] **SEED-02**: MCP server has a `bulk_upsert_catalog` tool for batch catalog population
- [x] **SEED-03**: Catalog MCP tools include attribution fields (sourceUrl, manufacturer, imageCredit) as parameters
### Infrastructure
- [x] **INFR-01**: Public API endpoints are rate-limited to prevent abuse
- [x] **INFR-02**: Discovery feed endpoint uses cursor pagination for scalability
## Future Requirements
Deferred to future milestones. Tracked but not in current roadmap.
### Personalization
- **PERS-01**: Logged-in users see a feed tuned to their collection categories
- **PERS-02**: Feed algorithm recommends content based on user's hobby interests
### Reviews & Content
- **REVW-01**: Users can write structured reviews on catalog items
- **REVW-02**: Reviews appear in the discovery feed
- **REVW-03**: Curated/linked external reviews surface in feed
### SEO
- **SEO-01**: Catalog pages are crawlable by search engine bots
- **SEO-02**: Catalog pages have proper meta tags and structured data
### Catalog Seeding
- **SEED-04**: Initial seeding run populates 100+ items across key categories via agent swarm
### Reviews & Ratings (from v2.0)
- **REV-01**: User can rate a global item with an overall star rating
- **REV-02**: User can rate a global item on predefined dimensions (durability, value, etc.)
- **REV-03**: Item detail pages show average ratings from all reviewers
### Aggregation (from v2.0)
- **AGG-01**: Item detail pages show crowd-verified specs (manufacturer vs community-measured weight)
- **AGG-02**: Item detail pages show which setups include this item
- **AGG-03**: Setup composition insights ("commonly paired with")
### Social (from v2.0)
- **SOCL-01**: User can fork/copy a public setup as a template
- **SOCL-02**: Planning thread candidates can link to global items for auto-populated specs
- **SOCL-03**: User can follow other users
- **SOCL-04**: User can view an activity feed of followed users' content
### Content Moderation (from v2.0)
- **MOD-01**: User can submit freeform text reviews
- **MOD-02**: User can report inappropriate content
- **MOD-03**: Admin can review and act on reported content
## Out of Scope
Explicitly excluded. Documented to prevent scope creep.
| Feature | Reason |
|---------|--------|
| Personalized feed algorithm | Requires usage data and collection analysis — build simple feed first |
| SSR / static prerendering for SEO | Needs dedicated research on approach for TanStack Router — defer to v2.2+ |
| Freeform reviews / comments | No moderation infrastructure yet — structured UGC only |
| Admin role / permission system | Current auth model has no role distinction — API key sufficient for v2.1 |
| Image scraping automation | Legal gray area — agent seeding uses manufacturer-provided images with attribution |
| User-to-user messaging | High moderation burden, not core to discovery |
| Wiki-style open item editing | Quality control risk; structured contributions only |
| Marketplace / buy-sell | Payment processing, fraud, legal liability |
| AI gear recommendations | Training data requirements, hallucination risk |
| Gamification (badges, points) | Incentivizes quantity over quality |
| Price tracking / deal alerts | Requires scraping, fragile, legal gray area |
| Mobile native app | Web-first, responsive design sufficient |
## Traceability
Which phases cover which requirements. Updated during roadmap creation.
| Requirement | Phase | Status |
|-------------|-------|--------|
| PUBL-01 | Phase 24 | Complete |
| PUBL-02 | Phase 24 | Complete |
| PUBL-03 | Phase 24 | Complete |
| PUBL-04 | Phase 24 | Complete |
| PUBL-05 | Phase 24 | Complete |
| INFR-01 | Phase 24 | Complete |
| CATL-01 | Phase 25 | Complete |
| CATL-02 | Phase 25 | Complete |
| CATL-03 | Phase 25 | Complete |
| CATL-04 | Phase 25 | Complete |
| CATL-05 | Phase 25 | Complete |
| SEED-01 | Phase 25 | Complete |
| SEED-02 | Phase 25 | Complete |
| SEED-03 | Phase 25 | Complete |
| DISC-01 | Phase 26 | Complete |
| DISC-02 | Phase 26 | Complete |
| DISC-03 | Phase 26 | Complete |
| DISC-04 | Phase 26 | Complete |
| DISC-05 | Phase 26 | Complete |
| INFR-02 | Phase 26 | Complete |
**Coverage:**
- v2.1 requirements: 20 total
- Mapped to phases: 20
- Unmapped: 0
---
*Requirements defined: 2026-04-09*
*Last updated: 2026-04-09 after roadmap creation*

View File

@@ -0,0 +1,340 @@
# Roadmap: GearBox
## Milestones
-**v1.0 MVP** — Phases 1-3 (shipped 2026-03-15)
-**v1.1 Fixes & Polish** — Phases 4-6 (shipped 2026-03-15)
-**v1.2 Collection Power-Ups** — Phases 7-9 (shipped 2026-03-16)
-**v1.3 Research & Decision Tools** — Phases 10-13 (shipped 2026-04-08)
-**v2.0 Platform Foundation** — Phases 14-23 (shipped 2026-04-08)
-**v2.1 Public Discovery** — Phases 24-27 (shipped 2026-04-12)
- 🚧 **v2.2 User Experience Polish** — Phases 28-31 (in progress)
- 📋 **v2.3 Global & Social Ready** — Phases 32-34 (planned)
## Phases
<details>
<summary>✅ v1.0 MVP (Phases 1-3) — SHIPPED 2026-03-15</summary>
- [x] Phase 1: Foundation and Collection (4/4 plans) — completed 2026-03-14
- [x] Phase 2: Planning Threads (3/3 plans) — completed 2026-03-15
- [x] Phase 3: Setups and Dashboard (3/3 plans) — completed 2026-03-15
</details>
<details>
<summary>✅ v1.1 Fixes & Polish (Phases 4-6) — SHIPPED 2026-03-15</summary>
- [x] Phase 4: Database & Planning Fixes (2/2 plans) — completed 2026-03-15
- [x] Phase 5: Image Handling (2/2 plans) — completed 2026-03-15
- [x] Phase 6: Category Icons (3/3 plans) — completed 2026-03-15
</details>
<details>
<summary>✅ v1.2 Collection Power-Ups (Phases 7-9) — SHIPPED 2026-03-16</summary>
- [x] Phase 7: Weight Unit Selection (2/2 plans) — completed 2026-03-16
- [x] Phase 8: Search, Filter, and Candidate Status (2/2 plans) — completed 2026-03-16
- [x] Phase 9: Weight Classification and Visualization (2/2 plans) — completed 2026-03-16
</details>
<details>
<summary>✅ v1.3 Research & Decision Tools (Phases 10-13) — SHIPPED 2026-04-08</summary>
- [x] Phase 10: Schema Foundation + Pros/Cons Fields (1/1 plans) — completed 2026-03-16
- [x] Phase 11: Candidate Ranking (2/2 plans) — completed 2026-03-16
- [x] Phase 12: Comparison View (1/1 plans) — completed 2026-03-17
- [x] Phase 13: Setup Impact Preview (2/2 plans) — completed 2026-04-08
</details>
<details>
<summary>✅ v2.0 Platform Foundation (Phases 14-23) — SHIPPED 2026-04-08</summary>
- [x] Phase 14: PostgreSQL Migration (6/6 plans) — completed 2026-04-05
- [x] Phase 15: External Authentication (3/3 plans) — completed 2026-04-05
- [x] Phase 16: Multi-User Data Model (4/4 plans) — completed 2026-04-05
- [x] Phase 17: Object Storage (3/3 plans) — completed 2026-04-05
- [x] Phase 18: Global Items & Public Profiles (5/5 plans) — completed 2026-04-05
- [x] Phase 19: Reference Item Model & Tags Schema (3/3 plans) — completed 2026-04-05
- [x] Phase 20: FAB & Full-Screen Catalog Search (2/2 plans) — completed 2026-04-06
- [x] Phase 21: Item & Catalog Detail Pages (3/3 plans) — completed 2026-04-06
- [x] Phase 22: Add-from-Catalog & Thread Integration (2/2 plans) — completed 2026-04-06
- [x] Phase 23: Manual Entry Fallback (1/1 plans) — completed 2026-04-06
</details>
<details>
<summary>✅ v2.1 Public Discovery (Phases 24-27) — SHIPPED 2026-04-12</summary>
- [x] Phase 24: Public Access & Infrastructure (2/2 plans) — completed 2026-04-10
- [x] Phase 25: Catalog Enrichment & Agent Tools (2/2 plans) — completed 2026-04-10
- [x] Phase 26: Discovery Landing Page (3/3 plans) — completed 2026-04-10
- [x] Phase 27: Top Nav Restructure & Search Bar Rethink (4/4 plans) — completed 2026-04-12
</details>
### v2.2 User Experience Polish (In Progress)
**Milestone Goal:** Fix broken user-facing features and polish the experience for real users — working profiles, better image handling, refreshed onboarding, and mobile refinements.
- [x] **Phase 28: Profile & Logto Integration** — Fix profile page, integrate Logto for profile management, customize login branding, configure email verification (completed 2026-04-12)
- [x] **Phase 29: Image Presentation** — Fit-within framing with letterbox/pillarbox instead of hard crops, optional crop positioning (completed 2026-04-12)
- [x] **Phase 30: Onboarding Redesign** — Catalog-driven onboarding replacing manual entry, visual refresh to match current UI (promotes 999.2) (completed 2026-04-12)
- [x] **Phase 31: Mobile Polish** — Icon-based action buttons on item views, small UX improvements (completed 2026-04-12)
### v2.3 Global & Social Ready (Planned)
**Milestone Goal:** Make GearBox work for a global audience with setup sharing, multi-currency support, and localization infrastructure.
- [ ] **Phase 32: Setup Sharing System** — Visibility toggle (private/link/public), link sharing, schema future-proofed for likes, friends, and collaborative editing
- [ ] **Phase 33: Currency System** — Multi-currency support (USD/EUR/GBP), price display per user preference
- [ ] **Phase 34: i18n Foundation** — Translation framework, string extraction, locale-aware formatting
## Phase Details
### Phase 24: Public Access & Infrastructure
**Goal**: Anyone can browse the catalog, public setups, and user profiles without logging in
**Depends on**: Phase 23 (v2.0 complete)
**Requirements**: PUBL-01, PUBL-02, PUBL-03, PUBL-04, PUBL-05, INFR-01
**Success Criteria** (what must be TRUE):
1. Visiting the app without a session shows the app content immediately — no auth spinner, no redirect to login
2. An unauthenticated visitor can browse the global item catalog and open a catalog detail page
3. An unauthenticated visitor can view a public setup and see its items and totals
4. An unauthenticated visitor can view a user's public profile page
5. Attempting to create, edit, or delete any item/setup/thread while unauthenticated redirects to login
**Plans**: 2 plans
Plans:
- [x] 24-01-PLAN.md — Rate limit factory and tiered public endpoint protection
- [x] 24-02-PLAN.md — Client-side public access (render-first root, auth prompt, setup/catalog guards)
**UI hint**: yes
### Phase 25: Catalog Enrichment & Agent Tools
**Goal**: Global items carry attribution metadata and can be bulk-populated by an MCP agent swarm
**Depends on**: Phase 24
**Requirements**: CATL-01, CATL-02, CATL-03, CATL-04, CATL-05, SEED-01, SEED-02, SEED-03
**Success Criteria** (what must be TRUE):
1. A catalog item detail page displays image credit and a link to the image source
2. Attempting to import two items with the same brand and model updates the existing record rather than creating a duplicate
3. A single API call with an array of items imports them all, upserting on (brand, model) conflict
4. An MCP agent can call `upsert_catalog_item` with attribution fields and the item appears in the catalog
5. An MCP agent can call `bulk_upsert_catalog` with a batch of items and all are persisted with attribution
**Plans**: 2 plans
Plans:
- [x] 25-01-PLAN.md — Schema migration (attribution columns + unique constraint) and upsert service layer
- [ ] 25-02-PLAN.md — HTTP upsert routes, MCP catalog tools, and client attribution display
### Phase 26: Discovery Landing Page
**Goal**: The app opens to a public discovery feed with prominent catalog search, not a personal dashboard
**Depends on**: Phase 25
**Requirements**: DISC-01, DISC-02, DISC-03, DISC-04, DISC-05, INFR-02
**Success Criteria** (what must be TRUE):
1. The root URL shows a landing page with a catalog search bar at the top, visible without logging in
2. Below the search bar, a feed of popular public setups is visible with titles, creator names, and item counts
3. The landing page shows a section of recently added catalog items
4. The landing page shows a section of trending categories
5. A logged-in user sees a "Go to Collection" link or button on the landing page that navigates to their personal collection
**Plans**: 3 plans
Plans:
- [x] 26-01-PLAN.md — Discovery service layer with cursor pagination (TDD)
- [x] 26-02-PLAN.md — Discovery routes, server registration, and client hooks
- [x] 26-03-PLAN.md — Landing page UI and PublicSetupCard enhancement
**UI hint**: yes
### Phase 27: Top Nav Restructure & Search Bar Rethink
**Goal**: Replace the minimal TotalsBar with a persistent top navigation bar (logo, section links, catalog search, user avatar) and move mobile navigation to a bottom tab bar — elevating Setups to top-level and removing the landing page hero
**Depends on**: Phase 26
**Requirements**: NAV-01, NAV-02, NAV-03, NAV-04, NAV-05
**Success Criteria** (what must be TRUE):
1. A persistent top nav bar shows logo, Home/Collection/Setups links, catalog search, and user avatar on desktop
2. Clicking Collection or Setups while anonymous triggers AuthPromptModal instead of navigating
3. On mobile, navigation appears as a fixed bottom tab bar with Home, Collection, Setups, and Search icons
4. The landing page no longer has a hero section — content starts with Popular Setups
5. Setups has its own top-level route accessible from the nav bar, not nested in Collection tabs
**Plans**: 4 plans
Plans:
- [x] 27-00-PLAN.md — Wave 0: E2E test scaffolding for nav restructure
- [x] 27-01-PLAN.md — TopNav and BottomTabBar components
- [x] 27-02-PLAN.md — Setups top-level route and Collection tab simplification
- [x] 27-03-PLAN.md — Root layout wiring, hero removal, and visual verification
**UI hint**: yes
### Phase 28: Profile & Logto Integration
**Goal**: Users have a working profile page with account management powered by Logto, branded login screens, and email verification
**Depends on**: Phase 27 (v2.1 complete)
**Requirements**: TBD (discuss phase)
**Success Criteria** (what must be TRUE):
TBD (discuss phase)
**Plans**: TBD
### Phase 29: Image Presentation
**Goal**: Images display within the fixed aspect ratio using fit-within framing (letterbox/pillarbox) instead of hard crops, preserving the full image
**Depends on**: Phase 28
**Requirements**: TBD (discuss phase)
**Success Criteria** (what must be TRUE):
TBD (discuss phase)
**Plans**: TBD
### Phase 30: Onboarding Redesign
**Goal**: New users experience a polished, catalog-driven onboarding flow that matches the current UI style and guides them through their first setup
**Depends on**: Phase 28
**Requirements**: TBD (discuss phase)
**Success Criteria** (what must be TRUE):
TBD (discuss phase)
**Plans**: TBD
**UI hint**: yes
### Phase 31: Mobile Polish
**Goal**: Mobile item views use icon-based action buttons instead of text labels, with small UX refinements across touch interactions
**Depends on**: None
**Requirements**: TBD (discuss phase)
**Success Criteria** (what must be TRUE):
TBD (discuss phase)
**Plans**: TBD
**UI hint**: yes
### Phase 32: Setup Sharing System
**Goal**: Setup owners can toggle visibility between private, link-shared, and public, with schema designed for future likes, friends, and collaborative editing
**Depends on**: Phase 28 (profiles working)
**Requirements**: TBD (discuss phase)
**Success Criteria** (what must be TRUE):
TBD (discuss phase)
**Plans**: TBD
**UI hint**: yes
### Phase 33: Currency System
**Goal**: Users can select their preferred currency (USD/EUR/GBP) and all prices display accordingly
**Depends on**: Phase 32
**Requirements**: TBD (discuss phase)
**Success Criteria** (what must be TRUE):
TBD (discuss phase)
**Plans**: TBD
### Phase 34: i18n Foundation
**Goal**: Translation framework in place with string extraction, locale-aware formatting, and at least English + one additional language
**Depends on**: Phase 33
**Requirements**: TBD (discuss phase)
**Success Criteria** (what must be TRUE):
TBD (discuss phase)
**Plans**: TBD
## Progress
| Phase | Milestone | Plans Complete | Status | Completed |
|-------|-----------|----------------|--------|-----------|
| 1. Foundation and Collection | v1.0 | 4/4 | Complete | 2026-03-14 |
| 2. Planning Threads | v1.0 | 3/3 | Complete | 2026-03-15 |
| 3. Setups and Dashboard | v1.0 | 3/3 | Complete | 2026-03-15 |
| 4. Database & Planning Fixes | v1.1 | 2/2 | Complete | 2026-03-15 |
| 5. Image Handling | v1.1 | 2/2 | Complete | 2026-03-15 |
| 6. Category Icons | v1.1 | 3/3 | Complete | 2026-03-15 |
| 7. Weight Unit Selection | v1.2 | 2/2 | Complete | 2026-03-16 |
| 8. Search, Filter, and Candidate Status | 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 |
| 11. Candidate Ranking | v1.3 | 2/2 | Complete | 2026-03-16 |
| 12. Comparison View | v1.3 | 1/1 | Complete | 2026-03-17 |
| 13. Setup Impact Preview | v1.3 | 2/2 | Complete | 2026-04-08 |
| 14. PostgreSQL Migration | v2.0 | 6/6 | Complete | 2026-04-05 |
| 15. External Authentication | v2.0 | 3/3 | Complete | 2026-04-05 |
| 16. Multi-User Data Model | v2.0 | 4/4 | Complete | 2026-04-05 |
| 17. Object Storage | v2.0 | 3/3 | Complete | 2026-04-05 |
| 18. Global Items & Public Profiles | v2.0 | 5/5 | Complete | 2026-04-05 |
| 19. Reference Item Model & Tags Schema | v2.0 | 3/3 | Complete | 2026-04-05 |
| 20. FAB & Full-Screen Catalog Search | v2.0 | 2/2 | Complete | 2026-04-06 |
| 21. Item & Catalog Detail Pages | v2.0 | 3/3 | Complete | 2026-04-06 |
| 22. Add-from-Catalog & Thread Integration | v2.0 | 2/2 | Complete | 2026-04-06 |
| 23. Manual Entry Fallback | v2.0 | 1/1 | Complete | 2026-04-06 |
| 24. Public Access & Infrastructure | v2.1 | 2/2 | Complete | 2026-04-10 |
| 25. Catalog Enrichment & Agent Tools | v2.1 | 2/2 | Complete | 2026-04-10 |
| 26. Discovery Landing Page | v2.1 | 3/3 | Complete | 2026-04-10 |
| 27. Top Nav Restructure & Search Bar Rethink | v2.1 | 4/4 | Complete | 2026-04-12 |
| 28. Profile & Logto Integration | v2.2 | 3/3 | Complete | 2026-04-12 |
| 29. Image Presentation | v2.2 | 5/5 | Complete | 2026-04-13 |
| 30. Onboarding Redesign | v2.2 | 3/3 | Complete | 2026-04-12 |
| 31. Mobile Polish | v2.2 | 2/2 | Complete | 2026-04-12 |
| 32. Setup Sharing System | v2.3 | TBD | Pending | — |
| 33. Currency System | v2.3 | TBD | Pending | — |
| 34. i18n Foundation | v2.3 | TBD | Pending | — |
## Backlog
### Phase 999.1: Rewrite E2E Tests for OIDC Auth (BACKLOG)
**Goal**: E2E tests currently expect local username/password login but auth moved to external OIDC (Logto). Rewrite with mock OIDC provider or API-key-based auth bypass. Seed migration to Postgres is already done.
**Requirements**: TBD
**Plans**: TBD
Plans:
- [ ] TBD (promote with /gsd:review-backlog when ready)
### Phase 999.2: Revamp Onboarding Flow (BACKLOG)
**Goal**: Redesign the onboarding experience to match the current app style and flow. Replace the manual item edit form with the catalog search function. Visual refresh to align with the newer UI patterns.
**Status**: Promoted to Phase 30 (v2.2)
**Requirements**: TBD
**Plans**: TBD
Plans:
- [ ] TBD (promote with /gsd:review-backlog when ready)
### Phase 999.5: Legal Pages — ToS, Privacy Policy, and Compliance (BACKLOG)
**Goal**: Create Terms of Service, Privacy Policy, and any other required legal/compliance pages for a public-facing platform. Essential before opening to real users.
**Requirements**: TBD
**Plans**: TBD
Plans:
- [ ] TBD (promote with /gsd:review-backlog when ready)
### Phase 999.6: Admin Panel (BACKLOG)
**Goal**: Build an admin panel for reviewing user-submitted items (catalog submissions), managing global/reference items, and general platform administration. Includes approval workflows for community contributions.
**Requirements**: TBD
**Plans**: TBD
Plans:
- [ ] TBD (promote with /gsd:review-backlog when ready)
### Phase 999.7: User Feedback System (BACKLOG)
**Goal**: Add an in-app feedback collection mechanism so users can report bugs, suggest features, and share general feedback. Could be a simple form, widget, or integration with an external tool.
**Requirements**: TBD
**Plans**: TBD
Plans:
- [ ] TBD (promote with /gsd:review-backlog when ready)
### Phase 999.8: Analytics Integration (BACKLOG)
**Goal**: Integrate privacy-respecting analytics (PostHog, Umami, or similar) to understand usage patterns, popular categories, search behavior, and feature adoption. Self-hosted preferred to align with independent ethos.
**Requirements**: TBD
**Plans**: TBD
Plans:
- [ ] TBD (promote with /gsd:review-backlog when ready)
### Phase 999.9: Mobile App (BACKLOG)
**Goal**: Bring GearBox to mobile. Start with a PWA for quick wins (offline support, home screen install), then evaluate dedicated native apps (React Native / Flutter) for richer experience — camera for weight verification, barcode scanning, etc.
**Requirements**: TBD
**Plans**: TBD
Plans:
- [ ] TBD (promote with /gsd:review-backlog when ready)
### Phase 999.10: Monetization Strategy (BACKLOG)
**Goal**: Define how GearBox sustains itself financially. Options to explore: sponsored/promoted items (brand X promotes product Y), premium features, affiliate links. Critical tension: revenue vs. independent credibility — GearBox's value is unbiased gear data, so monetization must not compromise trust. Needs deep discussion before implementation.
**Requirements**: TBD
**Plans**: TBD
Plans:
- [ ] TBD (promote with /gsd:review-backlog when ready)
### Phase 999.11: Marketing Website (BACKLOG)
**Goal**: Build a separate marketing/brand website (www.gearbox.de) distinct from the app (app.gearbox.de). Hero section with search bar, value proposition, feature highlights, how-it-works, social proof, and sign-up CTA. This is the public-facing front door — the first thing people see before they enter the app. The current discovery page is the in-app experience; this is the standalone website around it.
**Requirements**: TBD
**Plans**: TBD
Plans:
- [ ] TBD (promote with /gsd:review-backlog when ready)

View File

@@ -0,0 +1,60 @@
---
status: complete
phase: 28-profile-and-logto-integration
source: [28-01-SUMMARY.md, 28-02-SUMMARY.md, 28-03-SUMMARY.md]
started: 2026-04-12T18:30:00Z
updated: 2026-04-12T21:00:00Z
---
## Current Test
[testing complete]
## Tests
### 1. Profile page navigation
expected: Click your avatar in the top nav. The dropdown shows "Profile" above "Settings". Clicking it navigates to /profile.
result: pass
### 2. Profile page sections
expected: /profile page shows four sections: Profile Info (displayName, bio, avatar), Account Info (email, member-since date), Security (password change), and Danger Zone (delete account).
result: pass
### 3. Settings page separation
expected: /settings page shows only app preferences: weight unit, currency, import/export, API keys. No profile section.
result: pass
### 4. Edit display name, bio, and avatar
expected: On /profile, upload an avatar, change display name and bio, click Save. Avatar image renders. Refreshing shows updated values.
result: pass
reported: "Fixed: avatar now uses presigned S3 URLs instead of /uploads/ paths. Avatar also shows in top nav."
### 5. Email display
expected: Account Info section shows your email address (from Logto) and a "Change" button next to it.
result: pass
reported: "Fixed: M2M credentials configured, email change now reflects in UI immediately via optimistic cache update."
### 6. Password change form
expected: Security section shows a password change form. Current password, new password, confirm new password fields.
result: pass
### 7. Delete account UI
expected: Danger Zone shows a red-bordered card with "Delete Account" button. Clicking it shows a confirmation dialog requiring you to type "DELETE" before proceeding.
result: pass
### 8. Member-since date
expected: Account Info section shows a "Member since" date formatted nicely (e.g., "April 2026").
result: pass
## Summary
total: 8
passed: 8
issues: 0
pending: 0
skipped: 0
blocked: 0
## Gaps
[none]

View File

@@ -0,0 +1,169 @@
---
phase: 29-image-presentation
plan: 05
type: execute
wave: 1
depends_on: []
files_modified:
- src/client/components/ImageUpload.tsx
autonomous: true
gap_closure: true
requirements: []
must_haves:
truths:
- "After cropping in the upload crop editor, the GearImage preview immediately reflects the crop values without needing to save the form"
artifacts:
- path: "src/client/components/ImageUpload.tsx"
provides: "Local crop state that feeds GearImage preview"
contains: "cropZoom"
key_links:
- from: "ImageCropEditor onSave"
to: "GearImage cropZoom/cropX/cropY props"
via: "local state in ImageUpload"
pattern: "localCrop"
---
<objective>
Fix cropped image preview not updating immediately after cropping in edit mode.
Purpose: When a user crops an image via the ImageCropEditor inside ImageUpload, the preview should reflect the crop immediately — not only after form save and query refetch.
Output: ImageUpload component with local crop state that feeds into GearImage preview props.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@src/client/components/ImageUpload.tsx
@src/client/components/GearImage.tsx
@src/client/components/ImageCropEditor.tsx
<interfaces>
<!-- GearImage accepts optional crop props -->
From src/client/components/GearImage.tsx:
```typescript
interface GearImageProps {
src: string;
alt: string;
dominantColor?: string | null;
cropZoom?: number | null;
cropX?: number | null;
cropY?: number | null;
className?: string;
cover?: boolean;
}
```
<!-- ImageCropEditor returns CropResult on save -->
From src/client/components/ImageCropEditor.tsx:
```typescript
interface CropResult {
zoom: number;
x: number;
y: number;
}
// onSave: (result: CropResult) => void;
```
<!-- ImageUpload current props -->
From src/client/components/ImageUpload.tsx:
```typescript
interface ImageUploadProps {
value: string | null;
imageUrl?: string | null;
dominantColor?: string | null;
onChange: (filename: string | null, dominantColor?: string | null) => void;
onCropChange?: (crop: { zoom: number; x: number; y: number }) => void;
}
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Add local crop state to ImageUpload and wire to GearImage preview</name>
<files>src/client/components/ImageUpload.tsx</files>
<action>
In ImageUpload.tsx, make these changes:
1. Add a local crop state to track the most recent crop values:
```typescript
const [localCrop, setLocalCrop] = useState<{ zoom: number; x: number; y: number } | null>(null);
```
2. In the ImageCropEditor onSave handler (around line 88-91), update localCrop before calling the parent onCropChange:
```typescript
onSave={(result) => {
setLocalCrop(result);
onCropChange(result);
setShowCropEditor(false);
}}
```
3. In the GearImage render (around line 110-114), pass localCrop values as props:
```typescript
<GearImage
src={displayUrl}
alt="Item"
dominantColor={dominantColor}
cropZoom={localCrop?.zoom}
cropX={localCrop?.x}
cropY={localCrop?.y}
/>
```
4. When the image is removed (handleRemove), also clear localCrop:
```typescript
function handleRemove(e: React.MouseEvent) {
e.stopPropagation();
setLocalPreview(null);
setLocalCrop(null);
onChange(null);
}
```
This ensures the GearImage preview immediately reflects crop adjustments without waiting for a server round-trip and query refetch.
</action>
<verify>
<automated>cd /home/jlmak/Projects/jlmak/GearBox && bunx tsc --noEmit --pretty 2>&1 | head -30</automated>
</verify>
<done>After using the crop editor on an uploaded image, the GearImage preview in ImageUpload immediately shows the cropped framing. Removing the image clears both the preview and crop state.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
No new trust boundaries — this is a client-side-only state management fix within existing components.
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-29-05-01 | T (Tampering) | localCrop state | accept | Client-side display only; actual crop values are persisted via existing server mutation in parent component |
</threat_model>
<verification>
1. TypeScript compiles without errors
2. Manual: Open item in edit mode, upload image, crop it, verify preview shows crop immediately (without clicking Save)
3. Manual: Open existing item in edit mode, click crop button, adjust, save framing — preview updates immediately
</verification>
<success_criteria>
- Cropped image preview updates in edit state immediately after cropping, without needing to save the form
- No TypeScript errors
- Image removal clears crop state
</success_criteria>
<output>
After completion, create `.planning/phases/29-image-presentation/29-05-SUMMARY.md`
</output>

View File

@@ -0,0 +1,34 @@
---
phase: 29-image-presentation
plan: 05
status: complete
gap_closure: true
started: 2026-04-13T12:00:00Z
completed: 2026-04-13T12:10:00Z
---
## Summary
Fixed cropped image preview not updating immediately in edit mode. Added `localCrop` state to `ImageUpload` that captures crop values from `ImageCropEditor` and passes them to `GearImage` as props. Previously, the preview only reflected crop settings after saving the form and refetching from the server.
## Accomplishments
- Added `localCrop` useState to ImageUpload for immediate crop feedback
- Wired ImageCropEditor onSave to set localCrop before forwarding to parent
- Passed localCrop values (cropZoom, cropX, cropY) to GearImage preview
- Clear localCrop on image removal to prevent stale state
## Key Files
### Modified
- `src/client/components/ImageUpload.tsx` — local crop state + GearImage prop wiring
## Self-Check: PASSED
- TypeScript compiles without errors (no new errors in ImageUpload.tsx)
- Local crop state correctly flows: ImageCropEditor → localCrop → GearImage props
- Image removal clears both preview and crop state
## Deviations
None — implemented exactly as planned.

View File

@@ -0,0 +1,66 @@
---
status: diagnosed
phase: 29-image-presentation
source: [29-01-SUMMARY.md, 29-02-SUMMARY.md, 29-03-SUMMARY.md, 29-04-SUMMARY.md]
started: 2026-04-12T19:10:00Z
updated: 2026-04-13T12:15:00Z
---
## Current Test
[testing complete]
## Tests
### 1. Images use fit-within instead of crop
expected: Browse any page with item/catalog cards. Images should fit inside the frame without cropping — full image visible, no parts cut off.
result: pass
### 2. Dominant color background fill
expected: Where an image doesn't fill the entire frame, the empty space is filled with a color extracted from the image (not white or gray).
result: pass
### 3. Crop editor on item detail
expected: Open an item that has an image. In edit mode, you should see a crop icon button next to the trash icon, positioned as an overlay on the image. Clicking it opens a crop editor with zoom slider.
result: pass
reported: "Initially reported as issue but confirmed working on re-test — false claim"
### 4. Crop editor on image upload
expected: Upload a new image to an item. After the upload completes, a crop editor should appear automatically. After cropping, the preview should reflect the crop immediately.
result: issue
reported: "crop editor opens on upload correctly, but after cropping the cropped image isn't shown in the edit state always — after clicking save it is shown correctly"
severity: minor
### 5. Crop settings persist
expected: Adjust the crop on an item image, save it. Navigate away and come back — image displays with saved crop settings.
result: pass
### 6. Consistency across surfaces
expected: All image surfaces use the same fit-within + dominant color treatment.
result: pass
## Summary
total: 6
passed: 5
issues: 1
pending: 0
skipped: 0
blocked: 0
## Gaps
- truth: "Cropped image preview should update in edit state immediately after cropping"
status: failed
reason: "User reported: cropped image not shown in edit state after cropping, but renders correctly after save"
severity: minor
test: 4
root_cause: "ImageUpload component does not store or forward crop values to its GearImage preview after crop editor closes. onCropChange sends to server but no local state is updated. GearImage in ImageUpload receives zero crop props. Only after form save + query refetch do crop values appear."
artifacts:
- path: "src/client/components/ImageUpload.tsx"
issue: "GearImage preview (line 110-114) rendered without cropZoom/cropX/cropY props; no local crop state exists"
- path: "src/client/routes/items/$itemId.tsx"
issue: "onCropChange (line 288-293) fires server mutation but updates no local/form state"
missing:
- Add local crop state in ImageUpload that gets set from crop editor result and passed as props to GearImage
debug_session: ".planning/debug/crop-preview-edit-state.md"

View File

@@ -0,0 +1,71 @@
---
status: partial
phase: 30-onboarding-redesign
source: [30-01-SUMMARY.md, 30-02-SUMMARY.md, 30-03-SUMMARY.md]
started: 2026-04-12T19:30:00Z
updated: 2026-04-13T12:30:00Z
---
## Current Test
[testing paused — 3 items blocked by catalog seed data]
## Tests
### 1. Onboarding triggers on first login
expected: After creating a new account and signing in for the first time, a full-screen onboarding flow appears (not the old small modal wizard).
result: pass
### 2. Welcome step
expected: First screen shows a welcome message with a "Get Started" button. Full-screen, big visuals, immersive feel.
result: pass
### 3. Hobby picker (required step)
expected: Second screen shows hobby cards with icons (Bikepacking, Hiking, Climbing, Cycling, etc.). You can select one or more. This step cannot be skipped.
result: issue
reported: "Works but selected cards need stronger visual distinction — dark gray fill with inverted text/icon instead of just a border change."
severity: cosmetic
### 4. Item browser
expected: After picking a hobby, you see a grid of popular catalog items filtered by that hobby.
result: blocked
blocked_by: server
reason: "Catalog is empty on test server — need some kind of seeding for the test env."
### 5. Review screen
expected: After selecting items, a review/summary screen shows all selections grouped by category.
result: blocked
blocked_by: prior-phase
reason: "Depends on test 4 — catalog seed data needed."
### 6. Completion and collection
expected: After confirming, items are batch-added to collection with auto-created categories.
result: blocked
blocked_by: prior-phase
reason: "Depends on test 4 — catalog seed data needed."
### 7. Onboarding doesn't show again
expected: Refresh the page or sign out and back in. Onboarding does NOT appear again.
result: pass
## Summary
total: 7
passed: 3
issues: 1
pending: 0
skipped: 0
blocked: 3
## Gaps
- truth: "Selected hobby cards should have strong visual distinction"
status: failed
reason: "User reported: selected cards need dark gray fill with inverted text/icon, not just border change"
severity: cosmetic
test: 3
artifacts:
- path: "src/client/components/onboarding/OnboardingHobbyPicker.tsx"
issue: "Weak selected state styling"
missing:
- Stronger selected state styling (dark bg, inverted colors)

View File

@@ -0,0 +1,33 @@
---
status: complete
phase: 31-mobile-polish
source: [31-01-SUMMARY.md, 31-02-SUMMARY.md]
started: 2026-04-12T19:45:00Z
updated: 2026-04-12T19:45:00Z
---
## Current Test
[testing complete]
## Tests
### 1. Icon action buttons on mobile
expected: Open an item detail page on mobile. Action buttons (Edit, Delete, Duplicate) show as icons only instead of text labels.
result: pass
### 2. Icons across all detail pages
expected: Candidate detail, setup detail, catalog item detail all have icon buttons on mobile too.
result: pass
## Summary
total: 2
passed: 2
issues: 0
pending: 0
skipped: 0
## Gaps
[none]

View File

@@ -1,76 +0,0 @@
---
status: partial
phase: 28-profile-and-logto-integration
source: [28-01-SUMMARY.md, 28-02-SUMMARY.md, 28-03-SUMMARY.md]
started: 2026-04-12T18:30:00Z
updated: 2026-04-12T19:00:00Z
---
## Current Test
[testing complete]
## Tests
### 1. Profile page navigation
expected: Click your avatar in the top nav. The dropdown shows "Profile" above "Settings". Clicking it navigates to /profile.
result: pass
### 2. Profile page sections
expected: /profile page shows four sections: Profile Info (displayName, bio, avatar), Account Info (email, member-since date), Security (password change), and Danger Zone (delete account).
result: pass
### 3. Settings page separation
expected: /settings page shows only app preferences: weight unit, currency, import/export, API keys. No profile section.
result: pass
### 4. Edit display name and bio
expected: On /profile, change display name and bio, click Save. Success message appears. Refreshing the page shows the updated values.
result: issue
reported: "Save works for name/bio after Zod null fix, but avatar upload doesn't persist after save. Also needs: crop editor for avatar, larger profile pic with name/bio side-by-side layout, click-to-open modal with full-size image + Remove/Update buttons."
severity: major
### 5. Email display
expected: Account Info section shows your email address (from Logto) and a "Change" button next to it.
result: blocked
blocked_by: third-party
reason: "Logto Management API returns 500 — M2M env vars (LOGTO_MANAGEMENT_API_ENDPOINT, LOGTO_M2M_APP_ID, LOGTO_M2M_APP_SECRET) not configured on test env"
### 6. Password change form
expected: Security section shows a password change form. If you signed in with email+password: current password, new password, confirm new password fields. If social login only: just new password + confirm fields.
result: blocked
blocked_by: third-party
reason: "Logto Management API returns 500 — M2M env vars not configured on test env"
### 7. Delete account UI
expected: Danger Zone shows a red-bordered card with "Delete Account" button. Clicking it shows a confirmation dialog requiring you to type "DELETE" before proceeding.
result: blocked
blocked_by: third-party
reason: "Logto Management API returns 500 — M2M env vars not configured on test env"
### 8. Member-since date
expected: Account Info section shows a "Member since" date formatted nicely (e.g., "April 2026").
result: pass
## Summary
total: 8
passed: 4
issues: 1
pending: 0
skipped: 0
blocked: 3
## Gaps
- truth: "Avatar upload should persist after save. Profile pic should have crop editor, larger display with side-by-side layout, and click-to-open modal."
status: failed
reason: "User reported: avatar upload doesn't persist. Also needs crop editor, layout redesign (larger pic, name/bio beside it), click-to-open modal with Remove/Update."
severity: major
test: 4
artifacts:
- src/client/components/ProfileSection.tsx
missing:
- Avatar save persistence fix
- Crop editor integration for avatar
- Profile layout redesign (larger pic, side-by-side, modal)

View File

@@ -1,115 +0,0 @@
---
status: complete
phase: 29-image-presentation
source: [29-01-SUMMARY.md, 29-02-SUMMARY.md, 29-03-SUMMARY.md, 29-04-SUMMARY.md]
started: 2026-04-12T19:10:00Z
updated: 2026-04-12T19:20:00Z
---
## Current Test
[testing complete]
## Tests
### 1. Images use fit-within instead of crop
expected: Browse any page with item/catalog cards. Images should fit inside the frame without cropping — full image visible, no parts cut off.
result: issue
reported: "Fit-within works on detail pages but collection view cards don't render images at all."
severity: major
### 2. Dominant color background fill
expected: Where an image doesn't fill the entire frame, the empty space is filled with a color extracted from the image (not white or gray).
result: issue
reported: "Background is just plain gray. Even newly uploaded images don't get a dominant color — extraction not working or color not passed to frontend."
severity: major
### 3. Crop editor on item detail
expected: Open an item that has an image. You should see an "Adjust framing" button. Clicking it opens a crop editor with zoom slider.
result: issue
reported: "'Adjust framing' text doesn't feel like an action. Should be an icon button, only visible in edit mode, positioned below the X icon. X icon should be a trash icon to symbolize removal."
severity: minor
### 4. Crop editor on image upload
expected: Upload a new image to an item. After the upload completes, a crop editor should appear.
result: issue
reported: "Crop editor doesn't open when adding an image to an existing item. Can only be edited afterward."
severity: major
### 5. Crop settings persist
expected: Adjust the crop on an item image, save it. Navigate away and come back — image displays with saved crop settings.
result: issue
reported: "Framing adjustment doesn't save. No error message, no console log — silently fails."
severity: blocker
### 6. Consistency across surfaces
expected: All image surfaces use the same fit-within + dominant color treatment.
result: pass
reported: "Consistent across all surfaces where images render (detail pages). Collection cards don't render images (see test 1)."
## Summary
total: 6
passed: 2
issues: 4
pending: 0
skipped: 0
## Gaps
- truth: "Collection view cards should render images using GearImage component"
status: failed
reason: "User reported: images not rendering on collection view cards, only on detail pages"
severity: major
test: 1
artifacts:
- src/client/components/ItemCard.tsx
- src/client/components/GearImage.tsx
missing:
- GearImage integration in collection card view
- truth: "Dominant color should be extracted on upload and used as background fill"
status: failed
reason: "User reported: background is plain gray even for newly uploaded images — extraction not working or color not reaching frontend"
severity: major
test: 2
artifacts:
- src/server/services/image.service.ts
- src/client/components/GearImage.tsx
missing:
- Debug extractDominantColor pipeline
- Verify color is returned from upload API and stored in DB
- Verify frontend reads and applies dominantColor
- truth: "Crop editor should be an icon button visible only in edit mode, with trash icon for image removal"
status: failed
reason: "User reported: 'Adjust framing' text doesn't feel like an action. Should be icon, edit-mode only. X should be trash icon."
severity: minor
test: 3
artifacts:
- src/client/routes/items/$itemId.tsx
- src/client/components/ImageCropEditor.tsx
missing:
- Redesign crop trigger as icon button in edit mode
- Replace X with trash icon for image removal
- truth: "Crop editor should open automatically when uploading a new image"
status: failed
reason: "User reported: crop editor doesn't open when adding image to existing item"
severity: major
test: 4
artifacts:
- src/client/components/ImageUpload.tsx
missing:
- Trigger crop editor after upload in ImageUpload component
- truth: "Crop settings (zoom, x, y) should persist to DB and render on subsequent views"
status: failed
reason: "User reported: framing adjustment doesn't save, no error, no log — silent failure"
severity: blocker
test: 5
artifacts:
- src/client/routes/items/$itemId.tsx
- src/server/routes/items.ts
missing:
- Debug crop save pipeline (client mutation → API → DB)

View File

@@ -0,0 +1,15 @@
---
created: 2026-04-13T13:00:00.000Z
title: Auth prompt sign-in button should redirect directly to Logto
area: auth
files:
- src/client/components/AuthPromptModal.tsx
---
## Problem
When an unauthenticated user clicks a protected action, the AuthPromptModal pops up. Clicking "Sign In" navigates to an intermediate /signin page, where they have to click again to be redirected to Logto. That's two extra clicks — the modal + the signin page — before reaching the actual login.
## Solution
Make the "Sign In" / "Sign Up" buttons in the AuthPromptModal redirect directly to the Logto OIDC login endpoint (e.g., `/api/auth/login` or wherever the OIDC redirect is triggered). Skip the intermediate page entirely — one click from modal to Logto.

View File

@@ -0,0 +1,15 @@
---
created: 2026-04-13T11:39:30.356Z
title: Fix Add Candidate button shows wrong modal on thread page
area: ui
files:
- src/client/routes/threads/$threadId.tsx
---
## Problem
The "Add Candidate" button at the top of the thread detail page opens a manual-add modal (plain form fields) instead of the catalog search dialogue. The FAB (floating action button) in the bottom right of the same page correctly opens the catalog search dialog where you can browse and pick from global items. Both buttons should behave the same way — showing the catalog search dialog as the primary add flow.
## Solution
Wire the top "Add Candidate" button to open the same catalog search dialog/overlay that the FAB triggers. The manual-add form should still be reachable as a fallback (e.g., "Can't find it? Add manually") but not be the default.

1
scripts/fixing.sh Executable file
View File

@@ -0,0 +1 @@
set -e; echo "=== Step 1: Check columns ==="; bun -e 'import { db } from "./src/db/index.ts"; import { sql } from "drizzle-orm"; const r = await db.execute(sql`SELECT column_name FROM information_schema.columns WHERE table_name = '"'"'global_items'"'"' ORDER BY ordinal_position`); console.log(r.rows.map(x => x.column_name));'; echo "=== Step 2: Push schema ==="; bun run db:push; echo "=== Step 3: Nuke data ==="; bun -e 'import { db } from "./src/db/index.ts"; import { apiKeys, categories, globalItemTags, globalItems, items, oauthCodes, oauthTokens, settings, setupItems, setups, tags, threadCandidates, threads, users } from "./src/db/schema.ts"; await db.delete(setupItems); await db.delete(setups); await db.delete(threadCandidates); await db.delete(threads); await db.delete(items); await db.delete(settings); await db.delete(apiKeys); await db.delete(oauthTokens); await db.delete(oauthCodes); await db.delete(categories); await db.delete(users); await db.delete(globalItemTags); await db.delete(tags); await db.delete(globalItems); console.log("Nuke complete");'; echo "=== Step 4: Re-seed ==="; bun run db:seed:dev; echo "=== Step 5: Verify tags ==="; bun -e 'import { db } from "./src/db/index.ts"; import { tags, globalItemTags } from "./src/db/schema.ts"; import { inArray } from "drizzle-orm"; const hobbyNames = ["bikepacking","cycling","camping","hiking","backpacking","climbing","running"]; const found = await db.select().from(tags).where(inArray(tags.name, hobbyNames)); console.log("Hobby tags:", found.map(t => t.name)); const a = await db.select().from(globalItemTags); console.log("Tag assignments:", a.length);'; echo "=== Step 6: Test query ==="; bun -e 'import { getPopularItemsByTags } from "./src/server/services/discovery.service.ts"; const r = await getPopularItemsByTags(undefined, ["bikepacking","cycling","camping"], 5); console.log("Items found:", r.length); for (const i of r) console.log(" -", i.brand, i.model);'; echo "=== Done ==="

View File

@@ -231,6 +231,10 @@ export function CollectionView() {
imageUrl={item.imageUrl}
productUrl={item.productUrl}
brand={item.brand}
dominantColor={item.dominantColor}
cropZoom={item.cropZoom}
cropX={item.cropX}
cropY={item.cropY}
/>
))}
</div>
@@ -264,8 +268,13 @@ export function CollectionView() {
categoryName={categoryName}
categoryIcon={categoryIcon}
imageFilename={item.imageFilename}
imageUrl={item.imageUrl}
productUrl={item.productUrl}
brand={item.brand}
dominantColor={item.dominantColor}
cropZoom={item.cropZoom}
cropX={item.cropX}
cropY={item.cropY}
/>
))}
</div>

View File

@@ -12,7 +12,7 @@ interface GearImageProps {
export function GearImage({
src,
alt,
dominantColor: _dominantColor,
dominantColor,
cropZoom,
cropX,
cropY,
@@ -20,6 +20,9 @@ export function GearImage({
cover = false,
}: GearImageProps) {
const hasCrop = cropZoom != null && cropZoom > 1;
const bgStyle = dominantColor
? { backgroundColor: dominantColor }
: undefined;
if (cover) {
return (
@@ -33,24 +36,31 @@ export function GearImage({
if (hasCrop) {
return (
<img
src={src}
alt={alt}
className={`w-full h-full object-cover ${className}`}
style={{
transform: `scale(${cropZoom}) translate(${cropX ?? 0}%, ${cropY ?? 0}%)`,
transformOrigin: "center center",
}}
/>
<div className="w-full h-full overflow-hidden" style={bgStyle}>
<img
src={src}
alt={alt}
className={`w-full h-full object-cover ${className}`}
style={{
transform: `scale(${cropZoom}) translate(${cropX ?? 0}%, ${cropY ?? 0}%)`,
transformOrigin: "center center",
}}
/>
</div>
);
}
return (
<img
src={src}
alt={alt}
className={`w-full h-full object-contain ${className}`}
/>
<div
className="w-full h-full flex items-center justify-center"
style={bgStyle}
>
<img
src={src}
alt={alt}
className={`w-full h-full object-contain ${className}`}
/>
</div>
);
}

View File

@@ -7,7 +7,7 @@ interface ImageUploadProps {
value: string | null;
imageUrl?: string | null;
dominantColor?: string | null;
onChange: (filename: string | null) => void;
onChange: (filename: string | null, dominantColor?: string | null) => void;
onCropChange?: (crop: { zoom: number; x: number; y: number }) => void;
}
@@ -25,6 +25,11 @@ export function ImageUpload({
const [error, setError] = useState<string | null>(null);
const [localPreview, setLocalPreview] = useState<string | null>(null);
const [showCropEditor, setShowCropEditor] = useState(false);
const [localCrop, setLocalCrop] = useState<{
zoom: number;
x: number;
y: number;
} | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
async function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
@@ -49,8 +54,11 @@ export function ImageUpload({
setUploading(true);
try {
const result = await apiUpload<{ filename: string }>("/api/images", file);
onChange(result.filename);
const result = await apiUpload<{
filename: string;
dominantColor?: string | null;
}>("/api/images", file);
onChange(result.filename, result.dominantColor);
if (onCropChange) {
setShowCropEditor(true);
}
@@ -67,6 +75,7 @@ export function ImageUpload({
function handleRemove(e: React.MouseEvent) {
e.stopPropagation();
setLocalPreview(null);
setLocalCrop(null);
onChange(null);
}
@@ -83,6 +92,7 @@ export function ImageUpload({
imageUrl={displayUrl}
dominantColor={dominantColor}
onSave={(result) => {
setLocalCrop(result);
onCropChange(result);
setShowCropEditor(false);
}}
@@ -108,7 +118,33 @@ export function ImageUpload({
src={displayUrl}
alt="Item"
dominantColor={dominantColor}
cropZoom={localCrop?.zoom}
cropX={localCrop?.x}
cropY={localCrop?.y}
/>
{/* Crop button */}
{onCropChange && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setShowCropEditor(true);
}}
className="absolute top-2 right-11 w-7 h-7 flex items-center justify-center bg-white/80 hover:bg-white rounded-full text-gray-600 hover:text-gray-900 transition-colors shadow-sm"
title="Adjust framing"
>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
strokeWidth={2}
>
<path d="M6.13 1L6 16a2 2 0 0 0 2 2h15" />
<path d="M1 6.13L16 6a2 2 0 0 1 2 2v15" />
</svg>
</button>
)}
{/* Remove button */}
<button
type="button"
@@ -125,7 +161,7 @@ export function ImageUpload({
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
d="M3 6h18M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"
/>
</svg>
</button>

View File

@@ -11,8 +11,9 @@ export function ProfileSection() {
const [displayName, setDisplayName] = useState("");
const [bio, setBio] = useState("");
const [avatarUrl, setAvatarUrl] = useState<string | null>(null);
const [initialized, setInitialized] = useState(false);
const [avatarFilename, setAvatarFilename] = useState<string | null>(null);
const [avatarDisplayUrl, setAvatarDisplayUrl] = useState<string | null>(null);
const [dirty, setDirty] = useState(false);
const [message, setMessage] = useState<{
type: "success" | "error";
text: string;
@@ -21,13 +22,13 @@ export function ProfileSection() {
const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (profile && !initialized) {
if (profile && !dirty) {
setDisplayName(profile.displayName ?? "");
setBio(profile.bio ?? "");
setAvatarUrl(profile.avatarUrl ?? null);
setInitialized(true);
setAvatarFilename(profile.avatarUrl ?? null);
setAvatarDisplayUrl(profile.avatarImageUrl ?? null);
}
}, [profile, initialized]);
}, [profile, dirty]);
async function handleSave(e: React.FormEvent) {
e.preventDefault();
@@ -35,9 +36,10 @@ export function ProfileSection() {
try {
await updateProfile.mutateAsync({
displayName: displayName.trim() || undefined,
avatarUrl,
avatarUrl: avatarFilename,
bio: bio.trim() || undefined,
});
setDirty(false);
setMessage({ type: "success", text: "Profile updated" });
} catch (err) {
setMessage({ type: "error", text: (err as Error).message });
@@ -63,12 +65,16 @@ export function ProfileSection() {
return;
}
const localPreview = URL.createObjectURL(file);
setAvatarDisplayUrl(localPreview);
setUploading(true);
setMessage(null);
try {
const result = await apiUpload<{ filename: string }>("/api/images", file);
setAvatarUrl(result.filename);
setAvatarFilename(result.filename);
setDirty(true);
} catch {
setAvatarDisplayUrl(null);
setMessage({ type: "error", text: "Avatar upload failed." });
} finally {
setUploading(false);
@@ -91,9 +97,9 @@ export function ProfileSection() {
onClick={() => fileInputRef.current?.click()}
className="relative w-16 h-16 rounded-full overflow-hidden cursor-pointer group shrink-0"
>
{avatarUrl ? (
{avatarDisplayUrl ? (
<img
src={`/uploads/${avatarUrl}`}
src={avatarDisplayUrl}
alt="Avatar"
className="w-full h-full object-cover"
/>
@@ -144,10 +150,14 @@ export function ProfileSection() {
>
{uploading ? "Uploading..." : "Change avatar"}
</button>
{avatarUrl && (
{avatarFilename && (
<button
type="button"
onClick={() => setAvatarUrl(null)}
onClick={() => {
setAvatarFilename(null);
setAvatarDisplayUrl(null);
setDirty(true);
}}
className="block text-xs text-red-500 hover:text-red-700 mt-0.5"
>
Remove
@@ -175,7 +185,10 @@ export function ProfileSection() {
id="displayName"
type="text"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
onChange={(e) => {
setDisplayName(e.target.value);
setDirty(true);
}}
maxLength={100}
placeholder="Your display name"
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"
@@ -193,7 +206,10 @@ export function ProfileSection() {
<textarea
id="bio"
value={bio}
onChange={(e) => setBio(e.target.value)}
onChange={(e) => {
setBio(e.target.value);
setDirty(true);
}}
maxLength={500}
rows={3}
placeholder="Tell others about yourself and your gear interests"

View File

@@ -1,12 +1,17 @@
import { Link } from "@tanstack/react-router";
import { useEffect, useRef, useState } from "react";
import { useLogout } from "../hooks/useAuth";
import { useAuth, useLogout } from "../hooks/useAuth";
import { usePublicProfile } from "../hooks/useProfile";
import { LucideIcon } from "../lib/iconData";
export function UserMenu() {
const [open, setOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
const { logout } = useLogout();
const { data: auth } = useAuth();
const userId = auth?.user?.id ? Number(auth.user.id) : null;
const { data: profile } = usePublicProfile(userId);
const avatarUrl = profile?.avatarImageUrl;
useEffect(() => {
if (!open) return;
@@ -24,9 +29,17 @@ export function UserMenu() {
<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"
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 overflow-hidden"
>
<LucideIcon name="circle-user" size={22} />
{avatarUrl ? (
<img
src={avatarUrl}
alt="Profile"
className="w-8 h-8 rounded-full object-cover"
/>
) : (
<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">

View File

@@ -21,14 +21,26 @@ export function HobbyCard({
onClick={onClick}
className={`w-40 h-40 flex flex-col items-center justify-center gap-3 p-5 rounded-2xl cursor-pointer transition-all ${
selected
? "border-gray-700 ring-2 ring-gray-700/20 bg-white border"
? "bg-gray-800 border border-gray-800 ring-2 ring-gray-700/20"
: "bg-gray-50 border border-gray-200 hover:border-gray-300 hover:shadow-sm"
}`}
>
<LucideIcon name={icon} size={32} className="text-gray-700" />
<LucideIcon
name={icon}
size={32}
className={selected ? "text-white" : "text-gray-700"}
/>
<div className="text-center">
<div className="text-sm font-semibold text-gray-900">{name}</div>
<div className="text-xs text-gray-400">{descriptor}</div>
<div
className={`text-sm font-semibold ${selected ? "text-white" : "text-gray-900"}`}
>
{name}
</div>
<div
className={`text-xs ${selected ? "text-gray-300" : "text-gray-400"}`}
>
{descriptor}
</div>
</div>
</button>
);

View File

@@ -22,6 +22,28 @@ export function OnboardingItemBrowser({
const hasItems = items && items.length > 0;
// Group items by category, cap at 5 categories with 4 items each
const MAX_CATEGORIES = 5;
const MAX_PER_CATEGORY = 4;
const allGrouped = hasItems
? items.reduce<Record<string, typeof items>>((acc, item) => {
const cat = item.category || "other";
const label = cat.charAt(0).toUpperCase() + cat.slice(1);
if (!acc[label]) acc[label] = [];
acc[label].push(item);
return acc;
}, {})
: {};
// Take top categories by item count, limit items per category
const categories = Object.keys(allGrouped)
.sort((a, b) => allGrouped[b].length - allGrouped[a].length)
.slice(0, MAX_CATEGORIES);
const grouped = Object.fromEntries(
categories.map((cat) => [cat, allGrouped[cat].slice(0, MAX_PER_CATEGORY)]),
);
return (
<div className="flex flex-col items-center min-h-screen px-8 py-16">
<div className="max-w-5xl w-full text-center">
@@ -51,23 +73,30 @@ export function OnboardingItemBrowser({
</div>
)}
{!isLoading && hasItems && (
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4 mb-8">
{items.map((item) => (
<SelectableItemCard
key={item.id}
brand={item.brand}
model={item.model}
imageUrl={item.imageUrl}
weightGrams={item.weightGrams}
priceCents={item.priceCents}
ownerCount={item.ownerCount}
selected={selectedItemIds.has(item.id)}
onClick={() => onToggleItem(item.id)}
/>
))}
</div>
)}
{!isLoading &&
hasItems &&
categories.map((cat) => (
<div key={cat} className="mb-8">
<h2 className="text-lg font-semibold text-gray-900 text-left mb-3">
{cat}
</h2>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4">
{grouped[cat].map((item) => (
<SelectableItemCard
key={item.id}
brand={item.brand}
model={item.model}
imageUrl={item.imageUrl}
weightGrams={item.weightGrams}
priceCents={item.priceCents}
ownerCount={item.ownerCount}
selected={selectedItemIds.has(item.id)}
onClick={() => onToggleItem(item.id)}
/>
))}
</div>
</div>
))}
<div className="flex items-center justify-center gap-4">
{hasItems && selectedItemIds.size > 0 && (

View File

@@ -22,7 +22,7 @@ export function SelectableItemCard({
selected,
onClick,
}: SelectableItemCardProps) {
const { formatWeight, formatPrice } = useFormatters();
const { weight: formatWeight, price: formatPrice } = useFormatters();
return (
<button

View File

@@ -20,9 +20,13 @@ export function useChangeEmail() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: { newEmail: string }) =>
apiPost<{ ok: boolean }>("/api/account/email", data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["auth"] });
apiPost<{ ok: boolean; email: string }>("/api/account/email", data),
onSuccess: (_data, variables) => {
// Update auth cache with new email since the OIDC session still has the old one
queryClient.setQueryData(["auth"], (old: any) => {
if (!old?.user) return old;
return { ...old, user: { ...old.user, email: variables.newEmail } };
});
},
});
}

View File

@@ -5,6 +5,7 @@ interface PublicProfile {
id: number;
displayName: string | null;
avatarUrl: string | null;
avatarImageUrl: string | null;
bio: string | null;
setups: { id: number; name: string; createdAt: string }[];
}

View File

@@ -273,9 +273,24 @@ function ItemDetail() {
<ImageUpload
value={form.imageFilename}
imageUrl={imageUrl}
onChange={(filename) =>
setForm((f) => ({ ...f, imageFilename: filename }))
}
dominantColor={item.dominantColor}
onChange={(filename, dominantColor) => {
setForm((f) => ({ ...f, imageFilename: filename }));
if (dominantColor) {
updateItem.mutate({
id: item.id,
dominantColor,
});
}
}}
onCropChange={(crop) => {
updateItem.mutate({
id: item.id,
cropZoom: crop.zoom,
cropX: crop.x,
cropY: crop.y,
});
}}
/>
</div>
) : editingCrop && imageUrl ? (
@@ -300,44 +315,33 @@ function ItemDetail() {
/>
</div>
) : (
<>
<div
className="aspect-[4/3] rounded-xl overflow-hidden mb-2"
style={{
backgroundColor: imageUrl
? imageContainerBg(item.dominantColor)
: undefined,
}}
>
{imageUrl ? (
<GearImage
src={imageUrl}
alt={item.name}
dominantColor={item.dominantColor}
cropZoom={item.cropZoom}
cropX={item.cropX}
cropY={item.cropY}
<div
className="aspect-[4/3] rounded-xl overflow-hidden mb-2"
style={{
backgroundColor: imageUrl
? imageContainerBg(item.dominantColor)
: undefined,
}}
>
{imageUrl ? (
<GearImage
src={imageUrl}
alt={item.name}
dominantColor={item.dominantColor}
cropZoom={item.cropZoom}
cropX={item.cropX}
cropY={item.cropY}
/>
) : (
<div className="w-full h-full bg-gray-50 flex flex-col items-center justify-center">
<LucideIcon
name={item.categoryIcon}
size={64}
className="text-gray-300"
/>
) : (
<div className="w-full h-full bg-gray-50 flex flex-col items-center justify-center">
<LucideIcon
name={item.categoryIcon}
size={64}
className="text-gray-300"
/>
</div>
)}
</div>
{imageUrl && !isEditing && (
<button
type="button"
onClick={() => setEditingCrop(true)}
className="mb-4 text-sm text-gray-500 hover:text-gray-700 transition-colors"
>
Adjust framing
</button>
</div>
)}
</>
</div>
)}
{/* Header / Name */}

View File

@@ -52,9 +52,9 @@ function PublicProfilePage() {
<div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-10">
{/* Profile header */}
<div className="flex items-center gap-5 mb-8">
{profile.avatarUrl ? (
{profile.avatarImageUrl ? (
<img
src={`/uploads/${profile.avatarUrl}`}
src={profile.avatarImageUrl}
alt={displayName}
className="w-20 h-20 rounded-full object-cover"
/>

View File

@@ -369,36 +369,181 @@ export const DEV_GLOBAL_ITEMS = [
// Maps global item index -> tag names. Tags are seeded by seedGlobalItems().
export const DEV_TAG_ASSIGNMENTS = [
{ globalItemIndex: 0, tagNames: ["saddlebag", "bike-bag"] },
{ globalItemIndex: 1, tagNames: ["handlebar-bag", "bike-bag"] },
{ globalItemIndex: 2, tagNames: ["framebag", "bike-bag"] },
{ globalItemIndex: 3, tagNames: ["handlebar-bag", "bike-bag"] },
{ globalItemIndex: 4, tagNames: ["framebag", "bike-bag"] },
{ globalItemIndex: 5, tagNames: ["top-tube-bag", "bike-bag"] },
{ globalItemIndex: 6, tagNames: ["tent"] },
{ globalItemIndex: 7, tagNames: ["tent"] },
{ globalItemIndex: 8, tagNames: ["tent"] },
{ globalItemIndex: 9, tagNames: ["tent"] },
{ globalItemIndex: 10, tagNames: ["quilt"] },
{ globalItemIndex: 11, tagNames: ["sleeping-pad"] },
{ globalItemIndex: 12, tagNames: ["sleeping-pad"] },
{ globalItemIndex: 13, tagNames: ["pillow"] },
{ globalItemIndex: 14, tagNames: ["sleeping-bag"] },
{ globalItemIndex: 15, tagNames: ["stove"] },
{ globalItemIndex: 16, tagNames: ["stove"] },
{ globalItemIndex: 17, tagNames: ["cookware", "mug"] },
{ globalItemIndex: 18, tagNames: ["cookware"] },
{ globalItemIndex: 19, tagNames: ["stove"] },
{ globalItemIndex: 20, tagNames: ["headlamp"] },
{ globalItemIndex: 21, tagNames: ["bike-light"] },
{ globalItemIndex: 22, tagNames: ["headlamp"] },
{ globalItemIndex: 29, tagNames: ["water-filter"] },
{ globalItemIndex: 30, tagNames: ["water-filter"] },
{ globalItemIndex: 31, tagNames: ["water-bottle"] },
{ globalItemIndex: 32, tagNames: ["multi-tool", "repair-kit"] },
{ globalItemIndex: 33, tagNames: ["rain-jacket"] },
{ globalItemIndex: 34, tagNames: ["bike-computer", "gps"] },
{ globalItemIndex: 35, tagNames: ["handlebar-bag", "bike-bag", "dry-bag"] },
// Bags — bikepacking/cycling gear
{
globalItemIndex: 0,
tagNames: ["saddlebag", "bike-bag", "bikepacking", "cycling"],
},
{
globalItemIndex: 1,
tagNames: ["handlebar-bag", "bike-bag", "bikepacking", "cycling"],
},
{
globalItemIndex: 2,
tagNames: ["framebag", "bike-bag", "bikepacking", "cycling"],
},
{
globalItemIndex: 3,
tagNames: ["handlebar-bag", "bike-bag", "bikepacking", "cycling"],
},
{
globalItemIndex: 4,
tagNames: ["framebag", "bike-bag", "bikepacking", "cycling"],
},
{
globalItemIndex: 5,
tagNames: ["top-tube-bag", "bike-bag", "bikepacking", "cycling"],
},
// Shelter — camping/hiking/bikepacking
{
globalItemIndex: 6,
tagNames: ["tent", "camping", "hiking", "bikepacking", "backpacking"],
},
{
globalItemIndex: 7,
tagNames: ["tent", "camping", "hiking", "bikepacking", "backpacking"],
},
{
globalItemIndex: 8,
tagNames: ["tent", "camping", "hiking", "backpacking"],
},
{
globalItemIndex: 9,
tagNames: [
"tent",
"camping",
"hiking",
"backpacking",
"climbing",
"mountaineering",
],
},
// Sleep — camping/hiking/bikepacking
{
globalItemIndex: 10,
tagNames: ["quilt", "camping", "hiking", "bikepacking", "backpacking"],
},
{
globalItemIndex: 11,
tagNames: [
"sleeping-pad",
"camping",
"hiking",
"bikepacking",
"backpacking",
],
},
{
globalItemIndex: 12,
tagNames: ["sleeping-pad", "camping", "hiking", "backpacking"],
},
{
globalItemIndex: 13,
tagNames: ["pillow", "camping", "hiking", "bikepacking", "backpacking"],
},
{
globalItemIndex: 14,
tagNames: ["sleeping-bag", "camping", "hiking", "backpacking", "climbing"],
},
// Cooking — camping/hiking/bikepacking
{
globalItemIndex: 15,
tagNames: ["stove", "camping", "hiking", "bikepacking", "backpacking"],
},
{
globalItemIndex: 16,
tagNames: ["stove", "camping", "hiking", "backpacking"],
},
{
globalItemIndex: 17,
tagNames: ["cookware", "mug", "camping", "hiking", "bikepacking"],
},
{
globalItemIndex: 18,
tagNames: ["cookware", "camping", "hiking", "backpacking"],
},
{
globalItemIndex: 19,
tagNames: ["stove", "camping", "hiking", "backpacking", "climbing"],
},
// Lighting — general outdoor
{
globalItemIndex: 20,
tagNames: [
"headlamp",
"camping",
"hiking",
"climbing",
"backpacking",
"running",
"trail-running",
],
},
{
globalItemIndex: 21,
tagNames: [
"bike-light",
"bikepacking",
"cycling",
"road-cycling",
"gravel",
],
},
{
globalItemIndex: 22,
tagNames: ["headlamp", "camping", "hiking", "climbing", "backpacking"],
},
// Water — hiking/camping/bikepacking
{
globalItemIndex: 29,
tagNames: [
"water-filter",
"hiking",
"camping",
"bikepacking",
"backpacking",
],
},
{
globalItemIndex: 30,
tagNames: ["water-filter", "hiking", "camping", "backpacking"],
},
{
globalItemIndex: 31,
tagNames: ["water-bottle", "hiking", "camping", "cycling", "running"],
},
// Tools — bikepacking/cycling
{
globalItemIndex: 32,
tagNames: ["multi-tool", "repair-kit", "bikepacking", "cycling"],
},
// Clothing — general outdoor
{
globalItemIndex: 33,
tagNames: [
"rain-jacket",
"hiking",
"camping",
"bikepacking",
"climbing",
"running",
],
},
// Electronics — bikepacking/cycling
{
globalItemIndex: 34,
tagNames: ["bike-computer", "gps", "bikepacking", "cycling", "gravel"],
},
{
globalItemIndex: 35,
tagNames: [
"handlebar-bag",
"bike-bag",
"dry-bag",
"bikepacking",
"cycling",
],
},
] as const;
// ── Category name mapping (for FK lookups by category name) ────────

View File

@@ -5,6 +5,18 @@ import { globalItems, tags } from "./schema.ts";
type Db = typeof prodDb;
const SEED_TAGS = [
// Hobby / activity tags (used by onboarding hobby picker)
"bikepacking",
"cycling",
"hiking",
"backpacking",
"camping",
"climbing",
"mountaineering",
"road-cycling",
"gravel",
"running",
"trail-running",
// Bag types
"handlebar-bag",
"framebag",
@@ -58,14 +70,16 @@ const SEED_TAGS = [
/**
* Seed curated tags for outdoor/adventure gear.
* Idempotent: skips if any tags already exist.
* Idempotent: inserts only tags that don't already exist.
*/
export async function seedTags(db: Db = prodDb) {
const existing = await db.select().from(tags).limit(1);
if (existing.length > 0) return;
const existing = await db.select().from(tags);
const existingNames = new Set(existing.map((t) => t.name));
for (const name of SEED_TAGS) {
await db.insert(tags).values({ name });
if (!existingNames.has(name)) {
await db.insert(tags).values({ name });
}
}
}

View File

@@ -70,7 +70,7 @@ app.post("/email", zValidator("json", changeEmailSchema), async (c) => {
const logtoSub = await getLogtoSub(db, userId);
await logtoClient.updateEmail(logtoSub, newEmail);
return c.json({ ok: true });
return c.json({ ok: true, email: newEmail });
});
// ── Has Password ────────────────────────────────────────────────────

View File

@@ -14,6 +14,7 @@ import {
listApiKeys,
} from "../services/auth.service.ts";
import { updateProfile } from "../services/profile.service.ts";
import { getImageUrl } from "../services/storage.service.ts";
type Env = { Variables: { db?: any; userId?: number } };
@@ -97,7 +98,12 @@ app.put(
const data = c.req.valid("json");
const updated = await updateProfile(db, userId, data);
if (!updated) return c.json({ error: "User not found" }, 404);
return c.json(updated);
return c.json({
...updated,
avatarImageUrl: updated.avatarUrl
? await getImageUrl(updated.avatarUrl)
: null,
});
},
);

View File

@@ -5,7 +5,6 @@ import {
getRecentGlobalItems,
getTrendingCategories,
} from "../services/discovery.service.ts";
import { withImageUrls } from "../services/storage.service.ts";
type Env = { Variables: { db?: any } };
@@ -52,8 +51,7 @@ app.get("/popular-items", async (c) => {
}
const results = await getPopularItemsByTags(db, tagNames, limit);
const enriched = await withImageUrls(results);
return c.json({ items: enriched });
return c.json({ items: results });
});
export { app as discoveryRoutes };

View File

@@ -1,9 +1,21 @@
import { Hono } from "hono";
import { parseId } from "../lib/params.ts";
import { getPublicProfile } from "../services/profile.service.ts";
import { getImageUrl } from "../services/storage.service.ts";
type Env = { Variables: { db?: any; userId?: number } };
async function enrichAvatarUrl<T extends { avatarUrl: string | null }>(
record: T,
): Promise<T & { avatarImageUrl: string | null }> {
return {
...record,
avatarImageUrl: record.avatarUrl
? await getImageUrl(record.avatarUrl)
: null,
};
}
const app = new Hono<Env>();
// GET /:id/profile — Public profile (no auth required)
@@ -15,7 +27,7 @@ app.get("/:id/profile", async (c) => {
const profile = await getPublicProfile(db, id);
if (!profile) return c.json({ error: "User not found" }, 404);
return c.json(profile);
return c.json(await enrichAvatarUrl(profile));
});
export { app as profileRoutes };

View File

@@ -150,7 +150,7 @@ export async function getPopularItemsByTags(
category: string | null;
weightGrams: number | null;
priceCents: number | null;
imageFilename: string | null;
imageUrl: string | null;
description: string | null;
ownerCount: number;
}>
@@ -165,7 +165,7 @@ export async function getPopularItemsByTags(
category: globalItems.category,
weightGrams: globalItems.weightGrams,
priceCents: globalItems.priceCents,
imageFilename: globalItems.imageFilename,
imageUrl: globalItems.imageUrl,
description: globalItems.description,
ownerCount: sql<number>`CAST(COUNT(DISTINCT ${items.id}) AS INT)`,
})

View File

@@ -38,6 +38,10 @@ export async function getAllItems(db: Db, userId: number) {
brand: sql<
string | null
>`COALESCE(${globalItems.brand}, ${items.brand})`.as("brand"),
dominantColor: items.dominantColor,
cropZoom: items.cropZoom,
cropX: items.cropX,
cropY: items.cropY,
createdAt: items.createdAt,
updatedAt: items.updatedAt,
categoryName: categories.name,
@@ -82,6 +86,10 @@ export async function getItemById(db: Db, userId: number, id: number) {
brand: sql<
string | null
>`COALESCE(${globalItems.brand}, ${items.brand})`.as("brand"),
dominantColor: items.dominantColor,
cropZoom: items.cropZoom,
cropX: items.cropX,
cropY: items.cropY,
createdAt: items.createdAt,
updatedAt: items.updatedAt,
categoryName: categories.name,
@@ -154,6 +162,10 @@ export async function updateItem(
globalItemId: number;
purchasePriceCents: number;
brand: string;
dominantColor: string | null;
cropZoom: number | null;
cropX: number | null;
cropY: number | null;
}>,
) {
// Check if item exists and belongs to user

View File

@@ -91,7 +91,17 @@ export class LogtoManagementClient {
});
if (!res.ok) {
throw new Error(`Logto M2M token request failed: HTTP ${res.status}`);
const errorBody = await res.text();
console.error(
`[Logto M2M] Token request failed: HTTP ${res.status}`,
`\n URL: ${tokenUrl}`,
`\n Resource: ${config.apiResource}`,
`\n App ID: ${config.m2mAppId.slice(0, 8)}...`,
`\n Response: ${errorBody}`,
);
throw new Error(
`Logto M2M token request failed: HTTP ${res.status}${errorBody}`,
);
}
const data = (await res.json()) as {

View File

@@ -103,7 +103,8 @@ export async function completeOnboarding(
userId,
weightGrams: gi.weightGrams,
priceCents: gi.priceCents,
imageFilename: gi.imageFilename,
// globalItem images are external URLs, not S3 filenames — skip copying
// The item inherits its image via globalItemId linkage
globalItemId: gi.id,
});
itemsCreated++;