---
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>
