diff --git a/apps/api/src/controllers/gsc-oauth-callback.controller.ts b/apps/api/src/controllers/gsc-oauth-callback.controller.ts index fd5975e3..39b33c16 100644 --- a/apps/api/src/controllers/gsc-oauth-callback.controller.ts +++ b/apps/api/src/controllers/gsc-oauth-callback.controller.ts @@ -4,6 +4,22 @@ import type { FastifyReply, FastifyRequest } from 'fastify'; import { z } from 'zod'; import { LogError } from '@/utils/errors'; +const OAUTH_SENSITIVE_KEYS = ['code', 'state']; + +function sanitizeOAuthQuery( + query: Record | null | undefined +): Record { + if (!query || typeof query !== 'object') { + return {}; + } + return Object.fromEntries( + Object.entries(query).map(([k, v]) => [ + k, + OAUTH_SENSITIVE_KEYS.includes(k) ? '' : String(v), + ]) + ); +} + export async function gscGoogleCallback( req: FastifyRequest, reply: FastifyReply @@ -16,10 +32,10 @@ export async function gscGoogleCallback( const query = schema.safeParse(req.query); if (!query.success) { - throw new LogError('Invalid GSC callback query params', { - error: query.error, - query: req.query, - }); + throw new LogError( + 'Invalid GSC callback query params', + sanitizeOAuthQuery(req.query as Record) + ); } const { code, state } = query.data; @@ -27,16 +43,24 @@ export async function gscGoogleCallback( const codeVerifier = req.cookies.gsc_code_verifier ?? null; const projectId = req.cookies.gsc_project_id ?? null; - if (!storedState || !codeVerifier || !projectId) { + const hasStoredState = storedState !== null; + const hasCodeVerifier = codeVerifier !== null; + const hasProjectId = projectId !== null; + const hasAllCookies = hasStoredState && hasCodeVerifier && hasProjectId; + if (!hasAllCookies) { throw new LogError('Missing GSC OAuth cookies', { - storedState: storedState === null, - codeVerifier: codeVerifier === null, - projectId: projectId === null, + storedState: !hasStoredState, + codeVerifier: !hasCodeVerifier, + projectId: !hasProjectId, }); } if (state !== storedState) { - throw new LogError('GSC OAuth state mismatch', { state, storedState }); + throw new LogError('GSC OAuth state mismatch', { + hasState: true, + hasStoredState: true, + stateMismatch: true, + }); } const tokens = await googleGsc.validateAuthorizationCode( @@ -91,6 +115,9 @@ export async function gscGoogleCallback( return reply.redirect(redirectUrl); } catch (error) { req.log.error(error); + reply.clearCookie('gsc_oauth_state'); + reply.clearCookie('gsc_code_verifier'); + reply.clearCookie('gsc_project_id'); return redirectWithError(reply, error); } } diff --git a/apps/start/src/components/page/gsc-breakdown-table.tsx b/apps/start/src/components/page/gsc-breakdown-table.tsx index 51298787..09282c58 100644 --- a/apps/start/src/components/page/gsc-breakdown-table.tsx +++ b/apps/start/src/components/page/gsc-breakdown-table.tsx @@ -43,6 +43,7 @@ export function GscBreakdownTable({ projectId, value, type }: GscBreakdownTableP const breakdownKey = type === 'page' ? 'query' : 'page'; const breakdownLabel = type === 'page' ? 'Query' : 'Page'; + const pluralLabel = type === 'page' ? 'queries' : 'pages'; const maxClicks = Math.max( ...(breakdownRows as { clicks: number }[]).map((r) => r.clicks), @@ -52,7 +53,7 @@ export function GscBreakdownTable({ projectId, value, type }: GscBreakdownTableP return (
-

Top {breakdownLabel.toLowerCase()}s

+

Top {pluralLabel}

{isLoading ? ( { + setPage((p) => Math.max(0, Math.min(p, pageCount - 1))); + }, [items, pageSize, pageCount]); const paginatedItems = useMemo( () => items.slice(page * pageSize, (page + 1) * pageSize), [items, page, pageSize] @@ -99,7 +109,6 @@ export function GscCannibalization({ ))} {paginatedItems.map((item) => { const isOpen = expanded.has(item.query); - const winner = item.pages[0]; const avgCtr = item.pages.reduce((s, p) => s + p.ctr, 0) / item.pages.length; diff --git a/apps/start/src/components/pages/page-sparkline.tsx b/apps/start/src/components/pages/page-sparkline.tsx index a724ec1d..5ebf48d2 100644 --- a/apps/start/src/components/pages/page-sparkline.tsx +++ b/apps/start/src/components/pages/page-sparkline.tsx @@ -8,7 +8,7 @@ interface SparklineBarsProps { data: { date: string; pageviews: number }[]; } -const gap = 1; +const defaultGap = 1; const height = 24; const width = 100; @@ -38,7 +38,16 @@ function SparklineBars({ data }: SparklineBarsProps) { } const max = Math.max(...data.map((d) => d.pageviews), 1); const total = data.length; - const barW = Math.max(2, Math.floor((width - gap * (total - 1)) / total)); + // Compute bar width to fit SVG width; reduce gap if needed so barW >= 1 when possible + let gap = defaultGap; + let barW = Math.floor((width - gap * (total - 1)) / total); + if (barW < 1 && total > 1) { + gap = 0; + barW = Math.floor((width - gap * (total - 1)) / total); + } + if (barW < 1) { + barW = 1; + } const trend = getTrendDirection(data); const trendColor = trend === '↑' @@ -71,9 +80,9 @@ function SparklineBars({ data }: SparklineBarsProps) { diff --git a/apps/start/src/components/pages/table/columns.tsx b/apps/start/src/components/pages/table/columns.tsx index ba3b433e..a1b1d41e 100644 --- a/apps/start/src/components/pages/table/columns.tsx +++ b/apps/start/src/components/pages/table/columns.tsx @@ -103,6 +103,16 @@ export function useColumns({ if (prev == null) { return ; } + if (prev === 0) { + return ( +
+ + {number.short(row.original.sessions)} + + new +
+ ); + } const pct = ((row.original.sessions - prev) / prev) * 100; const isPos = pct >= 0; @@ -112,15 +122,12 @@ export function useColumns({ {number.short(row.original.sessions)} - {prev === 0 && new} - {prev > 0 && ( - - {isPos ? '+' : ''} - {pct.toFixed(1)}% - - )} + + {isPos ? '+' : ''} + {pct.toFixed(1)}% +
); }, diff --git a/apps/start/src/components/pages/table/index.tsx b/apps/start/src/components/pages/table/index.tsx index 8d798bf8..f09a8f64 100644 --- a/apps/start/src/components/pages/table/index.tsx +++ b/apps/start/src/components/pages/table/index.tsx @@ -26,7 +26,12 @@ export function PagesTable({ projectId }: PagesTableProps) { const pagesQuery = useQuery( trpc.event.pages.queryOptions( - { projectId, cursor: 1, take: 1000, search: undefined, range, interval }, + { + projectId, + search: debouncedSearch ?? undefined, + range, + interval, + }, { placeholderData: keepPreviousData }, ), ); @@ -44,7 +49,7 @@ export function PagesTable({ projectId }: PagesTableProps) { range, startDate: startDate ?? undefined, endDate: endDate ?? undefined, - limit: 1000, + limit: 10_000, }, { enabled: isGscConnected }, ), @@ -88,22 +93,11 @@ export function PagesTable({ projectId }: PagesTableProps) { })); }, [pagesQuery.data, gscMap]); - const filteredData = useMemo(() => { - if (!debouncedSearch) return rawData; - const q = debouncedSearch.toLowerCase(); - return rawData.filter( - (p) => - p.path.toLowerCase().includes(q) || - p.origin.toLowerCase().includes(q) || - (p.title ?? '').toLowerCase().includes(q), - ); - }, [rawData, debouncedSearch]); - const columns = useColumns({ projectId, isGscConnected, previousMap }); const { table } = useTable({ columns, - data: filteredData, + data: rawData, loading: pagesQuery.isLoading, pageSize: 50, name: 'pages', @@ -133,7 +127,9 @@ export function PagesTable({ projectId }: PagesTableProps) { : 'Integrate our web SDK to your site to get pages here.', }} onRowClick={(row) => { - if (!isGscConnected) return; + if (!isGscConnected) { + return; + } const page = row.original; pushModal('PageDetails', { type: 'page', diff --git a/apps/start/src/modals/gsc-details.tsx b/apps/start/src/modals/gsc-details.tsx index c94c7c8f..1891744a 100644 --- a/apps/start/src/modals/gsc-details.tsx +++ b/apps/start/src/modals/gsc-details.tsx @@ -23,33 +23,65 @@ import { SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'; import { useTRPC } from '@/integrations/trpc/react'; import { getChartColor } from '@/utils/theme'; -type GscChartData = { date: string; clicks: number; impressions: number }; +interface GscChartData { + date: string; + clicks: number; + impressions: number; +} +interface GscViewsChartData { + date: string; + views: number; +} const { TooltipProvider, Tooltip: GscTooltip } = createChartTooltip< - GscChartData, + GscChartData | GscViewsChartData, Record >(({ data }) => { const item = data[0]; if (!item) { return null; } + if (!('date' in item)) { + return null; + } + if ('views' in item && item.views != null) { + return ( + <> + +
{item.date}
+
+ +
+ Views + {item.views.toLocaleString()} +
+
+ + ); + } + const clicks = 'clicks' in item ? item.clicks : undefined; + const impressions = 'impressions' in item ? item.impressions : undefined; return ( <>
{item.date}
- -
- Clicks - {item.clicks.toLocaleString()} -
-
- -
- Impressions - {item.impressions.toLocaleString()} -
-
+ {clicks != null && ( + +
+ Clicks + {clicks.toLocaleString()} +
+
+ )} + {impressions != null && ( + +
+ Impressions + {impressions.toLocaleString()} +
+
+ )} ); }); @@ -93,10 +125,25 @@ export default function GscDetails(props: Props) { ) ); - const pagesTimeseriesQuery = useQuery( - trpc.event.pagesTimeseries.queryOptions( - { projectId, ...dateInput }, - { enabled: type === 'page' } + const { origin: pageOrigin, path: pagePath } = + type === 'page' + ? (() => { + try { + const url = new URL(value); + return { origin: url.origin, path: url.pathname + url.search }; + } catch { + return { + origin: typeof window !== 'undefined' ? window.location.origin : '', + path: value, + }; + } + })() + : { origin: '', path: '' }; + + const pageTimeseriesQuery = useQuery( + trpc.event.pageTimeseries.queryOptions( + { projectId, ...dateInput, origin: pageOrigin, path: pagePath }, + { enabled: type === 'page' && !!pagePath } ) ); @@ -105,7 +152,7 @@ export default function GscDetails(props: Props) { type === 'page' ? pageQuery.isLoading : queryQuery.isLoading; const timeseries = data?.timeseries ?? []; - const pagesTimeseries = pagesTimeseriesQuery.data ?? []; + const pageTimeseries = pageTimeseriesQuery.data ?? []; const breakdownRows = type === 'page' ? ((data as { queries?: unknown[] } | undefined)?.queries ?? []) @@ -131,13 +178,14 @@ export default function GscDetails(props: Props) { {type === 'page' && (

Views & Sessions

- {isLoading ? ( + {isLoading || pageTimeseriesQuery.isLoading ? ( ) : ( r.origin + r.path === value) - .map((r) => ({ date: r.date, views: r.pageviews }))} + data={pageTimeseries.map((r) => ({ + date: r.date, + views: r.pageviews, + }))} /> )}
diff --git a/apps/start/src/modals/page-details.tsx b/apps/start/src/modals/page-details.tsx index 8c29f579..5d4a571e 100644 --- a/apps/start/src/modals/page-details.tsx +++ b/apps/start/src/modals/page-details.tsx @@ -21,14 +21,17 @@ export default function PageDetails({ type, projectId, value }: Props) {
{type === 'page' && (() => { - let origin = value; - let path = '/'; + let origin: string; + let path: string; try { const url = new URL(value); origin = url.origin; path = url.pathname + url.search; } catch { - // value might already be just a path + // value is path-only (e.g. "/docs/foo") + origin = + typeof window !== 'undefined' ? window.location.origin : ''; + path = value; } return ( 0 ? existing.clicks / existing.impressions : 0; existing.position = Math.min(existing.position, row.position); } else { entry.pages.push({ diff --git a/packages/db/src/services/pages.service.ts b/packages/db/src/services/pages.service.ts index dd3b2065..b014c416 100644 --- a/packages/db/src/services/pages.service.ts +++ b/packages/db/src/services/pages.service.ts @@ -8,6 +8,7 @@ export interface IGetPagesInput { endDate: string; timezone: string; search?: string; + limit?: number; } export interface IPageTimeseriesRow { @@ -37,6 +38,7 @@ export class PagesService { endDate, timezone, search, + limit, }: IGetPagesInput): Promise { // CTE: Get titles from the last 30 days for faster retrieval const titlesCte = clix(this.client, timezone) @@ -92,12 +94,18 @@ export class PagesService { clix.datetime(endDate, 'toDateTime'), ]) .when(!!search, (q) => { - q.where('e.path', 'LIKE', `%${search}%`); + const term = `%${search}%`; + q.whereGroup() + .where('e.path', 'LIKE', term) + .orWhere('e.origin', 'LIKE', term) + .orWhere('pt.title', 'LIKE', term) + .end(); }) .groupBy(['e.origin', 'e.path', 'pt.title']) - .orderBy('sessions', 'DESC') - .limit(1000); - + .orderBy('sessions', 'DESC'); + if (limit !== undefined) { + query.limit(limit); + } return query.execute(); } diff --git a/packages/trpc/src/routers/event.ts b/packages/trpc/src/routers/event.ts index 87eec053..385de6b1 100644 --- a/packages/trpc/src/routers/event.ts +++ b/packages/trpc/src/routers/event.ts @@ -320,7 +320,7 @@ export const eventRouter = createTRPCRouter({ z.object({ projectId: z.string(), cursor: z.number().optional(), - take: z.number().default(20), + take: z.number().min(1).optional(), search: z.string().optional(), range: zRange, interval: zTimeInterval, @@ -335,6 +335,7 @@ export const eventRouter = createTRPCRouter({ endDate, timezone, search: input.search, + limit: input.take, }); }), diff --git a/packages/trpc/src/routers/gsc.ts b/packages/trpc/src/routers/gsc.ts index 129d4b31..d95d45cb 100644 --- a/packages/trpc/src/routers/gsc.ts +++ b/packages/trpc/src/routers/gsc.ts @@ -185,7 +185,7 @@ export const gscRouter = createTRPCRouter({ getPages: protectedProcedure .input( zGscDateInput.extend({ - limit: z.number().min(1).max(1000).optional().default(100), + limit: z.number().min(1).max(10_000).optional().default(100), }) ) .query(async ({ input, ctx }) => {