docs(phase-26): research discovery landing page
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
591
.planning/phases/26-discovery-landing-page/26-RESEARCH.md
Normal file
591
.planning/phases/26-discovery-landing-page/26-RESEARCH.md
Normal 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)
|
||||
Reference in New Issue
Block a user