Skip to content

Commit bd4f3cc

Browse files
authored
fix(search): show default cards on empty search state (#79)
* fix(search): show default cards on empty search state Card search page showed nothing on initial load when no query, colors, or types were set. Now calls getDefaultCards() to return 25 alphabetically-ordered cards from the DB, cached for a day. - Add getDefaultCards() to lib/search/card-search.ts with 'use cache' + cacheLife("days") - Call it in SearchResults when all filters are empty - Pass isDefault flag to SearchForm; shows "showing popular cards — search to filter" hint * no-mistakes(review): rename label and guard default badge in AI mode * no-mistakes(document): update runbook EXPLAIN battery for getDefaultCards
1 parent 33a4683 commit bd4f3cc

6 files changed

Lines changed: 132 additions & 9 deletions

File tree

app/(ui)/search/page.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
import { Suspense } from "react";
22
import { connection } from "next/server";
33
import { Eyebrow } from "@/components/ui/eyebrow";
4-
import { searchCards } from "@/lib/search/card-search";
5-
import { searchCardsBySyntax } from "@/lib/search/card-search";
4+
import { getDefaultCards, searchCards, searchCardsBySyntax } from "@/lib/search/card-search";
65
import { parseSyntax } from "@/lib/search/syntax-parser";
76
import { SearchForm } from "@/app/_components/search/search-form";
87

@@ -34,6 +33,7 @@ async function SearchResults({
3433
const limit = Math.min(parseInt(limitParam ?? "60", 10) || 60, 120);
3534

3635
let results: Awaited<ReturnType<typeof searchCards>> = [];
36+
let isDefault = false;
3737

3838
if (query || colors.length || types.length) {
3939
if (searchMode === "syntax") {
@@ -49,6 +49,9 @@ async function SearchResults({
4949
}
5050
}
5151
// AI mode: results handled client-side via server action, no SSR results
52+
} else {
53+
results = await getDefaultCards();
54+
isDefault = true;
5255
}
5356

5457
return (
@@ -59,6 +62,7 @@ async function SearchResults({
5962
initialTypes={types}
6063
initialResults={results}
6164
initialCount={results.length}
65+
isDefault={isDefault}
6266
/>
6367
);
6468
}

app/_components/search/__tests__/search-form.test.tsx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,4 +105,23 @@ describe("SearchForm", () => {
105105
const href = replaceMock.mock.calls.at(-1)![0];
106106
expect(href).toContain("q=bolt");
107107
});
108+
109+
it("shows 'Browse cards — search to filter' label when isDefault is true in simple mode", () => {
110+
renderForm({ isDefault: true, initialMode: "simple" });
111+
112+
expect(screen.getByText(/browse cards search to filter/i)).toBeInTheDocument();
113+
});
114+
115+
it("does not show browse label when isDefault is false", () => {
116+
renderForm({ isDefault: false, initialMode: "simple" });
117+
118+
expect(screen.queryByText(/browse cards search to filter/i)).not.toBeInTheDocument();
119+
expect(screen.getByText(/sorted by relevance/i)).toBeInTheDocument();
120+
});
121+
122+
it("does not show browse label in AI mode even when isDefault is true", () => {
123+
renderForm({ isDefault: true, initialMode: "ai" });
124+
125+
expect(screen.queryByText(/browse cards search to filter/i)).not.toBeInTheDocument();
126+
});
108127
});

app/_components/search/search-form.tsx

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ interface SearchFormProps {
4141
initialTypes: string[];
4242
initialResults: CardSearchResult[];
4343
initialCount: number;
44+
isDefault?: boolean;
4445
}
4546

4647
// ── Component ─────────────────────────────────────────────────────────────────
@@ -52,6 +53,7 @@ export function SearchForm({
5253
initialTypes,
5354
initialResults,
5455
initialCount,
56+
isDefault = false,
5557
}: SearchFormProps) {
5658
const router = useRouter();
5759
const pathname = usePathname();
@@ -231,11 +233,13 @@ export function SearchForm({
231233
{initialMode === "ai" && !aiTranslated ? "Waiting for prompt" : `${displayCount} result${displayCount !== 1 ? "s" : ""}`}
232234
</Eyebrow>
233235
<span className="font-mono text-[11px] text-muted-foreground/60">
234-
{initialMode === "syntax"
235-
? "scryfall-syntax mode"
236-
: initialMode === "ai"
237-
? "natural language → syntax"
238-
: "sorted by relevance"}
236+
{isDefault && initialMode !== "ai"
237+
? "Browse cards — search to filter"
238+
: initialMode === "syntax"
239+
? "scryfall-syntax mode"
240+
: initialMode === "ai"
241+
? "natural language → syntax"
242+
: "sorted by relevance"}
239243
</span>
240244
</div>
241245

docs/runbook/postgres.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,8 @@ Capture `EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)` for each query after a warm ca
6161
| Card name search — 1-token (e.g. `"bolt"`) | `lib/search/card-search.ts:67` |
6262
| Card name search — 4-token (e.g. `"sol ring ramp elf"`) | `lib/search/card-search.ts:134` |
6363
| Commander-eligible name search (`?commander=1`) — adds un-indexed `ILIKE` on `type_line`/`oracle_text` | `lib/search/card-search.ts:44` |
64-
| Exact-name resolve (EDHREC suggestions, `= ANY` + DFC `LIKE ANY` fallback) | `lib/search/card-search.ts:251` |
64+
| Default-state browse — first 25 cards alphabetically (`LATERAL` join for image, `ORDER BY c.name`) | `lib/search/card-search.ts:242` |
65+
| Exact-name resolve (EDHREC suggestions, `= ANY` + DFC `LIKE ANY` fallback) | `lib/search/card-search.ts:296` |
6566
| `getDeckById` | `lib/deck/queries.ts:404` |
6667
| `getPublicDecksWithPreview` with `?page=2` | `lib/deck/queries.ts:279` |
6768
| Diff upsert — Card batch insert + update loop | `workflows/scryfall/steps.ts:213–240` |

lib/search/__tests__/card-search.test.ts

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ vi.mock("@/lib/db", () => ({
1313
import { cacheTag } from "next/cache";
1414
import { prisma } from "@/lib/db";
1515
import { type ParsedWhere } from "../syntax-parser";
16-
import { findCardsByNames, searchCards, searchCardsBySyntax } from "../card-search";
16+
import { findCardsByNames, getDefaultCards, searchCards, searchCardsBySyntax } from "../card-search";
1717

1818
const mockQueryRaw = vi.mocked(prisma.$queryRaw);
1919
const mockCacheTag = vi.mocked(cacheTag);
@@ -377,3 +377,53 @@ describe("findCardsByNames", () => {
377377
expect(fallback.values).toContainEqual(["100\\%\\_Borrowed // %"]);
378378
});
379379
});
380+
381+
describe("getDefaultCards", () => {
382+
it("queries the database and returns mapped CardSearchResult rows", async () => {
383+
mockQueryRaw.mockResolvedValue([RAW_ROW] as never);
384+
385+
const result = await getDefaultCards();
386+
387+
expect(mockQueryRaw).toHaveBeenCalledTimes(1);
388+
expect(result).toHaveLength(1);
389+
expect(result[0]).toEqual({
390+
id: 1,
391+
name: "Lightning Bolt",
392+
mainType: "INSTANT",
393+
typeLine: "Instant",
394+
manaCost: "{R}",
395+
imageUri: "bolt.jpg",
396+
legalities: { modern: "legal" },
397+
gameChanger: true,
398+
colorIdentity: ["R"],
399+
});
400+
});
401+
402+
it("tags cache with 'card-search'", async () => {
403+
mockQueryRaw.mockResolvedValue([RAW_ROW] as never);
404+
405+
await getDefaultCards();
406+
407+
expect(mockCacheTag).toHaveBeenCalledWith("card-search");
408+
});
409+
410+
it("applies null-safe fallbacks for legalities, gameChanger, colorIdentity", async () => {
411+
mockQueryRaw.mockResolvedValue([
412+
{ ...RAW_ROW, legalities: null, game_changer: null, color_identity: null },
413+
] as never);
414+
415+
const [row] = await getDefaultCards();
416+
417+
expect(row?.legalities).toEqual({});
418+
expect(row?.gameChanger).toBe(false);
419+
expect(row?.colorIdentity).toEqual([]);
420+
});
421+
422+
it("returns empty array when no rows returned", async () => {
423+
mockQueryRaw.mockResolvedValue([] as never);
424+
425+
const result = await getDefaultCards();
426+
427+
expect(result).toEqual([]);
428+
});
429+
});

lib/search/card-search.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,51 @@ export async function searchCardsBySyntax(
230230
}));
231231
}
232232

233+
/**
234+
* Return a representative sample of cards for the default (no-query) search state.
235+
* Results are cached for a day so the empty-state load is free.
236+
*/
237+
export async function getDefaultCards(limit = 25): Promise<CardSearchResult[]> {
238+
"use cache";
239+
cacheLife("days");
240+
cacheTag("card-search");
241+
242+
const rows = await prisma.$queryRaw<RawCardRow[]>(Prisma.sql`
243+
SELECT
244+
c.id,
245+
c.name,
246+
c.main_type,
247+
c.type_line,
248+
c.mana_cost,
249+
c.legalities,
250+
c.game_changer,
251+
c.color_identity,
252+
p.image_uri
253+
FROM card c
254+
INNER JOIN LATERAL (
255+
SELECT image_uri
256+
FROM printing
257+
WHERE card_id = c.id
258+
ORDER BY id ASC
259+
LIMIT 1
260+
) p ON true
261+
ORDER BY c.name
262+
LIMIT ${limit}
263+
`);
264+
265+
return rows.map((row) => ({
266+
id: row.id,
267+
name: row.name,
268+
mainType: row.main_type,
269+
typeLine: row.type_line,
270+
manaCost: row.mana_cost,
271+
imageUri: row.image_uri,
272+
legalities: (row.legalities ?? {}) as Legalities,
273+
gameChanger: row.game_changer ?? false,
274+
colorIdentity: row.color_identity ?? [],
275+
}));
276+
}
277+
233278
/** Upper bound on names resolved per call — bounds the IN-list and result size. */
234279
const MAX_NAMES = 500;
235280

0 commit comments

Comments
 (0)