feat: GSC UI + Striking Distance
This commit is contained in:
parent
b733be4a5e
commit
b8f47a51cc
@ -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() }
|
||||||
|
: {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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)),
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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 {
|
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",
|
||||||
|
|||||||
@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
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
|
* 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 &&
|
||||||
|
|||||||
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