From b8f47a51cc11febc6da25643b7a13b1fb62f2444 Mon Sep 17 00:00:00 2001 From: mattmacrocket Date: Thu, 2 Jul 2026 16:37:36 -0500 Subject: [PATCH] feat: GSC UI + Striking Distance --- src/client/components/table/AppDataTable.tsx | 8 +- .../gsc/SearchConsoleConnectionCard.tsx | 14 + .../SearchPerformanceColumns.tsx | 147 +++++++ .../SearchPerformancePage.tsx | 353 +++++++++++++++++ .../SearchPerformanceParts.tsx | 373 ++++++++++++++++++ src/client/navigation/items.ts | 11 + src/routeTree.gen.ts | 23 ++ .../p/$projectId/search-performance.tsx | 13 + .../gsc/searchPerformanceReport.test.ts | 140 +++++++ .../features/gsc/searchPerformanceReport.ts | 144 +++++++ .../features/gsc/services/GscService.ts | 2 +- src/serverFunctions/searchPerformance.ts | 210 ++++++++++ src/types/schemas/search-performance.ts | 63 +++ 13 files changed, 1499 insertions(+), 2 deletions(-) create mode 100644 src/client/features/search-performance/SearchPerformanceColumns.tsx create mode 100644 src/client/features/search-performance/SearchPerformancePage.tsx create mode 100644 src/client/features/search-performance/SearchPerformanceParts.tsx create mode 100644 src/routes/_project/p/$projectId/search-performance.tsx create mode 100644 src/server/features/gsc/searchPerformanceReport.test.ts create mode 100644 src/server/features/gsc/searchPerformanceReport.ts create mode 100644 src/serverFunctions/searchPerformance.ts create mode 100644 src/types/schemas/search-performance.ts diff --git a/src/client/components/table/AppDataTable.tsx b/src/client/components/table/AppDataTable.tsx index 24c349d..e88d1e2 100644 --- a/src/client/components/table/AppDataTable.tsx +++ b/src/client/components/table/AppDataTable.tsx @@ -2,6 +2,7 @@ import { flexRender, getCoreRowModel, getExpandedRowModel, + getPaginationRowModel, getSortedRowModel, useReactTable, type ColumnDef, @@ -38,15 +39,20 @@ type UseAppTableOptions = Omit< > & { withSorting?: boolean; withExpanded?: boolean; + withPagination?: boolean; }; export function useAppTable(options: UseAppTableOptions) { - const { withSorting, withExpanded, ...tableOptions } = options; + const { withSorting, withExpanded, withPagination, ...tableOptions } = + options; return useReactTable({ ...tableOptions, getCoreRowModel: getCoreRowModel(), ...(withSorting ? { getSortedRowModel: getSortedRowModel() } : {}), ...(withExpanded ? { getExpandedRowModel: getExpandedRowModel() } : {}), + ...(withPagination + ? { getPaginationRowModel: getPaginationRowModel() } + : {}), }); } diff --git a/src/client/features/gsc/SearchConsoleConnectionCard.tsx b/src/client/features/gsc/SearchConsoleConnectionCard.tsx index 4a52b1a..087373c 100644 --- a/src/client/features/gsc/SearchConsoleConnectionCard.tsx +++ b/src/client/features/gsc/SearchConsoleConnectionCard.tsx @@ -63,6 +63,14 @@ export function SearchConsoleConnectionCard({ setPicking(false); void queryClient.invalidateQueries({ queryKey: connectionKey }); void queryClient.invalidateQueries({ queryKey: GRANT_STATUS_KEY }); + // The Search Performance report caches {connected:false}; refresh it so + // the page shows data right after connecting instead of the stale card. + void queryClient.invalidateQueries({ + queryKey: ["searchPerformance", projectId], + }); + void queryClient.invalidateQueries({ + queryKey: ["searchPerformanceTable", projectId], + }); }, onError: (error) => toast.error(getStandardErrorMessage(error)), }); @@ -76,6 +84,12 @@ export function SearchConsoleConnectionCard({ // Disconnect can drop the account-level grant server-side; keep the // shared grant-status cache (onboarding step + re-engagement nudge) honest. void queryClient.invalidateQueries({ queryKey: GRANT_STATUS_KEY }); + void queryClient.invalidateQueries({ + queryKey: ["searchPerformance", projectId], + }); + void queryClient.invalidateQueries({ + queryKey: ["searchPerformanceTable", projectId], + }); }, onError: (error) => toast.error(getStandardErrorMessage(error)), }); diff --git a/src/client/features/search-performance/SearchPerformanceColumns.tsx b/src/client/features/search-performance/SearchPerformanceColumns.tsx new file mode 100644 index 0000000..2b2efa4 --- /dev/null +++ b/src/client/features/search-performance/SearchPerformanceColumns.tsx @@ -0,0 +1,147 @@ +import { createColumnHelper, type ColumnDef } from "@tanstack/react-table"; +import type { MutableRefObject } from "react"; +import { makeSelectionColumn } from "@/client/components/table/AppDataTable"; +import { SortableHeader } from "@/client/components/table/SortableHeader"; +import type { SelectionAnchor } from "@/client/components/table/tableSelection"; +import type { + getSearchPerformanceReport, + getSearchPerformanceTable, +} from "@/serverFunctions/searchPerformance"; + +export type Report = Extract< + Awaited>, + { connected: true } +>; +export type SearchPerformanceTableRow = Extract< + Awaited>, + { connected: true } +>["rows"][number]; +type DimensionRow = SearchPerformanceTableRow; +type StrikingRow = Report["strikingDistance"][number]; + +const numberFormat = new Intl.NumberFormat("en-US"); + +export function formatCount(value: number): string { + return numberFormat.format(Math.round(value)); +} + +export function formatCtr(value: number): string { + return `${(value * 100).toFixed(1)}%`; +} + +export function formatPosition(value: number): string { + return value.toFixed(1); +} + +const rightAligned = { + headerClassName: "text-right", + cellClassName: "text-right tabular-nums", +} as const; + +const dimensionHelper = createColumnHelper(); + +export function buildDimensionColumns( + keyLabel: string, +): ColumnDef[] { + return [ + dimensionHelper.accessor("key", { + enableSorting: false, + header: () => keyLabel, + cell: ({ getValue }) => ( + + {getValue()} + + ), + }), + dimensionHelper.accessor("clicks", { + header: ({ column }) => ( + + ), + cell: ({ getValue }) => formatCount(getValue()), + meta: rightAligned, + }), + dimensionHelper.accessor("impressions", { + header: ({ column }) => ( + + ), + cell: ({ getValue }) => formatCount(getValue()), + meta: rightAligned, + }), + dimensionHelper.accessor("ctr", { + header: ({ column }) => ( + + ), + cell: ({ getValue }) => formatCtr(getValue()), + meta: rightAligned, + }), + dimensionHelper.accessor("position", { + header: ({ column }) => ( + + ), + cell: ({ getValue }) => formatPosition(getValue()), + meta: rightAligned, + }), + ]; +} + +const strikingHelper = createColumnHelper(); + +export function buildStrikingColumns( + anchorRef: MutableRefObject, +): ColumnDef[] { + return [ + makeSelectionColumn(anchorRef), + strikingHelper.accessor("query", { + enableSorting: false, + header: () => "Query", + cell: ({ getValue }) => ( + + {getValue()} + + ), + }), + strikingHelper.accessor("page", { + enableSorting: false, + header: () => "Page", + // GSC page keys are canonical http(s) URLs of the verified property; + // the scheme check is defense-in-depth before rendering an href. + cell: ({ getValue }) => + /^https?:\/\//.test(getValue()) ? ( + + {getValue()} + + ) : ( + + {getValue()} + + ), + }), + strikingHelper.accessor("impressions", { + header: ({ column }) => ( + + ), + cell: ({ getValue }) => formatCount(getValue()), + meta: rightAligned, + }), + strikingHelper.accessor("clicks", { + header: ({ column }) => ( + + ), + cell: ({ getValue }) => formatCount(getValue()), + meta: rightAligned, + }), + strikingHelper.accessor("position", { + header: ({ column }) => ( + + ), + cell: ({ getValue }) => formatPosition(getValue()), + meta: rightAligned, + }), + ]; +} diff --git a/src/client/features/search-performance/SearchPerformancePage.tsx b/src/client/features/search-performance/SearchPerformancePage.tsx new file mode 100644 index 0000000..e80f668 --- /dev/null +++ b/src/client/features/search-performance/SearchPerformancePage.tsx @@ -0,0 +1,353 @@ +import { useEffect, useState } from "react"; +import { + keepPreviousData, + queryOptions, + useQuery, + useQueryClient, +} from "@tanstack/react-query"; +import { Download, Loader2, Sheet } from "lucide-react"; +import { toast } from "sonner"; +import { TableExportMenu } from "@/client/components/table/TableBulkActionBar"; +import { TablePagination } from "@/client/components/table/TablePagination"; +import { SearchConsoleConnectionCard } from "@/client/features/gsc/SearchConsoleConnectionCard"; +import { + DimensionTable, + exportDimensionRows, + exportStriking, + StrikingDistanceTable, + TabButton, + TotalsCards, + type ExportTarget, + type Tab, +} from "@/client/features/search-performance/SearchPerformanceParts"; +import { getStandardErrorMessage } from "@/client/lib/error-messages"; +import { + exportSearchPerformanceTable, + getSearchPerformanceReport, + getSearchPerformanceTable, +} from "@/serverFunctions/searchPerformance"; +import { + GSC_DEVICES, + SEARCH_PERFORMANCE_DEFAULT_PAGE_SIZE, + SEARCH_PERFORMANCE_PAGE_SIZES, + SEARCH_PERFORMANCE_RANGES, + type SearchPerformanceDateRange, + type SearchPerformanceDevice, + type SearchPerformanceTableDimension, +} from "@/types/schemas/search-performance"; + +const RANGE_LABELS: Record = { + last_7_days: "Last 7 days", + last_28_days: "Last 28 days", + last_3_months: "Last 3 months", +}; +const RANGE_OPTIONS = SEARCH_PERFORMANCE_RANGES.map((value) => ({ + value, + label: RANGE_LABELS[value], +})); + +const DEVICE_LABELS: Record = { + DESKTOP: "Desktop", + MOBILE: "Mobile", + TABLET: "Tablet", +}; +const DEVICE_OPTIONS = GSC_DEVICES.map((value) => ({ + value, + label: DEVICE_LABELS[value], +})); + +// Sentinel for "no filter" in the selects; never sent to the server. +const ALL = "ALL"; + +function isDateRange(value: string): value is SearchPerformanceDateRange { + return SEARCH_PERFORMANCE_RANGES.some((option) => option === value); +} + +function isDevice(value: string): value is SearchPerformanceDevice { + return GSC_DEVICES.some((option) => option === value); +} + +function tabDimension(tab: Tab): SearchPerformanceTableDimension { + return tab === "pages" ? "page" : "query"; +} + +type FilterInput = { + dateRange: SearchPerformanceDateRange; + device?: SearchPerformanceDevice; + country?: string; +}; + +// The server filter payload: drop device/country when set to the "ALL" sentinel. +function buildFilterInput( + range: SearchPerformanceDateRange, + device: SearchPerformanceDevice | typeof ALL, + country: string, +): FilterInput { + return { + dateRange: range, + ...(device === ALL ? {} : { device }), + ...(country === ALL ? {} : { country }), + }; +} + +// Single source for the paginated table query, shared by the live query and the +// warm-on-connect prefetch so their key + fn can never drift apart. +function tableQueryOptions( + projectId: string, + dimension: SearchPerformanceTableDimension, + page: number, + pageSize: number, + filterInput: FilterInput, +) { + return queryOptions({ + queryKey: [ + "searchPerformanceTable", + projectId, + dimension, + page, + pageSize, + filterInput, + ], + queryFn: () => + getSearchPerformanceTable({ + data: { projectId, dimension, page, pageSize, ...filterInput }, + }), + }); +} + +export function SearchPerformancePage({ projectId }: { projectId: string }) { + const queryClient = useQueryClient(); + const [range, setRange] = + useState("last_28_days"); + const [device, setDevice] = useState( + ALL, + ); + const [country, setCountry] = useState(ALL); + const [tab, setTab] = useState("striking"); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState( + SEARCH_PERFORMANCE_DEFAULT_PAGE_SIZE, + ); + + // Any change to the query set (tab, filters, page size) restarts at page 1. + useEffect(() => { + setPage(1); + }, [tab, range, device, country, pageSize]); + + const filterInput = buildFilterInput(range, device, country); + + const reportQuery = useQuery({ + queryKey: ["searchPerformance", projectId, range, device, country], + queryFn: () => + getSearchPerformanceReport({ data: { projectId, ...filterInput } }), + placeholderData: keepPreviousData, + }); + const report = reportQuery.data; + + const isTableTab = tab === "queries" || tab === "pages"; + const dimension = tabDimension(tab); + const tableQuery = useQuery({ + ...tableQueryOptions(projectId, dimension, page, pageSize, filterInput), + enabled: report?.connected === true && isTableTab, + placeholderData: keepPreviousData, + }); + const tableData = tableQuery.data; + const tableRows = tableData?.connected ? tableData.rows : []; + const hasNextPage = tableData?.connected ? tableData.hasNextPage : false; + + // Warm the Queries tab (first page) as soon as the report connects so the tab + // opens instantly instead of showing a spinner. Free first-party GSC data. + useEffect(() => { + if (report?.connected !== true) return; + void queryClient.prefetchQuery( + tableQueryOptions( + projectId, + "query", + 1, + SEARCH_PERFORMANCE_DEFAULT_PAGE_SIZE, + buildFilterInput(range, device, country), + ), + ); + }, [report?.connected, projectId, range, device, country, queryClient]); + + const handleExport = async (target: ExportTarget) => { + if (!report?.connected) return; + try { + if (tab === "striking") { + exportStriking(report, target); + return; + } + const data = await exportSearchPerformanceTable({ + data: { projectId, dimension, ...filterInput }, + }); + exportDimensionRows(dimension, data.rows, report.range, target); + } catch (error) { + toast.error(getStandardErrorMessage(error, "Export failed")); + } + }; + + return ( +
+
+
+

Search Performance

+

+ See your site's clicks, impressions, CTR, and position from + Google Search Console. +

+
+ + {reportQuery.isPending ? ( +
+ Loading Search Console + data… +
+ ) : reportQuery.isError ? ( +
+ + {getStandardErrorMessage(reportQuery.error)} + +
+ ) : !report?.connected ? ( +
+

+ Find your striking-distance keywords — queries ranking just off + the top of page one, where a small improvement can win the most + new clicks. Connect Search Console to see them. +

+ +
+ ) : ( + <> + +
+
+
+ setTab("striking")} + label={`Striking distance (${report.strikingDistance.length})`} + /> + setTab("queries")} + label="Queries" + /> + setTab("pages")} + label="Pages" + /> +
+
+ {reportQuery.isFetching && !reportQuery.isPending ? ( + + ) : null} + + + + , + onClick: () => void handleExport("sheets"), + }, + { + label: "Download CSV", + icon: , + onClick: () => void handleExport("csv"), + }, + ]} + /> +
+
+ + {tab === "striking" ? ( + + ) : tableQuery.isPending ? ( +
+ Loading… +
+ ) : tableQuery.isError ? ( +
+
+ + {getStandardErrorMessage(tableQuery.error)} + +
+
+ ) : ( + <> +
+ +
+ + + )} +
+ + )} +
+
+ ); +} diff --git a/src/client/features/search-performance/SearchPerformanceParts.tsx b/src/client/features/search-performance/SearchPerformanceParts.tsx new file mode 100644 index 0000000..2e541a1 --- /dev/null +++ b/src/client/features/search-performance/SearchPerformanceParts.tsx @@ -0,0 +1,373 @@ +import { useMemo, useState } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { Copy, Loader2, Save } from "lucide-react"; +import { toast } from "sonner"; +import { + AppDataTable, + useAppTable, + useSelectionAnchor, +} from "@/client/components/table/AppDataTable"; +import { + TableBulkActionBar, + TableBulkActionButton, +} from "@/client/components/table/TableBulkActionBar"; +import { TablePagination } from "@/client/components/table/TablePagination"; +import { + buildDimensionColumns, + buildStrikingColumns, + formatCount, + formatCtr, + formatPosition, + type Report, + type SearchPerformanceTableRow, +} from "@/client/features/search-performance/SearchPerformanceColumns"; +import { buildCsv, downloadCsv, type CsvValue } from "@/client/lib/csv"; +import { getStandardErrorMessage } from "@/client/lib/error-messages"; +import { exportTableToSheets } from "@/client/lib/exportToSheets"; +import { captureClientEvent } from "@/client/lib/posthog"; +import { + SEARCH_PERFORMANCE_PAGE_SIZES, + type SearchPerformanceTableDimension, +} from "@/types/schemas/search-performance"; +import { saveKeywords } from "@/serverFunctions/keywords"; + +export type Tab = "striking" | "queries" | "pages"; +export type ExportTarget = "csv" | "sheets"; + +type ExportTable = { filename: string; headers: string[]; rows: CsvValue[][] }; + +function strikingExportTable(report: Report): ExportTable { + const stamp = `${report.range.startDate}-to-${report.range.endDate}`; + return { + filename: `search-performance-striking-distance-${stamp}.csv`, + headers: ["Query", "Page", "Impressions", "Clicks", "Position"], + rows: report.strikingDistance.map((row) => [ + row.query, + row.page, + row.impressions, + row.clicks, + row.position, + ]), + }; +} + +function dimensionExportTable( + dimension: SearchPerformanceTableDimension, + rows: SearchPerformanceTableRow[], + stamp: string, +): ExportTable { + const isPage = dimension === "page"; + return { + filename: `search-performance-${isPage ? "pages" : "queries"}-${stamp}.csv`, + headers: [ + isPage ? "Page" : "Query", + "Clicks", + "Impressions", + "CTR", + "Position", + ], + rows: rows.map((row) => [ + row.key, + row.clicks, + row.impressions, + row.ctr, + row.position, + ]), + }; +} + +function runExport(table: ExportTable, target: ExportTarget): void { + if (target === "csv") { + downloadCsv(table.filename, buildCsv(table.headers, table.rows)); + captureClientEvent("data:export", { + source_feature: "search_performance", + result_count: table.rows.length, + }); + return; + } + void exportTableToSheets({ + headers: table.headers, + rows: table.rows, + feature: "search_performance", + }); +} + +export function exportStriking(report: Report, target: ExportTarget): void { + runExport(strikingExportTable(report), target); +} + +/** Export the full queries/pages dataset (fetched separately, not the visible + * page) so pagination never truncates a download. */ +export function exportDimensionRows( + dimension: SearchPerformanceTableDimension, + rows: SearchPerformanceTableRow[], + range: Report["range"], + target: ExportTarget, +): void { + const stamp = `${range.startDate}-to-${range.endDate}`; + runExport(dimensionExportTable(dimension, rows, stamp), target); +} + +export function TabButton({ + active, + onClick, + label, +}: { + active: boolean; + onClick: () => void; + label: string; +}) { + return ( + + ); +} + +type Delta = { text: string; improved: boolean } | null; + +function percentDelta(current: number, previous: number): Delta { + if (previous <= 0) return null; + const change = (current - previous) / previous; + const pct = (change * 100).toFixed(1); + return { text: `${change >= 0 ? "+" : ""}${pct}%`, improved: change >= 0 }; +} + +/** Position falls as rankings improve, so the delta is inverted. */ +function positionDelta(current: number, previous: number): Delta { + if (previous <= 0 || current <= 0) return null; + const change = previous - current; + return { + text: `${change >= 0 ? "+" : ""}${change.toFixed(1)}`, + improved: change >= 0, + }; +} + +export function TotalsCards({ report }: { report: Report }) { + const { totals, prevTotals, range } = report; + const deltaTitle = `vs ${range.prevStartDate} to ${range.prevEndDate}`; + return ( +
+ + + + +
+ ); +} + +function TotalCard({ + label, + value, + delta, + deltaTitle, +}: { + label: string; + value: string; + delta: Delta; + deltaTitle: string; +}) { + return ( +
+
+ {label} +
+
+ {value} + {delta ? ( + + {delta.text} + + ) : null} +
+
+ ); +} + +export function DimensionTable({ + rows, + keyLabel, +}: { + rows: SearchPerformanceTableRow[]; + keyLabel: string; +}) { + const columns = useMemo(() => buildDimensionColumns(keyLabel), [keyLabel]); + const table = useAppTable({ + data: rows, + columns, + withSorting: true, + initialState: { sorting: [{ id: "clicks", desc: true }] }, + }); + return ( + + No data for this period yet. Search Console data trails by a few days. +

+ } + /> + ); +} + +export function StrikingDistanceTable({ + projectId, + rows, +}: { + projectId: string; + rows: Report["strikingDistance"]; +}) { + const queryClient = useQueryClient(); + const anchorRef = useSelectionAnchor(); + const [rowSelection, setRowSelection] = useState({}); + const columns = useMemo(() => buildStrikingColumns(anchorRef), [anchorRef]); + const table = useAppTable({ + data: rows, + columns, + withSorting: true, + withPagination: true, + enableRowSelection: true, + state: { rowSelection }, + onRowSelectionChange: setRowSelection, + getRowId: (row) => `${row.query}::${row.page}`, + initialState: { + sorting: [{ id: "impressions", desc: true }], + // All rows are already loaded; paginate client-side to keep the table + // short. 50/page by default. + pagination: { pageIndex: 0, pageSize: 50 }, + }, + }); + const pagination = table.getState().pagination; + + // Rows are query x page; saving/copying dedupes to the query strings. + const selectedQueries = Array.from( + new Set(table.getSelectedRowModel().rows.map((row) => row.original.query)), + ); + + const copyKeywords = async () => { + try { + await navigator.clipboard.writeText(selectedQueries.join("\n")); + toast.success( + `Copied ${selectedQueries.length} ${selectedQueries.length === 1 ? "keyword" : "keywords"}`, + ); + } catch { + toast.error("Couldn't copy to clipboard"); + } + }; + + const save = useMutation({ + mutationFn: (keywords: string[]) => + saveKeywords({ data: { projectId, keywords } }), + onSuccess: (_result, keywords) => { + captureClientEvent("keyword:save", { + source_feature: "search_performance", + keyword_count: keywords.length, + }); + void queryClient.invalidateQueries({ + queryKey: ["savedKeywords", projectId], + }); + toast.success( + `Saved ${keywords.length} ${keywords.length === 1 ? "keyword" : "keywords"}`, + ); + setRowSelection({}); + }, + onError: (error) => { + toast.error(getStandardErrorMessage(error, "Could not save keywords")); + }, + }); + + if (rows.length === 0) { + return ( +

+ No striking-distance queries in this period. These are queries ranking + at positions 5 to 20, where an improvement is most likely to move + traffic. +

+ ); + } + + return ( + <> +
+

+ Queries ranking at positions 5 to 20, sorted by impressions. Improve + the listed page to move them into the top results. +

+ +
+ table.setPageIndex(nextPage - 1)} + onPageSizeChange={(nextSize) => table.setPageSize(nextSize)} + /> + setRowSelection({})} + actions={ +
+ } + onClick={() => void copyKeywords()} + > + Copy keywords + + + ) : ( + + ) + } + onClick={() => save.mutate(selectedQueries)} + disabled={save.isPending} + > + Save as keywords + +
+ } + /> + + ); +} diff --git a/src/client/navigation/items.ts b/src/client/navigation/items.ts index 9635dff..139af7f 100644 --- a/src/client/navigation/items.ts +++ b/src/client/navigation/items.ts @@ -1,4 +1,5 @@ import { + BarChart3, Bookmark, Bot, ClipboardCheck, @@ -30,6 +31,12 @@ const projectNavItems = [ icon: TrendingUp, matchSegment: "/rank-tracking", }, + { + to: "/p/$projectId/search-performance" as const, + label: "Search Performance", + icon: BarChart3, + matchSegment: "/search-performance", + }, { to: "/p/$projectId/domain" as const, label: "Domain Overview", @@ -95,6 +102,10 @@ export function getProjectNavGroups(projectId: string) { bySegment("/rank-tracking"), ], }, + { + type: "standalone" as const, + item: bySegment("/search-performance"), + }, { type: "group" as const, label: "Domain", diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 124d0df..d676e12 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -36,6 +36,7 @@ import { Route as ProjectPProjectIdRouteRouteImport } from './routes/_project/p/ import { Route as ProjectPProjectIdIndexRouteImport } from './routes/_project/p/$projectId/index' import { Route as ApiGscOauthCallbackRouteImport } from './routes/api/gsc/oauth/callback' import { Route as ProjectPProjectIdSettingsRouteImport } from './routes/_project/p/$projectId/settings' +import { Route as ProjectPProjectIdSearchPerformanceRouteImport } from './routes/_project/p/$projectId/search-performance' import { Route as ProjectPProjectIdSavedRouteImport } from './routes/_project/p/$projectId/saved' import { Route as ProjectPProjectIdRankTrackingRouteImport } from './routes/_project/p/$projectId/rank-tracking' import { Route as ProjectPProjectIdPromptExplorerRouteImport } from './routes/_project/p/$projectId/prompt-explorer' @@ -185,6 +186,12 @@ const ProjectPProjectIdSettingsRoute = path: '/settings', getParentRoute: () => ProjectPProjectIdRouteRoute, } as any) +const ProjectPProjectIdSearchPerformanceRoute = + ProjectPProjectIdSearchPerformanceRouteImport.update({ + id: '/search-performance', + path: '/search-performance', + getParentRoute: () => ProjectPProjectIdRouteRoute, + } as any) const ProjectPProjectIdSavedRoute = ProjectPProjectIdSavedRouteImport.update({ id: '/saved', path: '/saved', @@ -284,6 +291,7 @@ export interface FileRoutesByFullPath { '/p/$projectId/prompt-explorer': typeof ProjectPProjectIdPromptExplorerRoute '/p/$projectId/rank-tracking': typeof ProjectPProjectIdRankTrackingRouteWithChildren '/p/$projectId/saved': typeof ProjectPProjectIdSavedRoute + '/p/$projectId/search-performance': typeof ProjectPProjectIdSearchPerformanceRoute '/p/$projectId/settings': typeof ProjectPProjectIdSettingsRoute '/api/gsc/oauth/callback': typeof ApiGscOauthCallbackRoute '/p/$projectId/': typeof ProjectPProjectIdIndexRoute @@ -318,6 +326,7 @@ export interface FileRoutesByTo { '/p/$projectId/keywords': typeof ProjectPProjectIdKeywordsRoute '/p/$projectId/prompt-explorer': typeof ProjectPProjectIdPromptExplorerRoute '/p/$projectId/saved': typeof ProjectPProjectIdSavedRoute + '/p/$projectId/search-performance': typeof ProjectPProjectIdSearchPerformanceRoute '/p/$projectId/settings': typeof ProjectPProjectIdSettingsRoute '/api/gsc/oauth/callback': typeof ApiGscOauthCallbackRoute '/p/$projectId': typeof ProjectPProjectIdIndexRoute @@ -360,6 +369,7 @@ export interface FileRoutesById { '/_project/p/$projectId/prompt-explorer': typeof ProjectPProjectIdPromptExplorerRoute '/_project/p/$projectId/rank-tracking': typeof ProjectPProjectIdRankTrackingRouteWithChildren '/_project/p/$projectId/saved': typeof ProjectPProjectIdSavedRoute + '/_project/p/$projectId/search-performance': typeof ProjectPProjectIdSearchPerformanceRoute '/_project/p/$projectId/settings': typeof ProjectPProjectIdSettingsRoute '/api/gsc/oauth/callback': typeof ApiGscOauthCallbackRoute '/_project/p/$projectId/': typeof ProjectPProjectIdIndexRoute @@ -399,6 +409,7 @@ export interface FileRouteTypes { | '/p/$projectId/prompt-explorer' | '/p/$projectId/rank-tracking' | '/p/$projectId/saved' + | '/p/$projectId/search-performance' | '/p/$projectId/settings' | '/api/gsc/oauth/callback' | '/p/$projectId/' @@ -433,6 +444,7 @@ export interface FileRouteTypes { | '/p/$projectId/keywords' | '/p/$projectId/prompt-explorer' | '/p/$projectId/saved' + | '/p/$projectId/search-performance' | '/p/$projectId/settings' | '/api/gsc/oauth/callback' | '/p/$projectId' @@ -474,6 +486,7 @@ export interface FileRouteTypes { | '/_project/p/$projectId/prompt-explorer' | '/_project/p/$projectId/rank-tracking' | '/_project/p/$projectId/saved' + | '/_project/p/$projectId/search-performance' | '/_project/p/$projectId/settings' | '/api/gsc/oauth/callback' | '/_project/p/$projectId/' @@ -688,6 +701,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ProjectPProjectIdSettingsRouteImport parentRoute: typeof ProjectPProjectIdRouteRoute } + '/_project/p/$projectId/search-performance': { + id: '/_project/p/$projectId/search-performance' + path: '/search-performance' + fullPath: '/p/$projectId/search-performance' + preLoaderRoute: typeof ProjectPProjectIdSearchPerformanceRouteImport + parentRoute: typeof ProjectPProjectIdRouteRoute + } '/_project/p/$projectId/saved': { id: '/_project/p/$projectId/saved' path: '/saved' @@ -843,6 +863,7 @@ interface ProjectPProjectIdRouteRouteChildren { ProjectPProjectIdPromptExplorerRoute: typeof ProjectPProjectIdPromptExplorerRoute ProjectPProjectIdRankTrackingRoute: typeof ProjectPProjectIdRankTrackingRouteWithChildren ProjectPProjectIdSavedRoute: typeof ProjectPProjectIdSavedRoute + ProjectPProjectIdSearchPerformanceRoute: typeof ProjectPProjectIdSearchPerformanceRoute ProjectPProjectIdSettingsRoute: typeof ProjectPProjectIdSettingsRoute ProjectPProjectIdIndexRoute: typeof ProjectPProjectIdIndexRoute } @@ -858,6 +879,8 @@ const ProjectPProjectIdRouteRouteChildren: ProjectPProjectIdRouteRouteChildren = ProjectPProjectIdRankTrackingRoute: ProjectPProjectIdRankTrackingRouteWithChildren, ProjectPProjectIdSavedRoute: ProjectPProjectIdSavedRoute, + ProjectPProjectIdSearchPerformanceRoute: + ProjectPProjectIdSearchPerformanceRoute, ProjectPProjectIdSettingsRoute: ProjectPProjectIdSettingsRoute, ProjectPProjectIdIndexRoute: ProjectPProjectIdIndexRoute, } diff --git a/src/routes/_project/p/$projectId/search-performance.tsx b/src/routes/_project/p/$projectId/search-performance.tsx new file mode 100644 index 0000000..4ddf2b4 --- /dev/null +++ b/src/routes/_project/p/$projectId/search-performance.tsx @@ -0,0 +1,13 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { SearchPerformancePage } from "@/client/features/search-performance/SearchPerformancePage"; + +export const Route = createFileRoute( + "/_project/p/$projectId/search-performance", +)({ + component: SearchPerformanceRoute, +}); + +function SearchPerformanceRoute() { + const { projectId } = Route.useParams(); + return ; +} diff --git a/src/server/features/gsc/searchPerformanceReport.test.ts b/src/server/features/gsc/searchPerformanceReport.test.ts new file mode 100644 index 0000000..28b3730 --- /dev/null +++ b/src/server/features/gsc/searchPerformanceReport.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, it } from "vitest"; +import { + buildStrikingDistanceRows, + previousPeriod, + sumSearchTotals, + toDimensionRows, +} from "@/server/features/gsc/searchPerformanceReport"; + +describe("sumSearchTotals", () => { + it("sums clicks/impressions and impression-weights position", () => { + const totals = sumSearchTotals([ + { clicks: 10, impressions: 100, ctr: 0.1, position: 2 }, + { clicks: 5, impressions: 300, ctr: 0.016, position: 10 }, + ]); + expect(totals.clicks).toBe(15); + expect(totals.impressions).toBe(400); + expect(totals.ctr).toBeCloseTo(15 / 400); + // (2*100 + 10*300) / 400 = 8 + expect(totals.position).toBeCloseTo(8); + }); + + it("returns zeros for no rows instead of NaN", () => { + expect(sumSearchTotals([])).toEqual({ + clicks: 0, + impressions: 0, + ctr: 0, + position: 0, + }); + }); +}); + +describe("toDimensionRows", () => { + it("keeps the first key and drops keyless rows", () => { + const rows = toDimensionRows([ + { + keys: ["magento agency"], + clicks: 3, + impressions: 40, + ctr: 0.075, + position: 6.2, + }, + { clicks: 1, impressions: 5, ctr: 0.2, position: 1 }, + ]); + expect(rows).toEqual([ + { + key: "magento agency", + clicks: 3, + impressions: 40, + ctr: 0.075, + position: 6.2, + }, + ]); + }); +}); + +const row = (query: string, position: number, impressions: number) => ({ + keys: [query, `https://example.com/${query}`], + clicks: 1, + impressions, + ctr: 0.01, + position, +}); + +// Same query can map to multiple pages; this lets a test set distinct pages. +const pageRow = ( + query: string, + page: string, + position: number, + impressions: number, +) => ({ keys: [query, page], clicks: 1, impressions, ctr: 0.01, position }); + +describe("buildStrikingDistanceRows", () => { + it("keeps only positions 5..20 and sorts by impressions desc", () => { + const rows = buildStrikingDistanceRows([ + row("top-spot", 2, 900), + row("close", 6.4, 100), + row("closer", 11, 400), + row("page-3", 24, 800), + ]); + expect(rows.map((r) => r.query)).toEqual(["closer", "close"]); + }); + + it("includes the boundary positions and respects the limit", () => { + const rows = buildStrikingDistanceRows( + [row("low-edge", 5, 10), row("high-edge", 20, 20)], + 1, + ); + expect(rows).toHaveLength(1); + expect(rows[0].query).toBe("high-edge"); + }); + + it("drops rows without both query and page keys", () => { + const rows = buildStrikingDistanceRows([ + { + keys: ["only-query"], + clicks: 1, + impressions: 50, + ctr: 0.02, + position: 8, + }, + ]); + expect(rows).toHaveLength(0); + }); + + it("drops a query whose top page already ranks above the band", () => { + // openseo: homepage ranks #2, a secondary page ranks #6. The site already + // ranks near the top, so the query is not a striking-distance opportunity. + const rows = buildStrikingDistanceRows([ + pageRow("openseo", "https://x.com/home", 2, 900), + pageRow("openseo", "https://x.com/mcp", 6, 300), + ]); + expect(rows).toHaveLength(0); + }); + + it("collapses a query to its best-ranking page when that page is in band", () => { + const rows = buildStrikingDistanceRows([ + pageRow("kw", "https://x.com/a", 14, 100), + pageRow("kw", "https://x.com/b", 8, 500), + ]); + expect(rows).toHaveLength(1); + expect(rows[0].page).toBe("https://x.com/b"); + expect(rows[0].position).toBe(8); + }); +}); + +describe("previousPeriod", () => { + it("returns the same-length window ending the day before the start", () => { + expect(previousPeriod("2026-06-01", "2026-06-28")).toEqual({ + startDate: "2026-05-04", + endDate: "2026-05-31", + }); + }); + + it("handles a single-day range", () => { + expect(previousPeriod("2026-06-10", "2026-06-10")).toEqual({ + startDate: "2026-06-09", + endDate: "2026-06-09", + }); + }); +}); diff --git a/src/server/features/gsc/searchPerformanceReport.ts b/src/server/features/gsc/searchPerformanceReport.ts new file mode 100644 index 0000000..28e956a --- /dev/null +++ b/src/server/features/gsc/searchPerformanceReport.ts @@ -0,0 +1,144 @@ +import type { GscSearchAnalyticsRow } from "@/server/lib/gscClient"; + +/** + * Pure shaping helpers for the Search Performance page. Kept separate from the + * server function so the aggregation and striking-distance rules are unit + * testable without a GSC client. + */ + +type SearchPerformanceTotals = { + clicks: number; + impressions: number; + /** 0..1 (clicks / impressions). */ + ctr: number; + /** Impression-weighted average position; 0 when there were no impressions. */ + position: number; +}; + +type SearchPerformanceDimensionRow = { + key: string; + clicks: number; + impressions: number; + ctr: number; + position: number; +}; + +type StrikingDistanceRow = { + query: string; + page: string; + clicks: number; + impressions: number; + position: number; +}; + +// "Striking distance" = already ranking, not yet in the top spots: the queries +// where a content improvement most plausibly moves real traffic. +const STRIKING_DISTANCE_MIN_POSITION = 5; +const STRIKING_DISTANCE_MAX_POSITION = 20; +const STRIKING_DISTANCE_ROW_LIMIT = 100; + +export function sumSearchTotals( + rows: GscSearchAnalyticsRow[], +): SearchPerformanceTotals { + let clicks = 0; + let impressions = 0; + let weightedPosition = 0; + for (const row of rows) { + clicks += row.clicks; + impressions += row.impressions; + weightedPosition += row.position * row.impressions; + } + return { + clicks, + impressions, + ctr: impressions > 0 ? clicks / impressions : 0, + position: impressions > 0 ? weightedPosition / impressions : 0, + }; +} + +/** Flatten single-dimension rows (query or page) into a keyed table row. */ +export function toDimensionRows( + rows: GscSearchAnalyticsRow[], +): SearchPerformanceDimensionRow[] { + const output: SearchPerformanceDimensionRow[] = []; + for (const row of rows) { + const key = row.keys?.[0]; + if (!key) continue; + output.push({ + key, + clicks: row.clicks, + impressions: row.impressions, + ctr: row.ctr, + position: row.position, + }); + } + return output; +} + +/** Reduce `["query","page"]` rows to one striking-distance row per query. + * + * GSC returns a row per page that ranks for a query, so a query fans out across + * every page it appears on. A query only belongs in "striking distance" when + * the site's BEST-ranking page for it sits in the 5..20 band — if any page + * already ranks above position 5, the site effectively ranks near the top and + * improving a secondary page won't move traffic. So we collapse each query to + * its top page (lowest average position; ties broken by impressions) and keep + * it only when that top page is in band. Result is sorted by impressions. */ +export function buildStrikingDistanceRows( + rows: GscSearchAnalyticsRow[], + limit: number = STRIKING_DISTANCE_ROW_LIMIT, +): StrikingDistanceRow[] { + const topPageByQuery = new Map(); + for (const row of rows) { + const query = row.keys?.[0]; + const page = row.keys?.[1]; + if (!query || !page) continue; + + const current = topPageByQuery.get(query); + const isBetter = + !current || + row.position < current.position || + (row.position === current.position && + row.impressions > current.impressions); + if (!isBetter) continue; + + topPageByQuery.set(query, { + query, + page, + clicks: row.clicks, + impressions: row.impressions, + position: row.position, + }); + } + + return Array.from(topPageByQuery.values()) + .filter( + (row) => + row.position >= STRIKING_DISTANCE_MIN_POSITION && + row.position <= STRIKING_DISTANCE_MAX_POSITION, + ) + .toSorted((a, b) => b.impressions - a.impressions) + .slice(0, limit); +} + +/** The same-length period immediately before [startDate, endDate], for the + * totals comparison. Dates are YYYY-MM-DD in UTC. */ +export function previousPeriod( + startDate: string, + endDate: string, +): { startDate: string; endDate: string } { + const dayMs = 24 * 60 * 60 * 1000; + const start = Date.parse(`${startDate}T00:00:00Z`); + const end = Date.parse(`${endDate}T00:00:00Z`); + const lengthMs = Math.max(end - start, 0); + const prevEnd = start - dayMs; + const prevStart = prevEnd - lengthMs; + return { + startDate: formatUtcDate(prevStart), + endDate: formatUtcDate(prevEnd), + }; +} + +function formatUtcDate(ms: number): string { + return new Date(ms).toISOString().slice(0, 10); +} diff --git a/src/server/features/gsc/services/GscService.ts b/src/server/features/gsc/services/GscService.ts index 082057a..5011531 100644 --- a/src/server/features/gsc/services/GscService.ts +++ b/src/server/features/gsc/services/GscService.ts @@ -74,7 +74,7 @@ async function listSitesForUser(userId: string): Promise { * minted (refresh token revoked or expired), or Google rejected the call * (401/403). These surface a reconnect prompt instead of being routed through * error tracking. Other statuses (429, 5xx) are genuine faults and propagate. */ -function isExpectedGrantFailure(error: unknown): boolean { +export function isExpectedGrantFailure(error: unknown): boolean { if (error instanceof GscTokenError) return true; return ( error instanceof GscApiError && diff --git a/src/serverFunctions/searchPerformance.ts b/src/serverFunctions/searchPerformance.ts new file mode 100644 index 0000000..be3593c --- /dev/null +++ b/src/serverFunctions/searchPerformance.ts @@ -0,0 +1,210 @@ +import { createServerFn } from "@tanstack/react-start"; +import { + GscNotConnectedError, + GscService, + isExpectedGrantFailure, +} from "@/server/features/gsc/services/GscService"; +import { + resolveDateRange, + type GscPerformanceFilter, +} from "@/server/features/gsc/searchAnalytics"; +import { + buildStrikingDistanceRows, + previousPeriod, + sumSearchTotals, + toDimensionRows, +} from "@/server/features/gsc/searchPerformanceReport"; +import { requireProjectContext } from "@/serverFunctions/middleware"; +import { + searchPerformanceInputSchema, + searchPerformanceTableExportInputSchema, + searchPerformanceTableInputSchema, +} from "@/types/schemas/search-performance"; + +// query x page fan-out needs more rows to find the 5..20 band. +const STRIKING_DISTANCE_FETCH_LIMIT = 1000; +// dimensions:["date"] returns one row per day; the longest range is ~92 days. +const DAILY_ROW_LIMIT = 200; +const COUNTRY_ROW_LIMIT = 25; +// Export pulls the whole dimension in one shot, capped at GSC's per-call max +// (GSC_MAX_ROW_LIMIT). Large stores get everything up to this ceiling. +const EXPORT_ROW_LIMIT = 1000; + +/** Build GSC filter groups shared by every call. Device applies everywhere; + * country applies everywhere except the country breakdown itself (so the + * dropdown keeps every option visible while one country is selected). */ +function buildGscFilters(data: { device?: string; country?: string }): { + deviceFilters: GscPerformanceFilter[]; + filters: GscPerformanceFilter[]; +} { + const deviceFilters: GscPerformanceFilter[] = data.device + ? [{ dimension: "device", operator: "equals", expression: data.device }] + : []; + const filters: GscPerformanceFilter[] = data.country + ? [ + ...deviceFilters, + { dimension: "country", operator: "equals", expression: data.country }, + ] + : deviceFilters; + return { deviceFilters, filters }; +} + +/** Not connected, or a dead/denied grant (token failure or 401/403): the page + * renders the connect card. Other statuses (429, 5xx) are real faults. */ +function isExpectedConnectionFailure(error: unknown): boolean { + return error instanceof GscNotConnectedError || isExpectedGrantFailure(error); +} + +/** + * The Search Performance overview: current + previous-period totals, the + * striking-distance rows, and the country list that powers the filter dropdown. + * The queries/pages tables paginate separately (getSearchPerformanceTable) so + * page-flips never re-run the striking-distance scan. All first-party GSC data, + * free. + */ +export const getSearchPerformanceReport = createServerFn({ method: "POST" }) + .middleware(requireProjectContext) + .inputValidator((data: unknown) => searchPerformanceInputSchema.parse(data)) + .handler(async ({ data, context }) => { + const { startDate, endDate } = resolveDateRange({ + dateRange: data.dateRange, + }); + const prev = previousPeriod(startDate, endDate); + const projectId = context.projectId; + const { deviceFilters, filters } = buildGscFilters(data); + + try { + const [current, previous, queryPages, countries] = await Promise.all([ + GscService.getPerformance({ + projectId, + startDate, + endDate, + dimensions: ["date"], + filters, + rowLimit: DAILY_ROW_LIMIT, + }), + GscService.getPerformance({ + projectId, + startDate: prev.startDate, + endDate: prev.endDate, + dimensions: ["date"], + filters, + rowLimit: DAILY_ROW_LIMIT, + }), + GscService.getPerformance({ + projectId, + startDate, + endDate, + dimensions: ["query", "page"], + filters, + rowLimit: STRIKING_DISTANCE_FETCH_LIMIT, + }), + GscService.getPerformance({ + projectId, + startDate, + endDate, + dimensions: ["country"], + filters: deviceFilters, + rowLimit: COUNTRY_ROW_LIMIT, + }), + ]); + + return { + connected: true as const, + range: { + startDate, + endDate, + prevStartDate: prev.startDate, + prevEndDate: prev.endDate, + }, + totals: sumSearchTotals(current.rows), + prevTotals: sumSearchTotals(previous.rows), + strikingDistance: buildStrikingDistanceRows(queryPages.rows), + countries: toDimensionRows(countries.rows), + }; + } catch (error) { + if (isExpectedConnectionFailure(error)) { + return { connected: false as const }; + } + throw error; + } + }); + +/** + * One page of the queries or pages table, paginated server-side against GSC via + * `startRow` so it scales to large properties. GSC returns no total count, so we + * fetch one extra row to detect a next page. All first-party GSC data, free. + */ +export const getSearchPerformanceTable = createServerFn({ method: "POST" }) + .middleware(requireProjectContext) + .inputValidator((data: unknown) => + searchPerformanceTableInputSchema.parse(data), + ) + .handler(async ({ data, context }) => { + const { startDate, endDate } = resolveDateRange({ + dateRange: data.dateRange, + }); + const { filters } = buildGscFilters(data); + const offset = (data.page - 1) * data.pageSize; + + try { + const result = await GscService.getPerformance({ + projectId: context.projectId, + startDate, + endDate, + dimensions: [data.dimension], + filters, + // One extra row tells us whether a further page exists. + rowLimit: data.pageSize + 1, + startRow: offset, + }); + + const fetched = toDimensionRows(result.rows); + const hasNextPage = fetched.length > data.pageSize; + const rows = hasNextPage ? fetched.slice(0, data.pageSize) : fetched; + + return { + connected: true as const, + dimension: data.dimension, + page: data.page, + pageSize: data.pageSize, + hasNextPage, + rows, + }; + } catch (error) { + if (isExpectedConnectionFailure(error)) { + return { connected: false as const }; + } + throw error; + } + }); + +/** + * The full queries/pages dataset for CSV/Sheets export (capped at + * EXPORT_ROW_LIMIT), rather than only the visible page. + */ +export const exportSearchPerformanceTable = createServerFn({ method: "POST" }) + .middleware(requireProjectContext) + .inputValidator((data: unknown) => + searchPerformanceTableExportInputSchema.parse(data), + ) + .handler(async ({ data, context }) => { + const { startDate, endDate } = resolveDateRange({ + dateRange: data.dateRange, + }); + const { filters } = buildGscFilters(data); + + const result = await GscService.getPerformance({ + projectId: context.projectId, + startDate, + endDate, + dimensions: [data.dimension], + filters, + rowLimit: EXPORT_ROW_LIMIT, + }); + + return { + dimension: data.dimension, + rows: toDimensionRows(result.rows), + }; + }); diff --git a/src/types/schemas/search-performance.ts b/src/types/schemas/search-performance.ts new file mode 100644 index 0000000..88dfbed --- /dev/null +++ b/src/types/schemas/search-performance.ts @@ -0,0 +1,63 @@ +import { z } from "zod"; + +/** Date ranges offered by the Search Performance page. A deliberate subset of + * the GSC agent ranges (GSC_DATE_RANGES in searchAnalytics.ts); assignability + * to GscDateRange is compiler-checked at the resolveDateRange call site. */ +export const SEARCH_PERFORMANCE_RANGES = [ + "last_7_days", + "last_28_days", + "last_3_months", +] as const; + +/** Device values exactly as the GSC `device` dimension returns/accepts them. */ +export const GSC_DEVICES = ["DESKTOP", "MOBILE", "TABLET"] as const; + +export type SearchPerformanceDateRange = + (typeof SEARCH_PERFORMANCE_RANGES)[number]; +export type SearchPerformanceDevice = (typeof GSC_DEVICES)[number]; + +// Shared report/table filters. Spread into each request schema so the overview +// and the paginated table calls always accept the exact same filter surface. +const searchPerformanceFilterShape = { + projectId: z.string().min(1), + dateRange: z.enum(SEARCH_PERFORMANCE_RANGES).default("last_28_days"), + device: z.enum(GSC_DEVICES).optional(), + // ISO-3166-1 alpha-3, the code GSC returns in `country` dimension keys. + country: z + .string() + .length(3) + .transform((value) => value.toLowerCase()) + .optional(), +}; + +export const searchPerformanceInputSchema = z.object( + searchPerformanceFilterShape, +); + +/** The dimensions that get their own paginated table (query + page). Striking + * distance is computed from the overview call and never paginates. */ +export const SEARCH_PERFORMANCE_TABLE_DIMENSIONS = ["query", "page"] as const; +export type SearchPerformanceTableDimension = + (typeof SEARCH_PERFORMANCE_TABLE_DIMENSIONS)[number]; + +export const SEARCH_PERFORMANCE_PAGE_SIZES = [25, 50, 100] as const; +export const SEARCH_PERFORMANCE_DEFAULT_PAGE_SIZE = 25; + +export const searchPerformanceTableInputSchema = z.object({ + ...searchPerformanceFilterShape, + dimension: z.enum(SEARCH_PERFORMANCE_TABLE_DIMENSIONS), + page: z.number().int().positive().default(1), + pageSize: z + .number() + .int() + .refine((value) => + (SEARCH_PERFORMANCE_PAGE_SIZES as readonly number[]).includes(value), + ) + .default(SEARCH_PERFORMANCE_DEFAULT_PAGE_SIZE), +}); + +/** Export pulls the full dataset (capped) rather than a single page. */ +export const searchPerformanceTableExportInputSchema = z.object({ + ...searchPerformanceFilterShape, + dimension: z.enum(SEARCH_PERFORMANCE_TABLE_DIMENSIONS), +});