docs(26): create phase plan

This commit is contained in:
2026-04-10 14:45:38 +02:00
parent 9336cd80ed
commit 9226dd3d90
4 changed files with 999 additions and 5 deletions

View File

@@ -118,7 +118,12 @@ Plans:
3. The landing page shows a section of recently added catalog items
4. The landing page shows a section of trending categories
5. A logged-in user sees a "Go to Collection" link or button on the landing page that navigates to their personal collection
**Plans**: TBD
**Plans**: 3 plans
Plans:
- [ ] 26-01-PLAN.md — Discovery service layer with cursor pagination (TDD)
- [ ] 26-02-PLAN.md — Discovery routes, server registration, and client hooks
- [ ] 26-03-PLAN.md — Landing page UI and PublicSetupCard enhancement
**UI hint**: yes
## Progress
@@ -150,14 +155,19 @@ Plans:
| 23. Manual Entry Fallback | v2.0 | 1/1 | Complete | 2026-04-06 |
| 24. Public Access & Infrastructure | v2.1 | 2/2 | Complete | 2026-04-10 |
| 25. Catalog Enrichment & Agent Tools | v2.1 | 1/2 | Complete | 2026-04-10 |
| 26. Discovery Landing Page | v2.1 | 0/TBD | Not started | - |
| 26. Discovery Landing Page | v2.1 | 0/3 | Not started | - |
## Backlog
### Phase 999.1: Rewrite E2E Tests for OIDC Auth (BACKLOG)
**Goal**: E2E tests currently expect local username/password login but auth moved to external OIDC (Logto). Rewrite with mock OIDC provider or API-key-based auth bypass. Seed migration to Postgres is already done.
**Requirements**: TBD
**Plans**: TBD
**Plans**: 3 plans
Plans:
- [ ] 26-01-PLAN.md — Discovery service layer with cursor pagination (TDD)
- [ ] 26-02-PLAN.md — Discovery routes, server registration, and client hooks
- [ ] 26-03-PLAN.md — Landing page UI and PublicSetupCard enhancement
Plans:
- [ ] TBD (promote with /gsd:review-backlog when ready)
@@ -165,7 +175,12 @@ Plans:
### Phase 999.2: Revamp Onboarding Flow (BACKLOG)
**Goal**: Redesign the onboarding experience to match the current app style and flow. Replace the manual item edit form with the catalog search function. Visual refresh to align with the newer UI patterns.
**Requirements**: TBD
**Plans**: TBD
**Plans**: 3 plans
Plans:
- [ ] 26-01-PLAN.md — Discovery service layer with cursor pagination (TDD)
- [ ] 26-02-PLAN.md — Discovery routes, server registration, and client hooks
- [ ] 26-03-PLAN.md — Landing page UI and PublicSetupCard enhancement
Plans:
- [ ] TBD (promote with /gsd:review-backlog when ready)
@@ -173,7 +188,12 @@ Plans:
### Phase 999.3: Public Access Auth Model (BACKLOG)
**Goal**: Rework auth so the app is accessible without logging in. Currently all routes require authentication, but public-facing pages (discovery/browse, shared setups, public profiles) should be viewable by unauthenticated users. Auth only required for write operations and personal data.
**Requirements**: TBD
**Plans**: TBD
**Plans**: 3 plans
Plans:
- [ ] 26-01-PLAN.md — Discovery service layer with cursor pagination (TDD)
- [ ] 26-02-PLAN.md — Discovery routes, server registration, and client hooks
- [ ] 26-03-PLAN.md — Landing page UI and PublicSetupCard enhancement
Plans:
- [ ] TBD (promote with /gsd:review-backlog when ready)

View File

@@ -0,0 +1,182 @@
---
phase: 26-discovery-landing-page
plan: 01
type: tdd
wave: 1
depends_on: []
files_modified:
- src/server/services/discovery.service.ts
- tests/services/discovery.service.test.ts
autonomous: true
requirements: [DISC-02, DISC-03, DISC-04, INFR-02]
must_haves:
truths:
- "getPopularSetups returns public setups ordered by item count descending"
- "getRecentGlobalItems returns items ordered by createdAt descending"
- "getTrendingCategories returns categories ordered by item count, excluding nulls"
- "Cursor pagination returns next page without duplicates"
artifacts:
- path: "src/server/services/discovery.service.ts"
provides: "Discovery feed queries with cursor pagination"
exports: ["getPopularSetups", "getRecentGlobalItems", "getTrendingCategories"]
- path: "tests/services/discovery.service.test.ts"
provides: "Unit tests for all three discovery service functions"
min_lines: 100
key_links:
- from: "src/server/services/discovery.service.ts"
to: "src/db/schema.ts"
via: "Drizzle query builders using globalItems, setups, setupItems, users tables"
pattern: "from\\(globalItems\\)|from\\(setups\\)"
---
<objective>
Create the discovery service layer with three query functions: getPopularSetups, getRecentGlobalItems, and getTrendingCategories. All functions use cursor-based pagination per INFR-02 (except categories which use simple limit).
Purpose: Provides the data layer for the discovery landing page feed sections. TDD approach ensures correct ordering, filtering, and pagination before wiring to routes.
Output: `discovery.service.ts` with three exported functions, fully tested.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/26-discovery-landing-page/26-CONTEXT.md
@.planning/phases/26-discovery-landing-page/26-RESEARCH.md
@src/db/schema.ts
@tests/helpers/db.ts
@tests/services/global-item.service.test.ts (pattern reference for test structure)
@src/server/services/global-item.service.ts (pattern reference for service structure)
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Discovery service with TDD — popular setups, recent items, trending categories</name>
<files>src/server/services/discovery.service.ts, tests/services/discovery.service.test.ts</files>
<read_first>
- src/db/schema.ts (table definitions: globalItems, setups, setupItems, users)
- tests/helpers/db.ts (createTestDb pattern)
- tests/services/global-item.service.test.ts (test file structure, insertGlobalItem helper pattern)
- src/server/services/global-item.service.ts (service function patterns — how db param is typed, import style)
</read_first>
<behavior>
- getPopularSetups: returns only public setups (isPublic=true), ordered by setupItems count descending then by id descending. Each result includes id, name, createdAt, itemCount (number), creatorName (string|null from users.displayName). Private setups are excluded.
- getPopularSetups cursor: given cursor "5_42" (itemCount=5, id=42), returns setups where (itemCount < 5) OR (itemCount === 5 AND id < 42). hasMore is true when rows exceed limit.
- getRecentGlobalItems: returns globalItems ordered by createdAt descending. Each result includes all globalItems columns.
- getRecentGlobalItems cursor: given cursor ISO timestamp, returns items where createdAt < cursor timestamp. hasMore is true when rows exceed limit.
- getTrendingCategories: returns { name: string, itemCount: number }[] ordered by itemCount descending. Excludes rows where globalItems.category IS NULL. No cursor pagination (simple limit).
- getTrendingCategories empty: returns empty array when no items have a category set.
</behavior>
<action>
**RED phase — write tests first in `tests/services/discovery.service.test.ts`:**
Use the same test structure as `global-item.service.test.ts`:
- Import `{ beforeEach, describe, expect, it }` from `"bun:test"`
- Import schema tables: `globalItems, setups, setupItems, users` from `../../src/db/schema.ts`
- Import `createTestDb` from `../helpers/db.ts`
- Import service functions from `../../src/server/services/discovery.service.ts`
- Type `TestDb = Awaited<ReturnType<typeof createTestDb>>`
Helper functions needed in test file:
```typescript
async function insertGlobalItem(db, data: { brand: string; model: string; category?: string }) {
const [row] = await db.insert(globalItems).values({ brand: data.brand, model: data.model, category: data.category ?? null }).returning();
return row;
}
async function insertPublicSetup(db, userId: number, name: string, itemIds: number[]) {
const [setup] = await db.insert(setups).values({ name, userId, isPublic: true }).returning();
// Insert items into the items table first, then setupItems
for (const itemId of itemIds) {
await db.insert(setupItems).values({ setupId: setup.id, itemId });
}
return setup;
}
```
Note: `setupItems.itemId` references the `items` table, not `globalItems`. So tests need to insert real `items` rows first (use `db.insert(items).values({ name: "Test", categoryId: 1, userId })`) before creating setupItems.
Write tests for:
1. `getPopularSetups` — seed 2 public setups with different item counts, verify order is by count desc
2. `getPopularSetups` — seed 1 private setup, verify it's excluded
3. `getPopularSetups` — cursor pagination: seed 3 setups, fetch limit=1, verify hasMore=true and nextCursor returned, fetch page 2 with cursor, verify different setup returned
4. `getPopularSetups` — includes creatorName from users.displayName (seed user with displayName, verify it appears)
5. `getRecentGlobalItems` — seed 3 items with different createdAt, verify order is newest first
6. `getRecentGlobalItems` — cursor pagination: fetch limit=1, verify hasMore, fetch page 2 with cursor
7. `getTrendingCategories` — seed items in 3 categories with different counts, verify order by count desc
8. `getTrendingCategories` — seed item with null category, verify it's excluded from results
**GREEN phase — create `src/server/services/discovery.service.ts`:**
Import from drizzle-orm: `count, desc, eq, lt, sql, and, isNotNull`
Import schema: `globalItems, setups, setupItems, users`
Import types: infer Db type the same way as `global-item.service.ts` does
Three exported functions:
`getPopularSetups(db: Db, limit = 6, cursor?: string)`:
- Query: SELECT setups.id, setups.name, setups.createdAt, COUNT(setupItems.id) AS itemCount, users.displayName AS creatorName
- FROM setups LEFT JOIN setupItems ON setupItems.setupId = setups.id LEFT JOIN users ON users.id = setups.userId
- WHERE setups.isPublic = true
- GROUP BY setups.id, setups.name, setups.createdAt, users.displayName
- ORDER BY itemCount DESC, setups.id DESC
- LIMIT limit + 1
For cursor: parse "itemCount_id" format. Use SQL HAVING or WHERE with subquery. Since Drizzle groupBy with cursor is tricky, use the post-filter approach from RESEARCH.md:
- Fetch more rows (limit * 2 + 1 if cursor provided)
- Filter in JS: keep rows where (itemCount < cursorCount) OR (itemCount === cursorCount AND id < cursorId)
- Slice to limit + 1
Return `{ items: T[], nextCursor: string | null, hasMore: boolean }` shape:
- hasMore = rows.length > limit
- items = hasMore ? rows.slice(0, limit) : rows
- nextCursor = hasMore ? `${items[items.length-1].itemCount}_${items[items.length-1].id}` : null
`getRecentGlobalItems(db: Db, limit = 8, cursor?: string)`:
- Query: SELECT * FROM globalItems WHERE (cursor ? createdAt < new Date(cursor) : true) ORDER BY createdAt DESC LIMIT limit + 1
- Return `{ items, nextCursor, hasMore }` — nextCursor is ISO string of last item's createdAt
`getTrendingCategories(db: Db, limit = 12)`:
- Query: SELECT category AS name, COUNT(id) AS itemCount FROM globalItems WHERE category IS NOT NULL GROUP BY category ORDER BY COUNT(id) DESC LIMIT limit
- Return array directly (no cursor pagination per RESEARCH.md open question 3)
**REFACTOR:** Ensure all functions handle edge cases (empty results, no cursor). Extract shared `buildCursorResponse` helper if patterns are identical.
</action>
<verify>
<automated>cd /home/jean-luc-makiola/Development/projects/GearBox && bun test tests/services/discovery.service.test.ts</automated>
</verify>
<acceptance_criteria>
- tests/services/discovery.service.test.ts contains `describe("getPopularSetups"` and `describe("getRecentGlobalItems"` and `describe("getTrendingCategories"`
- tests/services/discovery.service.test.ts contains at least 8 `it(` calls
- src/server/services/discovery.service.ts contains `export async function getPopularSetups(`
- src/server/services/discovery.service.ts contains `export async function getRecentGlobalItems(`
- src/server/services/discovery.service.ts contains `export async function getTrendingCategories(`
- src/server/services/discovery.service.ts contains `isNotNull(globalItems.category)` (null category exclusion)
- src/server/services/discovery.service.ts contains `eq(setups.isPublic, true)` (public-only filter)
- src/server/services/discovery.service.ts contains `nextCursor` and `hasMore` in return shapes
- `bun test tests/services/discovery.service.test.ts` exits 0
</acceptance_criteria>
<done>All three discovery service functions pass their tests: correct ordering, cursor pagination works for setups and items, categories exclude nulls, and hasMore/nextCursor response shape is correct.</done>
</task>
</tasks>
<verification>
- `bun test tests/services/discovery.service.test.ts` — all tests pass
- `bun test` — full suite still green (no regressions)
</verification>
<success_criteria>
- Three exported service functions exist with cursor pagination (setups, items) and simple limit (categories)
- All tests pass covering ordering, filtering, cursor, and edge cases
- Service functions are pure (take db instance, no HTTP awareness)
</success_criteria>
<output>
After completion, create `.planning/phases/26-discovery-landing-page/26-01-SUMMARY.md`
</output>

View File

@@ -0,0 +1,315 @@
---
phase: 26-discovery-landing-page
plan: 02
type: execute
wave: 2
depends_on: [26-01]
files_modified:
- src/server/routes/discovery.ts
- src/server/index.ts
- src/client/hooks/useDiscovery.ts
- tests/routes/discovery.test.ts
autonomous: true
requirements: [DISC-02, DISC-03, DISC-04, INFR-02]
must_haves:
truths:
- "GET /api/discovery/setups returns popular setups for anonymous users"
- "GET /api/discovery/items returns recent catalog items for anonymous users"
- "GET /api/discovery/categories returns trending categories for anonymous users"
- "All discovery endpoints accept limit and cursor query params"
- "Discovery endpoints are rate-limited with browseTier"
artifacts:
- path: "src/server/routes/discovery.ts"
provides: "Hono route handlers for three discovery endpoints"
exports: ["discoveryRoutes"]
- path: "src/client/hooks/useDiscovery.ts"
provides: "React Query hooks for landing page data fetching"
exports: ["useDiscoverySetups", "useDiscoveryItems", "useDiscoveryCategories"]
- path: "tests/routes/discovery.test.ts"
provides: "Route-level integration tests for discovery endpoints"
min_lines: 50
key_links:
- from: "src/server/routes/discovery.ts"
to: "src/server/services/discovery.service.ts"
via: "imports getPopularSetups, getRecentGlobalItems, getTrendingCategories"
pattern: "from.*discovery\\.service"
- from: "src/server/index.ts"
to: "src/server/routes/discovery.ts"
via: "app.route registration and auth skip"
pattern: "discoveryRoutes|/api/discovery"
- from: "src/client/hooks/useDiscovery.ts"
to: "/api/discovery"
via: "apiGet fetch calls"
pattern: "apiGet.*api/discovery"
---
<objective>
Wire the discovery service to HTTP endpoints and create client-side React Query hooks. Register routes in server/index.ts with public access (auth skip) and browseTier rate limiting.
Purpose: Makes the discovery feed data accessible to the landing page UI via three REST endpoints and three React Query hooks.
Output: Working endpoints at `/api/discovery/{setups,items,categories}`, matching client hooks, route-level tests.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/26-discovery-landing-page/26-CONTEXT.md
@.planning/phases/26-discovery-landing-page/26-RESEARCH.md
@.planning/phases/26-discovery-landing-page/26-01-SUMMARY.md
<interfaces>
<!-- From plan 01: discovery service exports -->
From src/server/services/discovery.service.ts:
```typescript
export async function getPopularSetups(db: Db, limit?: number, cursor?: string): Promise<{ items: PopularSetup[], nextCursor: string | null, hasMore: boolean }>
export async function getRecentGlobalItems(db: Db, limit?: number, cursor?: string): Promise<{ items: GlobalItemRow[], nextCursor: string | null, hasMore: boolean }>
export async function getTrendingCategories(db: Db, limit?: number): Promise<{ name: string, itemCount: number }[]>
```
From src/server/routes/global-items.ts (pattern reference):
```typescript
type Env = { Variables: { db?: any } };
const app = new Hono<Env>();
app.get("/", async (c) => { ... });
export { app as globalItemRoutes };
```
From src/server/index.ts (auth skip pattern, lines 151-170):
```typescript
// Skip public global-items endpoint (GET /api/global-items)
if (c.req.path.startsWith("/api/global-items") && c.req.method === "GET")
return next();
```
From src/server/index.ts (rate limit pattern, lines 126-134):
```typescript
app.use("/api/global-items", async (c, next) => {
if (c.req.method === "GET" && !c.req.path.match(/^\/api\/global-items\/\d+$/))
return browseTier(c, next);
return next();
});
```
From src/client/hooks/useGlobalItems.ts (hook pattern):
```typescript
import { useQuery } from "@tanstack/react-query";
import { apiGet } from "../lib/api";
export function useGlobalItems(query?: string, tags?: string[]) {
return useQuery({
queryKey: ["global-items", query ?? "", tags ?? []],
queryFn: () => apiGet<GlobalItem[]>(`/api/global-items${qs ? `?${qs}` : ""}`),
});
}
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Discovery routes, server registration, and route tests</name>
<files>src/server/routes/discovery.ts, src/server/index.ts, tests/routes/discovery.test.ts</files>
<read_first>
- src/server/routes/global-items.ts (exact Hono route pattern to replicate)
- src/server/index.ts (auth skip list at lines 151-170, rate limit setup at lines 120-148, route registration at lines 173-183)
- tests/routes/global-items.test.ts (route test pattern: createTestApp, middleware setup, request format)
- src/server/services/discovery.service.ts (function signatures from plan 01)
</read_first>
<action>
**Create `src/server/routes/discovery.ts`:**
Follow the exact pattern of `global-items.ts`:
```typescript
import { Hono } from "hono";
import { getPopularSetups, getRecentGlobalItems, getTrendingCategories } from "../services/discovery.service.ts";
type Env = { Variables: { db?: any } };
const app = new Hono<Env>();
```
Three GET handlers:
`app.get("/setups", ...)`:
- Parse query params: `limit` (parseInt, default 6, max 50), `cursor` (string, optional)
- Call `getPopularSetups(db, limit, cursor)`
- Return `c.json(result)` — result already has `{ items, nextCursor, hasMore }` shape
`app.get("/items", ...)`:
- Parse query params: `limit` (parseInt, default 8, max 50), `cursor` (string, optional)
- Call `getRecentGlobalItems(db, limit, cursor)`
- Return `c.json(result)`
`app.get("/categories", ...)`:
- Parse query params: `limit` (parseInt, default 12, max 50)
- Call `getTrendingCategories(db, limit)`
- Return `c.json(result)` — result is array directly
Export: `export { app as discoveryRoutes };`
**Modify `src/server/index.ts`:**
1. Add import at top (after line 14, near other route imports):
`import { discoveryRoutes } from "./routes/discovery.ts";`
2. Add rate limiting for discovery endpoints (after line 134, in the browse tier section):
```typescript
app.use("/api/discovery/*", async (c, next) => {
if (c.req.method === "GET") return browseTier(c, next);
return next();
});
```
3. Add auth skip in the auth middleware block (after line 167, before the requireAuth call):
```typescript
// Skip public discovery endpoints (GET /api/discovery/*)
if (c.req.path.startsWith("/api/discovery") && c.req.method === "GET")
return next();
```
4. Add route registration (after line 183, near other app.route calls):
`app.route("/api/discovery", discoveryRoutes);`
**Create `tests/routes/discovery.test.ts`:**
Follow the exact test pattern from `global-items.test.ts`:
```typescript
import { beforeEach, describe, expect, it } from "bun:test";
import { Hono } from "hono";
import { globalItems, items, setups, setupItems } from "../../src/db/schema.ts";
import { discoveryRoutes } from "../../src/server/routes/discovery.ts";
import { createTestDb } from "../helpers/db.ts";
async function createTestApp() {
const { db, userId } = await createTestDb();
const app = new Hono();
app.use("*", async (c, next) => {
c.set("db", db);
await next();
});
// Note: NO userId set — discovery endpoints don't need auth
app.route("/api/discovery", discoveryRoutes);
return { app, db, userId };
}
```
Tests (minimum 6):
1. `GET /api/discovery/setups` returns 200 with `{ items, nextCursor, hasMore }` shape
2. `GET /api/discovery/items` returns 200 with `{ items, nextCursor, hasMore }` shape
3. `GET /api/discovery/categories` returns 200 with array shape
4. `GET /api/discovery/setups?limit=1` respects limit param
5. `GET /api/discovery/items?limit=1&cursor=<timestamp>` pagination works
6. `GET /api/discovery/categories?limit=2` respects limit param
For each test, seed appropriate data using db.insert() then make fetch requests via `app.request("/api/discovery/...")`.
</action>
<verify>
<automated>cd /home/jean-luc-makiola/Development/projects/GearBox && bun test tests/routes/discovery.test.ts</automated>
</verify>
<acceptance_criteria>
- src/server/routes/discovery.ts contains `app.get("/setups"` and `app.get("/items"` and `app.get("/categories"`
- src/server/routes/discovery.ts contains `export { app as discoveryRoutes }`
- src/server/index.ts contains `import { discoveryRoutes }` from `"./routes/discovery.ts"`
- src/server/index.ts contains `app.route("/api/discovery", discoveryRoutes)`
- src/server/index.ts contains `c.req.path.startsWith("/api/discovery")` in auth skip section
- src/server/index.ts contains `"/api/discovery/*"` in rate limit section with `browseTier`
- tests/routes/discovery.test.ts contains at least 6 `it(` calls
- `bun test tests/routes/discovery.test.ts` exits 0
</acceptance_criteria>
<done>Three discovery endpoints respond to GET requests with correct JSON shapes, anonymous access works (no auth required), rate limiting is applied, and route tests pass.</done>
</task>
<task type="auto">
<name>Task 2: Client-side React Query hooks for discovery data</name>
<files>src/client/hooks/useDiscovery.ts</files>
<read_first>
- src/client/hooks/useGlobalItems.ts (hook pattern: useQuery, apiGet, interface definitions, queryKey structure)
- src/client/lib/api.ts (apiGet signature)
</read_first>
<action>
Create `src/client/hooks/useDiscovery.ts` with three named exports.
**Type definitions** at top of file:
```typescript
export interface DiscoverySetup {
id: number;
name: string;
createdAt: string;
itemCount: number;
creatorName: string | null;
}
export interface DiscoveryCategory {
name: string;
itemCount: number;
}
interface CursorPage<T> {
items: T[];
nextCursor: string | null;
hasMore: boolean;
}
```
For GlobalItem type, import from useGlobalItems or re-define inline matching the existing `GlobalItem` interface in `useGlobalItems.ts` (id, brand, model, category, weightGrams, priceCents, imageUrl, description, sourceUrl, imageCredit, imageSourceUrl, createdAt — all as they appear in that file).
**Three hooks:**
`useDiscoverySetups(limit = 6)`:
- queryKey: `["discovery", "setups", limit]`
- queryFn: `apiGet<CursorPage<DiscoverySetup>>(`/api/discovery/setups?limit=${limit}`)`
- staleTime: `2 * 60 * 1000` (2 minutes)
`useDiscoveryItems(limit = 8)`:
- queryKey: `["discovery", "items", limit]`
- queryFn: `apiGet<CursorPage<GlobalItem>>(`/api/discovery/items?limit=${limit}`)`
- staleTime: `2 * 60 * 1000` (2 minutes)
`useDiscoveryCategories(limit = 12)`:
- queryKey: `["discovery", "categories", limit]`
- queryFn: `apiGet<DiscoveryCategory[]>(`/api/discovery/categories?limit=${limit}`)`
- staleTime: `5 * 60 * 1000` (5 minutes — categories change rarely)
Import `useQuery` from `@tanstack/react-query` and `apiGet` from `../lib/api`.
</action>
<verify>
<automated>cd /home/jean-luc-makiola/Development/projects/GearBox && npx tsc --noEmit src/client/hooks/useDiscovery.ts 2>&1 || echo "Checking with bun build" && bun build src/client/hooks/useDiscovery.ts --outdir /tmp/check-discovery --external react --external @tanstack/react-query 2>&1</automated>
</verify>
<acceptance_criteria>
- src/client/hooks/useDiscovery.ts contains `export function useDiscoverySetups(`
- src/client/hooks/useDiscovery.ts contains `export function useDiscoveryItems(`
- src/client/hooks/useDiscovery.ts contains `export function useDiscoveryCategories(`
- src/client/hooks/useDiscovery.ts contains `export interface DiscoverySetup`
- src/client/hooks/useDiscovery.ts contains `export interface DiscoveryCategory`
- src/client/hooks/useDiscovery.ts contains `staleTime: 2 * 60 * 1000` (for setups and items)
- src/client/hooks/useDiscovery.ts contains `staleTime: 5 * 60 * 1000` (for categories)
- src/client/hooks/useDiscovery.ts contains `queryKey: ["discovery",` for all three hooks
- src/client/hooks/useDiscovery.ts contains `apiGet` import from `../lib/api`
</acceptance_criteria>
<done>Three React Query hooks export correctly with proper types, query keys, stale times, and API endpoint URLs matching the server routes.</done>
</task>
</tasks>
<verification>
- `bun test tests/routes/discovery.test.ts` — route tests pass
- `bun test` — full suite green
- `bun run build` — client builds without TypeScript errors
</verification>
<success_criteria>
- Three GET endpoints at /api/discovery/{setups,items,categories} respond to anonymous requests
- Endpoints are rate-limited with browseTier
- Three React Query hooks ready for consumption by the landing page
- Route-level tests verify response shapes and status codes
</success_criteria>
<output>
After completion, create `.planning/phases/26-discovery-landing-page/26-02-SUMMARY.md`
</output>

View File

@@ -0,0 +1,477 @@
---
phase: 26-discovery-landing-page
plan: 03
type: execute
wave: 2
depends_on: [26-01]
files_modified:
- src/client/routes/index.tsx
- src/client/components/PublicSetupCard.tsx
- src/client/routes/users/$userId.tsx
autonomous: false
requirements: [DISC-01, DISC-02, DISC-03, DISC-04, DISC-05]
must_haves:
truths:
- "Root URL shows a hero section with a catalog search bar"
- "Clicking the search bar opens CatalogSearchOverlay"
- "Popular setups section shows setup cards with item counts"
- "Recently added items section shows GlobalItemCard components"
- "Trending categories section shows category names with item counts"
- "Authenticated users see Go to Collection link in hero"
- "Anonymous users see the page without login redirect"
artifacts:
- path: "src/client/routes/index.tsx"
provides: "Landing page with hero, popular setups, recent items, trending categories"
min_lines: 80
- path: "src/client/components/PublicSetupCard.tsx"
provides: "Enhanced setup card with optional itemCount and creatorName"
contains: "itemCount"
key_links:
- from: "src/client/routes/index.tsx"
to: "src/client/hooks/useDiscovery.ts"
via: "imports useDiscoverySetups, useDiscoveryItems, useDiscoveryCategories"
pattern: "from.*useDiscovery"
- from: "src/client/routes/index.tsx"
to: "src/client/stores/uiStore.ts"
via: "openCatalogSearch trigger from hero"
pattern: "openCatalogSearch"
- from: "src/client/routes/index.tsx"
to: "src/client/hooks/useAuth.ts"
via: "auth state for conditional Go to Collection CTA"
pattern: "useAuth"
- from: "src/client/routes/index.tsx"
to: "src/client/components/GlobalItemCard.tsx"
via: "renders catalog items in recent items section"
pattern: "GlobalItemCard"
- from: "src/client/routes/index.tsx"
to: "src/client/components/PublicSetupCard.tsx"
via: "renders setup cards in popular setups section"
pattern: "PublicSetupCard"
---
<objective>
Rewrite the landing page at `/` from a personal dashboard to a public discovery page. Enhance PublicSetupCard with itemCount and creatorName. Build hero section with search trigger, three content feed sections, and conditional auth CTA.
Purpose: This is the user-facing deliverable — the page visitors see first. Composes existing components with new discovery hooks into the layout specified by D-01 through D-11.
Output: Complete landing page at `/`, enhanced PublicSetupCard.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/26-discovery-landing-page/26-CONTEXT.md
@.planning/phases/26-discovery-landing-page/26-RESEARCH.md
@.planning/phases/26-discovery-landing-page/26-01-SUMMARY.md
<interfaces>
<!-- From plan 02: client hooks -->
From src/client/hooks/useDiscovery.ts:
```typescript
export interface DiscoverySetup {
id: number;
name: string;
createdAt: string;
itemCount: number;
creatorName: string | null;
}
export interface DiscoveryCategory {
name: string;
itemCount: number;
}
export function useDiscoverySetups(limit?: number): UseQueryResult
export function useDiscoveryItems(limit?: number): UseQueryResult
export function useDiscoveryCategories(limit?: number): UseQueryResult
```
From src/client/hooks/useAuth.ts:
```typescript
interface AuthState {
user: { id: string; email?: string } | null;
authenticated: boolean;
}
export function useAuth(): UseQueryResult<AuthState>
```
From src/client/stores/uiStore.ts:
```typescript
openCatalogSearch: (mode: "collection" | "thread") => void;
// Access via: const openCatalogSearch = useUIStore((s) => s.openCatalogSearch);
```
From src/client/components/GlobalItemCard.tsx:
```typescript
interface GlobalItemCardProps {
id: number; brand: string; model: string; category: string | null;
weightGrams: number | null; priceCents: number | null; imageUrl: string | null;
}
export function GlobalItemCard(props: GlobalItemCardProps): JSX.Element
```
From src/client/components/PublicSetupCard.tsx (current — will be enhanced):
```typescript
interface PublicSetupCardProps {
setup: { id: number; name: string; createdAt: string; };
}
export function PublicSetupCard({ setup }: PublicSetupCardProps): JSX.Element
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Enhance PublicSetupCard with itemCount and creatorName</name>
<files>src/client/components/PublicSetupCard.tsx, src/client/routes/users/$userId.tsx</files>
<read_first>
- src/client/components/PublicSetupCard.tsx (current component — full content)
- src/client/routes/users/$userId.tsx (existing usage of PublicSetupCard — check what shape is passed)
</read_first>
<action>
**Modify `src/client/components/PublicSetupCard.tsx`:**
Update the `PublicSetupCardProps` interface to add optional fields (per Pitfall 3 — keep optional to avoid breaking existing usages):
```typescript
interface PublicSetupCardProps {
setup: {
id: number;
name: string;
createdAt: string;
itemCount?: number; // NEW — optional for backward compat
creatorName?: string | null; // NEW — optional
};
}
```
Update the component JSX to display the new fields when present:
After the existing `<h3>` (setup name) and before/replacing the `<p>` date line, render:
- If `setup.creatorName` is truthy: `<p className="text-xs text-gray-400 mt-1">by {setup.creatorName}</p>`
- If `setup.itemCount` is defined and > 0: `<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-50 text-blue-400">{setup.itemCount} items</span>`
- Keep the existing date display
Layout for the bottom area of the card (below the name):
```tsx
<div className="flex items-center justify-between mt-2">
<div className="flex items-center gap-2">
{setup.itemCount != null && setup.itemCount > 0 && (
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-50 text-blue-400">
{setup.itemCount} {setup.itemCount === 1 ? "item" : "items"}
</span>
)}
</div>
<p className="text-xs text-gray-400">{formattedDate}</p>
</div>
{setup.creatorName && (
<p className="text-xs text-gray-400 mt-1">by {setup.creatorName}</p>
)}
```
Add `cursor-pointer` to the Link className (folded todo from CONTEXT.md).
**Verify `src/client/routes/users/$userId.tsx`:**
Read the file to confirm the existing `PublicSetupCard` usage still compiles. Since `itemCount` and `creatorName` are optional, the existing usage passing `{ id, name, createdAt }` will continue to work without changes. No modification needed to this file unless it already passes extra props.
</action>
<verify>
<automated>cd /home/jean-luc-makiola/Development/projects/GearBox && bun run build 2>&1 | tail -20</automated>
</verify>
<acceptance_criteria>
- src/client/components/PublicSetupCard.tsx contains `itemCount?: number`
- src/client/components/PublicSetupCard.tsx contains `creatorName?: string | null`
- src/client/components/PublicSetupCard.tsx contains `setup.itemCount` (conditional rendering)
- src/client/components/PublicSetupCard.tsx contains `setup.creatorName` (conditional rendering)
- src/client/components/PublicSetupCard.tsx contains `cursor-pointer`
- `bun run build` succeeds without TypeScript errors
</acceptance_criteria>
<done>PublicSetupCard renders item count and creator name when provided, existing usages compile without changes, cursor-pointer applied.</done>
</task>
<task type="auto">
<name>Task 2: Rewrite index.tsx as discovery landing page</name>
<files>src/client/routes/index.tsx</files>
<read_first>
- src/client/routes/index.tsx (current dashboard — will be completely rewritten)
- src/client/components/GlobalItemCard.tsx (props interface for rendering items)
- src/client/components/PublicSetupCard.tsx (enhanced props from Task 1)
- src/client/hooks/useDiscovery.ts (hook signatures from plan 02)
- src/client/hooks/useAuth.ts (useAuth hook pattern)
- src/client/stores/uiStore.ts (openCatalogSearch usage pattern)
- src/client/routes/__root.tsx (isDashboard detection — do NOT modify per Pitfall 2)
</read_first>
<action>
**Completely rewrite `src/client/routes/index.tsx`** (per D-03, retire DashboardPage entirely):
Remove ALL existing imports (DashboardCard, useFormatters, useSetups, useThreads, useTotals).
New imports:
```typescript
import { createFileRoute, Link } from "@tanstack/react-router";
import { Search } from "lucide-react";
import { GlobalItemCard } from "../components/GlobalItemCard";
import { PublicSetupCard } from "../components/PublicSetupCard";
import { useAuth } from "../hooks/useAuth";
import { useDiscoverySetups, useDiscoveryItems, useDiscoveryCategories } from "../hooks/useDiscovery";
import { useUIStore } from "../stores/uiStore";
```
Note: Use `lucide-react` for the Search icon. The project already uses lucide-react (check existing imports). If the project uses the custom `LucideIcon` component instead, use `<LucideIcon name="search" className="w-5 h-5 text-gray-400" />` from `../lib/iconData`.
**Route export** (same file route, new component):
```typescript
export const Route = createFileRoute("/")({
component: LandingPage,
});
```
**LandingPage function** (per D-01, D-02):
```typescript
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">
<HeroSection isAuthenticated={isAuthenticated} onSearchFocus={() => openCatalogSearch("collection")} />
<PopularSetupsSection />
<RecentItemsSection />
<TrendingCategoriesSection />
</div>
);
}
```
**HeroSection** (per D-01, D-04, D-10, D-11):
```typescript
function HeroSection({ isAuthenticated, onSearchFocus }: { isAuthenticated: boolean; onSearchFocus: () => void }) {
return (
<div className="text-center mb-16">
<h1 className="text-3xl sm:text-4xl font-bold text-gray-900 mb-2">Discover Gear</h1>
<p className="text-gray-500 mb-8">Browse what other people carry</p>
<div
onClick={onSearchFocus}
onKeyDown={(e) => e.key === "Enter" && onSearchFocus()}
role="button"
tabIndex={0}
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"
>
<Search className="w-5 h-5 text-gray-400 shrink-0" />
<span className="text-sm text-gray-400 flex-1 text-left">Search the catalog...</span>
</div>
{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>
);
}
```
**PopularSetupsSection** (per D-02, D-05):
```typescript
function PopularSetupsSection() {
const { data, isLoading } = useDiscoverySetups(6);
const setups = data?.items ?? [];
if (!isLoading && setups.length === 0) return null;
return (
<section className="mb-12">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-gray-900">Popular Setups</h2>
</div>
{isLoading ? (
<SectionSkeleton count={6} aspect="none" />
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{setups.map((setup) => (
<PublicSetupCard key={setup.id} setup={setup} />
))}
</div>
)}
</section>
);
}
```
**RecentItemsSection** (per D-02, D-06):
```typescript
function RecentItemsSection() {
const { data, isLoading } = useDiscoveryItems(8);
const items = data?.items ?? [];
if (!isLoading && items.length === 0) return null;
return (
<section className="mb-12">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-gray-900">Recently Added</h2>
</div>
{isLoading ? (
<SectionSkeleton count={8} aspect="[4/3]" />
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4">
{items.map((item) => (
<GlobalItemCard
key={item.id}
id={item.id}
brand={item.brand}
model={item.model}
category={item.category}
weightGrams={item.weightGrams}
priceCents={item.priceCents}
imageUrl={item.imageUrl}
/>
))}
</div>
)}
</section>
);
}
```
**TrendingCategoriesSection** (per D-02, D-07):
```typescript
function TrendingCategoriesSection() {
const { data, isLoading } = useDiscoveryCategories(12);
const categories = data ?? [];
if (!isLoading && categories.length === 0) return null;
return (
<section className="mb-12">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-gray-900">Trending Categories</h2>
</div>
{isLoading ? (
<div className="flex flex-wrap gap-2">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="h-8 w-24 bg-gray-100 rounded-full animate-pulse" />
))}
</div>
) : (
<div className="flex flex-wrap gap-2">
{categories.map((cat) => (
<span
key={cat.name}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm font-medium bg-gray-50 text-gray-700 border border-gray-100 hover:border-gray-200 hover:bg-gray-100 transition-all cursor-pointer"
>
{cat.name}
<span className="text-xs text-gray-400">{cat.itemCount}</span>
</span>
))}
</div>
)}
</section>
);
}
```
**SectionSkeleton** helper (matches CatalogSearchOverlay animate-pulse pattern):
```typescript
function SectionSkeleton({ count, aspect }: { count: number; aspect: string }) {
return (
<div className={`grid ${aspect === "none" ? "grid-cols-1 sm:grid-cols-2 lg:grid-cols-3" : "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">
{aspect !== "none" && <div className={`aspect-${aspect} 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>
);
}
```
Ensure all clickable elements have `cursor-pointer` (folded todo from CONTEXT.md).
</action>
<verify>
<automated>cd /home/jean-luc-makiola/Development/projects/GearBox && bun run build 2>&1 | tail -20</automated>
</verify>
<acceptance_criteria>
- src/client/routes/index.tsx does NOT contain `DashboardPage` or `DashboardCard` or `useTotals` (per D-03)
- src/client/routes/index.tsx contains `function LandingPage()`
- src/client/routes/index.tsx contains `function HeroSection(`
- src/client/routes/index.tsx contains `openCatalogSearch("collection")` (per D-04)
- src/client/routes/index.tsx contains `useDiscoverySetups` and `useDiscoveryItems` and `useDiscoveryCategories`
- src/client/routes/index.tsx contains `"Go to Collection"` (per D-10)
- src/client/routes/index.tsx contains `!!auth?.user` or `auth?.user` for auth check (per anti-pattern: do not use auth?.authenticated)
- src/client/routes/index.tsx contains `GlobalItemCard` import
- src/client/routes/index.tsx contains `PublicSetupCard` import
- src/client/routes/index.tsx contains `cursor-pointer` on the search bar div
- src/client/routes/index.tsx contains `animate-pulse` (loading skeletons)
- `bun run build` succeeds without errors
</acceptance_criteria>
<done>Landing page renders hero with search trigger, three feed sections with loading skeletons and empty-state handling, authenticated CTA, and all clickable elements have cursor-pointer. Build succeeds.</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<name>Task 3: Visual verification of discovery landing page</name>
<files>src/client/routes/index.tsx</files>
<action>
No code changes — this is a visual verification checkpoint. The executor should start the dev server with `bun run dev` and present the verification steps to the user.
</action>
<what-built>
Complete discovery landing page replacing the personal dashboard at /. Features:
- Hero section with "Discover Gear" heading and catalog search bar trigger
- Popular Setups section with enhanced cards (item count, creator name)
- Recently Added Items section with GlobalItemCard components
- Trending Categories section with category pills
- Conditional "Go to Collection" link for authenticated users
- Loading skeletons for all sections
- Empty state handling (sections hide when no data)
</what-built>
<how-to-verify>
1. Run `bun run dev` and open http://localhost:5173/ in a browser
2. Verify the hero section shows "Discover Gear" heading with search bar
3. Click the search bar — it should open the full CatalogSearchOverlay
4. Verify sections appear below: Popular Setups, Recently Added, Trending Categories
5. If there is seed data, verify cards show correct information (item counts, creator names, images)
6. If no data exists, verify empty sections are hidden gracefully (no broken/empty grids)
7. Log in and verify "Go to Collection" link appears in hero area
8. Click "Go to Collection" — should navigate to /collection
9. Check responsive behavior: resize browser to mobile width, verify single-column layout
10. Verify all clickable elements show pointer cursor on hover
</how-to-verify>
<verify>
<automated>cd /home/jean-luc-makiola/Development/projects/GearBox && bun run build 2>&1 | tail -5</automated>
</verify>
<done>User has visually verified the landing page renders correctly with all sections, search trigger works, auth CTA appears for logged-in users, and responsive layout is correct.</done>
<resume-signal>Type "approved" or describe issues to fix</resume-signal>
</task>
</tasks>
<verification>
- `bun run build` — builds without errors
- Visual inspection of landing page at localhost:5173
- CatalogSearchOverlay opens from hero search bar
- Authenticated user sees "Go to Collection" link
- Anonymous user sees content immediately
</verification>
<success_criteria>
- Root URL shows discovery landing page (not personal dashboard)
- Hero search bar triggers CatalogSearchOverlay on click
- Three content sections render with real data or hide when empty
- PublicSetupCard displays item count and creator name
- Authenticated users see "Go to Collection" CTA in hero
- All clickable elements have cursor-pointer
- Build succeeds
</success_criteria>
<output>
After completion, create `.planning/phases/26-discovery-landing-page/26-03-SUMMARY.md`
</output>