From b8f47a51cc11febc6da25643b7a13b1fb62f2444 Mon Sep 17 00:00:00 2001
From: mattmacrocket
Date: Thu, 2 Jul 2026 16:37:36 -0500
Subject: [PATCH] feat: GSC UI + Striking Distance
---
src/client/components/table/AppDataTable.tsx | 8 +-
.../gsc/SearchConsoleConnectionCard.tsx | 14 +
.../SearchPerformanceColumns.tsx | 147 +++++++
.../SearchPerformancePage.tsx | 353 +++++++++++++++++
.../SearchPerformanceParts.tsx | 373 ++++++++++++++++++
src/client/navigation/items.ts | 11 +
src/routeTree.gen.ts | 23 ++
.../p/$projectId/search-performance.tsx | 13 +
.../gsc/searchPerformanceReport.test.ts | 140 +++++++
.../features/gsc/searchPerformanceReport.ts | 144 +++++++
.../features/gsc/services/GscService.ts | 2 +-
src/serverFunctions/searchPerformance.ts | 210 ++++++++++
src/types/schemas/search-performance.ts | 63 +++
13 files changed, 1499 insertions(+), 2 deletions(-)
create mode 100644 src/client/features/search-performance/SearchPerformanceColumns.tsx
create mode 100644 src/client/features/search-performance/SearchPerformancePage.tsx
create mode 100644 src/client/features/search-performance/SearchPerformanceParts.tsx
create mode 100644 src/routes/_project/p/$projectId/search-performance.tsx
create mode 100644 src/server/features/gsc/searchPerformanceReport.test.ts
create mode 100644 src/server/features/gsc/searchPerformanceReport.ts
create mode 100644 src/serverFunctions/searchPerformance.ts
create mode 100644 src/types/schemas/search-performance.ts
diff --git a/src/client/components/table/AppDataTable.tsx b/src/client/components/table/AppDataTable.tsx
index 24c349d..e88d1e2 100644
--- a/src/client/components/table/AppDataTable.tsx
+++ b/src/client/components/table/AppDataTable.tsx
@@ -2,6 +2,7 @@ import {
flexRender,
getCoreRowModel,
getExpandedRowModel,
+ getPaginationRowModel,
getSortedRowModel,
useReactTable,
type ColumnDef,
@@ -38,15 +39,20 @@ type UseAppTableOptions = Omit<
> & {
withSorting?: boolean;
withExpanded?: boolean;
+ withPagination?: boolean;
};
export function useAppTable(options: UseAppTableOptions) {
- const { withSorting, withExpanded, ...tableOptions } = options;
+ const { withSorting, withExpanded, withPagination, ...tableOptions } =
+ options;
return useReactTable({
...tableOptions,
getCoreRowModel: getCoreRowModel(),
...(withSorting ? { getSortedRowModel: getSortedRowModel() } : {}),
...(withExpanded ? { getExpandedRowModel: getExpandedRowModel() } : {}),
+ ...(withPagination
+ ? { getPaginationRowModel: getPaginationRowModel() }
+ : {}),
});
}
diff --git a/src/client/features/gsc/SearchConsoleConnectionCard.tsx b/src/client/features/gsc/SearchConsoleConnectionCard.tsx
index 4a52b1a..087373c 100644
--- a/src/client/features/gsc/SearchConsoleConnectionCard.tsx
+++ b/src/client/features/gsc/SearchConsoleConnectionCard.tsx
@@ -63,6 +63,14 @@ export function SearchConsoleConnectionCard({
setPicking(false);
void queryClient.invalidateQueries({ queryKey: connectionKey });
void queryClient.invalidateQueries({ queryKey: GRANT_STATUS_KEY });
+ // The Search Performance report caches {connected:false}; refresh it so
+ // the page shows data right after connecting instead of the stale card.
+ void queryClient.invalidateQueries({
+ queryKey: ["searchPerformance", projectId],
+ });
+ void queryClient.invalidateQueries({
+ queryKey: ["searchPerformanceTable", projectId],
+ });
},
onError: (error) => toast.error(getStandardErrorMessage(error)),
});
@@ -76,6 +84,12 @@ export function SearchConsoleConnectionCard({
// Disconnect can drop the account-level grant server-side; keep the
// shared grant-status cache (onboarding step + re-engagement nudge) honest.
void queryClient.invalidateQueries({ queryKey: GRANT_STATUS_KEY });
+ void queryClient.invalidateQueries({
+ queryKey: ["searchPerformance", projectId],
+ });
+ void queryClient.invalidateQueries({
+ queryKey: ["searchPerformanceTable", projectId],
+ });
},
onError: (error) => toast.error(getStandardErrorMessage(error)),
});
diff --git a/src/client/features/search-performance/SearchPerformanceColumns.tsx b/src/client/features/search-performance/SearchPerformanceColumns.tsx
new file mode 100644
index 0000000..2b2efa4
--- /dev/null
+++ b/src/client/features/search-performance/SearchPerformanceColumns.tsx
@@ -0,0 +1,147 @@
+import { createColumnHelper, type ColumnDef } from "@tanstack/react-table";
+import type { MutableRefObject } from "react";
+import { makeSelectionColumn } from "@/client/components/table/AppDataTable";
+import { SortableHeader } from "@/client/components/table/SortableHeader";
+import type { SelectionAnchor } from "@/client/components/table/tableSelection";
+import type {
+ getSearchPerformanceReport,
+ getSearchPerformanceTable,
+} from "@/serverFunctions/searchPerformance";
+
+export type Report = Extract<
+ Awaited>,
+ { connected: true }
+>;
+export type SearchPerformanceTableRow = Extract<
+ Awaited>,
+ { connected: true }
+>["rows"][number];
+type DimensionRow = SearchPerformanceTableRow;
+type StrikingRow = Report["strikingDistance"][number];
+
+const numberFormat = new Intl.NumberFormat("en-US");
+
+export function formatCount(value: number): string {
+ return numberFormat.format(Math.round(value));
+}
+
+export function formatCtr(value: number): string {
+ return `${(value * 100).toFixed(1)}%`;
+}
+
+export function formatPosition(value: number): string {
+ return value.toFixed(1);
+}
+
+const rightAligned = {
+ headerClassName: "text-right",
+ cellClassName: "text-right tabular-nums",
+} as const;
+
+const dimensionHelper = createColumnHelper();
+
+export function buildDimensionColumns(
+ keyLabel: string,
+): ColumnDef[] {
+ return [
+ dimensionHelper.accessor("key", {
+ enableSorting: false,
+ header: () => keyLabel,
+ cell: ({ getValue }) => (
+
+ {getValue()}
+
+ ),
+ }),
+ dimensionHelper.accessor("clicks", {
+ header: ({ column }) => (
+
+ ),
+ cell: ({ getValue }) => formatCount(getValue()),
+ meta: rightAligned,
+ }),
+ dimensionHelper.accessor("impressions", {
+ header: ({ column }) => (
+
+ ),
+ cell: ({ getValue }) => formatCount(getValue()),
+ meta: rightAligned,
+ }),
+ dimensionHelper.accessor("ctr", {
+ header: ({ column }) => (
+
+ ),
+ cell: ({ getValue }) => formatCtr(getValue()),
+ meta: rightAligned,
+ }),
+ dimensionHelper.accessor("position", {
+ header: ({ column }) => (
+
+ ),
+ cell: ({ getValue }) => formatPosition(getValue()),
+ meta: rightAligned,
+ }),
+ ];
+}
+
+const strikingHelper = createColumnHelper();
+
+export function buildStrikingColumns(
+ anchorRef: MutableRefObject,
+): ColumnDef[] {
+ return [
+ makeSelectionColumn(anchorRef),
+ strikingHelper.accessor("query", {
+ enableSorting: false,
+ header: () => "Query",
+ cell: ({ getValue }) => (
+
+ {getValue()}
+
+ ),
+ }),
+ strikingHelper.accessor("page", {
+ enableSorting: false,
+ header: () => "Page",
+ // GSC page keys are canonical http(s) URLs of the verified property;
+ // the scheme check is defense-in-depth before rendering an href.
+ cell: ({ getValue }) =>
+ /^https?:\/\//.test(getValue()) ? (
+
+ {getValue()}
+
+ ) : (
+
+ {getValue()}
+
+ ),
+ }),
+ strikingHelper.accessor("impressions", {
+ header: ({ column }) => (
+
+ ),
+ cell: ({ getValue }) => formatCount(getValue()),
+ meta: rightAligned,
+ }),
+ strikingHelper.accessor("clicks", {
+ header: ({ column }) => (
+
+ ),
+ cell: ({ getValue }) => formatCount(getValue()),
+ meta: rightAligned,
+ }),
+ strikingHelper.accessor("position", {
+ header: ({ column }) => (
+
+ ),
+ cell: ({ getValue }) => formatPosition(getValue()),
+ meta: rightAligned,
+ }),
+ ];
+}
diff --git a/src/client/features/search-performance/SearchPerformancePage.tsx b/src/client/features/search-performance/SearchPerformancePage.tsx
new file mode 100644
index 0000000..e80f668
--- /dev/null
+++ b/src/client/features/search-performance/SearchPerformancePage.tsx
@@ -0,0 +1,353 @@
+import { useEffect, useState } from "react";
+import {
+ keepPreviousData,
+ queryOptions,
+ useQuery,
+ useQueryClient,
+} from "@tanstack/react-query";
+import { Download, Loader2, Sheet } from "lucide-react";
+import { toast } from "sonner";
+import { TableExportMenu } from "@/client/components/table/TableBulkActionBar";
+import { TablePagination } from "@/client/components/table/TablePagination";
+import { SearchConsoleConnectionCard } from "@/client/features/gsc/SearchConsoleConnectionCard";
+import {
+ DimensionTable,
+ exportDimensionRows,
+ exportStriking,
+ StrikingDistanceTable,
+ TabButton,
+ TotalsCards,
+ type ExportTarget,
+ type Tab,
+} from "@/client/features/search-performance/SearchPerformanceParts";
+import { getStandardErrorMessage } from "@/client/lib/error-messages";
+import {
+ exportSearchPerformanceTable,
+ getSearchPerformanceReport,
+ getSearchPerformanceTable,
+} from "@/serverFunctions/searchPerformance";
+import {
+ GSC_DEVICES,
+ SEARCH_PERFORMANCE_DEFAULT_PAGE_SIZE,
+ SEARCH_PERFORMANCE_PAGE_SIZES,
+ SEARCH_PERFORMANCE_RANGES,
+ type SearchPerformanceDateRange,
+ type SearchPerformanceDevice,
+ type SearchPerformanceTableDimension,
+} from "@/types/schemas/search-performance";
+
+const RANGE_LABELS: Record = {
+ last_7_days: "Last 7 days",
+ last_28_days: "Last 28 days",
+ last_3_months: "Last 3 months",
+};
+const RANGE_OPTIONS = SEARCH_PERFORMANCE_RANGES.map((value) => ({
+ value,
+ label: RANGE_LABELS[value],
+}));
+
+const DEVICE_LABELS: Record = {
+ DESKTOP: "Desktop",
+ MOBILE: "Mobile",
+ TABLET: "Tablet",
+};
+const DEVICE_OPTIONS = GSC_DEVICES.map((value) => ({
+ value,
+ label: DEVICE_LABELS[value],
+}));
+
+// Sentinel for "no filter" in the selects; never sent to the server.
+const ALL = "ALL";
+
+function isDateRange(value: string): value is SearchPerformanceDateRange {
+ return SEARCH_PERFORMANCE_RANGES.some((option) => option === value);
+}
+
+function isDevice(value: string): value is SearchPerformanceDevice {
+ return GSC_DEVICES.some((option) => option === value);
+}
+
+function tabDimension(tab: Tab): SearchPerformanceTableDimension {
+ return tab === "pages" ? "page" : "query";
+}
+
+type FilterInput = {
+ dateRange: SearchPerformanceDateRange;
+ device?: SearchPerformanceDevice;
+ country?: string;
+};
+
+// The server filter payload: drop device/country when set to the "ALL" sentinel.
+function buildFilterInput(
+ range: SearchPerformanceDateRange,
+ device: SearchPerformanceDevice | typeof ALL,
+ country: string,
+): FilterInput {
+ return {
+ dateRange: range,
+ ...(device === ALL ? {} : { device }),
+ ...(country === ALL ? {} : { country }),
+ };
+}
+
+// Single source for the paginated table query, shared by the live query and the
+// warm-on-connect prefetch so their key + fn can never drift apart.
+function tableQueryOptions(
+ projectId: string,
+ dimension: SearchPerformanceTableDimension,
+ page: number,
+ pageSize: number,
+ filterInput: FilterInput,
+) {
+ return queryOptions({
+ queryKey: [
+ "searchPerformanceTable",
+ projectId,
+ dimension,
+ page,
+ pageSize,
+ filterInput,
+ ],
+ queryFn: () =>
+ getSearchPerformanceTable({
+ data: { projectId, dimension, page, pageSize, ...filterInput },
+ }),
+ });
+}
+
+export function SearchPerformancePage({ projectId }: { projectId: string }) {
+ const queryClient = useQueryClient();
+ const [range, setRange] =
+ useState("last_28_days");
+ const [device, setDevice] = useState(
+ ALL,
+ );
+ const [country, setCountry] = useState(ALL);
+ const [tab, setTab] = useState("striking");
+ const [page, setPage] = useState(1);
+ const [pageSize, setPageSize] = useState(
+ SEARCH_PERFORMANCE_DEFAULT_PAGE_SIZE,
+ );
+
+ // Any change to the query set (tab, filters, page size) restarts at page 1.
+ useEffect(() => {
+ setPage(1);
+ }, [tab, range, device, country, pageSize]);
+
+ const filterInput = buildFilterInput(range, device, country);
+
+ const reportQuery = useQuery({
+ queryKey: ["searchPerformance", projectId, range, device, country],
+ queryFn: () =>
+ getSearchPerformanceReport({ data: { projectId, ...filterInput } }),
+ placeholderData: keepPreviousData,
+ });
+ const report = reportQuery.data;
+
+ const isTableTab = tab === "queries" || tab === "pages";
+ const dimension = tabDimension(tab);
+ const tableQuery = useQuery({
+ ...tableQueryOptions(projectId, dimension, page, pageSize, filterInput),
+ enabled: report?.connected === true && isTableTab,
+ placeholderData: keepPreviousData,
+ });
+ const tableData = tableQuery.data;
+ const tableRows = tableData?.connected ? tableData.rows : [];
+ const hasNextPage = tableData?.connected ? tableData.hasNextPage : false;
+
+ // Warm the Queries tab (first page) as soon as the report connects so the tab
+ // opens instantly instead of showing a spinner. Free first-party GSC data.
+ useEffect(() => {
+ if (report?.connected !== true) return;
+ void queryClient.prefetchQuery(
+ tableQueryOptions(
+ projectId,
+ "query",
+ 1,
+ SEARCH_PERFORMANCE_DEFAULT_PAGE_SIZE,
+ buildFilterInput(range, device, country),
+ ),
+ );
+ }, [report?.connected, projectId, range, device, country, queryClient]);
+
+ const handleExport = async (target: ExportTarget) => {
+ if (!report?.connected) return;
+ try {
+ if (tab === "striking") {
+ exportStriking(report, target);
+ return;
+ }
+ const data = await exportSearchPerformanceTable({
+ data: { projectId, dimension, ...filterInput },
+ });
+ exportDimensionRows(dimension, data.rows, report.range, target);
+ } catch (error) {
+ toast.error(getStandardErrorMessage(error, "Export failed"));
+ }
+ };
+
+ return (
+
+
+
+
Search Performance
+
+ See your site's clicks, impressions, CTR, and position from
+ Google Search Console.
+
+
+
+ {reportQuery.isPending ? (
+
+ Loading Search Console
+ data…
+
+ ) : reportQuery.isError ? (
+
+
+ {getStandardErrorMessage(reportQuery.error)}
+
+
+ ) : !report?.connected ? (
+
+
+ Find your striking-distance keywords — queries ranking just off
+ the top of page one, where a small improvement can win the most
+ new clicks. Connect Search Console to see them.
+
+
+
+ ) : (
+ <>
+
+
+
+
+ setTab("striking")}
+ label={`Striking distance (${report.strikingDistance.length})`}
+ />
+ setTab("queries")}
+ label="Queries"
+ />
+ setTab("pages")}
+ label="Pages"
+ />
+
+
+ {reportQuery.isFetching && !reportQuery.isPending ? (
+
+ ) : null}
+
{
+ setDevice(
+ isDevice(event.target.value) ? event.target.value : ALL,
+ );
+ }}
+ aria-label="Device filter"
+ >
+ All devices
+ {DEVICE_OPTIONS.map((option) => (
+
+ {option.label}
+
+ ))}
+
+
setCountry(event.target.value)}
+ aria-label="Country filter"
+ >
+ All countries
+ {report.countries.map((row) => (
+
+ {row.key.toUpperCase()}
+
+ ))}
+
+
{
+ if (isDateRange(event.target.value)) {
+ setRange(event.target.value);
+ }
+ }}
+ aria-label="Date range"
+ >
+ {RANGE_OPTIONS.map((option) => (
+
+ {option.label}
+
+ ))}
+
+
,
+ onClick: () => void handleExport("sheets"),
+ },
+ {
+ label: "Download CSV",
+ icon:
,
+ onClick: () => void handleExport("csv"),
+ },
+ ]}
+ />
+
+
+
+ {tab === "striking" ? (
+
+ ) : tableQuery.isPending ? (
+
+ Loading…
+
+ ) : tableQuery.isError ? (
+
+
+
+ {getStandardErrorMessage(tableQuery.error)}
+
+
+
+ ) : (
+ <>
+
+
+
+
+ >
+ )}
+
+ >
+ )}
+
+
+ );
+}
diff --git a/src/client/features/search-performance/SearchPerformanceParts.tsx b/src/client/features/search-performance/SearchPerformanceParts.tsx
new file mode 100644
index 0000000..2e541a1
--- /dev/null
+++ b/src/client/features/search-performance/SearchPerformanceParts.tsx
@@ -0,0 +1,373 @@
+import { useMemo, useState } from "react";
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { Copy, Loader2, Save } from "lucide-react";
+import { toast } from "sonner";
+import {
+ AppDataTable,
+ useAppTable,
+ useSelectionAnchor,
+} from "@/client/components/table/AppDataTable";
+import {
+ TableBulkActionBar,
+ TableBulkActionButton,
+} from "@/client/components/table/TableBulkActionBar";
+import { TablePagination } from "@/client/components/table/TablePagination";
+import {
+ buildDimensionColumns,
+ buildStrikingColumns,
+ formatCount,
+ formatCtr,
+ formatPosition,
+ type Report,
+ type SearchPerformanceTableRow,
+} from "@/client/features/search-performance/SearchPerformanceColumns";
+import { buildCsv, downloadCsv, type CsvValue } from "@/client/lib/csv";
+import { getStandardErrorMessage } from "@/client/lib/error-messages";
+import { exportTableToSheets } from "@/client/lib/exportToSheets";
+import { captureClientEvent } from "@/client/lib/posthog";
+import {
+ SEARCH_PERFORMANCE_PAGE_SIZES,
+ type SearchPerformanceTableDimension,
+} from "@/types/schemas/search-performance";
+import { saveKeywords } from "@/serverFunctions/keywords";
+
+export type Tab = "striking" | "queries" | "pages";
+export type ExportTarget = "csv" | "sheets";
+
+type ExportTable = { filename: string; headers: string[]; rows: CsvValue[][] };
+
+function strikingExportTable(report: Report): ExportTable {
+ const stamp = `${report.range.startDate}-to-${report.range.endDate}`;
+ return {
+ filename: `search-performance-striking-distance-${stamp}.csv`,
+ headers: ["Query", "Page", "Impressions", "Clicks", "Position"],
+ rows: report.strikingDistance.map((row) => [
+ row.query,
+ row.page,
+ row.impressions,
+ row.clicks,
+ row.position,
+ ]),
+ };
+}
+
+function dimensionExportTable(
+ dimension: SearchPerformanceTableDimension,
+ rows: SearchPerformanceTableRow[],
+ stamp: string,
+): ExportTable {
+ const isPage = dimension === "page";
+ return {
+ filename: `search-performance-${isPage ? "pages" : "queries"}-${stamp}.csv`,
+ headers: [
+ isPage ? "Page" : "Query",
+ "Clicks",
+ "Impressions",
+ "CTR",
+ "Position",
+ ],
+ rows: rows.map((row) => [
+ row.key,
+ row.clicks,
+ row.impressions,
+ row.ctr,
+ row.position,
+ ]),
+ };
+}
+
+function runExport(table: ExportTable, target: ExportTarget): void {
+ if (target === "csv") {
+ downloadCsv(table.filename, buildCsv(table.headers, table.rows));
+ captureClientEvent("data:export", {
+ source_feature: "search_performance",
+ result_count: table.rows.length,
+ });
+ return;
+ }
+ void exportTableToSheets({
+ headers: table.headers,
+ rows: table.rows,
+ feature: "search_performance",
+ });
+}
+
+export function exportStriking(report: Report, target: ExportTarget): void {
+ runExport(strikingExportTable(report), target);
+}
+
+/** Export the full queries/pages dataset (fetched separately, not the visible
+ * page) so pagination never truncates a download. */
+export function exportDimensionRows(
+ dimension: SearchPerformanceTableDimension,
+ rows: SearchPerformanceTableRow[],
+ range: Report["range"],
+ target: ExportTarget,
+): void {
+ const stamp = `${range.startDate}-to-${range.endDate}`;
+ runExport(dimensionExportTable(dimension, rows, stamp), target);
+}
+
+export function TabButton({
+ active,
+ onClick,
+ label,
+}: {
+ active: boolean;
+ onClick: () => void;
+ label: string;
+}) {
+ return (
+
+ {label}
+
+ );
+}
+
+type Delta = { text: string; improved: boolean } | null;
+
+function percentDelta(current: number, previous: number): Delta {
+ if (previous <= 0) return null;
+ const change = (current - previous) / previous;
+ const pct = (change * 100).toFixed(1);
+ return { text: `${change >= 0 ? "+" : ""}${pct}%`, improved: change >= 0 };
+}
+
+/** Position falls as rankings improve, so the delta is inverted. */
+function positionDelta(current: number, previous: number): Delta {
+ if (previous <= 0 || current <= 0) return null;
+ const change = previous - current;
+ return {
+ text: `${change >= 0 ? "+" : ""}${change.toFixed(1)}`,
+ improved: change >= 0,
+ };
+}
+
+export function TotalsCards({ report }: { report: Report }) {
+ const { totals, prevTotals, range } = report;
+ const deltaTitle = `vs ${range.prevStartDate} to ${range.prevEndDate}`;
+ return (
+
+
+
+
+
+
+ );
+}
+
+function TotalCard({
+ label,
+ value,
+ delta,
+ deltaTitle,
+}: {
+ label: string;
+ value: string;
+ delta: Delta;
+ deltaTitle: string;
+}) {
+ return (
+
+
+ {label}
+
+
+ {value}
+ {delta ? (
+
+ {delta.text}
+
+ ) : null}
+
+
+ );
+}
+
+export function DimensionTable({
+ rows,
+ keyLabel,
+}: {
+ rows: SearchPerformanceTableRow[];
+ keyLabel: string;
+}) {
+ const columns = useMemo(() => buildDimensionColumns(keyLabel), [keyLabel]);
+ const table = useAppTable({
+ data: rows,
+ columns,
+ withSorting: true,
+ initialState: { sorting: [{ id: "clicks", desc: true }] },
+ });
+ return (
+
+ No data for this period yet. Search Console data trails by a few days.
+
+ }
+ />
+ );
+}
+
+export function StrikingDistanceTable({
+ projectId,
+ rows,
+}: {
+ projectId: string;
+ rows: Report["strikingDistance"];
+}) {
+ const queryClient = useQueryClient();
+ const anchorRef = useSelectionAnchor();
+ const [rowSelection, setRowSelection] = useState({});
+ const columns = useMemo(() => buildStrikingColumns(anchorRef), [anchorRef]);
+ const table = useAppTable({
+ data: rows,
+ columns,
+ withSorting: true,
+ withPagination: true,
+ enableRowSelection: true,
+ state: { rowSelection },
+ onRowSelectionChange: setRowSelection,
+ getRowId: (row) => `${row.query}::${row.page}`,
+ initialState: {
+ sorting: [{ id: "impressions", desc: true }],
+ // All rows are already loaded; paginate client-side to keep the table
+ // short. 50/page by default.
+ pagination: { pageIndex: 0, pageSize: 50 },
+ },
+ });
+ const pagination = table.getState().pagination;
+
+ // Rows are query x page; saving/copying dedupes to the query strings.
+ const selectedQueries = Array.from(
+ new Set(table.getSelectedRowModel().rows.map((row) => row.original.query)),
+ );
+
+ const copyKeywords = async () => {
+ try {
+ await navigator.clipboard.writeText(selectedQueries.join("\n"));
+ toast.success(
+ `Copied ${selectedQueries.length} ${selectedQueries.length === 1 ? "keyword" : "keywords"}`,
+ );
+ } catch {
+ toast.error("Couldn't copy to clipboard");
+ }
+ };
+
+ const save = useMutation({
+ mutationFn: (keywords: string[]) =>
+ saveKeywords({ data: { projectId, keywords } }),
+ onSuccess: (_result, keywords) => {
+ captureClientEvent("keyword:save", {
+ source_feature: "search_performance",
+ keyword_count: keywords.length,
+ });
+ void queryClient.invalidateQueries({
+ queryKey: ["savedKeywords", projectId],
+ });
+ toast.success(
+ `Saved ${keywords.length} ${keywords.length === 1 ? "keyword" : "keywords"}`,
+ );
+ setRowSelection({});
+ },
+ onError: (error) => {
+ toast.error(getStandardErrorMessage(error, "Could not save keywords"));
+ },
+ });
+
+ if (rows.length === 0) {
+ return (
+
+ No striking-distance queries in this period. These are queries ranking
+ at positions 5 to 20, where an improvement is most likely to move
+ traffic.
+
+ );
+ }
+
+ return (
+ <>
+
+
+ Queries ranking at positions 5 to 20, sorted by impressions. Improve
+ the listed page to move them into the top results.
+
+
+
+ table.setPageIndex(nextPage - 1)}
+ onPageSizeChange={(nextSize) => table.setPageSize(nextSize)}
+ />
+ setRowSelection({})}
+ actions={
+
+
}
+ onClick={() => void copyKeywords()}
+ >
+ Copy keywords
+
+
+ ) : (
+
+ )
+ }
+ onClick={() => save.mutate(selectedQueries)}
+ disabled={save.isPending}
+ >
+ Save as keywords
+
+
+ }
+ />
+ >
+ );
+}
diff --git a/src/client/navigation/items.ts b/src/client/navigation/items.ts
index 9635dff..139af7f 100644
--- a/src/client/navigation/items.ts
+++ b/src/client/navigation/items.ts
@@ -1,4 +1,5 @@
import {
+ BarChart3,
Bookmark,
Bot,
ClipboardCheck,
@@ -30,6 +31,12 @@ const projectNavItems = [
icon: TrendingUp,
matchSegment: "/rank-tracking",
},
+ {
+ to: "/p/$projectId/search-performance" as const,
+ label: "Search Performance",
+ icon: BarChart3,
+ matchSegment: "/search-performance",
+ },
{
to: "/p/$projectId/domain" as const,
label: "Domain Overview",
@@ -95,6 +102,10 @@ export function getProjectNavGroups(projectId: string) {
bySegment("/rank-tracking"),
],
},
+ {
+ type: "standalone" as const,
+ item: bySegment("/search-performance"),
+ },
{
type: "group" as const,
label: "Domain",
diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts
index 124d0df..d676e12 100644
--- a/src/routeTree.gen.ts
+++ b/src/routeTree.gen.ts
@@ -36,6 +36,7 @@ import { Route as ProjectPProjectIdRouteRouteImport } from './routes/_project/p/
import { Route as ProjectPProjectIdIndexRouteImport } from './routes/_project/p/$projectId/index'
import { Route as ApiGscOauthCallbackRouteImport } from './routes/api/gsc/oauth/callback'
import { Route as ProjectPProjectIdSettingsRouteImport } from './routes/_project/p/$projectId/settings'
+import { Route as ProjectPProjectIdSearchPerformanceRouteImport } from './routes/_project/p/$projectId/search-performance'
import { Route as ProjectPProjectIdSavedRouteImport } from './routes/_project/p/$projectId/saved'
import { Route as ProjectPProjectIdRankTrackingRouteImport } from './routes/_project/p/$projectId/rank-tracking'
import { Route as ProjectPProjectIdPromptExplorerRouteImport } from './routes/_project/p/$projectId/prompt-explorer'
@@ -185,6 +186,12 @@ const ProjectPProjectIdSettingsRoute =
path: '/settings',
getParentRoute: () => ProjectPProjectIdRouteRoute,
} as any)
+const ProjectPProjectIdSearchPerformanceRoute =
+ ProjectPProjectIdSearchPerformanceRouteImport.update({
+ id: '/search-performance',
+ path: '/search-performance',
+ getParentRoute: () => ProjectPProjectIdRouteRoute,
+ } as any)
const ProjectPProjectIdSavedRoute = ProjectPProjectIdSavedRouteImport.update({
id: '/saved',
path: '/saved',
@@ -284,6 +291,7 @@ export interface FileRoutesByFullPath {
'/p/$projectId/prompt-explorer': typeof ProjectPProjectIdPromptExplorerRoute
'/p/$projectId/rank-tracking': typeof ProjectPProjectIdRankTrackingRouteWithChildren
'/p/$projectId/saved': typeof ProjectPProjectIdSavedRoute
+ '/p/$projectId/search-performance': typeof ProjectPProjectIdSearchPerformanceRoute
'/p/$projectId/settings': typeof ProjectPProjectIdSettingsRoute
'/api/gsc/oauth/callback': typeof ApiGscOauthCallbackRoute
'/p/$projectId/': typeof ProjectPProjectIdIndexRoute
@@ -318,6 +326,7 @@ export interface FileRoutesByTo {
'/p/$projectId/keywords': typeof ProjectPProjectIdKeywordsRoute
'/p/$projectId/prompt-explorer': typeof ProjectPProjectIdPromptExplorerRoute
'/p/$projectId/saved': typeof ProjectPProjectIdSavedRoute
+ '/p/$projectId/search-performance': typeof ProjectPProjectIdSearchPerformanceRoute
'/p/$projectId/settings': typeof ProjectPProjectIdSettingsRoute
'/api/gsc/oauth/callback': typeof ApiGscOauthCallbackRoute
'/p/$projectId': typeof ProjectPProjectIdIndexRoute
@@ -360,6 +369,7 @@ export interface FileRoutesById {
'/_project/p/$projectId/prompt-explorer': typeof ProjectPProjectIdPromptExplorerRoute
'/_project/p/$projectId/rank-tracking': typeof ProjectPProjectIdRankTrackingRouteWithChildren
'/_project/p/$projectId/saved': typeof ProjectPProjectIdSavedRoute
+ '/_project/p/$projectId/search-performance': typeof ProjectPProjectIdSearchPerformanceRoute
'/_project/p/$projectId/settings': typeof ProjectPProjectIdSettingsRoute
'/api/gsc/oauth/callback': typeof ApiGscOauthCallbackRoute
'/_project/p/$projectId/': typeof ProjectPProjectIdIndexRoute
@@ -399,6 +409,7 @@ export interface FileRouteTypes {
| '/p/$projectId/prompt-explorer'
| '/p/$projectId/rank-tracking'
| '/p/$projectId/saved'
+ | '/p/$projectId/search-performance'
| '/p/$projectId/settings'
| '/api/gsc/oauth/callback'
| '/p/$projectId/'
@@ -433,6 +444,7 @@ export interface FileRouteTypes {
| '/p/$projectId/keywords'
| '/p/$projectId/prompt-explorer'
| '/p/$projectId/saved'
+ | '/p/$projectId/search-performance'
| '/p/$projectId/settings'
| '/api/gsc/oauth/callback'
| '/p/$projectId'
@@ -474,6 +486,7 @@ export interface FileRouteTypes {
| '/_project/p/$projectId/prompt-explorer'
| '/_project/p/$projectId/rank-tracking'
| '/_project/p/$projectId/saved'
+ | '/_project/p/$projectId/search-performance'
| '/_project/p/$projectId/settings'
| '/api/gsc/oauth/callback'
| '/_project/p/$projectId/'
@@ -688,6 +701,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ProjectPProjectIdSettingsRouteImport
parentRoute: typeof ProjectPProjectIdRouteRoute
}
+ '/_project/p/$projectId/search-performance': {
+ id: '/_project/p/$projectId/search-performance'
+ path: '/search-performance'
+ fullPath: '/p/$projectId/search-performance'
+ preLoaderRoute: typeof ProjectPProjectIdSearchPerformanceRouteImport
+ parentRoute: typeof ProjectPProjectIdRouteRoute
+ }
'/_project/p/$projectId/saved': {
id: '/_project/p/$projectId/saved'
path: '/saved'
@@ -843,6 +863,7 @@ interface ProjectPProjectIdRouteRouteChildren {
ProjectPProjectIdPromptExplorerRoute: typeof ProjectPProjectIdPromptExplorerRoute
ProjectPProjectIdRankTrackingRoute: typeof ProjectPProjectIdRankTrackingRouteWithChildren
ProjectPProjectIdSavedRoute: typeof ProjectPProjectIdSavedRoute
+ ProjectPProjectIdSearchPerformanceRoute: typeof ProjectPProjectIdSearchPerformanceRoute
ProjectPProjectIdSettingsRoute: typeof ProjectPProjectIdSettingsRoute
ProjectPProjectIdIndexRoute: typeof ProjectPProjectIdIndexRoute
}
@@ -858,6 +879,8 @@ const ProjectPProjectIdRouteRouteChildren: ProjectPProjectIdRouteRouteChildren =
ProjectPProjectIdRankTrackingRoute:
ProjectPProjectIdRankTrackingRouteWithChildren,
ProjectPProjectIdSavedRoute: ProjectPProjectIdSavedRoute,
+ ProjectPProjectIdSearchPerformanceRoute:
+ ProjectPProjectIdSearchPerformanceRoute,
ProjectPProjectIdSettingsRoute: ProjectPProjectIdSettingsRoute,
ProjectPProjectIdIndexRoute: ProjectPProjectIdIndexRoute,
}
diff --git a/src/routes/_project/p/$projectId/search-performance.tsx b/src/routes/_project/p/$projectId/search-performance.tsx
new file mode 100644
index 0000000..4ddf2b4
--- /dev/null
+++ b/src/routes/_project/p/$projectId/search-performance.tsx
@@ -0,0 +1,13 @@
+import { createFileRoute } from "@tanstack/react-router";
+import { SearchPerformancePage } from "@/client/features/search-performance/SearchPerformancePage";
+
+export const Route = createFileRoute(
+ "/_project/p/$projectId/search-performance",
+)({
+ component: SearchPerformanceRoute,
+});
+
+function SearchPerformanceRoute() {
+ const { projectId } = Route.useParams();
+ return ;
+}
diff --git a/src/server/features/gsc/searchPerformanceReport.test.ts b/src/server/features/gsc/searchPerformanceReport.test.ts
new file mode 100644
index 0000000..28b3730
--- /dev/null
+++ b/src/server/features/gsc/searchPerformanceReport.test.ts
@@ -0,0 +1,140 @@
+import { describe, expect, it } from "vitest";
+import {
+ buildStrikingDistanceRows,
+ previousPeriod,
+ sumSearchTotals,
+ toDimensionRows,
+} from "@/server/features/gsc/searchPerformanceReport";
+
+describe("sumSearchTotals", () => {
+ it("sums clicks/impressions and impression-weights position", () => {
+ const totals = sumSearchTotals([
+ { clicks: 10, impressions: 100, ctr: 0.1, position: 2 },
+ { clicks: 5, impressions: 300, ctr: 0.016, position: 10 },
+ ]);
+ expect(totals.clicks).toBe(15);
+ expect(totals.impressions).toBe(400);
+ expect(totals.ctr).toBeCloseTo(15 / 400);
+ // (2*100 + 10*300) / 400 = 8
+ expect(totals.position).toBeCloseTo(8);
+ });
+
+ it("returns zeros for no rows instead of NaN", () => {
+ expect(sumSearchTotals([])).toEqual({
+ clicks: 0,
+ impressions: 0,
+ ctr: 0,
+ position: 0,
+ });
+ });
+});
+
+describe("toDimensionRows", () => {
+ it("keeps the first key and drops keyless rows", () => {
+ const rows = toDimensionRows([
+ {
+ keys: ["magento agency"],
+ clicks: 3,
+ impressions: 40,
+ ctr: 0.075,
+ position: 6.2,
+ },
+ { clicks: 1, impressions: 5, ctr: 0.2, position: 1 },
+ ]);
+ expect(rows).toEqual([
+ {
+ key: "magento agency",
+ clicks: 3,
+ impressions: 40,
+ ctr: 0.075,
+ position: 6.2,
+ },
+ ]);
+ });
+});
+
+const row = (query: string, position: number, impressions: number) => ({
+ keys: [query, `https://example.com/${query}`],
+ clicks: 1,
+ impressions,
+ ctr: 0.01,
+ position,
+});
+
+// Same query can map to multiple pages; this lets a test set distinct pages.
+const pageRow = (
+ query: string,
+ page: string,
+ position: number,
+ impressions: number,
+) => ({ keys: [query, page], clicks: 1, impressions, ctr: 0.01, position });
+
+describe("buildStrikingDistanceRows", () => {
+ it("keeps only positions 5..20 and sorts by impressions desc", () => {
+ const rows = buildStrikingDistanceRows([
+ row("top-spot", 2, 900),
+ row("close", 6.4, 100),
+ row("closer", 11, 400),
+ row("page-3", 24, 800),
+ ]);
+ expect(rows.map((r) => r.query)).toEqual(["closer", "close"]);
+ });
+
+ it("includes the boundary positions and respects the limit", () => {
+ const rows = buildStrikingDistanceRows(
+ [row("low-edge", 5, 10), row("high-edge", 20, 20)],
+ 1,
+ );
+ expect(rows).toHaveLength(1);
+ expect(rows[0].query).toBe("high-edge");
+ });
+
+ it("drops rows without both query and page keys", () => {
+ const rows = buildStrikingDistanceRows([
+ {
+ keys: ["only-query"],
+ clicks: 1,
+ impressions: 50,
+ ctr: 0.02,
+ position: 8,
+ },
+ ]);
+ expect(rows).toHaveLength(0);
+ });
+
+ it("drops a query whose top page already ranks above the band", () => {
+ // openseo: homepage ranks #2, a secondary page ranks #6. The site already
+ // ranks near the top, so the query is not a striking-distance opportunity.
+ const rows = buildStrikingDistanceRows([
+ pageRow("openseo", "https://x.com/home", 2, 900),
+ pageRow("openseo", "https://x.com/mcp", 6, 300),
+ ]);
+ expect(rows).toHaveLength(0);
+ });
+
+ it("collapses a query to its best-ranking page when that page is in band", () => {
+ const rows = buildStrikingDistanceRows([
+ pageRow("kw", "https://x.com/a", 14, 100),
+ pageRow("kw", "https://x.com/b", 8, 500),
+ ]);
+ expect(rows).toHaveLength(1);
+ expect(rows[0].page).toBe("https://x.com/b");
+ expect(rows[0].position).toBe(8);
+ });
+});
+
+describe("previousPeriod", () => {
+ it("returns the same-length window ending the day before the start", () => {
+ expect(previousPeriod("2026-06-01", "2026-06-28")).toEqual({
+ startDate: "2026-05-04",
+ endDate: "2026-05-31",
+ });
+ });
+
+ it("handles a single-day range", () => {
+ expect(previousPeriod("2026-06-10", "2026-06-10")).toEqual({
+ startDate: "2026-06-09",
+ endDate: "2026-06-09",
+ });
+ });
+});
diff --git a/src/server/features/gsc/searchPerformanceReport.ts b/src/server/features/gsc/searchPerformanceReport.ts
new file mode 100644
index 0000000..28e956a
--- /dev/null
+++ b/src/server/features/gsc/searchPerformanceReport.ts
@@ -0,0 +1,144 @@
+import type { GscSearchAnalyticsRow } from "@/server/lib/gscClient";
+
+/**
+ * Pure shaping helpers for the Search Performance page. Kept separate from the
+ * server function so the aggregation and striking-distance rules are unit
+ * testable without a GSC client.
+ */
+
+type SearchPerformanceTotals = {
+ clicks: number;
+ impressions: number;
+ /** 0..1 (clicks / impressions). */
+ ctr: number;
+ /** Impression-weighted average position; 0 when there were no impressions. */
+ position: number;
+};
+
+type SearchPerformanceDimensionRow = {
+ key: string;
+ clicks: number;
+ impressions: number;
+ ctr: number;
+ position: number;
+};
+
+type StrikingDistanceRow = {
+ query: string;
+ page: string;
+ clicks: number;
+ impressions: number;
+ position: number;
+};
+
+// "Striking distance" = already ranking, not yet in the top spots: the queries
+// where a content improvement most plausibly moves real traffic.
+const STRIKING_DISTANCE_MIN_POSITION = 5;
+const STRIKING_DISTANCE_MAX_POSITION = 20;
+const STRIKING_DISTANCE_ROW_LIMIT = 100;
+
+export function sumSearchTotals(
+ rows: GscSearchAnalyticsRow[],
+): SearchPerformanceTotals {
+ let clicks = 0;
+ let impressions = 0;
+ let weightedPosition = 0;
+ for (const row of rows) {
+ clicks += row.clicks;
+ impressions += row.impressions;
+ weightedPosition += row.position * row.impressions;
+ }
+ return {
+ clicks,
+ impressions,
+ ctr: impressions > 0 ? clicks / impressions : 0,
+ position: impressions > 0 ? weightedPosition / impressions : 0,
+ };
+}
+
+/** Flatten single-dimension rows (query or page) into a keyed table row. */
+export function toDimensionRows(
+ rows: GscSearchAnalyticsRow[],
+): SearchPerformanceDimensionRow[] {
+ const output: SearchPerformanceDimensionRow[] = [];
+ for (const row of rows) {
+ const key = row.keys?.[0];
+ if (!key) continue;
+ output.push({
+ key,
+ clicks: row.clicks,
+ impressions: row.impressions,
+ ctr: row.ctr,
+ position: row.position,
+ });
+ }
+ return output;
+}
+
+/** Reduce `["query","page"]` rows to one striking-distance row per query.
+ *
+ * GSC returns a row per page that ranks for a query, so a query fans out across
+ * every page it appears on. A query only belongs in "striking distance" when
+ * the site's BEST-ranking page for it sits in the 5..20 band — if any page
+ * already ranks above position 5, the site effectively ranks near the top and
+ * improving a secondary page won't move traffic. So we collapse each query to
+ * its top page (lowest average position; ties broken by impressions) and keep
+ * it only when that top page is in band. Result is sorted by impressions. */
+export function buildStrikingDistanceRows(
+ rows: GscSearchAnalyticsRow[],
+ limit: number = STRIKING_DISTANCE_ROW_LIMIT,
+): StrikingDistanceRow[] {
+ const topPageByQuery = new Map();
+ for (const row of rows) {
+ const query = row.keys?.[0];
+ const page = row.keys?.[1];
+ if (!query || !page) continue;
+
+ const current = topPageByQuery.get(query);
+ const isBetter =
+ !current ||
+ row.position < current.position ||
+ (row.position === current.position &&
+ row.impressions > current.impressions);
+ if (!isBetter) continue;
+
+ topPageByQuery.set(query, {
+ query,
+ page,
+ clicks: row.clicks,
+ impressions: row.impressions,
+ position: row.position,
+ });
+ }
+
+ return Array.from(topPageByQuery.values())
+ .filter(
+ (row) =>
+ row.position >= STRIKING_DISTANCE_MIN_POSITION &&
+ row.position <= STRIKING_DISTANCE_MAX_POSITION,
+ )
+ .toSorted((a, b) => b.impressions - a.impressions)
+ .slice(0, limit);
+}
+
+/** The same-length period immediately before [startDate, endDate], for the
+ * totals comparison. Dates are YYYY-MM-DD in UTC. */
+export function previousPeriod(
+ startDate: string,
+ endDate: string,
+): { startDate: string; endDate: string } {
+ const dayMs = 24 * 60 * 60 * 1000;
+ const start = Date.parse(`${startDate}T00:00:00Z`);
+ const end = Date.parse(`${endDate}T00:00:00Z`);
+ const lengthMs = Math.max(end - start, 0);
+ const prevEnd = start - dayMs;
+ const prevStart = prevEnd - lengthMs;
+ return {
+ startDate: formatUtcDate(prevStart),
+ endDate: formatUtcDate(prevEnd),
+ };
+}
+
+function formatUtcDate(ms: number): string {
+ return new Date(ms).toISOString().slice(0, 10);
+}
diff --git a/src/server/features/gsc/services/GscService.ts b/src/server/features/gsc/services/GscService.ts
index 082057a..5011531 100644
--- a/src/server/features/gsc/services/GscService.ts
+++ b/src/server/features/gsc/services/GscService.ts
@@ -74,7 +74,7 @@ async function listSitesForUser(userId: string): Promise {
* minted (refresh token revoked or expired), or Google rejected the call
* (401/403). These surface a reconnect prompt instead of being routed through
* error tracking. Other statuses (429, 5xx) are genuine faults and propagate. */
-function isExpectedGrantFailure(error: unknown): boolean {
+export function isExpectedGrantFailure(error: unknown): boolean {
if (error instanceof GscTokenError) return true;
return (
error instanceof GscApiError &&
diff --git a/src/serverFunctions/searchPerformance.ts b/src/serverFunctions/searchPerformance.ts
new file mode 100644
index 0000000..be3593c
--- /dev/null
+++ b/src/serverFunctions/searchPerformance.ts
@@ -0,0 +1,210 @@
+import { createServerFn } from "@tanstack/react-start";
+import {
+ GscNotConnectedError,
+ GscService,
+ isExpectedGrantFailure,
+} from "@/server/features/gsc/services/GscService";
+import {
+ resolveDateRange,
+ type GscPerformanceFilter,
+} from "@/server/features/gsc/searchAnalytics";
+import {
+ buildStrikingDistanceRows,
+ previousPeriod,
+ sumSearchTotals,
+ toDimensionRows,
+} from "@/server/features/gsc/searchPerformanceReport";
+import { requireProjectContext } from "@/serverFunctions/middleware";
+import {
+ searchPerformanceInputSchema,
+ searchPerformanceTableExportInputSchema,
+ searchPerformanceTableInputSchema,
+} from "@/types/schemas/search-performance";
+
+// query x page fan-out needs more rows to find the 5..20 band.
+const STRIKING_DISTANCE_FETCH_LIMIT = 1000;
+// dimensions:["date"] returns one row per day; the longest range is ~92 days.
+const DAILY_ROW_LIMIT = 200;
+const COUNTRY_ROW_LIMIT = 25;
+// Export pulls the whole dimension in one shot, capped at GSC's per-call max
+// (GSC_MAX_ROW_LIMIT). Large stores get everything up to this ceiling.
+const EXPORT_ROW_LIMIT = 1000;
+
+/** Build GSC filter groups shared by every call. Device applies everywhere;
+ * country applies everywhere except the country breakdown itself (so the
+ * dropdown keeps every option visible while one country is selected). */
+function buildGscFilters(data: { device?: string; country?: string }): {
+ deviceFilters: GscPerformanceFilter[];
+ filters: GscPerformanceFilter[];
+} {
+ const deviceFilters: GscPerformanceFilter[] = data.device
+ ? [{ dimension: "device", operator: "equals", expression: data.device }]
+ : [];
+ const filters: GscPerformanceFilter[] = data.country
+ ? [
+ ...deviceFilters,
+ { dimension: "country", operator: "equals", expression: data.country },
+ ]
+ : deviceFilters;
+ return { deviceFilters, filters };
+}
+
+/** Not connected, or a dead/denied grant (token failure or 401/403): the page
+ * renders the connect card. Other statuses (429, 5xx) are real faults. */
+function isExpectedConnectionFailure(error: unknown): boolean {
+ return error instanceof GscNotConnectedError || isExpectedGrantFailure(error);
+}
+
+/**
+ * The Search Performance overview: current + previous-period totals, the
+ * striking-distance rows, and the country list that powers the filter dropdown.
+ * The queries/pages tables paginate separately (getSearchPerformanceTable) so
+ * page-flips never re-run the striking-distance scan. All first-party GSC data,
+ * free.
+ */
+export const getSearchPerformanceReport = createServerFn({ method: "POST" })
+ .middleware(requireProjectContext)
+ .inputValidator((data: unknown) => searchPerformanceInputSchema.parse(data))
+ .handler(async ({ data, context }) => {
+ const { startDate, endDate } = resolveDateRange({
+ dateRange: data.dateRange,
+ });
+ const prev = previousPeriod(startDate, endDate);
+ const projectId = context.projectId;
+ const { deviceFilters, filters } = buildGscFilters(data);
+
+ try {
+ const [current, previous, queryPages, countries] = await Promise.all([
+ GscService.getPerformance({
+ projectId,
+ startDate,
+ endDate,
+ dimensions: ["date"],
+ filters,
+ rowLimit: DAILY_ROW_LIMIT,
+ }),
+ GscService.getPerformance({
+ projectId,
+ startDate: prev.startDate,
+ endDate: prev.endDate,
+ dimensions: ["date"],
+ filters,
+ rowLimit: DAILY_ROW_LIMIT,
+ }),
+ GscService.getPerformance({
+ projectId,
+ startDate,
+ endDate,
+ dimensions: ["query", "page"],
+ filters,
+ rowLimit: STRIKING_DISTANCE_FETCH_LIMIT,
+ }),
+ GscService.getPerformance({
+ projectId,
+ startDate,
+ endDate,
+ dimensions: ["country"],
+ filters: deviceFilters,
+ rowLimit: COUNTRY_ROW_LIMIT,
+ }),
+ ]);
+
+ return {
+ connected: true as const,
+ range: {
+ startDate,
+ endDate,
+ prevStartDate: prev.startDate,
+ prevEndDate: prev.endDate,
+ },
+ totals: sumSearchTotals(current.rows),
+ prevTotals: sumSearchTotals(previous.rows),
+ strikingDistance: buildStrikingDistanceRows(queryPages.rows),
+ countries: toDimensionRows(countries.rows),
+ };
+ } catch (error) {
+ if (isExpectedConnectionFailure(error)) {
+ return { connected: false as const };
+ }
+ throw error;
+ }
+ });
+
+/**
+ * One page of the queries or pages table, paginated server-side against GSC via
+ * `startRow` so it scales to large properties. GSC returns no total count, so we
+ * fetch one extra row to detect a next page. All first-party GSC data, free.
+ */
+export const getSearchPerformanceTable = createServerFn({ method: "POST" })
+ .middleware(requireProjectContext)
+ .inputValidator((data: unknown) =>
+ searchPerformanceTableInputSchema.parse(data),
+ )
+ .handler(async ({ data, context }) => {
+ const { startDate, endDate } = resolveDateRange({
+ dateRange: data.dateRange,
+ });
+ const { filters } = buildGscFilters(data);
+ const offset = (data.page - 1) * data.pageSize;
+
+ try {
+ const result = await GscService.getPerformance({
+ projectId: context.projectId,
+ startDate,
+ endDate,
+ dimensions: [data.dimension],
+ filters,
+ // One extra row tells us whether a further page exists.
+ rowLimit: data.pageSize + 1,
+ startRow: offset,
+ });
+
+ const fetched = toDimensionRows(result.rows);
+ const hasNextPage = fetched.length > data.pageSize;
+ const rows = hasNextPage ? fetched.slice(0, data.pageSize) : fetched;
+
+ return {
+ connected: true as const,
+ dimension: data.dimension,
+ page: data.page,
+ pageSize: data.pageSize,
+ hasNextPage,
+ rows,
+ };
+ } catch (error) {
+ if (isExpectedConnectionFailure(error)) {
+ return { connected: false as const };
+ }
+ throw error;
+ }
+ });
+
+/**
+ * The full queries/pages dataset for CSV/Sheets export (capped at
+ * EXPORT_ROW_LIMIT), rather than only the visible page.
+ */
+export const exportSearchPerformanceTable = createServerFn({ method: "POST" })
+ .middleware(requireProjectContext)
+ .inputValidator((data: unknown) =>
+ searchPerformanceTableExportInputSchema.parse(data),
+ )
+ .handler(async ({ data, context }) => {
+ const { startDate, endDate } = resolveDateRange({
+ dateRange: data.dateRange,
+ });
+ const { filters } = buildGscFilters(data);
+
+ const result = await GscService.getPerformance({
+ projectId: context.projectId,
+ startDate,
+ endDate,
+ dimensions: [data.dimension],
+ filters,
+ rowLimit: EXPORT_ROW_LIMIT,
+ });
+
+ return {
+ dimension: data.dimension,
+ rows: toDimensionRows(result.rows),
+ };
+ });
diff --git a/src/types/schemas/search-performance.ts b/src/types/schemas/search-performance.ts
new file mode 100644
index 0000000..88dfbed
--- /dev/null
+++ b/src/types/schemas/search-performance.ts
@@ -0,0 +1,63 @@
+import { z } from "zod";
+
+/** Date ranges offered by the Search Performance page. A deliberate subset of
+ * the GSC agent ranges (GSC_DATE_RANGES in searchAnalytics.ts); assignability
+ * to GscDateRange is compiler-checked at the resolveDateRange call site. */
+export const SEARCH_PERFORMANCE_RANGES = [
+ "last_7_days",
+ "last_28_days",
+ "last_3_months",
+] as const;
+
+/** Device values exactly as the GSC `device` dimension returns/accepts them. */
+export const GSC_DEVICES = ["DESKTOP", "MOBILE", "TABLET"] as const;
+
+export type SearchPerformanceDateRange =
+ (typeof SEARCH_PERFORMANCE_RANGES)[number];
+export type SearchPerformanceDevice = (typeof GSC_DEVICES)[number];
+
+// Shared report/table filters. Spread into each request schema so the overview
+// and the paginated table calls always accept the exact same filter surface.
+const searchPerformanceFilterShape = {
+ projectId: z.string().min(1),
+ dateRange: z.enum(SEARCH_PERFORMANCE_RANGES).default("last_28_days"),
+ device: z.enum(GSC_DEVICES).optional(),
+ // ISO-3166-1 alpha-3, the code GSC returns in `country` dimension keys.
+ country: z
+ .string()
+ .length(3)
+ .transform((value) => value.toLowerCase())
+ .optional(),
+};
+
+export const searchPerformanceInputSchema = z.object(
+ searchPerformanceFilterShape,
+);
+
+/** The dimensions that get their own paginated table (query + page). Striking
+ * distance is computed from the overview call and never paginates. */
+export const SEARCH_PERFORMANCE_TABLE_DIMENSIONS = ["query", "page"] as const;
+export type SearchPerformanceTableDimension =
+ (typeof SEARCH_PERFORMANCE_TABLE_DIMENSIONS)[number];
+
+export const SEARCH_PERFORMANCE_PAGE_SIZES = [25, 50, 100] as const;
+export const SEARCH_PERFORMANCE_DEFAULT_PAGE_SIZE = 25;
+
+export const searchPerformanceTableInputSchema = z.object({
+ ...searchPerformanceFilterShape,
+ dimension: z.enum(SEARCH_PERFORMANCE_TABLE_DIMENSIONS),
+ page: z.number().int().positive().default(1),
+ pageSize: z
+ .number()
+ .int()
+ .refine((value) =>
+ (SEARCH_PERFORMANCE_PAGE_SIZES as readonly number[]).includes(value),
+ )
+ .default(SEARCH_PERFORMANCE_DEFAULT_PAGE_SIZE),
+});
+
+/** Export pulls the full dataset (capped) rather than a single page. */
+export const searchPerformanceTableExportInputSchema = z.object({
+ ...searchPerformanceFilterShape,
+ dimension: z.enum(SEARCH_PERFORMANCE_TABLE_DIMENSIONS),
+});