feat: GSC UI + Striking Distance
This commit is contained in:
parent
b733be4a5e
commit
b8f47a51cc
@ -2,6 +2,7 @@ import {
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getExpandedRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
type ColumnDef,
|
||||
@ -38,15 +39,20 @@ type UseAppTableOptions<TData> = Omit<
|
||||
> & {
|
||||
withSorting?: boolean;
|
||||
withExpanded?: boolean;
|
||||
withPagination?: boolean;
|
||||
};
|
||||
|
||||
export function useAppTable<TData>(options: UseAppTableOptions<TData>) {
|
||||
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() }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -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)),
|
||||
});
|
||||
|
||||
@ -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,
|
||||
}),
|
||||
];
|
||||
}
|
||||
353
src/client/features/search-performance/SearchPerformancePage.tsx
Normal file
353
src/client/features/search-performance/SearchPerformancePage.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -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",
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
13
src/routes/_project/p/$projectId/search-performance.tsx
Normal file
13
src/routes/_project/p/$projectId/search-performance.tsx
Normal 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} />;
|
||||
}
|
||||
140
src/server/features/gsc/searchPerformanceReport.test.ts
Normal file
140
src/server/features/gsc/searchPerformanceReport.test.ts
Normal 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
144
src/server/features/gsc/searchPerformanceReport.ts
Normal file
144
src/server/features/gsc/searchPerformanceReport.ts
Normal 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);
|
||||
}
|
||||
@ -74,7 +74,7 @@ async function listSitesForUser(userId: string): Promise<GscSite[]> {
|
||||
* 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 &&
|
||||
|
||||
210
src/serverFunctions/searchPerformance.ts
Normal file
210
src/serverFunctions/searchPerformance.ts
Normal 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),
|
||||
};
|
||||
});
|
||||
63
src/types/schemas/search-performance.ts
Normal file
63
src/types/schemas/search-performance.ts
Normal 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),
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user