# ComparAIson v0.2–v0.4 Implementation Plan > **For Hermes:** Use subagent-driven-development skill to implement this plan task-by-task. **Goal:** Replace all mock data with real API-backed pages, wire auth into the comparison flow, and add search + management features. **Architecture:** Next.js App Router API routes backed by existing Drizzle ORM schema. Server actions for simple reads, REST endpoints for complex queries. Better Auth sessions for user identity. **Tech Stack:** Next.js 15, Drizzle ORM, PostgreSQL, Better Auth, shadcn/ui, Recharts --- ## Dependency Graph ``` v0.2 Feed & Profile (issues #1-#4) #1 API: Public feed endpoint ──────┐ #2 API: User comparisons endpoint ─┤ ├──> #3 Wire Explore page ├──> #4 Wire Profile page v0.3 Auth Integration (issue #5) #5 Associate comparisons with users v0.4 Search & Polish (issues #6-#8) #6 Functional search ──> depends on #3 #7 Delete/toggle visibility ──> depends on #5 #8 Loading/error states ──> can start anytime ``` ## Recommended Execution Order 1. **#1** — API: Public feed endpoint (no dependencies) 2. **#2** — API: User comparisons endpoint (no dependencies) 3. **#3** — Wire Explore page (depends on #1) 4. **#4** — Wire Profile page (depends on #2) 5. **#5** — Associate comparisons with users (foundational for v0.3) 6. **#6** — Functional search (depends on #3) 7. **#7** — Delete/toggle visibility (depends on #5) 8. **#8** — Loading/error states (independent, can parallelize) --- ## Phase 1: v0.2 — Feed & Profile (Milestone #5) ### Task 1.1: Create GET /api/comparisons route (#1) **Objective:** Paginated public feed API endpoint. **Files:** - Create: `src/app/api/comparisons/route.ts` **Step 1: Create the route file** ```typescript // src/app/api/comparisons/route.ts import { db } from "@/lib/db"; import { comparisons, comparisonItems } from "@/lib/db/schema"; import { eq, and, ilike, desc, sql } from "drizzle-orm"; export async function GET(request: Request) { const { searchParams } = new URL(request.url); const page = Math.max(1, Number(searchParams.get("page")) || 1); const limit = Math.min(50, Math.max(1, Number(searchParams.get("limit")) || 20)); const search = searchParams.get("search")?.trim() || ""; const offset = (page - 1) * limit; const conditions = [ eq(comparisons.isPublic, true), eq(comparisons.status, "completed"), ]; if (search) { conditions.push(ilike(comparisons.title, `%${search}%`)); } const where = and(...conditions); const [rows, countResult] = await Promise.all([ db .select() .from(comparisons) .where(where) .orderBy(desc(comparisons.createdAt)) .limit(limit) .offset(offset), db .select({ count: sql`count(*)` }) .from(comparisons) .where(where), ]); const total = countResult[0]?.count ?? 0; // Fetch item names for each comparison const comparisonIds = rows.map((r) => r.id); const items = comparisonIds.length ? await db .select({ comparisonId: comparisonItems.comparisonId, name: comparisonItems.name, }) .from(comparisonItems) .where(sql`${comparisonItems.comparisonId} IN ${comparisonIds}`) : []; const itemsByComparison = items.reduce>( (acc, item) => { if (!acc[item.comparisonId]) acc[item.comparisonId] = []; acc[item.comparisonId].push(item.name); return acc; }, {} ); return Response.json({ comparisons: rows.map((row) => ({ id: row.id, title: row.title, summary: row.summary?.slice(0, 200) || "", slug: row.slug, tags: row.tags || [], items: itemsByComparison[row.id] || [], viewCount: row.viewCount, createdAt: row.createdAt.toISOString(), })), total, page, limit, }); } ``` **Step 2: Create GET /api/comparisons/[slug]/route.ts** ```typescript // src/app/api/comparisons/[slug]/route.ts import { db } from "@/lib/db"; import { comparisons, comparisonItems } from "@/lib/db/schema"; import { eq, sql } from "drizzle-orm"; import { getComparison } from "@/app/actions/comparison"; export async function GET( request: Request, { params }: { params: Promise<{ slug: string }> } ) { const { slug } = await params; // Increment view count await db .update(comparisons) .set({ viewCount: sql`${comparisons.viewCount} + 1` }) .where(eq(comparisons.slug, slug)); const data = await getComparison(slug); if (!data) { return Response.json({ error: "Not found" }, { status: 404 }); } return Response.json(data); } ``` **Step 3: Verify** ```bash npm run build # check for type errors ``` --- ### Task 1.2: Create GET /api/user/comparisons and /api/user/stats (#2) **Objective:** User-specific comparison listing and stats. **Files:** - Create: `src/app/api/user/comparisons/route.ts` - Create: `src/app/api/user/stats/route.ts` **Step 1: User comparisons endpoint** ```typescript // src/app/api/user/comparisons/route.ts import { db } from "@/lib/db"; import { comparisons, comparisonItems } from "@/lib/db/schema"; import { eq, desc, sql } from "drizzle-orm"; import { auth } from "@/lib/auth"; import { headers } from "next/headers"; export async function GET(request: Request) { const session = await auth.api.getSession({ headers: await headers() }); if (!session?.user) { return Response.json({ error: "Unauthorized" }, { status: 401 }); } const { searchParams } = new URL(request.url); const page = Math.max(1, Number(searchParams.get("page")) || 1); const limit = Math.min(50, Math.max(1, Number(searchParams.get("limit")) || 20)); const offset = (page - 1) * limit; const userId = session.user.id; const [rows, countResult] = await Promise.all([ db .select() .from(comparisons) .where(eq(comparisons.userId, userId)) .orderBy(desc(comparisons.createdAt)) .limit(limit) .offset(offset), db .select({ count: sql`count(*)` }) .from(comparisons) .where(eq(comparisons.userId, userId)), ]); const total = countResult[0]?.count ?? 0; const comparisonIds = rows.map((r) => r.id); const items = comparisonIds.length ? await db .select({ comparisonId: comparisonItems.comparisonId, name: comparisonItems.name, }) .from(comparisonItems) .where(sql`${comparisonItems.comparisonId} IN ${comparisonIds}`) : []; const itemsByComparison = items.reduce>( (acc, item) => { if (!acc[item.comparisonId]) acc[item.comparisonId] = []; acc[item.comparisonId].push(item.name); return acc; }, {} ); return Response.json({ comparisons: rows.map((row) => ({ id: row.id, title: row.title, slug: row.slug, tags: row.tags || [], items: itemsByComparison[row.id] || [], status: row.status, isPublic: row.isPublic, viewCount: row.viewCount, createdAt: row.createdAt.toISOString(), })), total, page, }); } ``` **Step 2: User stats endpoint** ```typescript // src/app/api/user/stats/route.ts import { db } from "@/lib/db"; import { comparisons } from "@/lib/db/schema"; import { eq, sql } from "drizzle-orm"; import { auth } from "@/lib/auth"; import { headers } from "next/headers"; export async function GET() { const session = await auth.api.getSession({ headers: await headers() }); if (!session?.user) { return Response.json({ error: "Unauthorized" }, { status: 401 }); } const userId = session.user.id; const stats = await db .select({ totalComparisons: sql`count(*)`, totalViews: sql`coalesce(sum(${comparisons.viewCount}), 0)`, }) .from(comparisons) .where(eq(comparisons.userId, userId)); return Response.json({ totalComparisons: stats[0]?.totalComparisons ?? 0, totalViews: stats[0]?.totalViews ?? 0, }); } ``` **Step 3: Verify** ```bash npm run build ``` --- ### Task 1.3: Wire Explore page (#3) **Objective:** Replace mock data with real API calls. **Files:** - Modify: `src/app/(main)/explore/page.tsx` **Step 1: Replace mock data with fetch logic** Replace the entire `allComparisons` array and `categories` with: ```typescript "use client" import { useState, useEffect, useCallback } from "react" // ... existing imports ... interface ExploreComparison { id: string title: string summary: string slug: string tags: string[] items: string[] viewCount: number createdAt: string } export default function ExplorePage() { const [searchQuery, setSearchQuery] = useState("") const [comparisons, setComparisons] = useState([]) const [total, setTotal] = useState(0) const [page, setPage] = useState(1) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const limit = 20 const fetchComparisons = useCallback(async (search: string, pageNum: number) => { setLoading(true) setError(null) try { const params = new URLSearchParams({ page: String(pageNum), limit: String(limit), }) if (search) params.set("search", search) const res = await fetch(`/api/comparisons?${params}`) if (!res.ok) throw new Error("Failed to fetch") const data = await res.json() if (pageNum === 1) { setComparisons(data.comparisons) } else { setComparisons((prev) => [...prev, ...data.comparisons]) } setTotal(data.total) } catch (err) { setError(err instanceof Error ? err.message : "Failed to load comparisons") } finally { setLoading(false) } }, []) // Debounced search useEffect(() => { const timer = setTimeout(() => { setPage(1) fetchComparisons(searchQuery, 1) }, 300) return () => clearTimeout(timer) }, [searchQuery, fetchComparisons]) // Initial load useEffect(() => { fetchComparisons("", 1) }, [fetchComparisons]) // Extract unique tags from loaded comparisons const categories = ["All", ...new Set(comparisons.flatMap((c) => c.tags))] const [selectedCategory, setSelectedCategory] = useState("All") const filtered = selectedCategory === "All" ? comparisons : comparisons.filter((c) => c.tags.includes(selectedCategory)) const hasMore = comparisons.length < total // ... render (replace allComparisons references with `filtered`) // Change Link href from `/compare/${comparison.id}` to `/compare/${comparison.slug}` } ``` **Step 2: Update "Load More" button** ```tsx ``` --- ### Task 1.4: Wire Profile page (#4) **Objective:** Replace mock data with real user data. **Files:** - Modify: `src/app/(main)/profile/page.tsx` Similar pattern to Explore — replace mock arrays with `useEffect` + `fetch` to `/api/user/comparisons` and `/api/user/stats`. Show sign-in CTA if no session. --- ## Phase 2: v0.3 — Auth Integration (Milestone #6) ### Task 2.1: Associate comparisons with users (#5) **Objective:** Set userId on comparison creation. **Files:** - Modify: `src/app/api/compare/route.ts` - Modify: `src/app/actions/comparison.ts` Read auth session from headers, extract userId, pass to insert. Add auth check to compare page. --- ## Phase 3: v0.4 — Search & Polish (Milestone #7) ### Task 3.1: Functional search (#6) Navigate header search to `/explore?search=...`. Read URL params in Explore page. ### Task 3.2: Delete/toggle visibility (#7) Add `DELETE` and `PATCH` routes. Add dropdown menus to profile comparison cards. ### Task 3.3: Loading/error states (#8) Add error rendering in compare page, error boundary, toast notifications. --- ## Gitea Issues Summary | # | Title | Labels | Milestone | |---|-------|--------|-----------| | 1 | API: Public feed endpoint | feature, backend | v0.2 | | 2 | API: User comparisons endpoint | feature, backend | v0.2 | | 3 | Wire Explore page | feature, frontend | v0.2 | | 4 | Wire Profile page | feature, frontend | v0.2 | | 5 | Associate comparisons with users | bug, backend | v0.3 | | 6 | Functional search | feature, frontend | v0.4 | | 7 | Delete/toggle visibility | feature, frontend, backend | v0.4 | | 8 | Loading/error states | improvement, frontend | v0.4 |