--- 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\\)" --- 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. @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md @.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>` 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) - 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) After completion, create `.planning/phases/26-discovery-landing-page/26-01-SUMMARY.md`