diff --git a/.planning/phases/26-discovery-landing-page/26-RESEARCH.md b/.planning/phases/26-discovery-landing-page/26-RESEARCH.md new file mode 100644 index 0000000..ee5baab --- /dev/null +++ b/.planning/phases/26-discovery-landing-page/26-RESEARCH.md @@ -0,0 +1,591 @@ +# Phase 26: Discovery Landing Page - Research + +**Researched:** 2026-04-10 +**Domain:** React SPA landing page, public API feed endpoints, cursor pagination +**Confidence:** HIGH + +--- + + +## User Constraints (from CONTEXT.md) + +### Locked Decisions +- **D-01:** Full-width hero area with catalog search bar prominently centered. Below the hero, a vertical stack of content sections. +- **D-02:** Section order: (1) Hero with search bar, (2) Popular Setups, (3) Recently Added Items, (4) Trending Categories. Each section has a heading and optional "View all" link. +- **D-03:** The current `DashboardPage` component and its `DashboardCard` usage at `/` will be replaced entirely. The dashboard is now the landing page. +- **D-04:** The hero search bar triggers the existing `CatalogSearchOverlay` on focus or typing. This reuses the full-featured search without duplicating search UI. +- **D-05:** "Popular setups" ranked by item count descending (proxy for effort/completeness). Only public setups are shown. +- **D-06:** "Recently added items" shows the most recently created `globalItems`, ordered by `createdAt` descending. +- **D-07:** "Trending categories" ranked by global item count per distinct `globalItems.category` value. +- **D-08:** Cursor-based pagination for feed sections per INFR-02. Use `createdAt` cursor for recently added items; item count + ID cursor for popular setups. +- **D-09:** Same page content for both authenticated and anonymous users. Difference is purely navigational. +- **D-10:** Authenticated users see a "Go to Collection" CTA in the hero area, next to the search bar. Visible without scrolling. +- **D-11:** Anonymous users see the search bar and content sections immediately. Sign-in button in top-right per Phase 24. + +### Claude's Discretion +- Exact layout sizing, spacing, and responsive breakpoints +- Number of items shown per section before "View all" (suggest 6-8 for items/setups, 8-12 for categories) +- Empty states for sections with no data +- Loading skeletons for each section +- Whether "View all" links for setups/items route to existing pages or new dedicated feed pages + +### Folded Todos (IN SCOPE) +- **Add cursor pointer to all clickable links** — Apply broadly while building the new page. +- **Fix item image not showing on collection overview** — Investigate and fix image display issue since landing page will show `GlobalItemCard` components with images. +- **Investigate slow image loading** — Profile image loading performance. + +### Deferred Ideas (OUT OF SCOPE) +- Personalized feed based on user's collection categories (PERS-01, PERS-02) +- SSR/static prerendering for SEO (SEO-01, SEO-02) +- Engagement metrics (views, likes) for better ranking +- Setup preview images/thumbnails + + +--- + + +## Phase Requirements + +| ID | Description | Research Support | +|----|-------------|------------------| +| DISC-01 | Landing page displays an always-visible catalog search bar at the top | Hero section with `CatalogSearchOverlay` trigger; search bar renders unconditionally | +| DISC-02 | Landing page shows a feed of popular setups below the search | New `GET /api/discovery/setups` endpoint; `useDiscoverySetups` hook; enhanced `PublicSetupCard` | +| DISC-03 | Landing page shows recently added catalog items | New `GET /api/discovery/items` endpoint; `useDiscoveryItems` hook; `GlobalItemCard` reuse | +| DISC-04 | Landing page shows trending categories | New `GET /api/discovery/categories` endpoint; `useDiscoveryCategories` hook; category pill/card display | +| DISC-05 | Authenticated users see a "Go to Collection" entry point | `useAuth` hook; conditional CTA in hero using `auth.user` presence | +| INFR-02 | Discovery feed endpoint uses cursor pagination | Cursor-based pagination on `createdAt` / `(itemCount, id)` — no offset pagination | + + +--- + +## Summary + +Phase 26 replaces the personal dashboard at `/` with a public discovery landing page. The existing codebase is well-structured for this work: the current `index.tsx` is thin (61 lines), the reusable components (`GlobalItemCard`, `CatalogSearchOverlay`) are ready, the auth pattern (`useAuth`) is already in place, and the Tailwind design language is consistent throughout. + +The primary technical work divides into three areas: (1) three new server-side discovery endpoints with cursor pagination, (2) three corresponding React Query hooks on the client, and (3) rewriting `index.tsx` into the landing page layout with hero, sections, and conditional auth CTA. The `PublicSetupCard` needs a minor enhancement to show item count and creator name for the popular setups section. + +The folded todos (cursor pointer, image display bug) must be addressed during this phase since they directly affect the landing page experience. + +**Primary recommendation:** Build three `GET /api/discovery/*` endpoints, mirror them with three React Query hooks, and rewrite `routes/index.tsx` composing existing `GlobalItemCard` and enhanced `PublicSetupCard`. Add a `discoveryRoutes` file registered at `/api/discovery` in `server/index.ts`. + +--- + +## Standard Stack + +### Core (already in project) +| Library | Version | Purpose | Why Standard | +|---------|---------|---------|--------------| +| TanStack Router | file-based | Client routing; `routes/index.tsx` stays same file | Project standard | +| TanStack React Query | in project | Data fetching hooks; each section gets its own hook | Project standard | +| Tailwind CSS v4 | in project | Styling; `bg-white rounded-xl border border-gray-100` card pattern | Project standard | +| Hono | in project | Server routes; new `discoveryRoutes` file follows same pattern | Project standard | +| Drizzle ORM | in project | SQL queries with `desc`, `count`, `groupBy` for feed data | Project standard | +| Zustand (`uiStore`) | in project | `openCatalogSearch()` trigger from hero search bar | Project standard | + +### No New Dependencies +This phase requires zero new package installations. All patterns and libraries are already present. + +--- + +## Architecture Patterns + +### Recommended Project Structure Changes + +``` +src/ +├── client/ +│ ├── routes/ +│ │ └── index.tsx # REWRITE — landing page (replaces dashboard) +│ ├── hooks/ +│ │ └── useDiscovery.ts # NEW — three hooks: useDiscoverySetups, useDiscoveryItems, useDiscoveryCategories +│ └── components/ +│ └── PublicSetupCard.tsx # ENHANCE — add itemCount + creatorName props +└── server/ + ├── routes/ + │ └── discovery.ts # NEW — GET /setups, /items, /categories + ├── services/ + │ └── discovery.service.ts # NEW — getPopularSetups, getRecentItems, getTrendingCategories + └── index.ts # ADD — discoveryRoutes registration + public allowlist + rate limit +``` + +### Pattern 1: Discovery Endpoint with Cursor Pagination (INFR-02) + +**What:** GET endpoints accept an optional `cursor` query param and `limit`. Cursor encodes position in result set without OFFSET. + +**For recently added items** — cursor is the `createdAt` ISO timestamp of the last seen item: +```typescript +// Source: Drizzle ORM docs — cursor pagination +// GET /api/discovery/items?limit=8&cursor=2026-04-01T00:00:00.000Z +export async function getRecentGlobalItems( + db: Db, + limit = 8, + cursor?: string, // ISO timestamp of last seen item +) { + const conditions = cursor + ? [lt(globalItems.createdAt, new Date(cursor))] + : []; + + return db + .select() + .from(globalItems) + .where(conditions.length ? and(...conditions) : undefined) + .orderBy(desc(globalItems.createdAt)) + .limit(limit + 1); // fetch one extra to detect hasMore +} +``` + +**For popular setups** — cursor is `itemCount_id` (composite: item count + setup id for stable ordering): +```typescript +// GET /api/discovery/setups?limit=6&cursor=5_42 +// cursor = "{itemCount}_{id}" — both fields for stable pagination +export async function getPopularSetups( + db: Db, + limit = 6, + cursor?: string, +) { + const itemCountExpr = sql`CAST(COUNT(${setupItems.id}) AS INT)`; + + // Build base query with JOIN to count items per public setup + let query = db + .select({ + id: setups.id, + name: setups.name, + createdAt: setups.createdAt, + itemCount: itemCountExpr, + }) + .from(setups) + .leftJoin(setupItems, eq(setupItems.setupId, setups.id)) + .where(eq(setups.isPublic, true)) + .groupBy(setups.id, setups.name, setups.createdAt) + .orderBy(desc(itemCountExpr), desc(setups.id)) + .limit(limit + 1); + + // Cursor filtering applied post-query (simpler for composite cursor with SQLite) + const rows = await query; + if (cursor) { + const [cursorCount, cursorId] = cursor.split("_").map(Number); + // Filter: itemCount < cursorCount, OR (itemCount === cursorCount AND id < cursorId) + return rows.filter(r => + r.itemCount < cursorCount || + (r.itemCount === cursorCount && r.id < cursorId) + ).slice(0, limit + 1); + } + return rows; +} +``` + +**Note:** For SQLite (production DB is PostgreSQL per schema imports, but schema uses `pgTable`), the `ilike` operator is already in use in `global-item.service.ts`. The Drizzle operators `lt`, `desc`, `count`, `sql` are all already imported in the codebase. + +**hasMore detection pattern:** +```typescript +// In route handler — consistent across all three endpoints +const rows = await getRecentGlobalItems(db, limit + 1, cursor); +const hasMore = rows.length > limit; +const items = hasMore ? rows.slice(0, limit) : rows; +const nextCursor = hasMore ? buildCursor(items[items.length - 1]) : null; +return c.json({ items, nextCursor, hasMore }); +``` + +### Pattern 2: Discovery Route Registration + +**What:** New `discoveryRoutes` file registered at `/api/discovery` in `server/index.ts`, following the exact same Hono pattern as `globalItemRoutes`. + +```typescript +// src/server/routes/discovery.ts +import { Hono } from "hono"; +// ... +const app = new Hono(); +app.get("/setups", async (c) => { ... }); +app.get("/items", async (c) => { ... }); +app.get("/categories", async (c) => { ... }); +export { app as discoveryRoutes }; +``` + +```typescript +// src/server/index.ts additions: +// 1. Import discoveryRoutes +// 2. Add to public skiplist (GET /api/discovery/*) +// 3. Add browseTier rate limit for GET /api/discovery/* +// 4. app.route("/api/discovery", discoveryRoutes) +``` + +### Pattern 3: Discovery React Query Hooks + +**What:** Single file `useDiscovery.ts` with three named exports, following the exact pattern of `useGlobalItems`. + +```typescript +// src/client/hooks/useDiscovery.ts +export interface DiscoverySetup { + id: number; + name: string; + createdAt: string; + itemCount: number; + // creatorName: optional — depends on whether users join is implemented +} + +export interface DiscoveryCategory { + name: string; + itemCount: number; +} + +export function useDiscoverySetups(limit = 6) { + return useQuery({ + queryKey: ["discovery", "setups", limit], + queryFn: () => apiGet<{ items: DiscoverySetup[]; nextCursor: string | null }>(`/api/discovery/setups?limit=${limit}`), + staleTime: 2 * 60 * 1000, // 2 min — feed data, okay to be slightly stale + }); +} + +export function useDiscoveryItems(limit = 8) { + return useQuery({ + queryKey: ["discovery", "items", limit], + queryFn: () => apiGet<{ items: GlobalItem[]; nextCursor: string | null }>(`/api/discovery/items?limit=${limit}`), + staleTime: 2 * 60 * 1000, + }); +} + +export function useDiscoveryCategories(limit = 12) { + return useQuery({ + queryKey: ["discovery", "categories", limit], + queryFn: () => apiGet(`/api/discovery/categories?limit=${limit}`), + staleTime: 5 * 60 * 1000, // 5 min — categories change rarely + }); +} +``` + +### Pattern 4: Landing Page Component Structure + +**What:** `routes/index.tsx` rewritten as `LandingPage` function. `DashboardPage` and `DashboardCard` imports removed. Three sections below the hero, each as a standalone sub-component in the same file. + +```typescript +// src/client/routes/index.tsx +export const Route = createFileRoute("/")({ + component: LandingPage, +}); + +function LandingPage() { + const { data: auth } = useAuth(); + const isAuthenticated = !!auth?.user; + const openCatalogSearch = useUIStore((s) => s.openCatalogSearch); + + return ( +
+ {/* Hero */} + openCatalogSearch("collection")} /> + {/* Sections */} + + + +
+ ); +} +``` + +### Pattern 5: Enhanced `PublicSetupCard` + +**What:** Add `itemCount` and optional `creatorName` to `PublicSetupCardProps`. The card currently only shows `name` and formatted date — it needs item count to be useful in a "Popular Setups" feed. + +```typescript +interface PublicSetupCardProps { + setup: { + id: number; + name: string; + createdAt: string; + itemCount: number; // NEW — required for popular setups feed + creatorName?: string; // NEW — optional, shown if present + }; +} +``` + +**Important:** This is a non-breaking change. Existing usages of `PublicSetupCard` that pass the old shape will need to add `itemCount`. Check all usages before changing the interface. + +### Pattern 6: Hero Search Bar + +**What:** A styled `` or `
` that calls `openCatalogSearch("collection")` on click/focus. Does NOT perform search itself — just triggers `CatalogSearchOverlay`. This aligns with D-04. + +```typescript +function HeroSection({ isAuthenticated, onSearchFocus }: { isAuthenticated: boolean; onSearchFocus: () => void }) { + return ( +
+

Discover Gear

+

Browse what other people carry

+ + {/* Search trigger — visual only, opens overlay */} +
e.key === "Enter" && onSearchFocus()} + > + + Search the catalog... +
+ + {/* Authenticated CTA (D-10) */} + {isAuthenticated && ( +
+ + Go to Collection → + +
+ )} +
+ ); +} +``` + +### Anti-Patterns to Avoid +- **Do not build inline search results** — The search bar is a trigger only; `CatalogSearchOverlay` handles all search UI (D-04). Duplicating search logic will create state sync bugs. +- **Do not use OFFSET pagination** — Use cursor-based pagination for all discovery endpoints (INFR-02). OFFSET degrades with large tables and can skip/duplicate rows with concurrent inserts. +- **Do not use `auth?.authenticated` for conditional CTA** — Use `!!auth?.user` as established by `__root.tsx` pattern (`const isAuthenticated = !!auth?.user`). +- **Do not import DashboardCard or DashboardPage in new index.tsx** — They are being retired by D-03; remove imports entirely. +- **Do not fire-and-forget on auth check for CTA** — The `useAuth` query has `staleTime: 5 * 60 * 1000` and `retry: false`. The CTA should appear only after auth resolves (`auth?.user` is truthy), not while `isLoading`. + +--- + +## Don't Hand-Roll + +| Problem | Don't Build | Use Instead | Why | +|---------|-------------|-------------|-----| +| Search UI | Custom search input with results | `CatalogSearchOverlay` + `openCatalogSearch()` | Full feature set: debounce, tags, grid/list, manual entry; D-04 | +| Auth state | Manual JWT decode or session check | `useAuth()` hook | Caches auth state, handles race conditions | +| Weight/price formatting | `item.weightGrams + "g"` | `useFormatters()` → `weight()`, `price()` | Handles unit conversion, null, localization | +| Card skeleton | Custom loading spinner | `animate-pulse` Tailwind classes — match `CatalogSearchOverlay` skeleton pattern | Consistent with existing `SkeletonGrid` in `CatalogSearchOverlay` | +| Rate limiting | New rate limit implementation | `createRateLimit(browseTier)` factory | Already handles IP extraction, cleanup, 429 responses | + +**Key insight:** Nearly all plumbing already exists. The work is composition, not invention. + +--- + +## Common Pitfalls + +### Pitfall 1: `CatalogSearchOverlay` mounts at `top-[57px]` +**What goes wrong:** The overlay is positioned `fixed inset-x-0 top-[57px]` (below the `TotalsBar` which is `h-14` = 56px). If the landing page adds any sticky element above the TotalsBar, the overlay will overlap it. +**Why it happens:** The overlay's top offset is hardcoded to the TotalsBar height. +**How to avoid:** Do not add sticky elements to the landing page layout outside of TotalsBar. The hero section should be part of the normal document flow. +**Warning signs:** Overlay appears behind or over an element when opened from landing page. + +### Pitfall 2: `isDashboard` detection in `__root.tsx` +**What goes wrong:** `__root.tsx` has `const isDashboard = !!matchRoute({ to: "/" })`. This controls whether `TotalsBar` shows a `linkTo` prop (back link). If the new landing page keeps the `/` route, `isDashboard` will remain `true` and `TotalsBar` will render its title as a non-link — which is correct behavior (already handled). +**Why it happens:** No change needed, but worth knowing so it's not "fixed" accidentally. +**How to avoid:** Leave `isDashboard` logic in `__root.tsx` unchanged. + +### Pitfall 3: `PublicSetupCard` interface change breaks existing usages +**What goes wrong:** If `itemCount` is made required in `PublicSetupCardProps`, any existing usage that doesn't pass it will cause a TypeScript error. +**Why it happens:** The card is used in at least the public setup profile page. +**How to avoid:** Check all usages of `PublicSetupCard` before adding `itemCount` as required. Consider adding it as optional (`itemCount?: number`) with a fallback display. +**Warning signs:** TypeScript errors on `PublicSetupCard` usages in other files. + +### Pitfall 4: Discovery endpoint auth allowlist +**What goes wrong:** New `GET /api/discovery/*` endpoints return 401 for anonymous users because the auth skip list in `server/index.ts` doesn't include them. +**Why it happens:** The auth middleware at line 151-170 of `server/index.ts` skips specific paths by prefix check. New paths must be explicitly added. +**How to avoid:** Add this skip condition to `server/index.ts`: +```typescript +if (c.req.path.startsWith("/api/discovery") && c.req.method === "GET") + return next(); +``` +**Warning signs:** Anonymous page load shows empty sections or 401 errors in browser network tab. + +### Pitfall 5: Trending categories — `globalItems.category` is nullable +**What goes wrong:** SQL `GROUP BY globalItems.category` will include a `null` group if some items have no category set. This null group may appear in "Trending Categories" and render as an empty/broken category chip. +**Why it happens:** `category` column is `text` nullable in schema. +**How to avoid:** Add `WHERE globalItems.category IS NOT NULL` to the trending categories query. +**Warning signs:** Category section shows an empty/blank chip. + +### Pitfall 6: Creator name requires users join (scope risk) +**What goes wrong:** D-02 says setups show "creator names". But `setups` table only has `userId` — getting `displayName` requires joining `users` table. If `users.displayName` is null (common for new accounts), the join returns null. +**Why it happens:** User display names are optional in the schema. +**How to avoid:** Join users in `getPopularSetups` query and return `creatorName: users.displayName ?? null`. In the card, render creator name only when non-null (e.g., "by Jean-Luc" or omit entirely). Mark `creatorName` as optional in the TS interface. +**Warning signs:** Crashes on `.toLocaleLowerCase()` or similar when `creatorName` is null. + +--- + +## Code Examples + +### Drizzle: Trending categories query +```typescript +// Source: Drizzle docs + codebase patterns in global-item.service.ts +import { count, desc, isNotNull, groupBy } from "drizzle-orm"; + +export async function getTrendingCategories(db: Db, limit = 12) { + return db + .select({ + name: globalItems.category, + itemCount: count(globalItems.id), + }) + .from(globalItems) + .where(isNotNull(globalItems.category)) + .groupBy(globalItems.category) + .orderBy(desc(count(globalItems.id))) + .limit(limit); +} +// Returns: Array<{ name: string; itemCount: number }> +``` + +### Drizzle: Popular setups with item count +```typescript +// Source: Drizzle docs — leftJoin + groupBy + count +import { count, desc, eq } from "drizzle-orm"; + +export async function getPopularSetups(db: Db, limit = 6) { + return db + .select({ + id: setups.id, + name: setups.name, + createdAt: setups.createdAt, + itemCount: count(setupItems.id), + creatorName: users.displayName, + }) + .from(setups) + .leftJoin(setupItems, eq(setupItems.setupId, setups.id)) + .leftJoin(users, eq(users.id, setups.userId)) + .where(eq(setups.isPublic, true)) + .groupBy(setups.id, setups.name, setups.createdAt, users.displayName) + .orderBy(desc(count(setupItems.id)), desc(setups.id)) + .limit(limit); +} +``` + +### Cursor response shape (consistent across all three endpoints) +```typescript +// Applied to /api/discovery/items and /api/discovery/setups +interface CursorPage { + items: T[]; + nextCursor: string | null; + hasMore: boolean; +} + +// Usage in route handler: +const rows = await getRecentGlobalItems(db, limit + 1, cursor); +const hasMore = rows.length > limit; +const sliced = hasMore ? rows.slice(0, limit) : rows; +const nextCursor = hasMore + ? sliced[sliced.length - 1].createdAt.toISOString() + : null; +return c.json({ items: sliced, nextCursor, hasMore }); +``` + +### Section skeleton pattern (matches existing `CatalogSearchOverlay` style) +```typescript +// Reuse the animate-pulse pattern from CatalogSearchOverlay.tsx +function SectionSkeleton({ count = 6 }: { count?: number }) { + return ( +
+ {Array.from({ length: count }).map((_, i) => ( +
+
+
+
+
+
+
+ ))} +
+ ); +} +``` + +--- + +## State of the Art + +| Old Approach | Current Approach | When Changed | Impact | +|--------------|------------------|--------------|--------| +| Offset pagination (`LIMIT x OFFSET y`) | Cursor pagination (`WHERE createdAt < cursor`) | INFR-02 decision | Stable results, better performance at scale | +| Personal dashboard as `/` | Public discovery landing as `/` | D-03 (this phase) | New visitors see content, not a login gate | +| `PublicSetupCard` shows name + date only | Enhanced card adds item count + creator name | This phase | Sufficient context to judge "popular" setup quality | + +**Deprecated/outdated:** +- `DashboardPage` function in `index.tsx`: retired by D-03; file is rewritten, component removed +- `DashboardCard` component: no longer rendered from `/`; not deleted (may be useful elsewhere) but imports removed from `index.tsx` + +--- + +## Environment Availability + +Step 2.6: SKIPPED — This phase is purely client/server code changes within the existing project stack. No new external tools, services, runtimes, or CLI utilities are required beyond what's already installed (`bun`, `node`, PostgreSQL). + +--- + +## Validation Architecture + +### Test Framework +| Property | Value | +|----------|-------| +| Framework | Bun test runner (built-in) | +| Config file | none — `bun test` auto-discovers `*.test.ts` | +| Quick run command | `bun test tests/services/discovery.service.test.ts` | +| Full suite command | `bun test` | + +### Phase Requirements → Test Map +| Req ID | Behavior | Test Type | Automated Command | File Exists? | +|--------|----------|-----------|-------------------|-------------| +| DISC-01 | Hero search bar renders; clicking triggers CatalogSearchOverlay | smoke | E2E only — overlay integration | ❌ Wave 0 (E2E) | +| DISC-02 | `getPopularSetups` returns public setups ordered by item count desc | unit | `bun test tests/services/discovery.service.test.ts` | ❌ Wave 0 | +| DISC-02 | `GET /api/discovery/setups` returns 200 for anonymous requests | integration | `bun test tests/routes/discovery.test.ts` | ❌ Wave 0 | +| DISC-03 | `getRecentGlobalItems` returns items ordered by createdAt desc | unit | `bun test tests/services/discovery.service.test.ts` | ❌ Wave 0 | +| DISC-03 | `GET /api/discovery/items` returns 200 for anonymous requests | integration | `bun test tests/routes/discovery.test.ts` | ❌ Wave 0 | +| DISC-04 | `getTrendingCategories` excludes null categories, orders by count | unit | `bun test tests/services/discovery.service.test.ts` | ❌ Wave 0 | +| DISC-04 | `GET /api/discovery/categories` returns 200 for anonymous requests | integration | `bun test tests/routes/discovery.test.ts` | ❌ Wave 0 | +| DISC-05 | "Go to Collection" link absent for anonymous, present for authenticated | smoke | E2E only — requires auth session | ❌ Wave 0 (E2E) | +| INFR-02 | Cursor pagination: second page excludes items from first page | unit | `bun test tests/services/discovery.service.test.ts` | ❌ Wave 0 | + +### Sampling Rate +- **Per task commit:** `bun test tests/services/discovery.service.test.ts tests/routes/discovery.test.ts` +- **Per wave merge:** `bun test` +- **Phase gate:** Full suite green before `/gsd:verify-work` + +### Wave 0 Gaps +- [ ] `tests/services/discovery.service.test.ts` — covers DISC-02, DISC-03, DISC-04, INFR-02 (service layer) +- [ ] `tests/routes/discovery.test.ts` — covers DISC-02, DISC-03, DISC-04 route layer; anonymous access +- [ ] `src/server/routes/discovery.ts` — new route file (Wave 0 stub before tests) +- [ ] `src/server/services/discovery.service.ts` — new service file (Wave 0 stub before tests) +- [ ] `src/client/hooks/useDiscovery.ts` — new hooks file + +--- + +## Open Questions + +1. **Creator name display in popular setups feed** + - What we know: `setups.userId` exists; `users.displayName` is nullable text + - What's unclear: How many users have null `displayName` (could be most in early data) + - Recommendation: Render "by {name}" only when `creatorName` is non-null; show nothing otherwise. Do not fall back to email (privacy concern). + +2. **"View all" link destinations for setups and items** + - What we know: Claude's Discretion says this is unresolved + - What's unclear: No dedicated `/catalog` browse page or `/setups` public listing page exists yet + - Recommendation: "View all" for items links to `/global-items` (existing catalog page). "View all" for setups can be omitted for v1 of this page if no public setups listing page exists. Verify that `/global-items` route exists as a valid destination. + +3. **Cursor pagination for `categories` endpoint** + - What we know: INFR-02 requires cursor pagination for discovery feed; categories are ranked by count + - What's unclear: Categories list will be small (10-50 items max in early data); cursor pagination may be over-engineering for categories + - Recommendation: Use `limit` param for categories without cursor (no pagination). Categories don't grow unboundedly and the full list is small. Use cursor only for `setups` and `items` as decision D-08 specifies. + +--- + +## Sources + +### Primary (HIGH confidence) +- Codebase direct read — `src/server/routes/global-items.ts`, `src/server/services/global-item.service.ts`, `src/db/schema.ts`, `src/server/index.ts`, `src/client/routes/__root.tsx`, `src/client/stores/uiStore.ts`, `src/client/components/CatalogSearchOverlay.tsx`, `src/client/components/GlobalItemCard.tsx`, `src/client/components/PublicSetupCard.tsx` +- CONTEXT.md — Locked decisions D-01 through D-11 +- CLAUDE.md — Project stack, patterns, reusable component guidelines + +### Secondary (MEDIUM confidence) +- Drizzle ORM standard patterns for `leftJoin`, `groupBy`, `count`, `desc` — consistent with existing codebase usage + +### Tertiary (LOW confidence) +- None + +--- + +## Metadata + +**Confidence breakdown:** +- Standard stack: HIGH — all in project, no new deps +- Architecture patterns: HIGH — based on direct codebase reads, locked decisions +- Pitfalls: HIGH — derived from actual code inspection (nullable category, auth allowlist gaps, interface changes) +- Validation approach: HIGH — matches existing test patterns in `tests/services/` and `tests/routes/` + +**Research date:** 2026-04-10 +**Valid until:** 2026-05-10 (stable stack, no fast-moving dependencies)