Files
GearBox/.planning/phases/26-discovery-landing-page/26-01-PLAN.md

10 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
26-discovery-landing-page 01 tdd 1
src/server/services/discovery.service.ts
tests/services/discovery.service.test.ts
true
DISC-02
DISC-03
DISC-04
INFR-02
truths artifacts key_links
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
path provides exports
src/server/services/discovery.service.ts Discovery feed queries with cursor pagination
getPopularSetups
getRecentGlobalItems
getTrendingCategories
path provides min_lines
tests/services/discovery.service.test.ts Unit tests for all three discovery service functions 100
from to via pattern
src/server/services/discovery.service.ts src/db/schema.ts Drizzle query builders using globalItems, setups, setupItems, users tables from(globalItems)|from(setups)
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.

<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_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) Task 1: Discovery service with TDD — popular setups, recent items, trending categories src/server/services/discovery.service.ts, tests/services/discovery.service.test.ts - 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) - 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. **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.
cd /home/jean-luc-makiola/Development/projects/GearBox && bun test tests/services/discovery.service.test.ts - 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 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. - `bun test tests/services/discovery.service.test.ts` — all tests pass - `bun test` — full suite still green (no regressions)

<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>
After completion, create `.planning/phases/26-discovery-landing-page/26-01-SUMMARY.md`