Compare commits
5 Commits
1f2e8e18c4
...
Develop
| Author | SHA1 | Date | |
|---|---|---|---|
| d9ec330aca | |||
| 7890de141e | |||
| b41aa9301e | |||
| 076616cd1b | |||
| 0202d0bb5c |
@@ -1,17 +1,9 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
bump:
|
||||
description: "Version bump type"
|
||||
required: true
|
||||
default: "patch"
|
||||
type: choice
|
||||
options:
|
||||
- patch
|
||||
- minor
|
||||
- major
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
ci:
|
||||
@@ -45,25 +37,17 @@ jobs:
|
||||
cd repo
|
||||
git checkout ${{ gitea.ref_name }}
|
||||
|
||||
- name: Compute version
|
||||
- name: Resolve version from tag
|
||||
working-directory: repo
|
||||
run: |
|
||||
LATEST_TAG=$(git tag -l 'v*' --sort=-v:refname | head -n1)
|
||||
if [ -z "$LATEST_TAG" ]; then
|
||||
LATEST_TAG="v0.0.0"
|
||||
VERSION="${{ gitea.ref_name }}"
|
||||
PREV_TAG=$(git tag -l 'v*' --sort=-v:refname | grep -vxF "$VERSION" | head -n1)
|
||||
if [ -z "$PREV_TAG" ]; then
|
||||
PREV_TAG="v0.0.0"
|
||||
fi
|
||||
MAJOR=$(echo "$LATEST_TAG" | sed 's/v//' | cut -d. -f1)
|
||||
MINOR=$(echo "$LATEST_TAG" | sed 's/v//' | cut -d. -f2)
|
||||
PATCH=$(echo "$LATEST_TAG" | sed 's/v//' | cut -d. -f3)
|
||||
case "${{ gitea.event.inputs.bump }}" in
|
||||
major) MAJOR=$((MAJOR+1)); MINOR=0; PATCH=0 ;;
|
||||
minor) MINOR=$((MINOR+1)); PATCH=0 ;;
|
||||
patch) PATCH=$((PATCH+1)) ;;
|
||||
esac
|
||||
NEW_VERSION="v${MAJOR}.${MINOR}.${PATCH}"
|
||||
echo "VERSION=$NEW_VERSION" >> "$GITHUB_ENV"
|
||||
echo "PREV_TAG=$LATEST_TAG" >> "$GITHUB_ENV"
|
||||
echo "New version: $NEW_VERSION"
|
||||
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
|
||||
echo "PREV_TAG=$PREV_TAG" >> "$GITHUB_ENV"
|
||||
echo "Releasing $VERSION (previous: $PREV_TAG)"
|
||||
|
||||
- name: Generate changelog
|
||||
working-directory: repo
|
||||
@@ -77,14 +61,6 @@ jobs:
|
||||
echo "$CHANGELOG" >> "$GITHUB_ENV"
|
||||
echo "CHANGELOG_EOF" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Create and push tag
|
||||
working-directory: repo
|
||||
run: |
|
||||
git config user.name "Gitea Actions"
|
||||
git config user.email "actions@gitea.jeanlucmakiola.de"
|
||||
git tag -a "$VERSION" -m "Release $VERSION"
|
||||
git push origin "$VERSION"
|
||||
|
||||
- name: Build and push Docker image
|
||||
working-directory: repo
|
||||
run: |
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -233,6 +233,9 @@ e2e/pgdata
|
||||
test-results/
|
||||
playwright-report/
|
||||
|
||||
# JetBrains IDEs (full directory)
|
||||
.idea/
|
||||
|
||||
# Obsidian
|
||||
.obsidian/
|
||||
|
||||
|
||||
12
CLAUDE.md
12
CLAUDE.md
@@ -64,16 +64,12 @@ bun run build # Vite build → dist/client/
|
||||
|
||||
## Releasing
|
||||
|
||||
Releases are managed by a Gitea Actions workflow (`.gitea/workflows/release.yml`). **Never create tags or releases manually** — always trigger the pipeline.
|
||||
Releases are tag-driven. The Gitea Actions workflow (`.gitea/workflows/release.yml`) triggers on any pushed tag matching `v*` — it runs CI (lint, test, build), generates a changelog from the previous tag, builds and pushes a Docker image, and creates a Gitea release. The version comes from the tag name.
|
||||
|
||||
The workflow runs CI (lint, test, build), computes the next version from the latest tag, generates a changelog, creates the tag, builds and pushes a Docker image, and creates a Gitea release.
|
||||
|
||||
Trigger via Gitea API:
|
||||
To release, tag the desired commit on `Develop` and push:
|
||||
```bash
|
||||
curl -s -X POST "https://gitea.jeanlucmakiola.de/api/v1/repos/makiolaj/GearBox/actions/workflows/release.yml/dispatches" \
|
||||
-H "Authorization: token <GITEA_TOKEN>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"ref": "Develop", "inputs": {"bump": "patch"}}' # patch | minor | major
|
||||
git tag v2.3.0
|
||||
git push origin v2.3.0
|
||||
```
|
||||
|
||||
## Branching
|
||||
|
||||
115
docs/BACKLOG.md
Normal file
115
docs/BACKLOG.md
Normal file
@@ -0,0 +1,115 @@
|
||||
# GearBox — Backlog
|
||||
|
||||
*Migrated from `.planning/` on 2026-04-23. Forward-looking only — see `STATE.md` for what's shipped.*
|
||||
|
||||
Staging area for work not yet brainstormed into a superpowers plan. To start on anything here, run `superpowers:brainstorming` → `superpowers:writing-plans` → `superpowers:subagent-driven-development` (or `executing-plans`).
|
||||
|
||||
---
|
||||
|
||||
## v2.4 tail — finish line
|
||||
|
||||
- [ ] **Verify Phase 38 Admin Tag Management** — all 2 plans marked complete in roadmap, STATE.md says 97%. Run the phase verification and close out v2.4.
|
||||
- [ ] **Manufacturer picker UI** — backend entity is shipped (`manufacturers` table, service, route, FK on `globalItems`, seeding), but the user-facing picker component (analogous to `CategoryPicker`) is not yet built. The manual add form still takes freeform brand text in places. See also the pending todo below.
|
||||
|
||||
---
|
||||
|
||||
## Pending todos
|
||||
|
||||
Details preserved in `docs/backlog/todos/`. Only truly-open items listed here — the older v2.3 todos (wrong modal, missing images, slow loading, auth redirect, cursor pointer) all shipped in Phase 35.
|
||||
|
||||
- [ ] **Make tag selector in global search searchable** (2026-04-20, ui) — the tag selector inside `CatalogSearchOverlay.tsx` should support typing-to-filter. Currently scrollable only.
|
||||
- [ ] **Add manufacturer picker UI** (from 2026-04-10 "add-manufacturer-entity" todo) — backend shipped; picker component + wiring into the manual add form and catalog enrichment is the remaining slice.
|
||||
|
||||
**Dropped** (already resolved but stale in `.planning/todos/pending/`):
|
||||
- `add-cursor-pointer-to-all-clickable-links` → shipped in Phase 35 (FIX-05)
|
||||
- `fix-item-image-not-showing-on-collection-overview` → shipped in Phase 35 (FIX-02)
|
||||
- `fix-storage-service-tests` → resolved; `withImageUrl`/`withImageUrls` both exported and test imports match
|
||||
- `investigate-slow-image-loading` → shipped in Phase 35 (FIX-03, lazy loading + skeletons)
|
||||
- `auth-prompt-sign-in-button-should-redirect-directly-to-logto` → shipped in Phase 35 (FIX-04)
|
||||
- `fix-add-candidate-button-shows-wrong-modal-on-thread-page` → shipped in Phase 35 (FIX-01)
|
||||
|
||||
---
|
||||
|
||||
## Backlog phases (pre-brainstorm)
|
||||
|
||||
These are product intents, not plans. Each needs brainstorming before becoming a superpowers plan. Sourced from `.planning/ROADMAP.md` Backlog section. *Previously-listed entries 999.2, 999.3, 999.4, 999.6 were promoted to shipped phases and are omitted here.*
|
||||
|
||||
### 999.1 — Rewrite E2E Tests for OIDC Auth
|
||||
E2E tests expect local username/password login, but auth moved to Logto. Rewrite with a mock OIDC provider or API-key bypass. Postgres seed migration already done.
|
||||
|
||||
### 999.5 — Legal Pages: ToS, Privacy Policy, Compliance
|
||||
Terms of Service, Privacy Policy, and any required compliance pages. Essential before opening to real users.
|
||||
|
||||
### 999.7 — User Feedback System
|
||||
In-app feedback collection — bug reports, feature requests, general feedback. Simple form, widget, or external integration.
|
||||
|
||||
### 999.8 — Analytics Integration
|
||||
Privacy-respecting analytics (PostHog, Umami, or similar). Usage patterns, popular categories, search behavior, feature adoption. Self-hosted preferred to align with independent ethos.
|
||||
|
||||
### 999.9 — Mobile App
|
||||
PWA first (offline, home-screen install), then evaluate native (React Native / Flutter) for richer experience — camera for weight verification, barcode scanning, etc.
|
||||
|
||||
### 999.10 — Monetization Strategy
|
||||
How GearBox sustains itself. Options: sponsored/promoted items, premium features, affiliate links. Critical tension: revenue vs. credibility — GearBox's value is *unbiased* gear data. Needs deep discussion before implementation.
|
||||
|
||||
### 999.11 — Marketing Website
|
||||
Standalone marketing site (`www.gearbox.de`) separate from the app (`app.gearbox.de`). Hero, value prop, feature highlights, how-it-works, social proof, sign-up CTA. The public-facing front door.
|
||||
|
||||
### 999.12 — Admin UX Polish (new, 2026-04-20)
|
||||
Overhaul admin panel UX: TanStack Table (sortable/groupable columns), cmdk for GitLab-style composable filter bar (field→operator→value token input), hide FAB on `/admin/*`, replace tag inline form with popup modal, show tags expanded on item rows (collapse to +N when tight), group items by brand, prominent search bar on both admin list pages.
|
||||
|
||||
---
|
||||
|
||||
## Near-future scope (v2.5 candidates)
|
||||
|
||||
Explicitly planned for the next milestone but not yet a phase:
|
||||
|
||||
- **Tag-based spec schemas on global items** — key/value typed specs per category, sub-tag hierarchy. Enables proper structured product data per gear class.
|
||||
- **Global item engagement stats** — view count, likes/saves, setup appearances. Feeds future sorting/ranking.
|
||||
- **ComparisonTable currency normalization** — hooks are in place; needs real multi-currency test data to prove out.
|
||||
|
||||
---
|
||||
|
||||
## Deferred requirements (tracked, not scheduled)
|
||||
|
||||
Not in any current or v2.5 phase. Promote to a backlog phase when timing is right.
|
||||
|
||||
### Personalization
|
||||
- **PERS-01** — Logged-in feed tuned to the user's collection categories
|
||||
- **PERS-02** — Feed algorithm recommends by hobby interests
|
||||
|
||||
### Reviews & Content
|
||||
- **REVW-01/02/03** — Structured reviews on catalog items; surface in discovery feed; curated/linked external reviews
|
||||
- **REV-01/02/03** — Overall star rating, dimension ratings (durability, value), average ratings on item detail
|
||||
- **MOD-01/02/03** — Freeform text reviews, report inappropriate content, admin review + action *(gated on moderation infra)*
|
||||
|
||||
### SEO
|
||||
- **SEO-01** — Catalog pages crawlable by bots
|
||||
- **SEO-02** — Meta tags + structured data
|
||||
|
||||
### Catalog Seeding
|
||||
- **SEED-04** — Initial seeding run populates 100+ items across key categories via agent swarm *(crawl scripts exist — this would be the production run)*
|
||||
|
||||
### Aggregation
|
||||
- **AGG-01** — Crowd-verified specs on item detail (manufacturer vs community-measured weight)
|
||||
- **AGG-02** — "Which setups include this item"
|
||||
- **AGG-03** — "Commonly paired with" setup composition insights
|
||||
|
||||
### Social
|
||||
- **SOCL-01** — Fork/copy a public setup as template
|
||||
- **SOCL-02** — Thread candidates auto-populate from global items
|
||||
- **SOCL-03** — Follow other users
|
||||
- **SOCL-04** — Activity feed of followed users' content
|
||||
|
||||
---
|
||||
|
||||
## Workflow
|
||||
|
||||
**To start work on something here:**
|
||||
|
||||
1. Pick an item above.
|
||||
2. Run `superpowers:brainstorming` to turn the intent into a spec.
|
||||
3. For non-trivial work, create a feature branch off `Develop` (e.g., `feature/manufacturer-picker`, `fix/tag-selector-search`).
|
||||
4. Run `superpowers:writing-plans` to produce `docs/superpowers/plans/YYYY-MM-DD-<name>.md`.
|
||||
5. Execute with `superpowers:subagent-driven-development` (recommended) or `superpowers:executing-plans`.
|
||||
6. Remove the item from this doc when shipped.
|
||||
95
docs/STATE.md
Normal file
95
docs/STATE.md
Normal file
@@ -0,0 +1,95 @@
|
||||
# GearBox — Project State
|
||||
|
||||
*Snapshot: 2026-04-23. Migrated from `.planning/` (PROJECT.md, ROADMAP.md, STATE.md, REQUIREMENTS.md).*
|
||||
|
||||
## What GearBox Is
|
||||
|
||||
A gear management and discovery platform. Users catalog their gear (bikepacking, sim racing, any hobby), track weight, price, and source details, research purchases via planning threads with side-by-side comparison, and compose named setups (loadouts) with weight classification. A global item database with crowd-verified specs, market-aware pricing, and manufacturer attribution helps users make informed purchase decisions.
|
||||
|
||||
Multi-user, public-read, authenticated-write. Granular setup sharing (private / link / public), multi-currency (USD/EUR/GBP) with ECB rates, i18n (English + German).
|
||||
|
||||
**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.
|
||||
|
||||
## Where We Are
|
||||
|
||||
- **Current milestone:** v2.4 Admin Foundation — executing (Phases 35–38 all complete, pending final verification)
|
||||
- **Latest activity:** 2026-04-20 — quick task `260420-vk0` (image fetch-from-URL, crop, tag routing, tag-form UX fixes)
|
||||
- **Position in GSD tracker:** "stopped at Completed 38-02-PLAN.md — admin tag management client UI"
|
||||
- **Legacy planning data:** `.planning/` remains the live GSD workspace. This doc and `BACKLOG.md` are the superpowers-friendly summary.
|
||||
|
||||
## Shipped (v1.0 → v2.4)
|
||||
|
||||
**v1.0 MVP** — Collection CRUD, images, categories with totals, planning threads, thread resolution, named setups, dashboard, onboarding.
|
||||
|
||||
**v1.1 Fixes & Polish** — Thread creation modal, planning tab, image display polish, Lucide icon picker (119 icons), emoji→icon migration.
|
||||
|
||||
**v1.2 Collection Power-Ups** — Search + category filter, weight units (g/oz/lb/kg), per-setup classification (base/worn/consumable), donut chart, candidate status tracking.
|
||||
|
||||
**v1.3 Research & Decision Tools** — Pros/cons annotation, candidate ranking with drag-reorder, side-by-side comparison with deltas, setup impact preview.
|
||||
|
||||
**v2.0 Platform Foundation** — Postgres migration with PGlite test infra, Logto OIDC auth, multi-user isolation, MinIO/S3 storage, global item catalog, user profiles + public setup sharing, reference-item COALESCE merge, tag system, FAB + catalog search overlay, item/catalog detail pages, add-from-catalog flow.
|
||||
|
||||
**v2.1 Public Discovery** — Public read with rate limiting, catalog attribution + unique (brand, model), bulk import with upsert, MCP catalog tools (`upsert_catalog_item`, `bulk_upsert_catalog`), discovery landing page, top-nav + mobile bottom tab bar, Setups elevated to top-level route.
|
||||
|
||||
**v2.2 User Experience Polish** — Profile page with Logto-backed account management (name/bio/avatar/email/password/delete), fit-within image framing with dominant-color background and crop editor, catalog-driven onboarding (hobby picker → category-grouped item browser → batch create), mobile icon-based action buttons on detail pages.
|
||||
|
||||
**v2.3 Global & Social Ready** — Setup visibility (private/link/public) with shares table and 128-bit tokens, ShareModal (Google-Docs-style UX), `/s/:token` shared-setup viewer, multi-currency (market_prices + community_prices, ECB rates cached 24h), ownership-validated community price aggregation (median, 3-report minimum), react-i18next foundation with 7 namespaces and German translations, language picker.
|
||||
|
||||
**v2.4 Admin Foundation** (in verification) — Phase 35 bug-fix sweep (wrong modal, missing images, slow loading, auth redirect, cursor-pointer audit), isAdmin flag on users + CLI grant/revoke script, gated `/admin` route with requireAdmin middleware, admin global-item management (browse/edit/delete with search + tag filter), admin tag management (CRUD + parent-child hierarchy with cycle detection). Also during v2.4: manufacturer backend entity (table, FK, service, route, seeding; **picker UI still missing**), catalog ingestion agent scripts (`crawl-manufacturer`, `crawl-all`).
|
||||
|
||||
## Tech Stack
|
||||
|
||||
React 19, Hono, Drizzle ORM, PostgreSQL, TanStack Router/Query, Tailwind CSS v4, Lucide React, Recharts, framer-motion, **react-i18next**, **Sharp** (image processing), **react-easy-crop** — all on Bun. MinIO for S3-compatible image storage. Docker Compose for dev/prod. Logto (self-hosted OIDC) for auth; API keys for programmatic; OAuth 2.1 + PKCE for Claude mobile/web. MCP server exposes 21+ tools over streamable HTTP.
|
||||
|
||||
## Active Constraints
|
||||
|
||||
- **Runtime:** Bun (package manager + runtime)
|
||||
- **Design:** Light, airy, minimalist — white/light backgrounds, lots of whitespace
|
||||
- **Navigation:** Top nav (desktop) + bottom tab bar (mobile); public discovery landing page for unauth users
|
||||
- **Auth:** External self-hosted (Logto) — no in-house auth maintenance
|
||||
- **DB:** PostgreSQL + Drizzle; prices stored as cents; timestamps as unix epoch integers
|
||||
- **UGC:** Structured input only (ratings, predefined fields) — no freeform until moderation exists
|
||||
- **Scope:** Multi-user platform with public discovery; SQLite single-user path diverged at v2.0
|
||||
|
||||
## Load-Bearing Decisions (current)
|
||||
|
||||
| Decision | Rationale |
|
||||
|---|---|
|
||||
| External OIDC (Logto) | Avoid in-house auth burden |
|
||||
| Postgres over SQLite | Multi-user concurrency + provider needs it |
|
||||
| Structured UGC only (no freeform) | Minimize moderation burden |
|
||||
| Discovery-first, not social-first | Users come to research decisions |
|
||||
| COALESCE merge for reference items | Global base + personal overlay, no duplication |
|
||||
| Catalog-first add with manual fallback | Encourages catalog usage, preserves flexibility |
|
||||
| Detail pages over slide-out panels | Better UX + shareable URLs |
|
||||
| Setups as top-level route | Promoted out of Collection tabs (Phase 27) |
|
||||
| `visibility` text column (not boolean) | Future-proofs for additional sharing modes |
|
||||
| `shares` table separate from `setups` | Enables per-person shares, write permissions, revocation |
|
||||
| 128-bit base64url share tokens | URL-safe entropy, no external dep |
|
||||
| Deactivate/reactivate on visibility change | Share links survive round-trips |
|
||||
| EUR default currency | Matches early single-user data assumption |
|
||||
| Module-level ECB rate cache (24h) | Single-process; avoids Redis |
|
||||
| `isAdmin` boolean flag on users | Simplest; no Logto role claims needed |
|
||||
| Admin grant via CLI script | No public UI for privilege escalation |
|
||||
| Manufacturers as dedicated entity (FK, not text) | Avoids "Ortlieb" vs "ORTLIEB" drift; enables logos/URLs |
|
||||
|
||||
## Explicitly Out of Scope
|
||||
|
||||
- Personalized feed algorithm (needs usage data)
|
||||
- SSR / static prerendering (TanStack Router research needed; defer)
|
||||
- Freeform reviews / comments (no moderation infra)
|
||||
- Image scraping automation (legal gray area)
|
||||
- User-to-user messaging (moderation burden)
|
||||
- Wiki-style open item editing (quality risk)
|
||||
- Marketplace / buy-sell (payment + fraud + legal)
|
||||
- AI gear recommendations (training data + hallucination)
|
||||
- Gamification (incentivizes quantity over quality)
|
||||
- Price tracking / deal alerts (scraping fragility + legal)
|
||||
- Native mobile app (web-first, responsive; PWA considered — see 999.9)
|
||||
- Custom comparison parameters (complexity trap; weight/price covers 80%)
|
||||
- Barcode scanning (poor UX vs catalog search)
|
||||
- Maintaining SQLite single-user path in parallel (diverged at v2.0)
|
||||
|
||||
## Next Up
|
||||
|
||||
See `BACKLOG.md` for the forward-looking list (pending todos, unfinished v2.4 verification, backlog phases, deferred requirements).
|
||||
@@ -0,0 +1,18 @@
|
||||
---
|
||||
created: 2026-04-10T09:23:46.394Z
|
||||
title: Add manufacturer entity with brand details
|
||||
area: database
|
||||
files:
|
||||
- src/db/schema.ts
|
||||
- src/server/services/global-item.service.ts
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
The manual item adding form doesn't include detailed manufacturer info. Currently `brand` is just a text field on items and globalItems. There's no structured manufacturer data (logo, website, country, description). Users can't select from existing manufacturers — they type freeform text which leads to inconsistencies ("Ortlieb" vs "ORTLIEB" vs "ortlieb").
|
||||
|
||||
## Solution
|
||||
|
||||
Create a `manufacturers` table with fields: `id`, `name` (unique), `logoUrl`, `websiteUrl`, `country`, `description`, `createdAt`. Add a `manufacturerId` FK on `globalItems` (and potentially `items`). Build a manufacturer picker component (like CategoryPicker) with search and inline create. Update the manual add form and catalog enrichment to use the manufacturer entity. This is a significant feature — likely needs its own phase.
|
||||
|
||||
Note: This would also improve the MCP agent seeding workflow — agents could create manufacturers first, then reference them when creating catalog items.
|
||||
@@ -0,0 +1,15 @@
|
||||
---
|
||||
created: 2026-04-20T00:00:00.000Z
|
||||
title: Make tag selector in global search searchable
|
||||
area: ui
|
||||
files:
|
||||
- src/client/components/CatalogSearchOverlay.tsx
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
The tag filter in the global search overlay (`CatalogSearchOverlay.tsx`) displays all tags as chips but provides no search/filter input. When there are many tags, users must scroll through all of them to find the one they want — not ergonomic.
|
||||
|
||||
## Solution
|
||||
|
||||
Add a search input inside the tag filter dropdown/section so users can type to narrow down visible tags before selecting. Similar pattern to how the admin items list (`src/client/routes/admin/items/index.tsx`) handles tag filtering.
|
||||
235
docs/superpowers/plans/2026-04-23-tag-selector-search.md
Normal file
235
docs/superpowers/plans/2026-04-23-tag-selector-search.md
Normal file
@@ -0,0 +1,235 @@
|
||||
# Tag Selector Search Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add a typing-to-filter input above the tag chip list in the filter sidebar of `CatalogSearchOverlay` so users with many tags can narrow the visible list.
|
||||
|
||||
**Architecture:** Single-file UI change. Local `tagSearch` state, computed `filteredTags` derived via case-insensitive substring match, conditionally rendered input (only when `tags.length > 8`), muted "No tags match" hint when the filter empties the list, reset on overlay close. No backend, no new files, no new dependencies.
|
||||
|
||||
**Tech Stack:** React 19 + TanStack Query (via existing `useTags`) + Tailwind CSS v4.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-04-23-tag-selector-search-design.md`
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
**Modify only:**
|
||||
- `src/client/components/CatalogSearchOverlay.tsx` — add state, reset hook extension, and JSX changes inside the Tags section of the filter sidebar.
|
||||
|
||||
No new files.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Implement tag search in the filter sidebar
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/client/components/CatalogSearchOverlay.tsx`
|
||||
|
||||
### Step 1: Add `tagSearch` local state
|
||||
|
||||
- [ ] Add the following state declaration immediately after the existing `const [filterOpen, setFilterOpen] = useState(false);` (currently line 25). This keeps filter-panel state grouped together.
|
||||
|
||||
```tsx
|
||||
const [tagSearch, setTagSearch] = useState("");
|
||||
```
|
||||
|
||||
### Step 2: Extend the close-reset effect to reset `tagSearch`
|
||||
|
||||
- [ ] In the existing reset `useEffect` (currently lines 84–98), add `setTagSearch("");` to the list of resets so the filter clears on overlay close/reopen. The updated effect looks like:
|
||||
|
||||
```tsx
|
||||
// Reset state when overlay closes
|
||||
useEffect(() => {
|
||||
if (!catalogSearchOpen) {
|
||||
setSearchInput("");
|
||||
setDebouncedQuery("");
|
||||
setSelectedTags([]);
|
||||
setFilterOpen(false);
|
||||
setTagSearch("");
|
||||
setWeightMin(0);
|
||||
setWeightMax(5000);
|
||||
setPriceMin(0);
|
||||
setPriceMax(100000);
|
||||
setManualEntryMode(false);
|
||||
setSavedItemName(null);
|
||||
setCatalogSubmitted(false);
|
||||
}
|
||||
}, [catalogSearchOpen]);
|
||||
```
|
||||
|
||||
### Step 3: Replace the Tags section JSX with search-input + filtered list + empty hint
|
||||
|
||||
- [ ] In the filter sidebar, the Tags section currently spans lines 333–356. Replace the existing block with the following. This (a) conditionally renders a small filter input only when `tags.length > 8`, (b) computes a case-insensitive substring filter inline, (c) renders either the filtered chip list or a muted "No tags match" hint.
|
||||
|
||||
Current block to replace (exact):
|
||||
|
||||
```tsx
|
||||
{/* Tags */}
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold text-gray-400 uppercase tracking-wide mb-3">
|
||||
Tags
|
||||
</h3>
|
||||
<div className="space-y-1">
|
||||
{tags.map((tag) => {
|
||||
const isActive = selectedTags.includes(tag.name);
|
||||
return (
|
||||
<button
|
||||
key={tag.id}
|
||||
type="button"
|
||||
onClick={() => toggleTag(tag.name)}
|
||||
className={`w-full text-left px-2.5 py-1.5 rounded-lg text-sm transition-colors ${
|
||||
isActive
|
||||
? "bg-blue-50 text-blue-700 font-medium"
|
||||
: "text-gray-600 hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
{tag.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
Replacement block:
|
||||
|
||||
```tsx
|
||||
{/* Tags */}
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold text-gray-400 uppercase tracking-wide mb-3">
|
||||
Tags
|
||||
</h3>
|
||||
{tags.length > 8 && (
|
||||
<input
|
||||
type="text"
|
||||
value={tagSearch}
|
||||
onChange={(e) => setTagSearch(e.target.value)}
|
||||
placeholder="Filter tags..."
|
||||
className="w-full px-2 py-1 mb-2 border border-gray-200 rounded-md text-xs text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-gray-300 focus:border-transparent transition-colors"
|
||||
/>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
{(() => {
|
||||
const q = tagSearch.trim().toLowerCase();
|
||||
const filteredTags = q
|
||||
? tags.filter((t) => t.name.toLowerCase().includes(q))
|
||||
: tags;
|
||||
if (filteredTags.length === 0) {
|
||||
return (
|
||||
<p className="text-xs text-gray-400 px-2.5 py-1.5">
|
||||
No tags match
|
||||
</p>
|
||||
);
|
||||
}
|
||||
return filteredTags.map((tag) => {
|
||||
const isActive = selectedTags.includes(tag.name);
|
||||
return (
|
||||
<button
|
||||
key={tag.id}
|
||||
type="button"
|
||||
onClick={() => toggleTag(tag.name)}
|
||||
className={`w-full text-left px-2.5 py-1.5 rounded-lg text-sm transition-colors ${
|
||||
isActive
|
||||
? "bg-blue-50 text-blue-700 font-medium"
|
||||
: "text-gray-600 hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
{tag.name}
|
||||
</button>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
**Notes:**
|
||||
- The `(() => { ... })()` IIFE keeps the filter logic local to this block without adding another variable at component top-level. It runs on every render, which is fine — cost is O(n) per keystroke and `tags` is small.
|
||||
- `q` is trimmed-and-lowercased once so we don't recompute it per tag.
|
||||
- When `q` is empty, we use `tags` directly to avoid the filter allocation.
|
||||
- The filter input is only rendered when `tags.length > 8`; below that threshold, the old behavior (no input, no filtering) is preserved.
|
||||
|
||||
### Step 4: Run the linter
|
||||
|
||||
- [ ] Run:
|
||||
|
||||
```bash
|
||||
bun run lint
|
||||
```
|
||||
|
||||
Expected: exit 0 with no errors. Biome should be happy (tabs, double quotes, organized imports — existing conventions).
|
||||
|
||||
If there are complaints about the IIFE or `q` variable, resolve them before continuing. If Biome flags the `no-nested-ternary` or complexity rules, hoist the filter logic into a `useMemo` just above the return statement instead — functionally equivalent, syntactically flatter.
|
||||
|
||||
### Step 5: Start the dev server and verify in a browser
|
||||
|
||||
- [ ] Run (in a separate terminal or background):
|
||||
|
||||
```bash
|
||||
bun run dev
|
||||
```
|
||||
|
||||
- [ ] Open `http://localhost:5173`, sign in if required, and open the catalog search overlay (FAB or top-nav search). Toggle the Filter icon to open the sidebar.
|
||||
|
||||
- [ ] Walk through acceptance criteria from the spec:
|
||||
|
||||
1. With >8 tags, the "Filter tags..." input appears above the chip list. With ≤8 tags, the input is hidden.
|
||||
2. Typing narrows the list immediately (case-insensitive substring).
|
||||
3. Selecting a tag, then typing a query that excludes it: the chip disappears from the sidebar but the blue pill in the header row remains.
|
||||
4. Typing a query that matches nothing shows "No tags match".
|
||||
5. Closing the overlay (Esc / back arrow / clicking outside header) and reopening: the tag search input is empty.
|
||||
6. Weight/price range filters, active filter pills, and Clear-all still work as before.
|
||||
|
||||
- [ ] If any criterion fails, fix inline and re-verify. Do not commit broken work.
|
||||
|
||||
### Step 6: Commit
|
||||
|
||||
- [ ] Stage and commit:
|
||||
|
||||
```bash
|
||||
git add src/client/components/CatalogSearchOverlay.tsx
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(catalog): searchable tag filter in global catalog overlay
|
||||
|
||||
Adds a typing-to-filter input above the tag chip list in the filter
|
||||
sidebar, rendered only when there are more than eight tags. Case-
|
||||
insensitive substring match; shows "No tags match" when the query
|
||||
empties the list. Selected tags filtered out of the sidebar remain
|
||||
active as header pills. Resets with the rest of overlay state.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**Spec coverage:**
|
||||
|
||||
| Spec requirement | Task step |
|
||||
|---|---|
|
||||
| Add `tagSearch` local state | Task 1 Step 1 |
|
||||
| Reset `tagSearch` on overlay close | Task 1 Step 2 |
|
||||
| Render input only when `tags.length > 8` | Task 1 Step 3 (conditional) |
|
||||
| Case-insensitive substring match | Task 1 Step 3 (`q`/`includes`) |
|
||||
| Selected tag filtered out stays active via header pill | Existing behavior preserved (no code removed from header pill row) — verified in Step 5 criterion 3 |
|
||||
| "No tags match" hint on zero results | Task 1 Step 3 (early return) |
|
||||
| Placeholder `Filter tags...` | Task 1 Step 3 |
|
||||
| Style matches minimalist look, narrower for sidebar | Task 1 Step 3 (smaller padding, ring-1, text-xs) |
|
||||
| Closing and reopening shows empty input | Task 1 Step 2 + Step 5 criterion 5 |
|
||||
| No regressions to weight/price/pills | Step 5 criterion 6 |
|
||||
|
||||
All spec requirements mapped.
|
||||
|
||||
**Placeholders:** none. No "TBD" or "handle edge cases" language; all code is shown in full.
|
||||
|
||||
**Type consistency:** `tagSearch` is a `string`, always used as `tagSearch.trim().toLowerCase()`. `filteredTags` is `Tag[]` same shape as `tags`. Tag shape from `useTags` is `{ id: number; name: string }` — both fields used correctly.
|
||||
|
||||
---
|
||||
|
||||
## Execution Handoff
|
||||
|
||||
Scope is one file + six small steps. Subagent-driven would be over-engineered. Recommended: inline execution with `superpowers:executing-plans`.
|
||||
@@ -0,0 +1,66 @@
|
||||
# Tag Selector Search in CatalogSearchOverlay
|
||||
|
||||
**Date:** 2026-04-23
|
||||
**Status:** Approved, ready for implementation
|
||||
|
||||
## Problem
|
||||
|
||||
The tag filter in the global catalog search overlay (`src/client/components/CatalogSearchOverlay.tsx`) renders every tag as a chip in a narrow sidebar. When the tag taxonomy grows, users must scroll through the full list to find the one they want. There is no typing-to-filter affordance.
|
||||
|
||||
## Goal
|
||||
|
||||
Allow users to narrow the visible tag list by typing a query, without disrupting selection or the surrounding filter UX.
|
||||
|
||||
## Scope
|
||||
|
||||
Single-file change to `src/client/components/CatalogSearchOverlay.tsx`. No backend changes. No new dependencies. No shared component extraction.
|
||||
|
||||
## Behavior
|
||||
|
||||
**Search state**
|
||||
|
||||
- Add a `tagSearch: string` local state, defaulting to `""`.
|
||||
|
||||
**Input rendering**
|
||||
|
||||
- Render a compact text input at the top of the "Tags" section inside the filter sidebar (above the existing tag chip list).
|
||||
- Style to match the existing minimalist look: similar to the main catalog search input but narrower and with reduced padding so it fits the 224px sidebar.
|
||||
- Render the input only when `tags.length > 8`. Below that threshold scrolling isn't an issue and the extra chrome is noise.
|
||||
- Placeholder text: `Filter tags...`.
|
||||
|
||||
**Filter logic**
|
||||
|
||||
- Compute the visible tag list as: tags whose `name` includes `tagSearch` (case-insensitive substring match).
|
||||
- A selected tag that does not match the query is hidden from the sidebar list. It remains active and visible as a blue pill in the header row (existing behavior — no change required).
|
||||
- When the filter produces zero visible tags, render a small muted hint: `No tags match`.
|
||||
|
||||
**Reset**
|
||||
|
||||
- When the overlay closes, reset `tagSearch` to `""` by extending the existing reset `useEffect` at lines 84–98.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- No fuzzy matching or ranking — plain case-insensitive substring is sufficient.
|
||||
- No keyboard navigation of the tag list (arrow keys / Enter to toggle). Users click chips.
|
||||
- No persistence of `tagSearch` across overlay open/close cycles.
|
||||
- No changes to the active-pill row, active-filter counting, or weight/price range filters.
|
||||
- No unit tests — pure UI state, covered by manual UAT verification during milestone close-out.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
1. Typing in the tag search input immediately narrows the visible tag chips by case-insensitive substring match on tag name.
|
||||
2. Tags that are selected but no longer match the query disappear from the sidebar list and remain selected (visible as blue pills in the header).
|
||||
3. With 8 or fewer tags, the search input is not rendered.
|
||||
4. With more than 8 tags and a query that matches none of them, a `No tags match` hint is shown.
|
||||
5. Closing the overlay and reopening it shows an empty tag search input.
|
||||
6. No regressions to other filter behavior (weight range, price range, active filter pills, clear-all).
|
||||
|
||||
## Files touched
|
||||
|
||||
- `src/client/components/CatalogSearchOverlay.tsx`
|
||||
|
||||
## Out of scope for this spec
|
||||
|
||||
- Tag selector in other components (admin items list, etc.).
|
||||
- Any server-side tag search or query.
|
||||
- Hierarchy-aware filtering (parent match reveals children, etc.) — tags are consumed flat from `useTags()` in this component.
|
||||
@@ -23,6 +23,7 @@ export function CatalogSearchOverlay() {
|
||||
const [debouncedQuery, setDebouncedQuery] = useState("");
|
||||
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
||||
const [filterOpen, setFilterOpen] = useState(false);
|
||||
const [tagSearch, setTagSearch] = useState("");
|
||||
const [viewMode, setViewMode] = useState<ViewMode>("grid");
|
||||
const [manualEntryMode, setManualEntryMode] = useState(false);
|
||||
const [savedItemName, setSavedItemName] = useState<string | null>(null);
|
||||
@@ -87,6 +88,7 @@ export function CatalogSearchOverlay() {
|
||||
setDebouncedQuery("");
|
||||
setSelectedTags([]);
|
||||
setFilterOpen(false);
|
||||
setTagSearch("");
|
||||
setWeightMin(0);
|
||||
setWeightMax(5000);
|
||||
setPriceMin(0);
|
||||
@@ -334,8 +336,31 @@ export function CatalogSearchOverlay() {
|
||||
<h3 className="text-xs font-semibold text-gray-400 uppercase tracking-wide mb-3">
|
||||
Tags
|
||||
</h3>
|
||||
{tags.length > 8 && (
|
||||
<input
|
||||
type="text"
|
||||
value={tagSearch}
|
||||
onChange={(e) => setTagSearch(e.target.value)}
|
||||
placeholder="Filter tags..."
|
||||
className="w-full px-2 py-1 mb-2 border border-gray-200 rounded-md text-xs text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-gray-300 focus:border-transparent transition-colors"
|
||||
/>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
{tags.map((tag) => {
|
||||
{(() => {
|
||||
const q = tagSearch.trim().toLowerCase();
|
||||
const filteredTags = q
|
||||
? tags.filter((t) =>
|
||||
t.name.toLowerCase().includes(q),
|
||||
)
|
||||
: tags;
|
||||
if (filteredTags.length === 0) {
|
||||
return (
|
||||
<p className="text-xs text-gray-400 px-2.5 py-1.5">
|
||||
No tags match
|
||||
</p>
|
||||
);
|
||||
}
|
||||
return filteredTags.map((tag) => {
|
||||
const isActive = selectedTags.includes(tag.name);
|
||||
return (
|
||||
<button
|
||||
@@ -351,7 +376,8 @@ export function CatalogSearchOverlay() {
|
||||
{tag.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
});
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user