Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions app/(ui)/search/page.tsx
Original file line number Diff line number Diff line change
@@ -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";

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

let results: Awaited<ReturnType<typeof searchCards>> = [];
let isDefault = false;

if (query || colors.length || types.length) {
if (searchMode === "syntax") {
Expand All @@ -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 (
Expand All @@ -59,6 +62,7 @@ async function SearchResults({
initialTypes={types}
initialResults={results}
initialCount={results.length}
isDefault={isDefault}
/>
);
}
Expand Down
19 changes: 19 additions & 0 deletions app/_components/search/__tests__/search-form.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
14 changes: 9 additions & 5 deletions app/_components/search/search-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ interface SearchFormProps {
initialTypes: string[];
initialResults: CardSearchResult[];
initialCount: number;
isDefault?: boolean;
}

// ── Component ─────────────────────────────────────────────────────────────────
Expand All @@ -52,6 +53,7 @@ export function SearchForm({
initialTypes,
initialResults,
initialCount,
isDefault = false,
}: SearchFormProps) {
const router = useRouter();
const pathname = usePathname();
Expand Down Expand Up @@ -231,11 +233,13 @@ export function SearchForm({
{initialMode === "ai" && !aiTranslated ? "Waiting for prompt" : `${displayCount} result${displayCount !== 1 ? "s" : ""}`}
</Eyebrow>
<span className="font-mono text-[11px] text-muted-foreground/60">
{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"}
</span>
</div>

Expand Down
3 changes: 2 additions & 1 deletion docs/runbook/postgres.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down
52 changes: 51 additions & 1 deletion lib/search/__tests__/card-search.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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([]);
});
});
45 changes: 45 additions & 0 deletions lib/search/card-search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<CardSearchResult[]> {
"use cache";
cacheLife("days");
cacheTag("card-search");

const rows = await prisma.$queryRaw<RawCardRow[]>(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;

Expand Down
Loading