feat: GSC UI + Striking Distance

This commit is contained in:
mattmacrocket 2026-07-02 16:37:36 -05:00 committed by GitHub
parent b733be4a5e
commit b8f47a51cc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 1499 additions and 2 deletions

View File

@ -2,6 +2,7 @@ import {
flexRender, flexRender,
getCoreRowModel, getCoreRowModel,
getExpandedRowModel, getExpandedRowModel,
getPaginationRowModel,
getSortedRowModel, getSortedRowModel,
useReactTable, useReactTable,
type ColumnDef, type ColumnDef,
@ -38,15 +39,20 @@ type UseAppTableOptions<TData> = Omit<
> & { > & {
withSorting?: boolean; withSorting?: boolean;
withExpanded?: boolean; withExpanded?: boolean;
withPagination?: boolean;
}; };
export function useAppTable<TData>(options: UseAppTableOptions<TData>) { export function useAppTable<TData>(options: UseAppTableOptions<TData>) {
const { withSorting, withExpanded, ...tableOptions } = options; const { withSorting, withExpanded, withPagination, ...tableOptions } =
options;
return useReactTable({ return useReactTable({
...tableOptions, ...tableOptions,
getCoreRowModel: getCoreRowModel(), getCoreRowModel: getCoreRowModel(),
...(withSorting ? { getSortedRowModel: getSortedRowModel() } : {}), ...(withSorting ? { getSortedRowModel: getSortedRowModel() } : {}),
...(withExpanded ? { getExpandedRowModel: getExpandedRowModel() } : {}), ...(withExpanded ? { getExpandedRowModel: getExpandedRowModel() } : {}),
...(withPagination
? { getPaginationRowModel: getPaginationRowModel() }
: {}),
}); });
} }

View File

@ -63,6 +63,14 @@ export function SearchConsoleConnectionCard({
setPicking(false); setPicking(false);
void queryClient.invalidateQueries({ queryKey: connectionKey }); void queryClient.invalidateQueries({ queryKey: connectionKey });
void queryClient.invalidateQueries({ queryKey: GRANT_STATUS_KEY }); 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)), onError: (error) => toast.error(getStandardErrorMessage(error)),
}); });
@ -76,6 +84,12 @@ export function SearchConsoleConnectionCard({
// Disconnect can drop the account-level grant server-side; keep the // Disconnect can drop the account-level grant server-side; keep the
// shared grant-status cache (onboarding step + re-engagement nudge) honest. // shared grant-status cache (onboarding step + re-engagement nudge) honest.
void queryClient.invalidateQueries({ queryKey: GRANT_STATUS_KEY }); 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)), onError: (error) => toast.error(getStandardErrorMessage(error)),
}); });

View File

@ -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<ReturnType<typeof getSearchPerformanceReport>>,
{ connected: true }
>;
export type SearchPerformanceTableRow = Extract<
Awaited<ReturnType<typeof getSearchPerformanceTable>>,
{ 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<DimensionRow>();
export function buildDimensionColumns(
keyLabel: string,
): ColumnDef<DimensionRow>[] {
return [
dimensionHelper.accessor("key", {
enableSorting: false,
header: () => keyLabel,
cell: ({ getValue }) => (
<span className="block max-w-xl truncate" title={getValue()}>
{getValue()}
</span>
),
}),
dimensionHelper.accessor("clicks", {
header: ({ column }) => (
<SortableHeader column={column} label="Clicks" align="right" />
),
cell: ({ getValue }) => formatCount(getValue()),
meta: rightAligned,
}),
dimensionHelper.accessor("impressions", {
header: ({ column }) => (
<SortableHeader column={column} label="Impressions" align="right" />
),
cell: ({ getValue }) => formatCount(getValue()),
meta: rightAligned,
}),
dimensionHelper.accessor("ctr", {
header: ({ column }) => (
<SortableHeader column={column} label="CTR" align="right" />
),
cell: ({ getValue }) => formatCtr(getValue()),
meta: rightAligned,
}),
dimensionHelper.accessor("position", {
header: ({ column }) => (
<SortableHeader column={column} label="Position" align="right" />
),
cell: ({ getValue }) => formatPosition(getValue()),
meta: rightAligned,
}),
];
}
const strikingHelper = createColumnHelper<StrikingRow>();
export function buildStrikingColumns(
anchorRef: MutableRefObject<SelectionAnchor | null>,
): ColumnDef<StrikingRow>[] {
return [
makeSelectionColumn<StrikingRow>(anchorRef),
strikingHelper.accessor("query", {
enableSorting: false,
header: () => "Query",
cell: ({ getValue }) => (
<span className="block max-w-xs truncate" title={getValue()}>
{getValue()}
</span>
),
}),
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()) ? (
<a
href={getValue()}
target="_blank"
rel="noreferrer"
className="link link-hover block max-w-sm truncate"
title={getValue()}
>
{getValue()}
</a>
) : (
<span className="block max-w-sm truncate" title={getValue()}>
{getValue()}
</span>
),
}),
strikingHelper.accessor("impressions", {
header: ({ column }) => (
<SortableHeader column={column} label="Impressions" align="right" />
),
cell: ({ getValue }) => formatCount(getValue()),
meta: rightAligned,
}),
strikingHelper.accessor("clicks", {
header: ({ column }) => (
<SortableHeader column={column} label="Clicks" align="right" />
),
cell: ({ getValue }) => formatCount(getValue()),
meta: rightAligned,
}),
strikingHelper.accessor("position", {
header: ({ column }) => (
<SortableHeader column={column} label="Position" align="right" />
),
cell: ({ getValue }) => formatPosition(getValue()),
meta: rightAligned,
}),
];
}

View File

@ -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<SearchPerformanceDateRange, string> = {
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<SearchPerformanceDevice, string> = {
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<SearchPerformanceDateRange>("last_28_days");
const [device, setDevice] = useState<SearchPerformanceDevice | typeof ALL>(
ALL,
);
const [country, setCountry] = useState<string>(ALL);
const [tab, setTab] = useState<Tab>("striking");
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState<number>(
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 (
<div className="px-4 py-4 pb-24 overflow-auto md:px-6 md:py-6 md:pb-8">
<div className="mx-auto max-w-7xl space-y-4">
<div>
<h1 className="text-2xl font-semibold">Search Performance</h1>
<p className="text-sm text-base-content/70">
See your site&apos;s clicks, impressions, CTR, and position from
Google Search Console.
</p>
</div>
{reportQuery.isPending ? (
<div className="flex items-center gap-2 p-8 text-sm text-base-content/60">
<Loader2 className="size-4 animate-spin" /> Loading Search Console
data
</div>
) : reportQuery.isError ? (
<div className="alert alert-error">
<span className="text-sm">
{getStandardErrorMessage(reportQuery.error)}
</span>
</div>
) : !report?.connected ? (
<div className="max-w-2xl space-y-4">
<p className="text-sm text-base-content/70">
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.
</p>
<SearchConsoleConnectionCard projectId={projectId} />
</div>
) : (
<>
<TotalsCards report={report} />
<div className="overflow-hidden rounded-xl border border-base-300 bg-base-100">
<div className="flex flex-col gap-3 border-b border-base-300 px-4 py-3 lg:flex-row lg:items-center lg:justify-between">
<div role="tablist" className="tabs tabs-box w-fit">
<TabButton
active={tab === "striking"}
onClick={() => setTab("striking")}
label={`Striking distance (${report.strikingDistance.length})`}
/>
<TabButton
active={tab === "queries"}
onClick={() => setTab("queries")}
label="Queries"
/>
<TabButton
active={tab === "pages"}
onClick={() => setTab("pages")}
label="Pages"
/>
</div>
<div className="flex flex-wrap items-center gap-2">
{reportQuery.isFetching && !reportQuery.isPending ? (
<Loader2 className="size-4 animate-spin text-base-content/40" />
) : null}
<select
className="select select-bordered select-sm w-36"
value={device}
onChange={(event) => {
setDevice(
isDevice(event.target.value) ? event.target.value : ALL,
);
}}
aria-label="Device filter"
>
<option value={ALL}>All devices</option>
{DEVICE_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
<select
className="select select-bordered select-sm w-36"
value={country}
onChange={(event) => setCountry(event.target.value)}
aria-label="Country filter"
>
<option value={ALL}>All countries</option>
{report.countries.map((row) => (
<option key={row.key} value={row.key}>
{row.key.toUpperCase()}
</option>
))}
</select>
<select
className="select select-bordered select-sm w-36"
value={range}
onChange={(event) => {
if (isDateRange(event.target.value)) {
setRange(event.target.value);
}
}}
aria-label="Date range"
>
{RANGE_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
<TableExportMenu
buttonClassName="btn btn-ghost btn-sm gap-1"
actions={[
{
label: "Export to Sheets",
icon: <Sheet className="size-4" />,
onClick: () => void handleExport("sheets"),
},
{
label: "Download CSV",
icon: <Download className="size-4" />,
onClick: () => void handleExport("csv"),
},
]}
/>
</div>
</div>
{tab === "striking" ? (
<StrikingDistanceTable
projectId={projectId}
rows={report.strikingDistance}
/>
) : tableQuery.isPending ? (
<div className="flex items-center gap-2 p-8 text-sm text-base-content/60">
<Loader2 className="size-4 animate-spin" /> Loading
</div>
) : tableQuery.isError ? (
<div className="p-4">
<div className="alert alert-error">
<span className="text-sm">
{getStandardErrorMessage(tableQuery.error)}
</span>
</div>
</div>
) : (
<>
<div className="p-4">
<DimensionTable
rows={tableRows}
keyLabel={tab === "queries" ? "Query" : "Page"}
/>
</div>
<TablePagination
page={page}
pageSize={pageSize}
pageSizes={SEARCH_PERFORMANCE_PAGE_SIZES}
totalCount={null}
hasNextPage={hasNextPage}
isLoading={tableQuery.isFetching}
onPageChange={setPage}
onPageSizeChange={setPageSize}
/>
</>
)}
</div>
</>
)}
</div>
</div>
);
}

View File

@ -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 (
<button
type="button"
role="tab"
aria-selected={active}
className={`tab ${active ? "tab-active" : ""}`}
onClick={onClick}
>
{label}
</button>
);
}
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 (
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
<TotalCard
label="Clicks"
value={formatCount(totals.clicks)}
delta={percentDelta(totals.clicks, prevTotals.clicks)}
deltaTitle={deltaTitle}
/>
<TotalCard
label="Impressions"
value={formatCount(totals.impressions)}
delta={percentDelta(totals.impressions, prevTotals.impressions)}
deltaTitle={deltaTitle}
/>
<TotalCard
label="CTR"
value={formatCtr(totals.ctr)}
delta={percentDelta(totals.ctr, prevTotals.ctr)}
deltaTitle={deltaTitle}
/>
<TotalCard
label="Avg position"
value={formatPosition(totals.position)}
delta={positionDelta(totals.position, prevTotals.position)}
deltaTitle={deltaTitle}
/>
</div>
);
}
function TotalCard({
label,
value,
delta,
deltaTitle,
}: {
label: string;
value: string;
delta: Delta;
deltaTitle: string;
}) {
return (
<div className="rounded-lg border border-base-300 bg-base-100 p-4">
<div className="text-xs uppercase tracking-wide text-base-content/60">
{label}
</div>
<div className="mt-1 flex items-baseline gap-2">
<span className="text-2xl font-semibold">{value}</span>
{delta ? (
<span
className={`text-xs ${delta.improved ? "text-success" : "text-error"}`}
title={deltaTitle}
>
{delta.text}
</span>
) : null}
</div>
</div>
);
}
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 (
<AppDataTable
table={table}
className="table table-zebra table-sm"
wrapperClassName="overflow-x-auto"
empty={
<p className="p-6 text-sm text-base-content/60">
No data for this period yet. Search Console data trails by a few days.
</p>
}
/>
);
}
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 (
<p className="p-6 text-sm text-base-content/60">
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.
</p>
);
}
return (
<>
<div className="p-4">
<p className="mb-3 text-sm text-base-content/60">
Queries ranking at positions 5 to 20, sorted by impressions. Improve
the listed page to move them into the top results.
</p>
<AppDataTable
table={table}
className="table table-zebra table-sm"
wrapperClassName="overflow-x-auto"
/>
</div>
<TablePagination
page={pagination.pageIndex + 1}
pageSize={pagination.pageSize}
pageSizes={SEARCH_PERFORMANCE_PAGE_SIZES}
totalCount={rows.length}
hasNextPage={table.getCanNextPage()}
isLoading={false}
onPageChange={(nextPage) => table.setPageIndex(nextPage - 1)}
onPageSizeChange={(nextSize) => table.setPageSize(nextSize)}
/>
<TableBulkActionBar
selectedCount={selectedQueries.length}
selectedLabel={selectedQueries.length === 1 ? "query" : "queries"}
onClear={() => setRowSelection({})}
actions={
<div className="flex items-center gap-1 px-1.5">
<TableBulkActionButton
icon={<Copy className="size-3.5" />}
onClick={() => void copyKeywords()}
>
Copy keywords
</TableBulkActionButton>
<TableBulkActionButton
icon={
save.isPending ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<Save className="size-3.5" />
)
}
onClick={() => save.mutate(selectedQueries)}
disabled={save.isPending}
>
Save as keywords
</TableBulkActionButton>
</div>
}
/>
</>
);
}

View File

@ -1,4 +1,5 @@
import { import {
BarChart3,
Bookmark, Bookmark,
Bot, Bot,
ClipboardCheck, ClipboardCheck,
@ -30,6 +31,12 @@ const projectNavItems = [
icon: TrendingUp, icon: TrendingUp,
matchSegment: "/rank-tracking", matchSegment: "/rank-tracking",
}, },
{
to: "/p/$projectId/search-performance" as const,
label: "Search Performance",
icon: BarChart3,
matchSegment: "/search-performance",
},
{ {
to: "/p/$projectId/domain" as const, to: "/p/$projectId/domain" as const,
label: "Domain Overview", label: "Domain Overview",
@ -95,6 +102,10 @@ export function getProjectNavGroups(projectId: string) {
bySegment("/rank-tracking"), bySegment("/rank-tracking"),
], ],
}, },
{
type: "standalone" as const,
item: bySegment("/search-performance"),
},
{ {
type: "group" as const, type: "group" as const,
label: "Domain", label: "Domain",

View File

@ -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 ProjectPProjectIdIndexRouteImport } from './routes/_project/p/$projectId/index'
import { Route as ApiGscOauthCallbackRouteImport } from './routes/api/gsc/oauth/callback' import { Route as ApiGscOauthCallbackRouteImport } from './routes/api/gsc/oauth/callback'
import { Route as ProjectPProjectIdSettingsRouteImport } from './routes/_project/p/$projectId/settings' 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 ProjectPProjectIdSavedRouteImport } from './routes/_project/p/$projectId/saved'
import { Route as ProjectPProjectIdRankTrackingRouteImport } from './routes/_project/p/$projectId/rank-tracking' import { Route as ProjectPProjectIdRankTrackingRouteImport } from './routes/_project/p/$projectId/rank-tracking'
import { Route as ProjectPProjectIdPromptExplorerRouteImport } from './routes/_project/p/$projectId/prompt-explorer' import { Route as ProjectPProjectIdPromptExplorerRouteImport } from './routes/_project/p/$projectId/prompt-explorer'
@ -185,6 +186,12 @@ const ProjectPProjectIdSettingsRoute =
path: '/settings', path: '/settings',
getParentRoute: () => ProjectPProjectIdRouteRoute, getParentRoute: () => ProjectPProjectIdRouteRoute,
} as any) } as any)
const ProjectPProjectIdSearchPerformanceRoute =
ProjectPProjectIdSearchPerformanceRouteImport.update({
id: '/search-performance',
path: '/search-performance',
getParentRoute: () => ProjectPProjectIdRouteRoute,
} as any)
const ProjectPProjectIdSavedRoute = ProjectPProjectIdSavedRouteImport.update({ const ProjectPProjectIdSavedRoute = ProjectPProjectIdSavedRouteImport.update({
id: '/saved', id: '/saved',
path: '/saved', path: '/saved',
@ -284,6 +291,7 @@ export interface FileRoutesByFullPath {
'/p/$projectId/prompt-explorer': typeof ProjectPProjectIdPromptExplorerRoute '/p/$projectId/prompt-explorer': typeof ProjectPProjectIdPromptExplorerRoute
'/p/$projectId/rank-tracking': typeof ProjectPProjectIdRankTrackingRouteWithChildren '/p/$projectId/rank-tracking': typeof ProjectPProjectIdRankTrackingRouteWithChildren
'/p/$projectId/saved': typeof ProjectPProjectIdSavedRoute '/p/$projectId/saved': typeof ProjectPProjectIdSavedRoute
'/p/$projectId/search-performance': typeof ProjectPProjectIdSearchPerformanceRoute
'/p/$projectId/settings': typeof ProjectPProjectIdSettingsRoute '/p/$projectId/settings': typeof ProjectPProjectIdSettingsRoute
'/api/gsc/oauth/callback': typeof ApiGscOauthCallbackRoute '/api/gsc/oauth/callback': typeof ApiGscOauthCallbackRoute
'/p/$projectId/': typeof ProjectPProjectIdIndexRoute '/p/$projectId/': typeof ProjectPProjectIdIndexRoute
@ -318,6 +326,7 @@ export interface FileRoutesByTo {
'/p/$projectId/keywords': typeof ProjectPProjectIdKeywordsRoute '/p/$projectId/keywords': typeof ProjectPProjectIdKeywordsRoute
'/p/$projectId/prompt-explorer': typeof ProjectPProjectIdPromptExplorerRoute '/p/$projectId/prompt-explorer': typeof ProjectPProjectIdPromptExplorerRoute
'/p/$projectId/saved': typeof ProjectPProjectIdSavedRoute '/p/$projectId/saved': typeof ProjectPProjectIdSavedRoute
'/p/$projectId/search-performance': typeof ProjectPProjectIdSearchPerformanceRoute
'/p/$projectId/settings': typeof ProjectPProjectIdSettingsRoute '/p/$projectId/settings': typeof ProjectPProjectIdSettingsRoute
'/api/gsc/oauth/callback': typeof ApiGscOauthCallbackRoute '/api/gsc/oauth/callback': typeof ApiGscOauthCallbackRoute
'/p/$projectId': typeof ProjectPProjectIdIndexRoute '/p/$projectId': typeof ProjectPProjectIdIndexRoute
@ -360,6 +369,7 @@ export interface FileRoutesById {
'/_project/p/$projectId/prompt-explorer': typeof ProjectPProjectIdPromptExplorerRoute '/_project/p/$projectId/prompt-explorer': typeof ProjectPProjectIdPromptExplorerRoute
'/_project/p/$projectId/rank-tracking': typeof ProjectPProjectIdRankTrackingRouteWithChildren '/_project/p/$projectId/rank-tracking': typeof ProjectPProjectIdRankTrackingRouteWithChildren
'/_project/p/$projectId/saved': typeof ProjectPProjectIdSavedRoute '/_project/p/$projectId/saved': typeof ProjectPProjectIdSavedRoute
'/_project/p/$projectId/search-performance': typeof ProjectPProjectIdSearchPerformanceRoute
'/_project/p/$projectId/settings': typeof ProjectPProjectIdSettingsRoute '/_project/p/$projectId/settings': typeof ProjectPProjectIdSettingsRoute
'/api/gsc/oauth/callback': typeof ApiGscOauthCallbackRoute '/api/gsc/oauth/callback': typeof ApiGscOauthCallbackRoute
'/_project/p/$projectId/': typeof ProjectPProjectIdIndexRoute '/_project/p/$projectId/': typeof ProjectPProjectIdIndexRoute
@ -399,6 +409,7 @@ export interface FileRouteTypes {
| '/p/$projectId/prompt-explorer' | '/p/$projectId/prompt-explorer'
| '/p/$projectId/rank-tracking' | '/p/$projectId/rank-tracking'
| '/p/$projectId/saved' | '/p/$projectId/saved'
| '/p/$projectId/search-performance'
| '/p/$projectId/settings' | '/p/$projectId/settings'
| '/api/gsc/oauth/callback' | '/api/gsc/oauth/callback'
| '/p/$projectId/' | '/p/$projectId/'
@ -433,6 +444,7 @@ export interface FileRouteTypes {
| '/p/$projectId/keywords' | '/p/$projectId/keywords'
| '/p/$projectId/prompt-explorer' | '/p/$projectId/prompt-explorer'
| '/p/$projectId/saved' | '/p/$projectId/saved'
| '/p/$projectId/search-performance'
| '/p/$projectId/settings' | '/p/$projectId/settings'
| '/api/gsc/oauth/callback' | '/api/gsc/oauth/callback'
| '/p/$projectId' | '/p/$projectId'
@ -474,6 +486,7 @@ export interface FileRouteTypes {
| '/_project/p/$projectId/prompt-explorer' | '/_project/p/$projectId/prompt-explorer'
| '/_project/p/$projectId/rank-tracking' | '/_project/p/$projectId/rank-tracking'
| '/_project/p/$projectId/saved' | '/_project/p/$projectId/saved'
| '/_project/p/$projectId/search-performance'
| '/_project/p/$projectId/settings' | '/_project/p/$projectId/settings'
| '/api/gsc/oauth/callback' | '/api/gsc/oauth/callback'
| '/_project/p/$projectId/' | '/_project/p/$projectId/'
@ -688,6 +701,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ProjectPProjectIdSettingsRouteImport preLoaderRoute: typeof ProjectPProjectIdSettingsRouteImport
parentRoute: typeof ProjectPProjectIdRouteRoute 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': { '/_project/p/$projectId/saved': {
id: '/_project/p/$projectId/saved' id: '/_project/p/$projectId/saved'
path: '/saved' path: '/saved'
@ -843,6 +863,7 @@ interface ProjectPProjectIdRouteRouteChildren {
ProjectPProjectIdPromptExplorerRoute: typeof ProjectPProjectIdPromptExplorerRoute ProjectPProjectIdPromptExplorerRoute: typeof ProjectPProjectIdPromptExplorerRoute
ProjectPProjectIdRankTrackingRoute: typeof ProjectPProjectIdRankTrackingRouteWithChildren ProjectPProjectIdRankTrackingRoute: typeof ProjectPProjectIdRankTrackingRouteWithChildren
ProjectPProjectIdSavedRoute: typeof ProjectPProjectIdSavedRoute ProjectPProjectIdSavedRoute: typeof ProjectPProjectIdSavedRoute
ProjectPProjectIdSearchPerformanceRoute: typeof ProjectPProjectIdSearchPerformanceRoute
ProjectPProjectIdSettingsRoute: typeof ProjectPProjectIdSettingsRoute ProjectPProjectIdSettingsRoute: typeof ProjectPProjectIdSettingsRoute
ProjectPProjectIdIndexRoute: typeof ProjectPProjectIdIndexRoute ProjectPProjectIdIndexRoute: typeof ProjectPProjectIdIndexRoute
} }
@ -858,6 +879,8 @@ const ProjectPProjectIdRouteRouteChildren: ProjectPProjectIdRouteRouteChildren =
ProjectPProjectIdRankTrackingRoute: ProjectPProjectIdRankTrackingRoute:
ProjectPProjectIdRankTrackingRouteWithChildren, ProjectPProjectIdRankTrackingRouteWithChildren,
ProjectPProjectIdSavedRoute: ProjectPProjectIdSavedRoute, ProjectPProjectIdSavedRoute: ProjectPProjectIdSavedRoute,
ProjectPProjectIdSearchPerformanceRoute:
ProjectPProjectIdSearchPerformanceRoute,
ProjectPProjectIdSettingsRoute: ProjectPProjectIdSettingsRoute, ProjectPProjectIdSettingsRoute: ProjectPProjectIdSettingsRoute,
ProjectPProjectIdIndexRoute: ProjectPProjectIdIndexRoute, ProjectPProjectIdIndexRoute: ProjectPProjectIdIndexRoute,
} }

View File

@ -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 <SearchPerformancePage projectId={projectId} />;
}

View File

@ -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",
});
});
});

View File

@ -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<string, StrikingDistanceRow>();
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);
}

View File

@ -74,7 +74,7 @@ async function listSitesForUser(userId: string): Promise<GscSite[]> {
* minted (refresh token revoked or expired), or Google rejected the call * minted (refresh token revoked or expired), or Google rejected the call
* (401/403). These surface a reconnect prompt instead of being routed through * (401/403). These surface a reconnect prompt instead of being routed through
* error tracking. Other statuses (429, 5xx) are genuine faults and propagate. */ * 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; if (error instanceof GscTokenError) return true;
return ( return (
error instanceof GscApiError && error instanceof GscApiError &&

View File

@ -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),
};
});

View File

@ -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),
});