29 KiB
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
DashboardPagecomponent and itsDashboardCardusage at/will be replaced entirely. The dashboard is now the landing page. - D-04: The hero search bar triggers the existing
CatalogSearchOverlayon 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 bycreatedAtdescending. - D-07: "Trending categories" ranked by global item count per distinct
globalItems.categoryvalue. - D-08: Cursor-based pagination for feed sections per INFR-02. Use
createdAtcursor 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
GlobalItemCardcomponents 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:
// 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):
// 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:
// 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.
// 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 };
// 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.
// 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.
// 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.
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.
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;
CatalogSearchOverlayhandles 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?.authenticatedfor conditional CTA — Use!!auth?.useras established by__root.tsxpattern (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
useAuthquery hasstaleTime: 5 * 60 * 1000andretry: false. The CTA should appear only after auth resolves (auth?.useris truthy), not whileisLoading.
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:
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
// 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
// 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)
// 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)
// 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:
DashboardPagefunction inindex.tsx: retired by D-03; file is rewritten, component removedDashboardCardcomponent: no longer rendered from/; not deleted (may be useful elsewhere) but imports removed fromindex.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 accesssrc/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
-
Creator name display in popular setups feed
- What we know:
setups.userIdexists;users.displayNameis nullable text - What's unclear: How many users have null
displayName(could be most in early data) - Recommendation: Render "by {name}" only when
creatorNameis non-null; show nothing otherwise. Do not fall back to email (privacy concern).
- What we know:
-
"View all" link destinations for setups and items
- What we know: Claude's Discretion says this is unresolved
- What's unclear: No dedicated
/catalogbrowse page or/setupspublic 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-itemsroute exists as a valid destination.
-
Cursor pagination for
categoriesendpoint- 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
limitparam for categories without cursor (no pagination). Categories don't grow unboundedly and the full list is small. Use cursor only forsetupsanditemsas 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/andtests/routes/
Research date: 2026-04-10 Valid until: 2026-05-10 (stable stack, no fast-moving dependencies)