docs(phase-26): research discovery landing page

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-10 14:38:10 +02:00
parent 274bced96d
commit 6b446033b5

View File

@@ -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>
## 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
</user_constraints>
---
<phase_requirements>
## 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 |
</phase_requirements>
---
## 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<number>`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<Env>();
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<DiscoveryCategory[]>(`/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 (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
{/* Hero */}
<HeroSection isAuthenticated={isAuthenticated} onSearchFocus={() => openCatalogSearch("collection")} />
{/* Sections */}
<PopularSetupsSection />
<RecentItemsSection />
<TrendingCategoriesSection />
</div>
);
}
```
### 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 `<input>` or `<div>` 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 (
<div className="text-center mb-12">
<h1 className="text-3xl font-bold text-gray-900 mb-2">Discover Gear</h1>
<p className="text-gray-500 mb-6">Browse what other people carry</p>
{/* Search trigger — visual only, opens overlay */}
<div
onClick={onSearchFocus}
className="max-w-xl mx-auto flex items-center gap-3 px-4 py-3 bg-white rounded-xl border border-gray-200 hover:border-gray-300 cursor-pointer shadow-sm transition-all"
role="button"
tabIndex={0}
onKeyDown={(e) => e.key === "Enter" && onSearchFocus()}
>
<SearchIcon className="w-4 h-4 text-gray-400 shrink-0" />
<span className="text-sm text-gray-400 flex-1 text-left">Search the catalog...</span>
</div>
{/* Authenticated CTA (D-10) */}
{isAuthenticated && (
<div className="mt-4">
<Link to="/collection" className="text-sm text-gray-600 hover:text-gray-900 underline underline-offset-2 cursor-pointer">
Go to Collection
</Link>
</div>
)}
</div>
);
}
```
### 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<T> {
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 (
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4">
{Array.from({ length: count }).map((_, i) => (
<div key={i} className="bg-white rounded-xl border border-gray-100 overflow-hidden animate-pulse">
<div className="aspect-[4/3] bg-gray-100" />
<div className="p-4 space-y-2">
<div className="h-3 bg-gray-100 rounded w-16" />
<div className="h-4 bg-gray-100 rounded w-32" />
</div>
</div>
))}
</div>
);
}
```
---
## 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)