diff --git a/app/(ui)/search/page.tsx b/app/(ui)/search/page.tsx index 757e78f..ea768ed 100644 --- a/app/(ui)/search/page.tsx +++ b/app/(ui)/search/page.tsx @@ -1,8 +1,7 @@ import { Suspense } from "react"; import { connection } from "next/server"; import { Eyebrow } from "@/components/ui/eyebrow"; -import { searchCards } from "@/lib/search/card-search"; -import { searchCardsBySyntax } from "@/lib/search/card-search"; +import { getDefaultCards, searchCards, searchCardsBySyntax } from "@/lib/search/card-search"; import { parseSyntax } from "@/lib/search/syntax-parser"; import { SearchForm } from "@/app/_components/search/search-form"; @@ -34,6 +33,7 @@ async function SearchResults({ const limit = Math.min(parseInt(limitParam ?? "60", 10) || 60, 120); let results: Awaited> = []; + let isDefault = false; if (query || colors.length || types.length) { if (searchMode === "syntax") { @@ -49,6 +49,9 @@ async function SearchResults({ } } // AI mode: results handled client-side via server action, no SSR results + } else { + results = await getDefaultCards(); + isDefault = true; } return ( @@ -59,6 +62,7 @@ async function SearchResults({ initialTypes={types} initialResults={results} initialCount={results.length} + isDefault={isDefault} /> ); } diff --git a/app/_components/search/__tests__/search-form.test.tsx b/app/_components/search/__tests__/search-form.test.tsx index 2775982..dddbf53 100644 --- a/app/_components/search/__tests__/search-form.test.tsx +++ b/app/_components/search/__tests__/search-form.test.tsx @@ -105,4 +105,23 @@ describe("SearchForm", () => { const href = replaceMock.mock.calls.at(-1)![0]; expect(href).toContain("q=bolt"); }); + + it("shows 'Browse cards — search to filter' label when isDefault is true in simple mode", () => { + renderForm({ isDefault: true, initialMode: "simple" }); + + expect(screen.getByText(/browse cards — search to filter/i)).toBeInTheDocument(); + }); + + it("does not show browse label when isDefault is false", () => { + renderForm({ isDefault: false, initialMode: "simple" }); + + expect(screen.queryByText(/browse cards — search to filter/i)).not.toBeInTheDocument(); + expect(screen.getByText(/sorted by relevance/i)).toBeInTheDocument(); + }); + + it("does not show browse label in AI mode even when isDefault is true", () => { + renderForm({ isDefault: true, initialMode: "ai" }); + + expect(screen.queryByText(/browse cards — search to filter/i)).not.toBeInTheDocument(); + }); }); diff --git a/app/_components/search/search-form.tsx b/app/_components/search/search-form.tsx index 2dbec93..1334452 100644 --- a/app/_components/search/search-form.tsx +++ b/app/_components/search/search-form.tsx @@ -41,6 +41,7 @@ interface SearchFormProps { initialTypes: string[]; initialResults: CardSearchResult[]; initialCount: number; + isDefault?: boolean; } // ── Component ───────────────────────────────────────────────────────────────── @@ -52,6 +53,7 @@ export function SearchForm({ initialTypes, initialResults, initialCount, + isDefault = false, }: SearchFormProps) { const router = useRouter(); const pathname = usePathname(); @@ -231,11 +233,13 @@ export function SearchForm({ {initialMode === "ai" && !aiTranslated ? "Waiting for prompt" : `${displayCount} result${displayCount !== 1 ? "s" : ""}`} - {initialMode === "syntax" - ? "scryfall-syntax mode" - : initialMode === "ai" - ? "natural language → syntax" - : "sorted by relevance"} + {isDefault && initialMode !== "ai" + ? "Browse cards — search to filter" + : initialMode === "syntax" + ? "scryfall-syntax mode" + : initialMode === "ai" + ? "natural language → syntax" + : "sorted by relevance"} diff --git a/docs/runbook/postgres.md b/docs/runbook/postgres.md index 1dadd03..a6abdde 100644 --- a/docs/runbook/postgres.md +++ b/docs/runbook/postgres.md @@ -61,7 +61,8 @@ Capture `EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)` for each query after a warm ca | Card name search — 1-token (e.g. `"bolt"`) | `lib/search/card-search.ts:67` | | Card name search — 4-token (e.g. `"sol ring ramp elf"`) | `lib/search/card-search.ts:134` | | Commander-eligible name search (`?commander=1`) — adds un-indexed `ILIKE` on `type_line`/`oracle_text` | `lib/search/card-search.ts:44` | -| Exact-name resolve (EDHREC suggestions, `= ANY` + DFC `LIKE ANY` fallback) | `lib/search/card-search.ts:251` | +| Default-state browse — first 25 cards alphabetically (`LATERAL` join for image, `ORDER BY c.name`) | `lib/search/card-search.ts:242` | +| Exact-name resolve (EDHREC suggestions, `= ANY` + DFC `LIKE ANY` fallback) | `lib/search/card-search.ts:296` | | `getDeckById` | `lib/deck/queries.ts:404` | | `getPublicDecksWithPreview` with `?page=2` | `lib/deck/queries.ts:279` | | Diff upsert — Card batch insert + update loop | `workflows/scryfall/steps.ts:213–240` | diff --git a/lib/search/__tests__/card-search.test.ts b/lib/search/__tests__/card-search.test.ts index 1b09e0a..33111f9 100644 --- a/lib/search/__tests__/card-search.test.ts +++ b/lib/search/__tests__/card-search.test.ts @@ -13,7 +13,7 @@ vi.mock("@/lib/db", () => ({ import { cacheTag } from "next/cache"; import { prisma } from "@/lib/db"; import { type ParsedWhere } from "../syntax-parser"; -import { findCardsByNames, searchCards, searchCardsBySyntax } from "../card-search"; +import { findCardsByNames, getDefaultCards, searchCards, searchCardsBySyntax } from "../card-search"; const mockQueryRaw = vi.mocked(prisma.$queryRaw); const mockCacheTag = vi.mocked(cacheTag); @@ -377,3 +377,53 @@ describe("findCardsByNames", () => { expect(fallback.values).toContainEqual(["100\\%\\_Borrowed // %"]); }); }); + +describe("getDefaultCards", () => { + it("queries the database and returns mapped CardSearchResult rows", async () => { + mockQueryRaw.mockResolvedValue([RAW_ROW] as never); + + const result = await getDefaultCards(); + + expect(mockQueryRaw).toHaveBeenCalledTimes(1); + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + id: 1, + name: "Lightning Bolt", + mainType: "INSTANT", + typeLine: "Instant", + manaCost: "{R}", + imageUri: "bolt.jpg", + legalities: { modern: "legal" }, + gameChanger: true, + colorIdentity: ["R"], + }); + }); + + it("tags cache with 'card-search'", async () => { + mockQueryRaw.mockResolvedValue([RAW_ROW] as never); + + await getDefaultCards(); + + expect(mockCacheTag).toHaveBeenCalledWith("card-search"); + }); + + it("applies null-safe fallbacks for legalities, gameChanger, colorIdentity", async () => { + mockQueryRaw.mockResolvedValue([ + { ...RAW_ROW, legalities: null, game_changer: null, color_identity: null }, + ] as never); + + const [row] = await getDefaultCards(); + + expect(row?.legalities).toEqual({}); + expect(row?.gameChanger).toBe(false); + expect(row?.colorIdentity).toEqual([]); + }); + + it("returns empty array when no rows returned", async () => { + mockQueryRaw.mockResolvedValue([] as never); + + const result = await getDefaultCards(); + + expect(result).toEqual([]); + }); +}); diff --git a/lib/search/card-search.ts b/lib/search/card-search.ts index 259eb23..fe8f6ab 100644 --- a/lib/search/card-search.ts +++ b/lib/search/card-search.ts @@ -230,6 +230,51 @@ export async function searchCardsBySyntax( })); } +/** + * Return a representative sample of cards for the default (no-query) search state. + * Results are cached for a day so the empty-state load is free. + */ +export async function getDefaultCards(limit = 25): Promise { + "use cache"; + cacheLife("days"); + cacheTag("card-search"); + + const rows = await prisma.$queryRaw(Prisma.sql` + SELECT + c.id, + c.name, + c.main_type, + c.type_line, + c.mana_cost, + c.legalities, + c.game_changer, + c.color_identity, + p.image_uri + FROM card c + INNER JOIN LATERAL ( + SELECT image_uri + FROM printing + WHERE card_id = c.id + ORDER BY id ASC + LIMIT 1 + ) p ON true + ORDER BY c.name + LIMIT ${limit} + `); + + return rows.map((row) => ({ + id: row.id, + name: row.name, + mainType: row.main_type, + typeLine: row.type_line, + manaCost: row.mana_cost, + imageUri: row.image_uri, + legalities: (row.legalities ?? {}) as Legalities, + gameChanger: row.game_changer ?? false, + colorIdentity: row.color_identity ?? [], + })); +} + /** Upper bound on names resolved per call — bounds the IN-list and result size. */ const MAX_NAMES = 500;