From 298163889308215495b26016f2cdf8cd4824953e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carl-Gerhard=20Lindesva=CC=88rd?= Date: Fri, 6 Mar 2026 13:59:03 +0100 Subject: [PATCH 1/6] wip --- .../gsc-oauth-callback.controller.ts | 132 +++++++ apps/api/src/index.ts | 2 + apps/api/src/routes/gsc-callback.router.ts | 17 + .../src/components/sidebar-project-menu.tsx | 2 + apps/start/src/routeTree.gen.ts | 45 +++ .../_app.$organizationId.$projectId.seo.tsx | 289 +++++++++++++++ ...zationId.$projectId.settings._tabs.gsc.tsx | 274 ++++++++++++++ ...ganizationId.$projectId.settings._tabs.tsx | 1 + apps/worker/src/boot-cron.ts | 5 + apps/worker/src/boot-workers.ts | 14 + apps/worker/src/jobs/cron.ts | 4 + apps/worker/src/jobs/gsc.ts | 142 ++++++++ packages/auth/src/oauth.ts | 6 + packages/db/code-migrations/12-add-gsc.ts | 85 +++++ packages/db/index.ts | 1 + packages/db/prisma/schema.prisma | 19 + packages/db/src/clickhouse/client.ts | 3 + packages/db/src/gsc.ts | 341 ++++++++++++++++++ packages/queue/src/queues.ts | 25 +- packages/trpc/src/root.ts | 2 + packages/trpc/src/routers/gsc.ts | 201 +++++++++++ 21 files changed, 1609 insertions(+), 1 deletion(-) create mode 100644 apps/api/src/controllers/gsc-oauth-callback.controller.ts create mode 100644 apps/api/src/routes/gsc-callback.router.ts create mode 100644 apps/start/src/routes/_app.$organizationId.$projectId.seo.tsx create mode 100644 apps/start/src/routes/_app.$organizationId.$projectId.settings._tabs.gsc.tsx create mode 100644 apps/worker/src/jobs/gsc.ts create mode 100644 packages/db/code-migrations/12-add-gsc.ts create mode 100644 packages/db/src/gsc.ts create mode 100644 packages/trpc/src/routers/gsc.ts diff --git a/apps/api/src/controllers/gsc-oauth-callback.controller.ts b/apps/api/src/controllers/gsc-oauth-callback.controller.ts new file mode 100644 index 000000000..639fc50f9 --- /dev/null +++ b/apps/api/src/controllers/gsc-oauth-callback.controller.ts @@ -0,0 +1,132 @@ +import { COOKIE_OPTIONS, googleGsc } from '@openpanel/auth'; +import { db } from '@openpanel/db'; +import type { FastifyReply, FastifyRequest } from 'fastify'; +import { z } from 'zod'; +import { LogError } from '@/utils/errors'; + +export async function gscInitiate(req: FastifyRequest, reply: FastifyReply) { + const schema = z.object({ + state: z.string(), + code_verifier: z.string(), + project_id: z.string(), + redirect: z.string().url(), + }); + + const query = schema.safeParse(req.query); + if (!query.success) { + return reply.status(400).send({ error: 'Invalid parameters' }); + } + + const { state, code_verifier, project_id, redirect } = query.data; + + reply.setCookie('gsc_oauth_state', state, { maxAge: 60 * 10, ...COOKIE_OPTIONS }); + reply.setCookie('gsc_code_verifier', code_verifier, { maxAge: 60 * 10, ...COOKIE_OPTIONS }); + reply.setCookie('gsc_project_id', project_id, { maxAge: 60 * 10, ...COOKIE_OPTIONS }); + + return reply.redirect(redirect); +} + +export async function gscGoogleCallback( + req: FastifyRequest, + reply: FastifyReply +) { + try { + const schema = z.object({ + code: z.string(), + state: z.string(), + }); + + const query = schema.safeParse(req.query); + if (!query.success) { + throw new LogError('Invalid GSC callback query params', { + error: query.error, + query: req.query, + }); + } + + const { code, state } = query.data; + const storedState = req.cookies.gsc_oauth_state ?? null; + const codeVerifier = req.cookies.gsc_code_verifier ?? null; + const projectId = req.cookies.gsc_project_id ?? null; + + if (!storedState || !codeVerifier || !projectId) { + throw new LogError('Missing GSC OAuth cookies', { + storedState: storedState === null, + codeVerifier: codeVerifier === null, + projectId: projectId === null, + }); + } + + if (state !== storedState) { + throw new LogError('GSC OAuth state mismatch', { state, storedState }); + } + + const tokens = await googleGsc.validateAuthorizationCode( + code, + codeVerifier + ); + + const accessToken = tokens.accessToken(); + const refreshToken = tokens.hasRefreshToken() + ? tokens.refreshToken() + : null; + const accessTokenExpiresAt = tokens.accessTokenExpiresAt(); + + if (!refreshToken) { + throw new LogError('No refresh token returned from Google GSC OAuth'); + } + + const project = await db.project.findUnique({ + where: { id: projectId }, + select: { id: true, organizationId: true }, + }); + + if (!project) { + throw new LogError('Project not found for GSC connection', { projectId }); + } + + await db.gscConnection.upsert({ + where: { projectId }, + create: { + projectId, + accessToken, + refreshToken, + accessTokenExpiresAt, + siteUrl: '', + }, + update: { + accessToken, + refreshToken, + accessTokenExpiresAt, + lastSyncStatus: null, + lastSyncError: null, + }, + }); + + reply.clearCookie('gsc_oauth_state'); + reply.clearCookie('gsc_code_verifier'); + reply.clearCookie('gsc_project_id'); + + const dashboardUrl = + process.env.DASHBOARD_URL || process.env.NEXT_PUBLIC_DASHBOARD_URL!; + const redirectUrl = `${dashboardUrl}/${project.organizationId}/${projectId}/settings/gsc`; + return reply.redirect(redirectUrl); + } catch (error) { + req.log.error(error); + return redirectWithError(reply, error); + } +} + +function redirectWithError(reply: FastifyReply, error: LogError | unknown) { + const url = new URL( + process.env.DASHBOARD_URL || process.env.NEXT_PUBLIC_DASHBOARD_URL! + ); + url.pathname = '/login'; + if (error instanceof LogError) { + url.searchParams.set('error', error.message); + } else { + url.searchParams.set('error', 'Failed to connect Google Search Console'); + } + url.searchParams.set('correlationId', reply.request.id); + return reply.redirect(url.toString()); +} diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index bf933329a..ee5e0fd71 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -42,6 +42,7 @@ import liveRouter from './routes/live.router'; import manageRouter from './routes/manage.router'; import miscRouter from './routes/misc.router'; import oauthRouter from './routes/oauth-callback.router'; +import gscCallbackRouter from './routes/gsc-callback.router'; import profileRouter from './routes/profile.router'; import trackRouter from './routes/track.router'; import webhookRouter from './routes/webhook.router'; @@ -194,6 +195,7 @@ const startServer = async () => { instance.register(liveRouter, { prefix: '/live' }); instance.register(webhookRouter, { prefix: '/webhook' }); instance.register(oauthRouter, { prefix: '/oauth' }); + instance.register(gscCallbackRouter, { prefix: '/gsc' }); instance.register(miscRouter, { prefix: '/misc' }); instance.register(aiRouter, { prefix: '/ai' }); }); diff --git a/apps/api/src/routes/gsc-callback.router.ts b/apps/api/src/routes/gsc-callback.router.ts new file mode 100644 index 000000000..0becfb77b --- /dev/null +++ b/apps/api/src/routes/gsc-callback.router.ts @@ -0,0 +1,17 @@ +import { gscGoogleCallback, gscInitiate } from '@/controllers/gsc-oauth-callback.controller'; +import type { FastifyPluginCallback } from 'fastify'; + +const router: FastifyPluginCallback = async (fastify) => { + fastify.route({ + method: 'GET', + url: '/initiate', + handler: gscInitiate, + }); + fastify.route({ + method: 'GET', + url: '/callback', + handler: gscGoogleCallback, + }); +}; + +export default router; diff --git a/apps/start/src/components/sidebar-project-menu.tsx b/apps/start/src/components/sidebar-project-menu.tsx index 50498c891..8c0a55948 100644 --- a/apps/start/src/components/sidebar-project-menu.tsx +++ b/apps/start/src/components/sidebar-project-menu.tsx @@ -14,6 +14,7 @@ import { LayoutDashboardIcon, LayoutPanelTopIcon, PlusIcon, + SearchIcon, SparklesIcon, TrendingUpDownIcon, UndoDotIcon, @@ -59,6 +60,7 @@ export default function SidebarProjectMenu({ +
Manage
diff --git a/apps/start/src/routeTree.gen.ts b/apps/start/src/routeTree.gen.ts index 221cc39de..8742b5411 100644 --- a/apps/start/src/routeTree.gen.ts +++ b/apps/start/src/routeTree.gen.ts @@ -42,6 +42,7 @@ import { Route as AppOrganizationIdProfileTabsRouteImport } from './routes/_app. import { Route as AppOrganizationIdMembersTabsRouteImport } from './routes/_app.$organizationId.members._tabs' import { Route as AppOrganizationIdIntegrationsTabsRouteImport } from './routes/_app.$organizationId.integrations._tabs' import { Route as AppOrganizationIdProjectIdSessionsRouteImport } from './routes/_app.$organizationId.$projectId.sessions' +import { Route as AppOrganizationIdProjectIdSeoRouteImport } from './routes/_app.$organizationId.$projectId.seo' import { Route as AppOrganizationIdProjectIdReportsRouteImport } from './routes/_app.$organizationId.$projectId.reports' import { Route as AppOrganizationIdProjectIdReferencesRouteImport } from './routes/_app.$organizationId.$projectId.references' import { Route as AppOrganizationIdProjectIdRealtimeRouteImport } from './routes/_app.$organizationId.$projectId.realtime' @@ -71,6 +72,7 @@ import { Route as AppOrganizationIdProjectIdEventsTabsIndexRouteImport } from '. import { Route as AppOrganizationIdProjectIdSettingsTabsWidgetsRouteImport } from './routes/_app.$organizationId.$projectId.settings._tabs.widgets' import { Route as AppOrganizationIdProjectIdSettingsTabsTrackingRouteImport } from './routes/_app.$organizationId.$projectId.settings._tabs.tracking' import { Route as AppOrganizationIdProjectIdSettingsTabsImportsRouteImport } from './routes/_app.$organizationId.$projectId.settings._tabs.imports' +import { Route as AppOrganizationIdProjectIdSettingsTabsGscRouteImport } from './routes/_app.$organizationId.$projectId.settings._tabs.gsc' import { Route as AppOrganizationIdProjectIdSettingsTabsEventsRouteImport } from './routes/_app.$organizationId.$projectId.settings._tabs.events' import { Route as AppOrganizationIdProjectIdSettingsTabsDetailsRouteImport } from './routes/_app.$organizationId.$projectId.settings._tabs.details' import { Route as AppOrganizationIdProjectIdSettingsTabsClientsRouteImport } from './routes/_app.$organizationId.$projectId.settings._tabs.clients' @@ -312,6 +314,12 @@ const AppOrganizationIdProjectIdSessionsRoute = path: '/sessions', getParentRoute: () => AppOrganizationIdProjectIdRoute, } as any) +const AppOrganizationIdProjectIdSeoRoute = + AppOrganizationIdProjectIdSeoRouteImport.update({ + id: '/seo', + path: '/seo', + getParentRoute: () => AppOrganizationIdProjectIdRoute, + } as any) const AppOrganizationIdProjectIdReportsRoute = AppOrganizationIdProjectIdReportsRouteImport.update({ id: '/reports', @@ -488,6 +496,12 @@ const AppOrganizationIdProjectIdSettingsTabsImportsRoute = path: '/imports', getParentRoute: () => AppOrganizationIdProjectIdSettingsTabsRoute, } as any) +const AppOrganizationIdProjectIdSettingsTabsGscRoute = + AppOrganizationIdProjectIdSettingsTabsGscRouteImport.update({ + id: '/gsc', + path: '/gsc', + getParentRoute: () => AppOrganizationIdProjectIdSettingsTabsRoute, + } as any) const AppOrganizationIdProjectIdSettingsTabsEventsRoute = AppOrganizationIdProjectIdSettingsTabsEventsRouteImport.update({ id: '/events', @@ -606,6 +620,7 @@ export interface FileRoutesByFullPath { '/$organizationId/$projectId/realtime': typeof AppOrganizationIdProjectIdRealtimeRoute '/$organizationId/$projectId/references': typeof AppOrganizationIdProjectIdReferencesRoute '/$organizationId/$projectId/reports': typeof AppOrganizationIdProjectIdReportsRoute + '/$organizationId/$projectId/seo': typeof AppOrganizationIdProjectIdSeoRoute '/$organizationId/$projectId/sessions': typeof AppOrganizationIdProjectIdSessionsRoute '/$organizationId/integrations': typeof AppOrganizationIdIntegrationsTabsRouteWithChildren '/$organizationId/members': typeof AppOrganizationIdMembersTabsRouteWithChildren @@ -640,6 +655,7 @@ export interface FileRoutesByFullPath { '/$organizationId/$projectId/settings/clients': typeof AppOrganizationIdProjectIdSettingsTabsClientsRoute '/$organizationId/$projectId/settings/details': typeof AppOrganizationIdProjectIdSettingsTabsDetailsRoute '/$organizationId/$projectId/settings/events': typeof AppOrganizationIdProjectIdSettingsTabsEventsRoute + '/$organizationId/$projectId/settings/gsc': typeof AppOrganizationIdProjectIdSettingsTabsGscRoute '/$organizationId/$projectId/settings/imports': typeof AppOrganizationIdProjectIdSettingsTabsImportsRoute '/$organizationId/$projectId/settings/tracking': typeof AppOrganizationIdProjectIdSettingsTabsTrackingRoute '/$organizationId/$projectId/settings/widgets': typeof AppOrganizationIdProjectIdSettingsTabsWidgetsRoute @@ -677,6 +693,7 @@ export interface FileRoutesByTo { '/$organizationId/$projectId/realtime': typeof AppOrganizationIdProjectIdRealtimeRoute '/$organizationId/$projectId/references': typeof AppOrganizationIdProjectIdReferencesRoute '/$organizationId/$projectId/reports': typeof AppOrganizationIdProjectIdReportsRoute + '/$organizationId/$projectId/seo': typeof AppOrganizationIdProjectIdSeoRoute '/$organizationId/$projectId/sessions': typeof AppOrganizationIdProjectIdSessionsRoute '/$organizationId/integrations': typeof AppOrganizationIdIntegrationsTabsIndexRoute '/$organizationId/members': typeof AppOrganizationIdMembersTabsIndexRoute @@ -708,6 +725,7 @@ export interface FileRoutesByTo { '/$organizationId/$projectId/settings/clients': typeof AppOrganizationIdProjectIdSettingsTabsClientsRoute '/$organizationId/$projectId/settings/details': typeof AppOrganizationIdProjectIdSettingsTabsDetailsRoute '/$organizationId/$projectId/settings/events': typeof AppOrganizationIdProjectIdSettingsTabsEventsRoute + '/$organizationId/$projectId/settings/gsc': typeof AppOrganizationIdProjectIdSettingsTabsGscRoute '/$organizationId/$projectId/settings/imports': typeof AppOrganizationIdProjectIdSettingsTabsImportsRoute '/$organizationId/$projectId/settings/tracking': typeof AppOrganizationIdProjectIdSettingsTabsTrackingRoute '/$organizationId/$projectId/settings/widgets': typeof AppOrganizationIdProjectIdSettingsTabsWidgetsRoute @@ -747,6 +765,7 @@ export interface FileRoutesById { '/_app/$organizationId/$projectId/realtime': typeof AppOrganizationIdProjectIdRealtimeRoute '/_app/$organizationId/$projectId/references': typeof AppOrganizationIdProjectIdReferencesRoute '/_app/$organizationId/$projectId/reports': typeof AppOrganizationIdProjectIdReportsRoute + '/_app/$organizationId/$projectId/seo': typeof AppOrganizationIdProjectIdSeoRoute '/_app/$organizationId/$projectId/sessions': typeof AppOrganizationIdProjectIdSessionsRoute '/_app/$organizationId/integrations': typeof AppOrganizationIdIntegrationsRouteWithChildren '/_app/$organizationId/integrations/_tabs': typeof AppOrganizationIdIntegrationsTabsRouteWithChildren @@ -789,6 +808,7 @@ export interface FileRoutesById { '/_app/$organizationId/$projectId/settings/_tabs/clients': typeof AppOrganizationIdProjectIdSettingsTabsClientsRoute '/_app/$organizationId/$projectId/settings/_tabs/details': typeof AppOrganizationIdProjectIdSettingsTabsDetailsRoute '/_app/$organizationId/$projectId/settings/_tabs/events': typeof AppOrganizationIdProjectIdSettingsTabsEventsRoute + '/_app/$organizationId/$projectId/settings/_tabs/gsc': typeof AppOrganizationIdProjectIdSettingsTabsGscRoute '/_app/$organizationId/$projectId/settings/_tabs/imports': typeof AppOrganizationIdProjectIdSettingsTabsImportsRoute '/_app/$organizationId/$projectId/settings/_tabs/tracking': typeof AppOrganizationIdProjectIdSettingsTabsTrackingRoute '/_app/$organizationId/$projectId/settings/_tabs/widgets': typeof AppOrganizationIdProjectIdSettingsTabsWidgetsRoute @@ -830,6 +850,7 @@ export interface FileRouteTypes { | '/$organizationId/$projectId/realtime' | '/$organizationId/$projectId/references' | '/$organizationId/$projectId/reports' + | '/$organizationId/$projectId/seo' | '/$organizationId/$projectId/sessions' | '/$organizationId/integrations' | '/$organizationId/members' @@ -864,6 +885,7 @@ export interface FileRouteTypes { | '/$organizationId/$projectId/settings/clients' | '/$organizationId/$projectId/settings/details' | '/$organizationId/$projectId/settings/events' + | '/$organizationId/$projectId/settings/gsc' | '/$organizationId/$projectId/settings/imports' | '/$organizationId/$projectId/settings/tracking' | '/$organizationId/$projectId/settings/widgets' @@ -901,6 +923,7 @@ export interface FileRouteTypes { | '/$organizationId/$projectId/realtime' | '/$organizationId/$projectId/references' | '/$organizationId/$projectId/reports' + | '/$organizationId/$projectId/seo' | '/$organizationId/$projectId/sessions' | '/$organizationId/integrations' | '/$organizationId/members' @@ -932,6 +955,7 @@ export interface FileRouteTypes { | '/$organizationId/$projectId/settings/clients' | '/$organizationId/$projectId/settings/details' | '/$organizationId/$projectId/settings/events' + | '/$organizationId/$projectId/settings/gsc' | '/$organizationId/$projectId/settings/imports' | '/$organizationId/$projectId/settings/tracking' | '/$organizationId/$projectId/settings/widgets' @@ -970,6 +994,7 @@ export interface FileRouteTypes { | '/_app/$organizationId/$projectId/realtime' | '/_app/$organizationId/$projectId/references' | '/_app/$organizationId/$projectId/reports' + | '/_app/$organizationId/$projectId/seo' | '/_app/$organizationId/$projectId/sessions' | '/_app/$organizationId/integrations' | '/_app/$organizationId/integrations/_tabs' @@ -1012,6 +1037,7 @@ export interface FileRouteTypes { | '/_app/$organizationId/$projectId/settings/_tabs/clients' | '/_app/$organizationId/$projectId/settings/_tabs/details' | '/_app/$organizationId/$projectId/settings/_tabs/events' + | '/_app/$organizationId/$projectId/settings/_tabs/gsc' | '/_app/$organizationId/$projectId/settings/_tabs/imports' | '/_app/$organizationId/$projectId/settings/_tabs/tracking' | '/_app/$organizationId/$projectId/settings/_tabs/widgets' @@ -1310,6 +1336,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AppOrganizationIdProjectIdSessionsRouteImport parentRoute: typeof AppOrganizationIdProjectIdRoute } + '/_app/$organizationId/$projectId/seo': { + id: '/_app/$organizationId/$projectId/seo' + path: '/seo' + fullPath: '/$organizationId/$projectId/seo' + preLoaderRoute: typeof AppOrganizationIdProjectIdSeoRouteImport + parentRoute: typeof AppOrganizationIdProjectIdRoute + } '/_app/$organizationId/$projectId/reports': { id: '/_app/$organizationId/$projectId/reports' path: '/reports' @@ -1520,6 +1553,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AppOrganizationIdProjectIdSettingsTabsImportsRouteImport parentRoute: typeof AppOrganizationIdProjectIdSettingsTabsRoute } + '/_app/$organizationId/$projectId/settings/_tabs/gsc': { + id: '/_app/$organizationId/$projectId/settings/_tabs/gsc' + path: '/gsc' + fullPath: '/$organizationId/$projectId/settings/gsc' + preLoaderRoute: typeof AppOrganizationIdProjectIdSettingsTabsGscRouteImport + parentRoute: typeof AppOrganizationIdProjectIdSettingsTabsRoute + } '/_app/$organizationId/$projectId/settings/_tabs/events': { id: '/_app/$organizationId/$projectId/settings/_tabs/events' path: '/events' @@ -1785,6 +1825,7 @@ interface AppOrganizationIdProjectIdSettingsTabsRouteChildren { AppOrganizationIdProjectIdSettingsTabsClientsRoute: typeof AppOrganizationIdProjectIdSettingsTabsClientsRoute AppOrganizationIdProjectIdSettingsTabsDetailsRoute: typeof AppOrganizationIdProjectIdSettingsTabsDetailsRoute AppOrganizationIdProjectIdSettingsTabsEventsRoute: typeof AppOrganizationIdProjectIdSettingsTabsEventsRoute + AppOrganizationIdProjectIdSettingsTabsGscRoute: typeof AppOrganizationIdProjectIdSettingsTabsGscRoute AppOrganizationIdProjectIdSettingsTabsImportsRoute: typeof AppOrganizationIdProjectIdSettingsTabsImportsRoute AppOrganizationIdProjectIdSettingsTabsTrackingRoute: typeof AppOrganizationIdProjectIdSettingsTabsTrackingRoute AppOrganizationIdProjectIdSettingsTabsWidgetsRoute: typeof AppOrganizationIdProjectIdSettingsTabsWidgetsRoute @@ -1799,6 +1840,8 @@ const AppOrganizationIdProjectIdSettingsTabsRouteChildren: AppOrganizationIdProj AppOrganizationIdProjectIdSettingsTabsDetailsRoute, AppOrganizationIdProjectIdSettingsTabsEventsRoute: AppOrganizationIdProjectIdSettingsTabsEventsRoute, + AppOrganizationIdProjectIdSettingsTabsGscRoute: + AppOrganizationIdProjectIdSettingsTabsGscRoute, AppOrganizationIdProjectIdSettingsTabsImportsRoute: AppOrganizationIdProjectIdSettingsTabsImportsRoute, AppOrganizationIdProjectIdSettingsTabsTrackingRoute: @@ -1837,6 +1880,7 @@ interface AppOrganizationIdProjectIdRouteChildren { AppOrganizationIdProjectIdRealtimeRoute: typeof AppOrganizationIdProjectIdRealtimeRoute AppOrganizationIdProjectIdReferencesRoute: typeof AppOrganizationIdProjectIdReferencesRoute AppOrganizationIdProjectIdReportsRoute: typeof AppOrganizationIdProjectIdReportsRoute + AppOrganizationIdProjectIdSeoRoute: typeof AppOrganizationIdProjectIdSeoRoute AppOrganizationIdProjectIdSessionsRoute: typeof AppOrganizationIdProjectIdSessionsRoute AppOrganizationIdProjectIdIndexRoute: typeof AppOrganizationIdProjectIdIndexRoute AppOrganizationIdProjectIdDashboardsDashboardIdRoute: typeof AppOrganizationIdProjectIdDashboardsDashboardIdRoute @@ -1862,6 +1906,7 @@ const AppOrganizationIdProjectIdRouteChildren: AppOrganizationIdProjectIdRouteCh AppOrganizationIdProjectIdReferencesRoute, AppOrganizationIdProjectIdReportsRoute: AppOrganizationIdProjectIdReportsRoute, + AppOrganizationIdProjectIdSeoRoute: AppOrganizationIdProjectIdSeoRoute, AppOrganizationIdProjectIdSessionsRoute: AppOrganizationIdProjectIdSessionsRoute, AppOrganizationIdProjectIdIndexRoute: AppOrganizationIdProjectIdIndexRoute, diff --git a/apps/start/src/routes/_app.$organizationId.$projectId.seo.tsx b/apps/start/src/routes/_app.$organizationId.$projectId.seo.tsx new file mode 100644 index 000000000..ba87e5f93 --- /dev/null +++ b/apps/start/src/routes/_app.$organizationId.$projectId.seo.tsx @@ -0,0 +1,289 @@ +import { PageContainer } from '@/components/page-container'; +import { PageHeader } from '@/components/page-header'; +import { Skeleton } from '@/components/skeleton'; +import { Button } from '@/components/ui/button'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { useAppParams } from '@/hooks/use-app-params'; +import { useTRPC } from '@/integrations/trpc/react'; +import { createProjectTitle } from '@/utils/title'; +import { useQuery } from '@tanstack/react-query'; +import { createFileRoute, useNavigate } from '@tanstack/react-router'; +import { subDays, format } from 'date-fns'; +import { + Area, + AreaChart, + CartesianGrid, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from 'recharts'; + +export const Route = createFileRoute( + '/_app/$organizationId/$projectId/seo' +)({ + component: SeoPage, + head: () => ({ + meta: [{ title: createProjectTitle('SEO') }], + }), +}); + +const startDate = format(subDays(new Date(), 30), 'yyyy-MM-dd'); +const endDate = format(subDays(new Date(), 1), 'yyyy-MM-dd'); + +function SeoPage() { + const { projectId, organizationId } = useAppParams(); + const trpc = useTRPC(); + const navigate = useNavigate(); + + const connectionQuery = useQuery( + trpc.gsc.getConnection.queryOptions({ projectId }) + ); + + const connection = connectionQuery.data; + const isConnected = connection && connection.siteUrl; + + const overviewQuery = useQuery( + trpc.gsc.getOverview.queryOptions( + { projectId, startDate, endDate }, + { enabled: !!isConnected } + ) + ); + + const pagesQuery = useQuery( + trpc.gsc.getPages.queryOptions( + { projectId, startDate, endDate, limit: 50 }, + { enabled: !!isConnected } + ) + ); + + const queriesQuery = useQuery( + trpc.gsc.getQueries.queryOptions( + { projectId, startDate, endDate, limit: 50 }, + { enabled: !!isConnected } + ) + ); + + if (connectionQuery.isLoading) { + return ( + + +
+ + +
+
+ ); + } + + if (!isConnected) { + return ( + + +
+
+ + + +
+

No SEO data yet

+

+ Connect Google Search Console to track your search impressions, clicks, and keyword rankings. +

+ +
+
+ ); + } + + const overview = overviewQuery.data ?? []; + const pages = pagesQuery.data ?? []; + const queries = queriesQuery.data ?? []; + + return ( + + + +
+ {/* Summary metrics */} +
+ {(['clicks', 'impressions', 'ctr', 'position'] as const).map((metric) => { + const total = overview.reduce((sum, row) => { + if (metric === 'ctr' || metric === 'position') { + return sum + row[metric]; + } + return sum + row[metric]; + }, 0); + const display = + metric === 'ctr' + ? `${((total / Math.max(overview.length, 1)) * 100).toFixed(1)}%` + : metric === 'position' + ? (total / Math.max(overview.length, 1)).toFixed(1) + : total.toLocaleString(); + const label = + metric === 'ctr' + ? 'Avg CTR' + : metric === 'position' + ? 'Avg Position' + : metric.charAt(0).toUpperCase() + metric.slice(1); + + return ( +
+
{label}
+
{overviewQuery.isLoading ? : display}
+
+ ); + })} +
+ + {/* Clicks over time chart */} +
+

Clicks over time

+ {overviewQuery.isLoading ? ( + + ) : ( + + + + + + + + + + + + + + + + )} +
+ + {/* Pages and Queries tables */} +
+ + +
+
+
+ ); +} + +interface GscTableRow { + clicks: number; + impressions: number; + ctr: number; + position: number; + [key: string]: string | number; +} + +function GscTable({ + title, + rows, + keyLabel, + keyField, + isLoading, +}: { + title: string; + rows: GscTableRow[]; + keyLabel: string; + keyField: string; + isLoading: boolean; +}) { + return ( +
+
+

{title}

+
+ + + + {keyLabel} + Clicks + Impressions + CTR + Position + + + + {isLoading && + Array.from({ length: 5 }).map((_, i) => ( + + {Array.from({ length: 5 }).map((_, j) => ( + + + + ))} + + ))} + {!isLoading && rows.length === 0 && ( + + + No data yet + + + )} + {rows.map((row) => ( + + + {String(row[keyField])} + + {row.clicks.toLocaleString()} + {row.impressions.toLocaleString()} + {(row.ctr * 100).toFixed(1)}% + {row.position.toFixed(1)} + + ))} + +
+
+ ); +} diff --git a/apps/start/src/routes/_app.$organizationId.$projectId.settings._tabs.gsc.tsx b/apps/start/src/routes/_app.$organizationId.$projectId.settings._tabs.gsc.tsx new file mode 100644 index 000000000..af18c356f --- /dev/null +++ b/apps/start/src/routes/_app.$organizationId.$projectId.settings._tabs.gsc.tsx @@ -0,0 +1,274 @@ +import { Skeleton } from '@/components/skeleton'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { useAppParams } from '@/hooks/use-app-params'; +import { useTRPC } from '@/integrations/trpc/react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { createFileRoute } from '@tanstack/react-router'; +import { formatDistanceToNow } from 'date-fns'; +import { CheckCircleIcon, Loader2Icon, XCircleIcon } from 'lucide-react'; +import { useState } from 'react'; +import { toast } from 'sonner'; + +export const Route = createFileRoute( + '/_app/$organizationId/$projectId/settings/_tabs/gsc' +)({ + component: GscSettings, +}); + +function GscSettings() { + const { projectId } = useAppParams(); + const trpc = useTRPC(); + const queryClient = useQueryClient(); + const [selectedSite, setSelectedSite] = useState(''); + + const connectionQuery = useQuery( + trpc.gsc.getConnection.queryOptions( + { projectId }, + { refetchInterval: 5000 } + ) + ); + + const sitesQuery = useQuery( + trpc.gsc.getSites.queryOptions( + { projectId }, + { enabled: !!connectionQuery.data && !connectionQuery.data.siteUrl } + ) + ); + + const initiateOAuth = useMutation( + trpc.gsc.initiateOAuth.mutationOptions({ + onSuccess: (data) => { + // Route through the API /gsc/initiate endpoint which sets cookies then redirects to Google + const apiUrl = (import.meta.env.VITE_API_URL as string) ?? ''; + const initiateUrl = new URL(`${apiUrl}/gsc/initiate`); + initiateUrl.searchParams.set('state', data.state); + initiateUrl.searchParams.set('code_verifier', data.codeVerifier); + initiateUrl.searchParams.set('project_id', data.projectId); + initiateUrl.searchParams.set('redirect', data.url); + window.location.href = initiateUrl.toString(); + }, + onError: () => { + toast.error('Failed to initiate Google Search Console connection'); + }, + }) + ); + + const selectSite = useMutation( + trpc.gsc.selectSite.mutationOptions({ + onSuccess: () => { + toast.success('Site connected', { + description: 'Backfill of 6 months of data has started.', + }); + queryClient.invalidateQueries(trpc.gsc.getConnection.pathFilter()); + }, + onError: () => { + toast.error('Failed to select site'); + }, + }) + ); + + const disconnect = useMutation( + trpc.gsc.disconnect.mutationOptions({ + onSuccess: () => { + toast.success('Disconnected from Google Search Console'); + queryClient.invalidateQueries(trpc.gsc.getConnection.pathFilter()); + }, + onError: () => { + toast.error('Failed to disconnect'); + }, + }) + ); + + const connection = connectionQuery.data; + + if (connectionQuery.isLoading) { + return ( +
+ +
+ ); + } + + // Not connected at all + if (!connection) { + return ( +
+
+

Google Search Console

+

+ Connect your Google Search Console property to import search performance data. +

+
+
+

+ You will be redirected to Google to authorize access. Only read-only access to Search Console data is requested. +

+ +
+
+ ); + } + + // Connected but no site selected yet + if (!connection.siteUrl) { + const sites = sitesQuery.data ?? []; + return ( +
+
+

Select a property

+

+ Choose which Google Search Console property to connect to this project. +

+
+
+ {sitesQuery.isLoading ? ( + + ) : sites.length === 0 ? ( +

+ No Search Console properties found for this Google account. +

+ ) : ( + <> + + + + )} +
+ +
+ ); + } + + // Fully connected + const syncStatusIcon = + connection.lastSyncStatus === 'success' ? ( + + ) : connection.lastSyncStatus === 'error' ? ( + + ) : null; + + const syncStatusVariant = + connection.lastSyncStatus === 'success' + ? 'success' + : connection.lastSyncStatus === 'error' + ? 'destructive' + : 'secondary'; + + return ( +
+
+

Google Search Console

+

+ Connected to Google Search Console. +

+
+ +
+
+
Property
+
+ {connection.siteUrl} +
+
+ + {connection.backfillStatus && ( +
+
Backfill
+ + {connection.backfillStatus === 'running' && ( + + )} + {connection.backfillStatus} + +
+ )} + + {connection.lastSyncedAt && ( +
+
Last synced
+
+ {connection.lastSyncStatus && ( + + {syncStatusIcon} + {connection.lastSyncStatus} + + )} + + {formatDistanceToNow(new Date(connection.lastSyncedAt), { + addSuffix: true, + })} + +
+
+ )} + + {connection.lastSyncError && ( +
+
Last error
+
+ {connection.lastSyncError} +
+
+ )} +
+ + +
+ ); +} diff --git a/apps/start/src/routes/_app.$organizationId.$projectId.settings._tabs.tsx b/apps/start/src/routes/_app.$organizationId.$projectId.settings._tabs.tsx index 205fb7bca..b0037ed9c 100644 --- a/apps/start/src/routes/_app.$organizationId.$projectId.settings._tabs.tsx +++ b/apps/start/src/routes/_app.$organizationId.$projectId.settings._tabs.tsx @@ -45,6 +45,7 @@ function ProjectDashboard() { { id: 'tracking', label: 'Tracking script' }, { id: 'widgets', label: 'Widgets' }, { id: 'imports', label: 'Imports' }, + { id: 'gsc', label: 'Google Search' }, ]; const handleTabChange = (tabId: string) => { diff --git a/apps/worker/src/boot-cron.ts b/apps/worker/src/boot-cron.ts index 106cec07c..e3835e917 100644 --- a/apps/worker/src/boot-cron.ts +++ b/apps/worker/src/boot-cron.ts @@ -78,6 +78,11 @@ export async function bootCron() { type: 'onboarding', pattern: '0 * * * *', }, + { + name: 'gscSync', + type: 'gscSync', + pattern: '0 3 * * *', + }, ]; if (process.env.SELF_HOSTED && process.env.NODE_ENV === 'production') { diff --git a/apps/worker/src/boot-workers.ts b/apps/worker/src/boot-workers.ts index 6d96dd61f..5b4285cad 100644 --- a/apps/worker/src/boot-workers.ts +++ b/apps/worker/src/boot-workers.ts @@ -6,6 +6,7 @@ import { type EventsQueuePayloadIncomingEvent, cronQueue, eventsGroupQueues, + gscQueue, importQueue, insightsQueue, miscQueue, @@ -20,6 +21,7 @@ import { setTimeout as sleep } from 'node:timers/promises'; import { Worker as GroupWorker } from 'groupmq'; import { cronJob } from './jobs/cron'; +import { gscJob } from './jobs/gsc'; import { incomingEvent } from './jobs/events.incoming-event'; import { importJob } from './jobs/import'; import { insightsProjectJob } from './jobs/insights'; @@ -59,6 +61,7 @@ function getEnabledQueues(): QueueName[] { 'misc', 'import', 'insights', + 'gsc', ]; } @@ -208,6 +211,17 @@ export async function bootWorkers() { logger.info('Started worker for insights', { concurrency }); } + // Start gsc worker + if (enabledQueues.includes('gsc')) { + const concurrency = getConcurrencyFor('gsc', 5); + const gscWorker = new Worker(gscQueue.name, gscJob, { + ...workerOptions, + concurrency, + }); + workers.push(gscWorker); + logger.info('Started worker for gsc', { concurrency }); + } + if (workers.length === 0) { logger.warn( 'No workers started. Check ENABLED_QUEUES environment variable.', diff --git a/apps/worker/src/jobs/cron.ts b/apps/worker/src/jobs/cron.ts index 8d69afecb..f94286144 100644 --- a/apps/worker/src/jobs/cron.ts +++ b/apps/worker/src/jobs/cron.ts @@ -4,6 +4,7 @@ import { eventBuffer, profileBackfillBuffer, profileBuffer, replayBuffer, sessio import type { CronQueuePayload } from '@openpanel/queue'; import { jobdeleteProjects } from './cron.delete-projects'; +import { gscSyncAllJob } from './gsc'; import { onboardingJob } from './cron.onboarding'; import { ping } from './cron.ping'; import { salt } from './cron.salt'; @@ -41,5 +42,8 @@ export async function cronJob(job: Job) { case 'onboarding': { return await onboardingJob(job); } + case 'gscSync': { + return await gscSyncAllJob(); + } } } diff --git a/apps/worker/src/jobs/gsc.ts b/apps/worker/src/jobs/gsc.ts new file mode 100644 index 000000000..6ed9f51af --- /dev/null +++ b/apps/worker/src/jobs/gsc.ts @@ -0,0 +1,142 @@ +import { db, syncGscData } from '@openpanel/db'; +import { gscQueue } from '@openpanel/queue'; +import type { GscQueuePayload } from '@openpanel/queue'; +import type { Job } from 'bullmq'; +import { logger } from '../utils/logger'; + +const BACKFILL_MONTHS = 6; +const CHUNK_DAYS = 14; + +export async function gscJob(job: Job) { + switch (job.data.type) { + case 'gscProjectSync': + return gscProjectSyncJob(job.data.payload.projectId); + case 'gscProjectBackfill': + return gscProjectBackfillJob(job.data.payload.projectId); + } +} + +async function gscProjectSyncJob(projectId: string) { + const conn = await db.gscConnection.findUnique({ where: { projectId } }); + if (!conn?.siteUrl) { + logger.warn('GSC sync skipped: no connection or siteUrl', { projectId }); + return; + } + + try { + // Sync rolling 3-day window (GSC data can arrive late) + const endDate = new Date(); + endDate.setDate(endDate.getDate() - 1); // yesterday + const startDate = new Date(endDate); + startDate.setDate(startDate.getDate() - 2); // 3 days total + + await syncGscData(projectId, startDate, endDate); + + await db.gscConnection.update({ + where: { projectId }, + data: { + lastSyncedAt: new Date(), + lastSyncStatus: 'success', + lastSyncError: null, + }, + }); + logger.info('GSC sync completed', { projectId }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + await db.gscConnection.update({ + where: { projectId }, + data: { + lastSyncedAt: new Date(), + lastSyncStatus: 'error', + lastSyncError: message, + }, + }); + logger.error('GSC sync failed', { projectId, error }); + throw error; + } +} + +async function gscProjectBackfillJob(projectId: string) { + const conn = await db.gscConnection.findUnique({ where: { projectId } }); + if (!conn?.siteUrl) { + logger.warn('GSC backfill skipped: no connection or siteUrl', { projectId }); + return; + } + + await db.gscConnection.update({ + where: { projectId }, + data: { backfillStatus: 'running' }, + }); + + try { + const endDate = new Date(); + endDate.setDate(endDate.getDate() - 1); // yesterday + + const startDate = new Date(endDate); + startDate.setMonth(startDate.getMonth() - BACKFILL_MONTHS); + + // Process in chunks to avoid timeouts and respect API limits + let chunkEnd = new Date(endDate); + while (chunkEnd > startDate) { + const chunkStart = new Date(chunkEnd); + chunkStart.setDate(chunkStart.getDate() - CHUNK_DAYS + 1); + if (chunkStart < startDate) { + chunkStart.setTime(startDate.getTime()); + } + + logger.info('GSC backfill chunk', { + projectId, + from: chunkStart.toISOString().slice(0, 10), + to: chunkEnd.toISOString().slice(0, 10), + }); + + await syncGscData(projectId, chunkStart, chunkEnd); + + chunkEnd = new Date(chunkStart); + chunkEnd.setDate(chunkEnd.getDate() - 1); + } + + await db.gscConnection.update({ + where: { projectId }, + data: { + backfillStatus: 'completed', + lastSyncedAt: new Date(), + lastSyncStatus: 'success', + lastSyncError: null, + }, + }); + logger.info('GSC backfill completed', { projectId }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + await db.gscConnection.update({ + where: { projectId }, + data: { + backfillStatus: 'failed', + lastSyncStatus: 'error', + lastSyncError: message, + }, + }); + logger.error('GSC backfill failed', { projectId, error }); + throw error; + } +} + +export async function gscSyncAllJob() { + const connections = await db.gscConnection.findMany({ + where: { + siteUrl: { not: '' }, + }, + select: { projectId: true }, + }); + + logger.info('GSC nightly sync: enqueuing projects', { + count: connections.length, + }); + + for (const conn of connections) { + await gscQueue.add('gscProjectSync', { + type: 'gscProjectSync', + payload: { projectId: conn.projectId }, + }); + } +} diff --git a/packages/auth/src/oauth.ts b/packages/auth/src/oauth.ts index 18464f6a6..9bd8cd14b 100644 --- a/packages/auth/src/oauth.ts +++ b/packages/auth/src/oauth.ts @@ -16,3 +16,9 @@ export const google = new Arctic.Google( process.env.GOOGLE_CLIENT_SECRET ?? '', process.env.GOOGLE_REDIRECT_URI ?? '', ); + +export const googleGsc = new Arctic.Google( + process.env.GOOGLE_CLIENT_ID ?? '', + process.env.GOOGLE_CLIENT_SECRET ?? '', + process.env.GSC_GOOGLE_REDIRECT_URI ?? '', +); diff --git a/packages/db/code-migrations/12-add-gsc.ts b/packages/db/code-migrations/12-add-gsc.ts new file mode 100644 index 000000000..43d82d9fc --- /dev/null +++ b/packages/db/code-migrations/12-add-gsc.ts @@ -0,0 +1,85 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { createTable, runClickhouseMigrationCommands } from '../src/clickhouse/migration'; +import { getIsCluster } from './helpers'; + +export async function up() { + const isClustered = getIsCluster(); + + const commonMetricColumns = [ + '`clicks` UInt32 CODEC(Delta(4), LZ4)', + '`impressions` UInt32 CODEC(Delta(4), LZ4)', + '`ctr` Float32 CODEC(Gorilla, LZ4)', + '`position` Float32 CODEC(Gorilla, LZ4)', + '`synced_at` DateTime DEFAULT now() CODEC(Delta(4), LZ4)', + ]; + + const sqls: string[] = [ + // Daily totals — accurate overview numbers + ...createTable({ + name: 'gsc_daily', + columns: [ + '`project_id` String CODEC(ZSTD(3))', + '`date` Date CODEC(Delta(2), LZ4)', + ...commonMetricColumns, + ], + orderBy: ['project_id', 'date'], + partitionBy: 'toYYYYMM(date)', + engine: 'ReplacingMergeTree(synced_at)', + distributionHash: 'cityHash64(project_id)', + replicatedVersion: '1', + isClustered, + }), + + // Per-page breakdown + ...createTable({ + name: 'gsc_pages_daily', + columns: [ + '`project_id` String CODEC(ZSTD(3))', + '`date` Date CODEC(Delta(2), LZ4)', + '`page` String CODEC(ZSTD(3))', + ...commonMetricColumns, + ], + orderBy: ['project_id', 'date', 'page'], + partitionBy: 'toYYYYMM(date)', + engine: 'ReplacingMergeTree(synced_at)', + distributionHash: 'cityHash64(project_id)', + replicatedVersion: '1', + isClustered, + }), + + // Per-query breakdown + ...createTable({ + name: 'gsc_queries_daily', + columns: [ + '`project_id` String CODEC(ZSTD(3))', + '`date` Date CODEC(Delta(2), LZ4)', + '`query` String CODEC(ZSTD(3))', + ...commonMetricColumns, + ], + orderBy: ['project_id', 'date', 'query'], + partitionBy: 'toYYYYMM(date)', + engine: 'ReplacingMergeTree(synced_at)', + distributionHash: 'cityHash64(project_id)', + replicatedVersion: '1', + isClustered, + }), + ]; + + fs.writeFileSync( + path.join(__filename.replace('.ts', '.sql')), + sqls + .map((sql) => + sql + .trim() + .replace(/;$/, '') + .replace(/\n{2,}/g, '\n') + .concat(';'), + ) + .join('\n\n---\n\n'), + ); + + if (!process.argv.includes('--dry')) { + await runClickhouseMigrationCommands(sqls); + } +} diff --git a/packages/db/index.ts b/packages/db/index.ts index f0e461c3b..33e8902ad 100644 --- a/packages/db/index.ts +++ b/packages/db/index.ts @@ -31,3 +31,4 @@ export * from './src/services/overview.service'; export * from './src/services/pages.service'; export * from './src/services/insights'; export * from './src/session-context'; +export * from './src/gsc'; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 784f94fb0..8bf72e143 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -203,6 +203,7 @@ model Project { notificationRules NotificationRule[] notifications Notification[] imports Import[] + gscConnection GscConnection? // When deleteAt > now(), the project will be deleted deleteAt DateTime? @@ -612,6 +613,24 @@ model InsightEvent { @@map("insight_events") } +model GscConnection { + id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid + projectId String @unique + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + siteUrl String @default("") + accessToken String + refreshToken String + accessTokenExpiresAt DateTime? + lastSyncedAt DateTime? + lastSyncStatus String? + lastSyncError String? + backfillStatus String? + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) @updatedAt + + @@map("gsc_connections") +} + model EmailUnsubscribe { id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid email String diff --git a/packages/db/src/clickhouse/client.ts b/packages/db/src/clickhouse/client.ts index d2899f825..3a0ba20ab 100644 --- a/packages/db/src/clickhouse/client.ts +++ b/packages/db/src/clickhouse/client.ts @@ -58,6 +58,9 @@ export const TABLE_NAMES = { sessions: 'sessions', events_imports: 'events_imports', session_replay_chunks: 'session_replay_chunks', + gsc_daily: 'gsc_daily', + gsc_pages_daily: 'gsc_pages_daily', + gsc_queries_daily: 'gsc_queries_daily', }; /** diff --git a/packages/db/src/gsc.ts b/packages/db/src/gsc.ts new file mode 100644 index 000000000..6a8e5e085 --- /dev/null +++ b/packages/db/src/gsc.ts @@ -0,0 +1,341 @@ +import { originalCh } from './clickhouse/client'; +import { db } from './prisma-client'; + +export interface GscSite { + siteUrl: string; + permissionLevel: string; +} + +async function refreshGscToken( + refreshToken: string +): Promise<{ accessToken: string; expiresAt: Date }> { + const params = new URLSearchParams({ + client_id: process.env.GOOGLE_CLIENT_ID ?? '', + client_secret: process.env.GOOGLE_CLIENT_SECRET ?? '', + refresh_token: refreshToken, + grant_type: 'refresh_token', + }); + + const res = await fetch('https://oauth2.googleapis.com/token', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: params.toString(), + }); + + if (!res.ok) { + const text = await res.text(); + throw new Error(`Failed to refresh GSC token: ${text}`); + } + + const data = (await res.json()) as { + access_token: string; + expires_in: number; + }; + const expiresAt = new Date(Date.now() + data.expires_in * 1000); + return { accessToken: data.access_token, expiresAt }; +} + +export async function getGscAccessToken(projectId: string): Promise { + const conn = await db.gscConnection.findUniqueOrThrow({ + where: { projectId }, + }); + + if ( + conn.accessTokenExpiresAt && + conn.accessTokenExpiresAt.getTime() > Date.now() + 60_000 + ) { + return conn.accessToken; + } + + const { accessToken, expiresAt } = await refreshGscToken(conn.refreshToken); + await db.gscConnection.update({ + where: { projectId }, + data: { accessToken, accessTokenExpiresAt: expiresAt }, + }); + return accessToken; +} + +export async function listGscSites(projectId: string): Promise { + const accessToken = await getGscAccessToken(projectId); + const res = await fetch('https://www.googleapis.com/webmaster/v3/sites', { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + + if (!res.ok) { + const text = await res.text(); + throw new Error(`Failed to list GSC sites: ${text}`); + } + + const data = (await res.json()) as { + siteEntry?: Array<{ siteUrl: string; permissionLevel: string }>; + }; + return data.siteEntry ?? []; +} + +interface GscApiRow { + keys: string[]; + clicks: number; + impressions: number; + ctr: number; + position: number; +} + +async function queryGscSearchAnalytics( + accessToken: string, + siteUrl: string, + startDate: string, + endDate: string, + dimensions: string[] +): Promise { + const encodedSiteUrl = encodeURIComponent(siteUrl); + const url = `https://www.googleapis.com/webmaster/v3/sites/${encodedSiteUrl}/searchAnalytics/query`; + + const allRows: GscApiRow[] = []; + let startRow = 0; + const rowLimit = 25000; + + while (true) { + const res = await fetch(url, { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + startDate, + endDate, + dimensions, + rowLimit, + startRow, + dataState: 'all', + }), + }); + + if (!res.ok) { + const text = await res.text(); + throw new Error(`GSC query failed for dimensions [${dimensions.join(',')}]: ${text}`); + } + + const data = (await res.json()) as { rows?: GscApiRow[] }; + const rows = data.rows ?? []; + allRows.push(...rows); + + if (rows.length < rowLimit) break; + startRow += rowLimit; + } + + return allRows; +} + +function formatDate(date: Date): string { + return date.toISOString().slice(0, 10); +} + +function nowString(): string { + return new Date().toISOString().replace('T', ' ').replace('Z', ''); +} + +export async function syncGscData( + projectId: string, + startDate: Date, + endDate: Date +): Promise { + const conn = await db.gscConnection.findUniqueOrThrow({ + where: { projectId }, + }); + + if (!conn.siteUrl) { + throw new Error('No GSC site URL configured for this project'); + } + + const accessToken = await getGscAccessToken(projectId); + const start = formatDate(startDate); + const end = formatDate(endDate); + const syncedAt = nowString(); + + // 1. Daily totals — authoritative numbers for overview chart + const dailyRows = await queryGscSearchAnalytics( + accessToken, + conn.siteUrl, + start, + end, + ['date'] + ); + + if (dailyRows.length > 0) { + await originalCh.insert({ + table: 'gsc_daily', + values: dailyRows.map((row) => ({ + project_id: projectId, + date: row.keys[0] ?? '', + clicks: row.clicks, + impressions: row.impressions, + ctr: row.ctr, + position: row.position, + synced_at: syncedAt, + })), + format: 'JSONEachRow', + }); + } + + // 2. Per-page breakdown + const pageRows = await queryGscSearchAnalytics( + accessToken, + conn.siteUrl, + start, + end, + ['date', 'page'] + ); + + if (pageRows.length > 0) { + await originalCh.insert({ + table: 'gsc_pages_daily', + values: pageRows.map((row) => ({ + project_id: projectId, + date: row.keys[0] ?? '', + page: row.keys[1] ?? '', + clicks: row.clicks, + impressions: row.impressions, + ctr: row.ctr, + position: row.position, + synced_at: syncedAt, + })), + format: 'JSONEachRow', + }); + } + + // 3. Per-query breakdown + const queryRows = await queryGscSearchAnalytics( + accessToken, + conn.siteUrl, + start, + end, + ['date', 'query'] + ); + + if (queryRows.length > 0) { + await originalCh.insert({ + table: 'gsc_queries_daily', + values: queryRows.map((row) => ({ + project_id: projectId, + date: row.keys[0] ?? '', + query: row.keys[1] ?? '', + clicks: row.clicks, + impressions: row.impressions, + ctr: row.ctr, + position: row.position, + synced_at: syncedAt, + })), + format: 'JSONEachRow', + }); + } +} + +export async function getGscOverview( + projectId: string, + startDate: string, + endDate: string +): Promise< + Array<{ + date: string; + clicks: number; + impressions: number; + ctr: number; + position: number; + }> +> { + const result = await originalCh.query({ + query: ` + SELECT + date, + sum(clicks) as clicks, + sum(impressions) as impressions, + avg(ctr) as ctr, + avg(position) as position + FROM gsc_daily + FINAL + WHERE project_id = {projectId: String} + AND date >= {startDate: String} + AND date <= {endDate: String} + GROUP BY date + ORDER BY date ASC + `, + query_params: { projectId, startDate, endDate }, + format: 'JSONEachRow', + }); + return result.json(); +} + +export async function getGscPages( + projectId: string, + startDate: string, + endDate: string, + limit = 100 +): Promise< + Array<{ + page: string; + clicks: number; + impressions: number; + ctr: number; + position: number; + }> +> { + const result = await originalCh.query({ + query: ` + SELECT + page, + sum(clicks) as clicks, + sum(impressions) as impressions, + avg(ctr) as ctr, + avg(position) as position + FROM gsc_pages_daily + FINAL + WHERE project_id = {projectId: String} + AND date >= {startDate: String} + AND date <= {endDate: String} + GROUP BY page + ORDER BY clicks DESC + LIMIT {limit: UInt32} + `, + query_params: { projectId, startDate, endDate, limit }, + format: 'JSONEachRow', + }); + return result.json(); +} + +export async function getGscQueries( + projectId: string, + startDate: string, + endDate: string, + limit = 100 +): Promise< + Array<{ + query: string; + clicks: number; + impressions: number; + ctr: number; + position: number; + }> +> { + const result = await originalCh.query({ + query: ` + SELECT + query, + sum(clicks) as clicks, + sum(impressions) as impressions, + avg(ctr) as ctr, + avg(position) as position + FROM gsc_queries_daily + FINAL + WHERE project_id = {projectId: String} + AND date >= {startDate: String} + AND date <= {endDate: String} + GROUP BY query + ORDER BY clicks DESC + LIMIT {limit: UInt32} + `, + query_params: { projectId, startDate, endDate, limit }, + format: 'JSONEachRow', + }); + return result.json(); +} diff --git a/packages/queue/src/queues.ts b/packages/queue/src/queues.ts index 4ba92b6a7..aa769b486 100644 --- a/packages/queue/src/queues.ts +++ b/packages/queue/src/queues.ts @@ -126,6 +126,10 @@ export type CronQueuePayloadFlushReplay = { type: 'flushReplay'; payload: undefined; }; +export type CronQueuePayloadGscSync = { + type: 'gscSync'; + payload: undefined; +}; export type CronQueuePayload = | CronQueuePayloadSalt | CronQueuePayloadFlushEvents @@ -136,7 +140,8 @@ export type CronQueuePayload = | CronQueuePayloadPing | CronQueuePayloadProject | CronQueuePayloadInsightsDaily - | CronQueuePayloadOnboarding; + | CronQueuePayloadOnboarding + | CronQueuePayloadGscSync; export type MiscQueuePayloadTrialEndingSoon = { type: 'trialEndingSoon'; @@ -268,3 +273,21 @@ export const insightsQueue = new Queue( }, } ); + +export type GscQueuePayloadSync = { + type: 'gscProjectSync'; + payload: { projectId: string }; +}; +export type GscQueuePayloadBackfill = { + type: 'gscProjectBackfill'; + payload: { projectId: string }; +}; +export type GscQueuePayload = GscQueuePayloadSync | GscQueuePayloadBackfill; + +export const gscQueue = new Queue(getQueueName('gsc'), { + connection: getRedisQueue(), + defaultJobOptions: { + removeOnComplete: 50, + removeOnFail: 100, + }, +}); diff --git a/packages/trpc/src/root.ts b/packages/trpc/src/root.ts index 068a321dd..626c13125 100644 --- a/packages/trpc/src/root.ts +++ b/packages/trpc/src/root.ts @@ -1,4 +1,5 @@ import { authRouter } from './routers/auth'; +import { gscRouter } from './routers/gsc'; import { chartRouter } from './routers/chart'; import { chatRouter } from './routers/chat'; import { clientRouter } from './routers/client'; @@ -53,6 +54,7 @@ export const appRouter = createTRPCRouter({ insight: insightRouter, widget: widgetRouter, email: emailRouter, + gsc: gscRouter, }); // export type definition of API diff --git a/packages/trpc/src/routers/gsc.ts b/packages/trpc/src/routers/gsc.ts new file mode 100644 index 000000000..2cab43cf2 --- /dev/null +++ b/packages/trpc/src/routers/gsc.ts @@ -0,0 +1,201 @@ +import { Arctic, googleGsc } from '@openpanel/auth'; +import { + db, + getGscOverview, + getGscPages, + getGscQueries, + listGscSites, +} from '@openpanel/db'; +import { gscQueue } from '@openpanel/queue'; +import { z } from 'zod'; +import { getProjectAccess } from '../access'; +import { TRPCAccessError, TRPCNotFoundError } from '../errors'; +import { createTRPCRouter, protectedProcedure } from '../trpc'; + +export const gscRouter = createTRPCRouter({ + getConnection: protectedProcedure + .input(z.object({ projectId: z.string() })) + .query(async ({ input, ctx }) => { + const access = await getProjectAccess({ + projectId: input.projectId, + userId: ctx.session.userId, + }); + if (!access) { + throw TRPCAccessError('You do not have access to this project'); + } + return db.gscConnection.findUnique({ + where: { projectId: input.projectId }, + select: { + id: true, + siteUrl: true, + lastSyncedAt: true, + lastSyncStatus: true, + lastSyncError: true, + backfillStatus: true, + createdAt: true, + updatedAt: true, + }, + }); + }), + + initiateOAuth: protectedProcedure + .input(z.object({ projectId: z.string() })) + .mutation(async ({ input, ctx }) => { + const access = await getProjectAccess({ + projectId: input.projectId, + userId: ctx.session.userId, + }); + if (!access) { + throw TRPCAccessError('You do not have access to this project'); + } + + const state = Arctic.generateState(); + const codeVerifier = Arctic.generateCodeVerifier(); + const url = googleGsc.createAuthorizationURL(state, codeVerifier, [ + 'https://www.googleapis.com/auth/webmaster.readonly', + ]); + url.searchParams.set('access_type', 'offline'); + url.searchParams.set('prompt', 'consent'); + + return { + url: url.toString(), + state, + codeVerifier, + projectId: input.projectId, + }; + }), + + getSites: protectedProcedure + .input(z.object({ projectId: z.string() })) + .query(async ({ input, ctx }) => { + const access = await getProjectAccess({ + projectId: input.projectId, + userId: ctx.session.userId, + }); + if (!access) { + throw TRPCAccessError('You do not have access to this project'); + } + return listGscSites(input.projectId); + }), + + selectSite: protectedProcedure + .input(z.object({ projectId: z.string(), siteUrl: z.string() })) + .mutation(async ({ input, ctx }) => { + const access = await getProjectAccess({ + projectId: input.projectId, + userId: ctx.session.userId, + }); + if (!access) { + throw TRPCAccessError('You do not have access to this project'); + } + + const conn = await db.gscConnection.findUnique({ + where: { projectId: input.projectId }, + }); + if (!conn) { + throw TRPCNotFoundError('GSC connection not found'); + } + + await db.gscConnection.update({ + where: { projectId: input.projectId }, + data: { + siteUrl: input.siteUrl, + backfillStatus: 'pending', + }, + }); + + await gscQueue.add('gscProjectBackfill', { + type: 'gscProjectBackfill', + payload: { projectId: input.projectId }, + }); + + return { ok: true }; + }), + + disconnect: protectedProcedure + .input(z.object({ projectId: z.string() })) + .mutation(async ({ input, ctx }) => { + const access = await getProjectAccess({ + projectId: input.projectId, + userId: ctx.session.userId, + }); + if (!access) { + throw TRPCAccessError('You do not have access to this project'); + } + + await db.gscConnection.deleteMany({ + where: { projectId: input.projectId }, + }); + + return { ok: true }; + }), + + getOverview: protectedProcedure + .input( + z.object({ + projectId: z.string(), + startDate: z.string(), + endDate: z.string(), + }) + ) + .query(async ({ input, ctx }) => { + const access = await getProjectAccess({ + projectId: input.projectId, + userId: ctx.session.userId, + }); + if (!access) { + throw TRPCAccessError('You do not have access to this project'); + } + return getGscOverview(input.projectId, input.startDate, input.endDate); + }), + + getPages: protectedProcedure + .input( + z.object({ + projectId: z.string(), + startDate: z.string(), + endDate: z.string(), + limit: z.number().min(1).max(1000).optional().default(100), + }) + ) + .query(async ({ input, ctx }) => { + const access = await getProjectAccess({ + projectId: input.projectId, + userId: ctx.session.userId, + }); + if (!access) { + throw TRPCAccessError('You do not have access to this project'); + } + return getGscPages( + input.projectId, + input.startDate, + input.endDate, + input.limit + ); + }), + + getQueries: protectedProcedure + .input( + z.object({ + projectId: z.string(), + startDate: z.string(), + endDate: z.string(), + limit: z.number().min(1).max(1000).optional().default(100), + }) + ) + .query(async ({ input, ctx }) => { + const access = await getProjectAccess({ + projectId: input.projectId, + userId: ctx.session.userId, + }); + if (!access) { + throw TRPCAccessError('You do not have access to this project'); + } + return getGscQueries( + input.projectId, + input.startDate, + input.endDate, + input.limit + ); + }), +}); From c9cf7901adf9a5c0106a0a80513daae6411b1f00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carl-Gerhard=20Lindesva=CC=88rd?= Date: Mon, 9 Mar 2026 12:30:28 +0100 Subject: [PATCH 2/6] wip --- .../gsc-oauth-callback.controller.ts | 34 +- apps/api/src/index.ts | 2 +- apps/api/src/routes/gsc-callback.router.ts | 7 +- .../components/page/gsc-breakdown-table.tsx | 142 +++ .../components/page/gsc-cannibalization.tsx | 217 ++++ .../src/components/page/gsc-clicks-chart.tsx | 197 ++++ .../src/components/page/gsc-ctr-benchmark.tsx | 228 +++++ .../components/page/gsc-position-chart.tsx | 129 +++ .../src/components/page/page-views-chart.tsx | 180 ++++ .../src/components/page/pages-insights.tsx | 332 +++++++ .../src/components/pages/page-sparkline.tsx | 113 +++ .../src/components/pages/table/columns.tsx | 199 ++++ .../src/components/pages/table/index.tsx | 147 +++ .../report-chart/common/serie-icon.urls.ts | 1 + .../src/components/sidebar-project-menu.tsx | 5 +- .../components/ui/data-table/data-table.tsx | 4 + .../src/hooks/use-format-date-interval.ts | 29 +- apps/start/src/modals/gsc-details.tsx | 392 ++++++++ apps/start/src/modals/index.tsx | 2 + apps/start/src/modals/page-details.tsx | 46 + .../_app.$organizationId.$projectId.pages.tsx | 339 +------ .../_app.$organizationId.$projectId.seo.tsx | 931 ++++++++++++++---- ...zationId.$projectId.settings._tabs.gsc.tsx | 164 ++- packages/auth/server/oauth.ts | 18 - packages/auth/src/oauth.ts | 7 +- packages/db/index.ts | 1 + .../20260306133001_gsc/migration.sql | 23 + packages/db/src/encryption.ts | 44 + packages/db/src/gsc.ts | 232 ++++- packages/db/src/services/pages.service.ts | 62 +- packages/trpc/src/routers/event.ts | 73 ++ packages/trpc/src/routers/gsc.ts | 277 +++++- 32 files changed, 3904 insertions(+), 673 deletions(-) create mode 100644 apps/start/src/components/page/gsc-breakdown-table.tsx create mode 100644 apps/start/src/components/page/gsc-cannibalization.tsx create mode 100644 apps/start/src/components/page/gsc-clicks-chart.tsx create mode 100644 apps/start/src/components/page/gsc-ctr-benchmark.tsx create mode 100644 apps/start/src/components/page/gsc-position-chart.tsx create mode 100644 apps/start/src/components/page/page-views-chart.tsx create mode 100644 apps/start/src/components/page/pages-insights.tsx create mode 100644 apps/start/src/components/pages/page-sparkline.tsx create mode 100644 apps/start/src/components/pages/table/columns.tsx create mode 100644 apps/start/src/components/pages/table/index.tsx create mode 100644 apps/start/src/modals/gsc-details.tsx create mode 100644 apps/start/src/modals/page-details.tsx delete mode 100644 packages/auth/server/oauth.ts create mode 100644 packages/db/prisma/migrations/20260306133001_gsc/migration.sql create mode 100644 packages/db/src/encryption.ts diff --git a/apps/api/src/controllers/gsc-oauth-callback.controller.ts b/apps/api/src/controllers/gsc-oauth-callback.controller.ts index 639fc50f9..fd5975e3a 100644 --- a/apps/api/src/controllers/gsc-oauth-callback.controller.ts +++ b/apps/api/src/controllers/gsc-oauth-callback.controller.ts @@ -1,31 +1,9 @@ -import { COOKIE_OPTIONS, googleGsc } from '@openpanel/auth'; -import { db } from '@openpanel/db'; +import { googleGsc } from '@openpanel/auth'; +import { db, encrypt } from '@openpanel/db'; import type { FastifyReply, FastifyRequest } from 'fastify'; import { z } from 'zod'; import { LogError } from '@/utils/errors'; -export async function gscInitiate(req: FastifyRequest, reply: FastifyReply) { - const schema = z.object({ - state: z.string(), - code_verifier: z.string(), - project_id: z.string(), - redirect: z.string().url(), - }); - - const query = schema.safeParse(req.query); - if (!query.success) { - return reply.status(400).send({ error: 'Invalid parameters' }); - } - - const { state, code_verifier, project_id, redirect } = query.data; - - reply.setCookie('gsc_oauth_state', state, { maxAge: 60 * 10, ...COOKIE_OPTIONS }); - reply.setCookie('gsc_code_verifier', code_verifier, { maxAge: 60 * 10, ...COOKIE_OPTIONS }); - reply.setCookie('gsc_project_id', project_id, { maxAge: 60 * 10, ...COOKIE_OPTIONS }); - - return reply.redirect(redirect); -} - export async function gscGoogleCallback( req: FastifyRequest, reply: FastifyReply @@ -89,14 +67,14 @@ export async function gscGoogleCallback( where: { projectId }, create: { projectId, - accessToken, - refreshToken, + accessToken: encrypt(accessToken), + refreshToken: encrypt(refreshToken), accessTokenExpiresAt, siteUrl: '', }, update: { - accessToken, - refreshToken, + accessToken: encrypt(accessToken), + refreshToken: encrypt(refreshToken), accessTokenExpiresAt, lastSyncStatus: null, lastSyncError: null, diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index ee5e0fd71..cb5cdece3 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -36,13 +36,13 @@ import { timestampHook } from './hooks/timestamp.hook'; import aiRouter from './routes/ai.router'; import eventRouter from './routes/event.router'; import exportRouter from './routes/export.router'; +import gscCallbackRouter from './routes/gsc-callback.router'; import importRouter from './routes/import.router'; import insightsRouter from './routes/insights.router'; import liveRouter from './routes/live.router'; import manageRouter from './routes/manage.router'; import miscRouter from './routes/misc.router'; import oauthRouter from './routes/oauth-callback.router'; -import gscCallbackRouter from './routes/gsc-callback.router'; import profileRouter from './routes/profile.router'; import trackRouter from './routes/track.router'; import webhookRouter from './routes/webhook.router'; diff --git a/apps/api/src/routes/gsc-callback.router.ts b/apps/api/src/routes/gsc-callback.router.ts index 0becfb77b..6ac0491d3 100644 --- a/apps/api/src/routes/gsc-callback.router.ts +++ b/apps/api/src/routes/gsc-callback.router.ts @@ -1,12 +1,7 @@ -import { gscGoogleCallback, gscInitiate } from '@/controllers/gsc-oauth-callback.controller'; +import { gscGoogleCallback } from '@/controllers/gsc-oauth-callback.controller'; import type { FastifyPluginCallback } from 'fastify'; const router: FastifyPluginCallback = async (fastify) => { - fastify.route({ - method: 'GET', - url: '/initiate', - handler: gscInitiate, - }); fastify.route({ method: 'GET', url: '/callback', diff --git a/apps/start/src/components/page/gsc-breakdown-table.tsx b/apps/start/src/components/page/gsc-breakdown-table.tsx new file mode 100644 index 000000000..51298787f --- /dev/null +++ b/apps/start/src/components/page/gsc-breakdown-table.tsx @@ -0,0 +1,142 @@ +import { useOverviewOptions } from '@/components/overview/useOverviewOptions'; +import { OverviewWidgetTable } from '@/components/overview/overview-widget-table'; +import { Skeleton } from '@/components/skeleton'; +import { useTRPC } from '@/integrations/trpc/react'; +import { useQuery } from '@tanstack/react-query'; + +interface GscBreakdownTableProps { + projectId: string; + value: string; + type: 'page' | 'query'; +} + +export function GscBreakdownTable({ projectId, value, type }: GscBreakdownTableProps) { + const { range, startDate, endDate } = useOverviewOptions(); + const trpc = useTRPC(); + + const dateInput = { + range, + startDate: startDate ?? undefined, + endDate: endDate ?? undefined, + }; + + const pageQuery = useQuery( + trpc.gsc.getPageDetails.queryOptions( + { projectId, page: value, ...dateInput }, + { enabled: type === 'page' }, + ), + ); + + const queryQuery = useQuery( + trpc.gsc.getQueryDetails.queryOptions( + { projectId, query: value, ...dateInput }, + { enabled: type === 'query' }, + ), + ); + + const isLoading = type === 'page' ? pageQuery.isLoading : queryQuery.isLoading; + + const breakdownRows: Record[] = + type === 'page' + ? ((pageQuery.data as { queries?: unknown[] } | undefined)?.queries ?? []) as Record[] + : ((queryQuery.data as { pages?: unknown[] } | undefined)?.pages ?? []) as Record[]; + + const breakdownKey = type === 'page' ? 'query' : 'page'; + const breakdownLabel = type === 'page' ? 'Query' : 'Page'; + + const maxClicks = Math.max( + ...(breakdownRows as { clicks: number }[]).map((r) => r.clicks), + 1, + ); + + return ( +
+
+

Top {breakdownLabel.toLowerCase()}s

+
+ {isLoading ? ( + String(i)} + getColumnPercentage={() => 0} + columns={[ + { name: breakdownLabel, width: 'w-full', render: () => }, + { name: 'Clicks', width: '70px', render: () => }, + { name: 'Impr.', width: '70px', render: () => }, + { name: 'CTR', width: '60px', render: () => }, + { name: 'Pos.', width: '55px', render: () => }, + ]} + /> + ) : ( + String(item[breakdownKey])} + getColumnPercentage={(item) => (item.clicks as number) / maxClicks} + columns={[ + { + name: breakdownLabel, + width: 'w-full', + render(item) { + return ( +
+ + {String(item[breakdownKey])} + +
+ ); + }, + }, + { + name: 'Clicks', + width: '70px', + getSortValue: (item) => item.clicks as number, + render(item) { + return ( + + {(item.clicks as number).toLocaleString()} + + ); + }, + }, + { + name: 'Impr.', + width: '70px', + getSortValue: (item) => item.impressions as number, + render(item) { + return ( + + {(item.impressions as number).toLocaleString()} + + ); + }, + }, + { + name: 'CTR', + width: '60px', + getSortValue: (item) => item.ctr as number, + render(item) { + return ( + + {((item.ctr as number) * 100).toFixed(1)}% + + ); + }, + }, + { + name: 'Pos.', + width: '55px', + getSortValue: (item) => item.position as number, + render(item) { + return ( + + {(item.position as number).toFixed(1)} + + ); + }, + }, + ]} + /> + )} +
+ ); +} diff --git a/apps/start/src/components/page/gsc-cannibalization.tsx b/apps/start/src/components/page/gsc-cannibalization.tsx new file mode 100644 index 000000000..bf0f2dec5 --- /dev/null +++ b/apps/start/src/components/page/gsc-cannibalization.tsx @@ -0,0 +1,217 @@ +import { keepPreviousData, useQuery } from '@tanstack/react-query'; +import { AlertCircleIcon, ChevronsUpDownIcon } from 'lucide-react'; +import { useMemo, useState } from 'react'; +import { Pagination } from '@/components/pagination'; +import { useAppContext } from '@/hooks/use-app-context'; +import { useTRPC } from '@/integrations/trpc/react'; +import { pushModal } from '@/modals'; +import { cn } from '@/utils/cn'; + +interface GscCannibalizationProps { + projectId: string; + range: string; + interval: string; + startDate?: string; + endDate?: string; +} + +export function GscCannibalization({ + projectId, + range, + interval, + startDate, + endDate, +}: GscCannibalizationProps) { + const trpc = useTRPC(); + const { apiUrl } = useAppContext(); + const [expanded, setExpanded] = useState>(new Set()); + const [page, setPage] = useState(0); + const pageSize = 15; + + const query = useQuery( + trpc.gsc.getCannibalization.queryOptions( + { projectId, range: range as any, interval: interval as any }, + { placeholderData: keepPreviousData } + ) + ); + + const toggle = (q: string) => { + setExpanded((prev) => { + const next = new Set(prev); + if (next.has(q)) { + next.delete(q); + } else { + next.add(q); + } + return next; + }); + }; + + const items = query.data ?? []; + + const pageCount = Math.ceil(items.length / pageSize) || 1; + const paginatedItems = useMemo( + () => items.slice(page * pageSize, (page + 1) * pageSize), + [items, page, pageSize] + ); + const rangeStart = items.length ? page * pageSize + 1 : 0; + const rangeEnd = Math.min((page + 1) * pageSize, items.length); + + if (!(query.isLoading || items.length)) { + return null; + } + + return ( +
+
+
+

Keyword Cannibalization

+ {items.length > 0 && ( + + {items.length} + + )} +
+ {items.length > 0 && ( +
+ + {items.length === 0 + ? '0 results' + : `${rangeStart}-${rangeEnd} of ${items.length}`} + + 0} + nextPage={() => setPage((p) => Math.min(pageCount - 1, p + 1))} + pageIndex={page} + previousPage={() => setPage((p) => Math.max(0, p - 1))} + /> +
+ )} +
+
+ {query.isLoading && + [1, 2, 3].map((i) => ( +
+
+
+
+ ))} + {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; + + return ( +
+ + + {isOpen && ( +
+

+ These pages all rank for{' '} + + "{item.query}" + + . Consider consolidating weaker pages into the top-ranking + one to concentrate link equity and avoid splitting clicks. +

+
+ {item.pages.map((page, idx) => { + // Strip hash fragments — GSC sometimes returns heading + // anchor URLs (e.g. /page#section) as separate entries + let cleanUrl = page.page; + let origin = ''; + let path = page.page; + try { + const u = new URL(page.page); + u.hash = ''; + cleanUrl = u.toString(); + origin = u.origin; + path = u.pathname + u.search; + } catch { + cleanUrl = page.page.split('#')[0] ?? page.page; + } + const isWinner = idx === 0; + + return ( + + ); + })} +
+
+ )} +
+ ); + })} +
+
+ ); +} diff --git a/apps/start/src/components/page/gsc-clicks-chart.tsx b/apps/start/src/components/page/gsc-clicks-chart.tsx new file mode 100644 index 000000000..6eb885d0c --- /dev/null +++ b/apps/start/src/components/page/gsc-clicks-chart.tsx @@ -0,0 +1,197 @@ +import { useQuery } from '@tanstack/react-query'; +import { + CartesianGrid, + ComposedChart, + Line, + ResponsiveContainer, + XAxis, + YAxis, +} from 'recharts'; +import { + ChartTooltipHeader, + ChartTooltipItem, + createChartTooltip, +} from '@/components/charts/chart-tooltip'; +import { useOverviewOptions } from '@/components/overview/useOverviewOptions'; +import { + useYAxisProps, + X_AXIS_STYLE_PROPS, +} from '@/components/report-chart/common/axis'; +import { Skeleton } from '@/components/skeleton'; +import { useFormatDateInterval } from '@/hooks/use-format-date-interval'; +import { useTRPC } from '@/integrations/trpc/react'; +import { getChartColor } from '@/utils/theme'; + +interface ChartData { + date: string; + clicks: number; + impressions: number; +} + +const { TooltipProvider, Tooltip } = createChartTooltip< + ChartData, + { formatDate: (date: Date | string) => string } +>(({ data, context }) => { + const item = data[0]; + if (!item) { + return null; + } + return ( + <> + +
{context.formatDate(item.date)}
+
+ +
+ Clicks + {item.clicks.toLocaleString()} +
+
+ +
+ Impressions + {item.impressions.toLocaleString()} +
+
+ + ); +}); + +interface GscClicksChartProps { + projectId: string; + value: string; + type: 'page' | 'query'; +} + +export function GscClicksChart({ + projectId, + value, + type, +}: GscClicksChartProps) { + const { range, startDate, endDate, interval } = useOverviewOptions(); + const trpc = useTRPC(); + const yAxisProps = useYAxisProps(); + const formatDateShort = useFormatDateInterval({ interval, short: true }); + const formatDateLong = useFormatDateInterval({ interval, short: false }); + + const dateInput = { + range, + startDate: startDate ?? undefined, + endDate: endDate ?? undefined, + }; + + const pageQuery = useQuery( + trpc.gsc.getPageDetails.queryOptions( + { projectId, page: value, ...dateInput }, + { enabled: type === 'page' } + ) + ); + + const queryQuery = useQuery( + trpc.gsc.getQueryDetails.queryOptions( + { projectId, query: value, ...dateInput }, + { enabled: type === 'query' } + ) + ); + + const isLoading = + type === 'page' ? pageQuery.isLoading : queryQuery.isLoading; + const timeseries = + (type === 'page' + ? pageQuery.data?.timeseries + : queryQuery.data?.timeseries) ?? []; + + const data: ChartData[] = timeseries.map((r) => ({ + date: r.date, + clicks: r.clicks, + impressions: r.impressions, + })); + + return ( +
+
+

Clicks & Impressions

+
+ + + Clicks + + + + Impressions + +
+
+ {isLoading ? ( + + ) : ( + + + + + + + + + + + + + + formatDateShort(v)} + type="category" + /> + + + + + + + + + )} +
+ ); +} diff --git a/apps/start/src/components/page/gsc-ctr-benchmark.tsx b/apps/start/src/components/page/gsc-ctr-benchmark.tsx new file mode 100644 index 000000000..10847d847 --- /dev/null +++ b/apps/start/src/components/page/gsc-ctr-benchmark.tsx @@ -0,0 +1,228 @@ +import { + CartesianGrid, + ComposedChart, + Line, + ResponsiveContainer, + XAxis, + YAxis, +} from 'recharts'; +import { + ChartTooltipHeader, + ChartTooltipItem, + createChartTooltip, +} from '@/components/charts/chart-tooltip'; +import { + useYAxisProps, + X_AXIS_STYLE_PROPS, +} from '@/components/report-chart/common/axis'; +import { Skeleton } from '@/components/skeleton'; +import { getChartColor } from '@/utils/theme'; + +// Industry average CTR by position (Google organic) +const BENCHMARK: Record = { + 1: 28.5, + 2: 15.7, + 3: 11.0, + 4: 8.0, + 5: 6.3, + 6: 5.0, + 7: 4.0, + 8: 3.3, + 9: 2.8, + 10: 2.5, + 11: 2.2, + 12: 2.0, + 13: 1.8, + 14: 1.5, + 15: 1.2, + 16: 1.1, + 17: 1.0, + 18: 0.9, + 19: 0.8, + 20: 0.7, +}; + +interface PageEntry { + path: string; + ctr: number; + impressions: number; +} + +interface ChartData { + position: number; + yourCtr: number | null; + benchmark: number; + pages: PageEntry[]; +} + +const { TooltipProvider, Tooltip } = createChartTooltip< + ChartData, + Record +>(({ data }) => { + const item = data[0]; + if (!item) { + return null; + } + return ( + <> + +
Position #{item.position}
+
+ {item.yourCtr != null && ( + +
+ Your avg CTR + {item.yourCtr.toFixed(1)}% +
+
+ )} + +
+ Benchmark + {item.benchmark.toFixed(1)}% +
+
+ {item.pages.length > 0 && ( +
+ {item.pages.map((p) => ( +
+ + {p.path} + + + {(p.ctr * 100).toFixed(1)}% + +
+ ))} +
+ )} + + ); +}); + +interface GscCtrBenchmarkProps { + data: Array<{ + page: string; + position: number; + ctr: number; + impressions: number; + }>; + isLoading: boolean; +} + +export function GscCtrBenchmark({ data, isLoading }: GscCtrBenchmarkProps) { + const yAxisProps = useYAxisProps(); + + const grouped = new Map(); + for (const d of data) { + const pos = Math.round(d.position); + if (pos < 1 || pos > 20 || d.impressions < 10) { + continue; + } + let path = d.page; + try { + path = new URL(d.page).pathname; + } catch { + // keep as-is + } + const entry = grouped.get(pos) ?? { ctrSum: 0, pages: [] }; + entry.ctrSum += d.ctr * 100; + entry.pages.push({ path, ctr: d.ctr, impressions: d.impressions }); + grouped.set(pos, entry); + } + + const chartData: ChartData[] = Array.from({ length: 20 }, (_, i) => { + const pos = i + 1; + const entry = grouped.get(pos); + const pages = entry + ? [...entry.pages].sort((a, b) => b.ctr - a.ctr).slice(0, 5) + : []; + return { + position: pos, + yourCtr: entry ? entry.ctrSum / entry.pages.length : null, + benchmark: BENCHMARK[pos] ?? 0, + pages, + }; + }); + + const hasAnyData = chartData.some((d) => d.yourCtr != null); + + return ( +
+
+

CTR vs Position

+
+ {hasAnyData && ( + + + Your CTR + + )} + + + Benchmark + +
+
+ {isLoading ? ( + + ) : ( + + + + + `#${v}`} + ticks={[1, 5, 10, 15, 20]} + type="number" + /> + `${v}%`} + /> + + + + + + + )} +
+ ); +} diff --git a/apps/start/src/components/page/gsc-position-chart.tsx b/apps/start/src/components/page/gsc-position-chart.tsx new file mode 100644 index 000000000..38b036e9d --- /dev/null +++ b/apps/start/src/components/page/gsc-position-chart.tsx @@ -0,0 +1,129 @@ +import { + CartesianGrid, + ComposedChart, + Line, + ResponsiveContainer, + XAxis, + YAxis, +} from 'recharts'; +import { + ChartTooltipHeader, + ChartTooltipItem, + createChartTooltip, +} from '@/components/charts/chart-tooltip'; +import { + useYAxisProps, + X_AXIS_STYLE_PROPS, +} from '@/components/report-chart/common/axis'; +import { Skeleton } from '@/components/skeleton'; +import { getChartColor } from '@/utils/theme'; + +interface ChartData { + date: string; + position: number; +} + +const { TooltipProvider, Tooltip } = createChartTooltip< + ChartData, + Record +>(({ data }) => { + const item = data[0]; + if (!item) return null; + return ( + <> + +
{item.date}
+
+ +
+ Avg Position + {item.position.toFixed(1)} +
+
+ + ); +}); + +interface GscPositionChartProps { + data: Array<{ date: string; position: number }>; + isLoading: boolean; +} + +export function GscPositionChart({ data, isLoading }: GscPositionChartProps) { + const yAxisProps = useYAxisProps(); + + const chartData: ChartData[] = data.map((r) => ({ + date: r.date, + position: r.position, + })); + + const positions = chartData.map((d) => d.position).filter((p) => p > 0); + const minPos = positions.length ? Math.max(1, Math.floor(Math.min(...positions)) - 2) : 1; + const maxPos = positions.length ? Math.ceil(Math.max(...positions)) + 2 : 20; + + return ( +
+
+

Avg Position

+ Lower is better +
+ {isLoading ? ( + + ) : ( + + + + + + + + + + + + + + v.slice(5)} + type="category" + /> + `#${v}`} + /> + + + + + + )} +
+ ); +} diff --git a/apps/start/src/components/page/page-views-chart.tsx b/apps/start/src/components/page/page-views-chart.tsx new file mode 100644 index 000000000..0f767a6ce --- /dev/null +++ b/apps/start/src/components/page/page-views-chart.tsx @@ -0,0 +1,180 @@ +import { useQuery } from '@tanstack/react-query'; +import { + CartesianGrid, + ComposedChart, + Line, + ResponsiveContainer, + XAxis, + YAxis, +} from 'recharts'; +import { + ChartTooltipHeader, + ChartTooltipItem, + createChartTooltip, +} from '@/components/charts/chart-tooltip'; +import { useOverviewOptions } from '@/components/overview/useOverviewOptions'; +import { useFormatDateInterval } from '@/hooks/use-format-date-interval'; +import { + useYAxisProps, + X_AXIS_STYLE_PROPS, +} from '@/components/report-chart/common/axis'; +import { Skeleton } from '@/components/skeleton'; +import { useTRPC } from '@/integrations/trpc/react'; +import { getChartColor } from '@/utils/theme'; + +interface ChartData { + date: string; + pageviews: number; + sessions: number; +} + +const { TooltipProvider, Tooltip } = createChartTooltip< + ChartData, + { formatDate: (date: Date | string) => string } +>(({ data, context }) => { + const item = data[0]; + if (!item) { + return null; + } + return ( + <> + +
{context.formatDate(item.date)}
+
+ +
+ Views + {item.pageviews.toLocaleString()} +
+
+ +
+ Sessions + {item.sessions.toLocaleString()} +
+
+ + ); +}); + +interface PageViewsChartProps { + projectId: string; + origin: string; + path: string; +} + +export function PageViewsChart({ + projectId, + origin, + path, +}: PageViewsChartProps) { + const { range, interval } = useOverviewOptions(); + const trpc = useTRPC(); + const yAxisProps = useYAxisProps(); + const formatDateShort = useFormatDateInterval({ interval, short: true }); + const formatDateLong = useFormatDateInterval({ interval, short: false }); + + const query = useQuery( + trpc.event.pageTimeseries.queryOptions({ + projectId, + range, + interval, + origin, + path, + }) + ); + + const data: ChartData[] = (query.data ?? []).map((r) => ({ + date: r.date, + pageviews: r.pageviews, + sessions: r.sessions, + })); + + return ( +
+
+

Views & Sessions

+
+ + + Views + + + + Sessions + +
+
+ {query.isLoading ? ( + + ) : ( + + + + + + + + + + + + + + formatDateShort(v)} + type="category" + /> + + + + + + + + + )} +
+ ); +} diff --git a/apps/start/src/components/page/pages-insights.tsx b/apps/start/src/components/page/pages-insights.tsx new file mode 100644 index 000000000..c89c18326 --- /dev/null +++ b/apps/start/src/components/page/pages-insights.tsx @@ -0,0 +1,332 @@ +import { keepPreviousData, useQuery } from '@tanstack/react-query'; +import { + AlertTriangleIcon, + EyeIcon, + MousePointerClickIcon, + TrendingUpIcon, +} from 'lucide-react'; +import { useMemo, useState } from 'react'; +import { useOverviewOptions } from '@/components/overview/useOverviewOptions'; +import { Pagination } from '@/components/pagination'; +import { useAppContext } from '@/hooks/use-app-context'; +import { useTRPC } from '@/integrations/trpc/react'; +import { pushModal } from '@/modals'; +import { cn } from '@/utils/cn'; + +type InsightType = + | 'low_ctr' + | 'near_page_one' + | 'invisible_clicks' + | 'high_bounce'; + +interface PageInsight { + page: string; + origin: string; + path: string; + type: InsightType; + impact: number; + headline: string; + suggestion: string; + metrics: string; +} + +const INSIGHT_CONFIG: Record< + InsightType, + { label: string; icon: React.ElementType; color: string; bg: string } +> = { + low_ctr: { + label: 'Low CTR', + icon: MousePointerClickIcon, + color: 'text-amber-600 dark:text-amber-400', + bg: 'bg-amber-100 dark:bg-amber-900/30', + }, + near_page_one: { + label: 'Near page 1', + icon: TrendingUpIcon, + color: 'text-blue-600 dark:text-blue-400', + bg: 'bg-blue-100 dark:bg-blue-900/30', + }, + invisible_clicks: { + label: 'Low visibility', + icon: EyeIcon, + color: 'text-violet-600 dark:text-violet-400', + bg: 'bg-violet-100 dark:bg-violet-900/30', + }, + high_bounce: { + label: 'High bounce', + icon: AlertTriangleIcon, + color: 'text-red-600 dark:text-red-400', + bg: 'bg-red-100 dark:bg-red-900/30', + }, +}; + +interface PagesInsightsProps { + projectId: string; +} + +export function PagesInsights({ projectId }: PagesInsightsProps) { + const trpc = useTRPC(); + const { range, interval, startDate, endDate } = useOverviewOptions(); + const { apiUrl } = useAppContext(); + const [page, setPage] = useState(0); + const pageSize = 8; + + const dateInput = { + range, + startDate: startDate ?? undefined, + endDate: endDate ?? undefined, + }; + + const gscPagesQuery = useQuery( + trpc.gsc.getPages.queryOptions( + { projectId, ...dateInput, limit: 1000 }, + { placeholderData: keepPreviousData } + ) + ); + + const analyticsQuery = useQuery( + trpc.event.pages.queryOptions( + { projectId, cursor: 1, take: 1000, search: undefined, range, interval }, + { placeholderData: keepPreviousData } + ) + ); + + const insights = useMemo(() => { + const gscPages = gscPagesQuery.data ?? []; + const analyticsPages = analyticsQuery.data ?? []; + + const analyticsMap = new Map( + analyticsPages.map((p) => [p.origin + p.path, p]) + ); + + const results: PageInsight[] = []; + + for (const gsc of gscPages) { + let origin = ''; + let path = gsc.page; + try { + const url = new URL(gsc.page); + origin = url.origin; + path = url.pathname + url.search; + } catch { + // keep as-is + } + + const analytics = analyticsMap.get(gsc.page); + + // 1. Low CTR: ranking on page 1 but click rate is poor + if (gsc.position <= 10 && gsc.ctr < 0.04 && gsc.impressions >= 100) { + results.push({ + page: gsc.page, + origin, + path, + type: 'low_ctr', + impact: gsc.impressions * (0.04 - gsc.ctr), + headline: `Ranking #${Math.round(gsc.position)} but only ${(gsc.ctr * 100).toFixed(1)}% CTR`, + suggestion: + 'You are on page 1 but people rarely click. Rewrite your title tag and meta description to be more compelling and match search intent.', + metrics: `Pos ${Math.round(gsc.position)} · ${gsc.impressions.toLocaleString()} impr · ${(gsc.ctr * 100).toFixed(1)}% CTR`, + }); + } + + // 2. Near page 1: just off the first page with decent visibility + if (gsc.position > 10 && gsc.position <= 20 && gsc.impressions >= 100) { + results.push({ + page: gsc.page, + origin, + path, + type: 'near_page_one', + impact: gsc.impressions / gsc.position, + headline: `Position ${Math.round(gsc.position)} — one push from page 1`, + suggestion: + 'A content refresh, more internal links, or a few backlinks could move this into the top 10 and dramatically increase clicks.', + metrics: `Pos ${Math.round(gsc.position)} · ${gsc.impressions.toLocaleString()} impr · ${gsc.clicks} clicks`, + }); + } + + // 3. Invisible clicks: high impressions but barely any clicks + if (gsc.impressions >= 500 && gsc.ctr < 0.01 && gsc.position > 10) { + results.push({ + page: gsc.page, + origin, + path, + type: 'invisible_clicks', + impact: gsc.impressions, + headline: `${gsc.impressions.toLocaleString()} impressions but only ${gsc.clicks} clicks`, + suggestion: + 'Google shows this page a lot, but it almost never gets clicked. Consider whether the page targets the right queries or if a different format (e.g. listicle, how-to) would perform better.', + metrics: `${gsc.impressions.toLocaleString()} impr · ${gsc.clicks} clicks · Pos ${Math.round(gsc.position)}`, + }); + } + + // 4. High bounce: good traffic but poor engagement (requires analytics match) + if ( + analytics && + analytics.bounce_rate >= 70 && + analytics.sessions >= 20 + ) { + results.push({ + page: gsc.page, + origin, + path, + type: 'high_bounce', + impact: analytics.sessions * (analytics.bounce_rate / 100), + headline: `${Math.round(analytics.bounce_rate)}% bounce rate on a page with ${analytics.sessions} sessions`, + suggestion: + 'Visitors are leaving without engaging. Check if the page delivers on its title/meta promise, improve page speed, and make sure key content is above the fold.', + metrics: `${Math.round(analytics.bounce_rate)}% bounce · ${analytics.sessions} sessions · ${gsc.impressions.toLocaleString()} impr`, + }); + } + } + + // Also check analytics pages without GSC match for high bounce + for (const p of analyticsPages) { + const fullUrl = p.origin + p.path; + if ( + !gscPagesQuery.data?.some((g) => g.page === fullUrl) && + p.bounce_rate >= 75 && + p.sessions >= 30 + ) { + results.push({ + page: fullUrl, + origin: p.origin, + path: p.path, + type: 'high_bounce', + impact: p.sessions * (p.bounce_rate / 100), + headline: `${Math.round(p.bounce_rate)}% bounce rate with ${p.sessions} sessions`, + suggestion: + 'High bounce rate with no search visibility. Review content quality and check if the page is indexed and targeting the right keywords.', + metrics: `${Math.round(p.bounce_rate)}% bounce · ${p.sessions} sessions`, + }); + } + } + + // Dedupe by (page, type), keep highest impact + const seen = new Set(); + const deduped = results.filter((r) => { + const key = `${r.page}::${r.type}`; + if (seen.has(key)) { + return false; + } + seen.add(key); + return true; + }); + + return deduped.sort((a, b) => b.impact - a.impact); + }, [gscPagesQuery.data, analyticsQuery.data]); + + const isLoading = gscPagesQuery.isLoading || analyticsQuery.isLoading; + + const pageCount = Math.ceil(insights.length / pageSize) || 1; + const paginatedInsights = useMemo( + () => insights.slice(page * pageSize, (page + 1) * pageSize), + [insights, page, pageSize] + ); + const rangeStart = insights.length ? page * pageSize + 1 : 0; + const rangeEnd = Math.min((page + 1) * pageSize, insights.length); + + if (!isLoading && !insights.length) { + return null; + } + + return ( +
+
+
+

Opportunities

+ {insights.length > 0 && ( + + {insights.length} + + )} +
+ {insights.length > 0 && ( +
+ + {insights.length === 0 + ? '0 results' + : `${rangeStart}-${rangeEnd} of ${insights.length}`} + + 0} + nextPage={() => setPage((p) => Math.min(pageCount - 1, p + 1))} + pageIndex={page} + previousPage={() => setPage((p) => Math.max(0, p - 1))} + /> +
+ )} +
+
+ {isLoading && + [1, 2, 3, 4].map((i) => ( +
+
+
+
+
+
+
+
+ ))} + {paginatedInsights.map((insight, i) => { + const config = INSIGHT_CONFIG[insight.type]; + const Icon = config.icon; + + return ( + + ); + })} +
+
+ ); +} diff --git a/apps/start/src/components/pages/page-sparkline.tsx b/apps/start/src/components/pages/page-sparkline.tsx new file mode 100644 index 000000000..a724ec1d7 --- /dev/null +++ b/apps/start/src/components/pages/page-sparkline.tsx @@ -0,0 +1,113 @@ +import { useQuery } from '@tanstack/react-query'; +import { Tooltiper } from '../ui/tooltip'; +import { LazyComponent } from '@/components/lazy-component'; +import { useOverviewOptions } from '@/components/overview/useOverviewOptions'; +import { useTRPC } from '@/integrations/trpc/react'; + +interface SparklineBarsProps { + data: { date: string; pageviews: number }[]; +} + +const gap = 1; +const height = 24; +const width = 100; + +function getTrendDirection(data: { pageviews: number }[]): '↑' | '↓' | '→' { + const n = data.length; + if (n < 3) { + return '→'; + } + const third = Math.max(1, Math.floor(n / 3)); + const firstAvg = + data.slice(0, third).reduce((s, d) => s + d.pageviews, 0) / third; + const lastAvg = + data.slice(n - third).reduce((s, d) => s + d.pageviews, 0) / third; + const threshold = firstAvg * 0.05; + if (lastAvg - firstAvg > threshold) { + return '↑'; + } + if (firstAvg - lastAvg > threshold) { + return '↓'; + } + return '→'; +} + +function SparklineBars({ data }: SparklineBarsProps) { + if (!data.length) { + return
; + } + 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)); + const trend = getTrendDirection(data); + const trendColor = + trend === '↑' + ? 'text-emerald-500' + : trend === '↓' + ? 'text-red-500' + : 'text-muted-foreground'; + + return ( +
+ + {data.map((d, i) => { + const barH = Math.max( + 2, + Math.round((d.pageviews / max) * (height * 0.8)) + ); + return ( + + ); + })} + + + + {trend} + + +
+ ); +} + +interface PageSparklineProps { + projectId: string; + origin: string; + path: string; +} + +export function PageSparkline({ projectId, origin, path }: PageSparklineProps) { + const { range, interval } = useOverviewOptions(); + const trpc = useTRPC(); + + const query = useQuery( + trpc.event.pageTimeseries.queryOptions({ + projectId, + range, + interval, + origin, + path, + }) + ); + + return ( + }> + + + ); +} diff --git a/apps/start/src/components/pages/table/columns.tsx b/apps/start/src/components/pages/table/columns.tsx new file mode 100644 index 000000000..ba3b433e6 --- /dev/null +++ b/apps/start/src/components/pages/table/columns.tsx @@ -0,0 +1,199 @@ +import type { ColumnDef } from '@tanstack/react-table'; +import { ExternalLinkIcon } from 'lucide-react'; +import { useMemo } from 'react'; +import { PageSparkline } from '@/components/pages/page-sparkline'; +import { createHeaderColumn } from '@/components/ui/data-table/data-table-helpers'; +import { useAppContext } from '@/hooks/use-app-context'; +import { fancyMinutes, useNumber } from '@/hooks/use-numer-formatter'; +import type { RouterOutputs } from '@/trpc/client'; + +export type PageRow = RouterOutputs['event']['pages'][number] & { + gsc?: { clicks: number; impressions: number; ctr: number; position: number }; +}; + +export function useColumns({ + projectId, + isGscConnected, + previousMap, +}: { + projectId: string; + isGscConnected: boolean; + previousMap?: Map; +}): ColumnDef[] { + const number = useNumber(); + const { apiUrl } = useAppContext(); + + return useMemo[]>(() => { + const cols: ColumnDef[] = [ + { + id: 'page', + accessorFn: (row) => `${row.origin}${row.path} ${row.title ?? ''}`, + header: createHeaderColumn('Page'), + size: 400, + meta: { bold: true }, + cell: ({ row }) => { + const page = row.original; + return ( +
+ { + (e.target as HTMLImageElement).style.display = 'none'; + }} + src={`${apiUrl}/misc/favicon?url=${page.origin}`} + /> +
+ {page.title && ( +
+ {page.title} +
+ )} + +
+
+ ); + }, + }, + { + id: 'trend', + header: 'Trend', + enableSorting: false, + size: 96, + cell: ({ row }) => ( + + ), + }, + { + accessorKey: 'pageviews', + header: createHeaderColumn('Views'), + size: 80, + cell: ({ row }) => ( + + {number.short(row.original.pageviews)} + + ), + }, + { + accessorKey: 'sessions', + header: createHeaderColumn('Sessions'), + size: 90, + cell: ({ row }) => { + const prev = previousMap?.get( + row.original.origin + row.original.path + ); + if (prev == null) { + return ; + } + + const pct = ((row.original.sessions - prev) / prev) * 100; + const isPos = pct >= 0; + + return ( +
+ + {number.short(row.original.sessions)} + + {prev === 0 && new} + {prev > 0 && ( + + {isPos ? '+' : ''} + {pct.toFixed(1)}% + + )} +
+ ); + }, + }, + { + accessorKey: 'bounce_rate', + header: createHeaderColumn('Bounce'), + size: 80, + cell: ({ row }) => ( + + {row.original.bounce_rate.toFixed(0)}% + + ), + }, + { + accessorKey: 'avg_duration', + header: createHeaderColumn('Duration'), + size: 90, + cell: ({ row }) => ( + + {fancyMinutes(row.original.avg_duration)} + + ), + }, + ]; + + if (isGscConnected) { + cols.push( + { + id: 'gsc_impressions', + accessorFn: (row) => row.gsc?.impressions ?? 0, + header: createHeaderColumn('Impr.'), + size: 80, + cell: ({ row }) => + row.original.gsc ? ( + + {number.short(row.original.gsc.impressions)} + + ) : ( + + ), + }, + { + id: 'gsc_ctr', + accessorFn: (row) => row.gsc?.ctr ?? 0, + header: createHeaderColumn('CTR'), + size: 70, + cell: ({ row }) => + row.original.gsc ? ( + + {(row.original.gsc.ctr * 100).toFixed(1)}% + + ) : ( + + ), + }, + { + id: 'gsc_clicks', + accessorFn: (row) => row.gsc?.clicks ?? 0, + header: createHeaderColumn('Clicks'), + size: 80, + cell: ({ row }) => + row.original.gsc ? ( + + {number.short(row.original.gsc.clicks)} + + ) : ( + + ), + } + ); + } + + return cols; + }, [isGscConnected, number, apiUrl, projectId, previousMap]); +} diff --git a/apps/start/src/components/pages/table/index.tsx b/apps/start/src/components/pages/table/index.tsx new file mode 100644 index 000000000..8d798bf89 --- /dev/null +++ b/apps/start/src/components/pages/table/index.tsx @@ -0,0 +1,147 @@ +import { OverviewInterval } from '@/components/overview/overview-interval'; +import { OverviewRange } from '@/components/overview/overview-range'; +import { useOverviewOptions } from '@/components/overview/useOverviewOptions'; +import { DataTable } from '@/components/ui/data-table/data-table'; +import { + AnimatedSearchInput, + DataTableToolbarContainer, +} from '@/components/ui/data-table/data-table-toolbar'; +import { DataTableViewOptions } from '@/components/ui/data-table/data-table-view-options'; +import { useTable } from '@/components/ui/data-table/use-table'; +import { useSearchQueryState } from '@/hooks/use-search-query-state'; +import { useTRPC } from '@/integrations/trpc/react'; +import { pushModal } from '@/modals'; +import { keepPreviousData, useQuery } from '@tanstack/react-query'; +import { useMemo } from 'react'; +import { type PageRow, useColumns } from './columns'; + +interface PagesTableProps { + projectId: string; +} + +export function PagesTable({ projectId }: PagesTableProps) { + const trpc = useTRPC(); + const { range, interval, startDate, endDate } = useOverviewOptions(); + const { debouncedSearch, setSearch, search } = useSearchQueryState(); + + const pagesQuery = useQuery( + trpc.event.pages.queryOptions( + { projectId, cursor: 1, take: 1000, search: undefined, range, interval }, + { placeholderData: keepPreviousData }, + ), + ); + + const connectionQuery = useQuery( + trpc.gsc.getConnection.queryOptions({ projectId }), + ); + + const isGscConnected = !!(connectionQuery.data?.siteUrl); + + const gscPagesQuery = useQuery( + trpc.gsc.getPages.queryOptions( + { + projectId, + range, + startDate: startDate ?? undefined, + endDate: endDate ?? undefined, + limit: 1000, + }, + { enabled: isGscConnected }, + ), + ); + + const previousPagesQuery = useQuery( + trpc.event.previousPages.queryOptions( + { projectId, range, interval }, + { placeholderData: keepPreviousData }, + ), + ); + + const previousMap = useMemo(() => { + const map = new Map(); + for (const p of previousPagesQuery.data ?? []) { + map.set(p.origin + p.path, p.sessions); + } + return map; + }, [previousPagesQuery.data]); + + const gscMap = useMemo(() => { + const map = new Map< + string, + { clicks: number; impressions: number; ctr: number; position: number } + >(); + for (const row of gscPagesQuery.data ?? []) { + map.set(row.page, { + clicks: row.clicks, + impressions: row.impressions, + ctr: row.ctr, + position: row.position, + }); + } + return map; + }, [gscPagesQuery.data]); + + const rawData: PageRow[] = useMemo(() => { + return (pagesQuery.data ?? []).map((p) => ({ + ...p, + gsc: gscMap.get(p.origin + p.path), + })); + }, [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, + loading: pagesQuery.isLoading, + pageSize: 50, + name: 'pages', + }); + + return ( + <> + + +
+ + + +
+
+ { + if (!isGscConnected) return; + const page = row.original; + pushModal('PageDetails', { + type: 'page', + projectId, + value: page.origin + page.path, + }); + }} + /> + + ); +} diff --git a/apps/start/src/components/report-chart/common/serie-icon.urls.ts b/apps/start/src/components/report-chart/common/serie-icon.urls.ts index 7e37d57fd..3542b033a 100644 --- a/apps/start/src/components/report-chart/common/serie-icon.urls.ts +++ b/apps/start/src/components/report-chart/common/serie-icon.urls.ts @@ -144,6 +144,7 @@ const data = { "dropbox": "https://www.dropbox.com", "openai": "https://openai.com", "chatgpt.com": "https://chatgpt.com", + "copilot.com": "https://www.copilot.com", "mailchimp": "https://mailchimp.com", "activecampaign": "https://www.activecampaign.com", "customer.io": "https://customer.io", diff --git a/apps/start/src/components/sidebar-project-menu.tsx b/apps/start/src/components/sidebar-project-menu.tsx index 8c0a55948..097379627 100644 --- a/apps/start/src/components/sidebar-project-menu.tsx +++ b/apps/start/src/components/sidebar-project-menu.tsx @@ -18,6 +18,7 @@ import { SparklesIcon, TrendingUpDownIcon, UndoDotIcon, + UserCircleIcon, UsersIcon, WallpaperIcon, } from 'lucide-react'; @@ -56,11 +57,11 @@ export default function SidebarProjectMenu({ label="Insights" /> + - - +
Manage
diff --git a/apps/start/src/components/ui/data-table/data-table.tsx b/apps/start/src/components/ui/data-table/data-table.tsx index 6cf1e7f59..52f35ab3e 100644 --- a/apps/start/src/components/ui/data-table/data-table.tsx +++ b/apps/start/src/components/ui/data-table/data-table.tsx @@ -22,6 +22,7 @@ export interface DataTableProps { title: string; description: string; }; + onRowClick?: (row: import('@tanstack/react-table').Row) => void; } declare module '@tanstack/react-table' { @@ -35,6 +36,7 @@ export function DataTable({ table, loading, className, + onRowClick, empty = { title: 'No data', description: 'We could not find any data here yet', @@ -78,6 +80,8 @@ export function DataTable({ onRowClick(row) : undefined} + className={onRowClick ? 'cursor-pointer' : undefined} > {row.getVisibleCells().map((cell) => ( +>(({ data }) => { + const item = data[0]; + if (!item) { + return null; + } + return ( + <> + +
{item.date}
+
+ +
+ Clicks + {item.clicks.toLocaleString()} +
+
+ +
+ Impressions + {item.impressions.toLocaleString()} +
+
+ + ); +}); + +type Props = + | { + type: 'page'; + projectId: string; + value: string; + range: IChartRange; + interval: IInterval; + } + | { + type: 'query'; + projectId: string; + value: string; + range: IChartRange; + interval: IInterval; + }; + +export default function GscDetails(props: Props) { + const { type, projectId, value, range, interval } = props; + const trpc = useTRPC(); + + const dateInput = { + range, + interval, + }; + + const pageQuery = useQuery( + trpc.gsc.getPageDetails.queryOptions( + { projectId, page: value, ...dateInput }, + { enabled: type === 'page' } + ) + ); + + const queryQuery = useQuery( + trpc.gsc.getQueryDetails.queryOptions( + { projectId, query: value, ...dateInput }, + { enabled: type === 'query' } + ) + ); + + const pagesTimeseriesQuery = useQuery( + trpc.event.pagesTimeseries.queryOptions( + { projectId, ...dateInput }, + { enabled: type === 'page' } + ) + ); + + const data = type === 'page' ? pageQuery.data : queryQuery.data; + const isLoading = + type === 'page' ? pageQuery.isLoading : queryQuery.isLoading; + + const timeseries = data?.timeseries ?? []; + const pagesTimeseries = pagesTimeseriesQuery.data ?? []; + const breakdownRows = + type === 'page' + ? ((data as { queries?: unknown[] } | undefined)?.queries ?? []) + : ((data as { pages?: unknown[] } | undefined)?.pages ?? []); + + const breakdownKey = type === 'page' ? 'query' : 'page'; + const breakdownLabel = type === 'page' ? 'Query' : 'Page'; + + const maxClicks = Math.max( + ...(breakdownRows as { clicks: number }[]).map((r) => r.clicks), + 1 + ); + + return ( + + + + {value} + + + +
+ {type === 'page' && ( +
+

Views & Sessions

+ {isLoading ? ( + + ) : ( + r.origin + r.path === value) + .map((r) => ({ date: r.date, views: r.pageviews }))} + /> + )} +
+ )} + +
+

Clicks & Impressions

+ {isLoading ? ( + + ) : ( + + )} +
+ +
+
+

+ Top {breakdownLabel.toLowerCase()}s +

+
+ {isLoading ? ( + , + }, + { + name: 'Clicks', + width: '70px', + render: () => , + }, + { + name: 'Pos.', + width: '55px', + render: () => , + }, + ]} + data={[1, 2, 3, 4, 5]} + getColumnPercentage={() => 0} + keyExtractor={(i) => String(i)} + /> + ) : ( + + + {String(item[breakdownKey])} + +
+ ); + }, + }, + { + name: 'Clicks', + width: '70px', + getSortValue: (item) => item.clicks as number, + render(item) { + return ( + + {(item.clicks as number).toLocaleString()} + + ); + }, + }, + { + name: 'Impr.', + width: '70px', + getSortValue: (item) => item.impressions as number, + render(item) { + return ( + + {(item.impressions as number).toLocaleString()} + + ); + }, + }, + { + name: 'CTR', + width: '60px', + getSortValue: (item) => item.ctr as number, + render(item) { + return ( + + {((item.ctr as number) * 100).toFixed(1)}% + + ); + }, + }, + { + name: 'Pos.', + width: '55px', + getSortValue: (item) => item.position as number, + render(item) { + return ( + + {(item.position as number).toFixed(1)} + + ); + }, + }, + ]} + data={breakdownRows as Record[]} + getColumnPercentage={(item) => + (item.clicks as number) / maxClicks + } + keyExtractor={(item) => String(item[breakdownKey])} + /> + )} +
+
+ + ); +} + +function GscViewsChart({ + data, +}: { + data: Array<{ date: string; views: number }>; +}) { + const yAxisProps = useYAxisProps(); + + return ( + + + + + + + + + + + + + + v.slice(5)} + type="category" + /> + + + + + + + + ); +} + +function GscTimeseriesChart({ + data, +}: { + data: Array<{ date: string; clicks: number; impressions: number }>; +}) { + const yAxisProps = useYAxisProps(); + + return ( + + + + + + + + + + + + + + v.slice(5)} + type="category" + /> + + + + + + + + + ); +} diff --git a/apps/start/src/modals/index.tsx b/apps/start/src/modals/index.tsx index 63658f206..91c704247 100644 --- a/apps/start/src/modals/index.tsx +++ b/apps/start/src/modals/index.tsx @@ -1,3 +1,4 @@ +import PageDetails from './page-details'; import { createPushModal } from 'pushmodal'; import AddClient from './add-client'; import AddDashboard from './add-dashboard'; @@ -34,6 +35,7 @@ import OverviewTopPagesModal from '@/components/overview/overview-top-pages-moda import { op } from '@/utils/op'; const modals = { + PageDetails, OverviewTopPagesModal, OverviewTopGenericModal, RequestPasswordReset, diff --git a/apps/start/src/modals/page-details.tsx b/apps/start/src/modals/page-details.tsx new file mode 100644 index 000000000..8c29f5792 --- /dev/null +++ b/apps/start/src/modals/page-details.tsx @@ -0,0 +1,46 @@ +import { GscBreakdownTable } from '@/components/page/gsc-breakdown-table'; +import { GscClicksChart } from '@/components/page/gsc-clicks-chart'; +import { PageViewsChart } from '@/components/page/page-views-chart'; +import { SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'; + +type Props = { + type: 'page' | 'query'; + projectId: string; + value: string; +}; + +export default function PageDetails({ type, projectId, value }: Props) { + return ( + + + + {value} + + + +
+ {type === 'page' && + (() => { + let origin = value; + let path = '/'; + try { + const url = new URL(value); + origin = url.origin; + path = url.pathname + url.search; + } catch { + // value might already be just a path + } + return ( + + ); + })()} + + +
+
+ ); +} diff --git a/apps/start/src/routes/_app.$organizationId.$projectId.pages.tsx b/apps/start/src/routes/_app.$organizationId.$projectId.pages.tsx index 365121afc..0672797eb 100644 --- a/apps/start/src/routes/_app.$organizationId.$projectId.pages.tsx +++ b/apps/start/src/routes/_app.$organizationId.$projectId.pages.tsx @@ -1,349 +1,22 @@ -import { FullPageEmptyState } from '@/components/full-page-empty-state'; -import { OverviewInterval } from '@/components/overview/overview-interval'; -import { OverviewRange } from '@/components/overview/overview-range'; -import { useOverviewOptions } from '@/components/overview/useOverviewOptions'; +import { PagesTable } from '@/components/pages/table'; import { PageContainer } from '@/components/page-container'; import { PageHeader } from '@/components/page-header'; -import { FloatingPagination } from '@/components/pagination-floating'; -import { ReportChart } from '@/components/report-chart'; -import { Skeleton } from '@/components/skeleton'; -import { Input } from '@/components/ui/input'; -import { TableButtons } from '@/components/ui/table'; -import { useAppContext } from '@/hooks/use-app-context'; -import { useNumber } from '@/hooks/use-numer-formatter'; -import { useSearchQueryState } from '@/hooks/use-search-query-state'; -import { useTRPC } from '@/integrations/trpc/react'; -import type { RouterOutputs } from '@/trpc/client'; import { PAGE_TITLES, createProjectTitle } from '@/utils/title'; -import type { IChartRange, IInterval } from '@openpanel/validation'; -import { keepPreviousData, useQuery } from '@tanstack/react-query'; import { createFileRoute } from '@tanstack/react-router'; -import { parseAsInteger, useQueryState } from 'nuqs'; -import { memo, useEffect, useMemo, useState } from 'react'; export const Route = createFileRoute('/_app/$organizationId/$projectId/pages')({ component: Component, - head: () => { - return { - meta: [ - { - title: createProjectTitle(PAGE_TITLES.PAGES), - }, - ], - }; - }, + head: () => ({ + meta: [{ title: createProjectTitle(PAGE_TITLES.PAGES) }], + }), }); function Component() { const { projectId } = Route.useParams(); - const trpc = useTRPC(); - const take = 20; - const { range, interval } = useOverviewOptions(); - const [cursor, setCursor] = useQueryState( - 'cursor', - parseAsInteger.withDefault(1), - ); - - const { debouncedSearch, setSearch, search } = useSearchQueryState(); - - // Track if we should use backend search (when client-side filtering finds nothing) - const [useBackendSearch, setUseBackendSearch] = useState(false); - - // Reset to client-side filtering when search changes - useEffect(() => { - setUseBackendSearch(false); - setCursor(1); - }, [debouncedSearch, setCursor]); - - // Query for all pages (without search) - used for client-side filtering - const allPagesQuery = useQuery( - trpc.event.pages.queryOptions( - { - projectId, - cursor: 1, - take: 1000, - search: undefined, // No search - get all pages - range, - interval, - }, - { - placeholderData: keepPreviousData, - }, - ), - ); - - // Query for backend search (only when client-side filtering finds nothing) - const backendSearchQuery = useQuery( - trpc.event.pages.queryOptions( - { - projectId, - cursor: 1, - take: 1000, - search: debouncedSearch || undefined, - range, - interval, - }, - { - placeholderData: keepPreviousData, - enabled: useBackendSearch && !!debouncedSearch, - }, - ), - ); - - // Client-side filtering: filter all pages by search query - const clientSideFiltered = useMemo(() => { - if (!debouncedSearch || useBackendSearch) { - return allPagesQuery.data ?? []; - } - const searchLower = debouncedSearch.toLowerCase(); - return (allPagesQuery.data ?? []).filter( - (page) => - page.path.toLowerCase().includes(searchLower) || - page.origin.toLowerCase().includes(searchLower), - ); - }, [allPagesQuery.data, debouncedSearch, useBackendSearch]); - - // Check if client-side filtering found results - useEffect(() => { - if ( - debouncedSearch && - !useBackendSearch && - allPagesQuery.isSuccess && - clientSideFiltered.length === 0 - ) { - // No results from client-side filtering, switch to backend search - setUseBackendSearch(true); - } - }, [ - debouncedSearch, - useBackendSearch, - allPagesQuery.isSuccess, - clientSideFiltered.length, - ]); - - // Determine which data source to use - const allData = useBackendSearch - ? (backendSearchQuery.data ?? []) - : clientSideFiltered; - - const isLoading = useBackendSearch - ? backendSearchQuery.isLoading - : allPagesQuery.isLoading; - - // Client-side pagination: slice the items based on cursor - const startIndex = (cursor - 1) * take; - const endIndex = startIndex + take; - const data = allData.slice(startIndex, endIndex); - const totalPages = Math.ceil(allData.length / take); - return ( - - - - - { - setSearch(e.target.value); - setCursor(1); - }} - /> - - {data.length === 0 && !isLoading && ( - - )} - {isLoading && ( -
- - - -
- )} -
- {data.map((page) => { - return ( - - ); - })} -
- {allData.length !== 0 && ( -
- 1 ? () => setCursor(1) : undefined} - canNextPage={cursor < totalPages} - canPreviousPage={cursor > 1} - pageIndex={cursor - 1} - nextPage={() => { - setCursor((p) => Math.min(p + 1, totalPages)); - }} - previousPage={() => { - setCursor((p) => Math.max(p - 1, 1)); - }} - /> -
- )} + +
); } - -const PageCard = memo( - ({ - page, - range, - interval, - projectId, - }: { - page: RouterOutputs['event']['pages'][number]; - range: IChartRange; - interval: IInterval; - projectId: string; - }) => { - const number = useNumber(); - const { apiUrl } = useAppContext(); - return ( -
-
-
- {page.title} -
-
- {page.title} -
- - {page.path} - -
-
-
-
-
-
- {number.formatWithUnit(page.avg_duration, 'min')} -
-
- duration -
-
-
-
- {number.formatWithUnit(page.bounce_rate / 100, '%')} -
-
- bounce rate -
-
-
-
- {number.format(page.sessions)} -
-
- sessions -
-
-
- -
- ); - }, -); - -const PageCardSkeleton = memo(() => { - return ( -
-
-
- -
- - -
-
-
-
-
- - -
-
- - -
-
- - -
-
-
- -
-
- ); -}); diff --git a/apps/start/src/routes/_app.$organizationId.$projectId.seo.tsx b/apps/start/src/routes/_app.$organizationId.$projectId.seo.tsx index ba87e5f93..79e988460 100644 --- a/apps/start/src/routes/_app.$organizationId.$projectId.seo.tsx +++ b/apps/start/src/routes/_app.$organizationId.$projectId.seo.tsx @@ -1,81 +1,217 @@ -import { PageContainer } from '@/components/page-container'; -import { PageHeader } from '@/components/page-header'; -import { Skeleton } from '@/components/skeleton'; -import { Button } from '@/components/ui/button'; import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from '@/components/ui/table'; -import { useAppParams } from '@/hooks/use-app-params'; -import { useTRPC } from '@/integrations/trpc/react'; -import { createProjectTitle } from '@/utils/title'; + getDefaultIntervalByRange, + intervals, + timeWindows, +} from '@openpanel/constants'; +import type { IChartRange, IInterval } from '@openpanel/validation'; import { useQuery } from '@tanstack/react-query'; import { createFileRoute, useNavigate } from '@tanstack/react-router'; -import { subDays, format } from 'date-fns'; +import { SearchIcon } from 'lucide-react'; +import { parseAsString, parseAsStringEnum, useQueryState } from 'nuqs'; +import { useMemo, useState } from 'react'; import { - Area, - AreaChart, CartesianGrid, + ComposedChart, + Line, ResponsiveContainer, - Tooltip, XAxis, YAxis, } from 'recharts'; +import { + ChartTooltipHeader, + ChartTooltipItem, + createChartTooltip, +} from '@/components/charts/chart-tooltip'; +import { FullPageEmptyState } from '@/components/full-page-empty-state'; +import { OverviewMetricCard } from '@/components/overview/overview-metric-card'; +import { OverviewWidgetTable } from '@/components/overview/overview-widget-table'; +import { GscCannibalization } from '@/components/page/gsc-cannibalization'; +import { GscCtrBenchmark } from '@/components/page/gsc-ctr-benchmark'; +import { GscPositionChart } from '@/components/page/gsc-position-chart'; +import { PagesInsights } from '@/components/page/pages-insights'; +import { PageContainer } from '@/components/page-container'; +import { PageHeader } from '@/components/page-header'; +import { Pagination } from '@/components/pagination'; +import { ReportInterval } from '@/components/report/ReportInterval'; +import { + useYAxisProps, + X_AXIS_STYLE_PROPS, +} from '@/components/report-chart/common/axis'; +import { SerieIcon } from '@/components/report-chart/common/serie-icon'; +import { Skeleton } from '@/components/skeleton'; +import { TimeWindowPicker } from '@/components/time-window-picker'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { useAppParams } from '@/hooks/use-app-params'; +import { useTRPC } from '@/integrations/trpc/react'; +import { pushModal } from '@/modals'; +import { getChartColor } from '@/utils/theme'; +import { createProjectTitle } from '@/utils/title'; -export const Route = createFileRoute( - '/_app/$organizationId/$projectId/seo' -)({ +export const Route = createFileRoute('/_app/$organizationId/$projectId/seo')({ component: SeoPage, head: () => ({ meta: [{ title: createProjectTitle('SEO') }], }), }); -const startDate = format(subDays(new Date(), 30), 'yyyy-MM-dd'); -const endDate = format(subDays(new Date(), 1), 'yyyy-MM-dd'); +interface GscChartData { + date: string; + clicks: number; + impressions: number; +} + +const { TooltipProvider, Tooltip: GscTooltip } = createChartTooltip< + GscChartData, + Record +>(({ data }) => { + const item = data[0]; + if (!item) { + return null; + } + return ( + <> + +
{item.date}
+
+ +
+ Clicks + {item.clicks.toLocaleString()} +
+
+ +
+ Impressions + {item.impressions.toLocaleString()} +
+
+ + ); +}); function SeoPage() { const { projectId, organizationId } = useAppParams(); const trpc = useTRPC(); const navigate = useNavigate(); + const [range, setRange] = useQueryState( + 'range', + parseAsStringEnum(Object.keys(timeWindows) as IChartRange[]).withDefault( + '30d' as IChartRange + ) + ); + const [startDate, setStartDate] = useQueryState('start', parseAsString); + const [endDate, setEndDate] = useQueryState('end', parseAsString); + const [interval, setInterval] = useQueryState( + 'interval', + parseAsStringEnum(Object.keys(intervals) as IInterval[]).withDefault( + (getDefaultIntervalByRange(range) ?? 'day') as IInterval + ) + ); + + const dateInput = { + range, + interval, + startDate: startDate ?? undefined, + endDate: endDate ?? undefined, + }; + const connectionQuery = useQuery( trpc.gsc.getConnection.queryOptions({ projectId }) ); const connection = connectionQuery.data; - const isConnected = connection && connection.siteUrl; + const isConnected = connection?.siteUrl; const overviewQuery = useQuery( trpc.gsc.getOverview.queryOptions( - { projectId, startDate, endDate }, + { projectId, ...dateInput, interval: interval ?? 'day' }, { enabled: !!isConnected } ) ); const pagesQuery = useQuery( trpc.gsc.getPages.queryOptions( - { projectId, startDate, endDate, limit: 50 }, + { projectId, ...dateInput, limit: 50 }, { enabled: !!isConnected } ) ); const queriesQuery = useQuery( trpc.gsc.getQueries.queryOptions( - { projectId, startDate, endDate, limit: 50 }, + { projectId, ...dateInput, limit: 50 }, { enabled: !!isConnected } ) ); + const searchEnginesQuery = useQuery( + trpc.gsc.getSearchEngines.queryOptions({ projectId, ...dateInput }) + ); + + const aiEnginesQuery = useQuery( + trpc.gsc.getAiEngines.queryOptions({ projectId, ...dateInput }) + ); + + const previousOverviewQuery = useQuery( + trpc.gsc.getPreviousOverview.queryOptions( + { projectId, ...dateInput, interval: interval ?? 'day' }, + { enabled: !!isConnected } + ) + ); + + const [pagesPage, setPagesPage] = useState(0); + const [queriesPage, setQueriesPage] = useState(0); + const pageSize = 15; + + const [pagesSearch, setPagesSearch] = useState(''); + const [queriesSearch, setQueriesSearch] = useState(''); + + const pages = pagesQuery.data ?? []; + const queries = queriesQuery.data ?? []; + + const filteredPages = useMemo(() => { + if (!pagesSearch.trim()) { + return pages; + } + const q = pagesSearch.toLowerCase(); + return pages.filter((row) => { + return String(row.page).toLowerCase().includes(q); + }); + }, [pages, pagesSearch]); + + const filteredQueries = useMemo(() => { + if (!queriesSearch.trim()) { + return queries; + } + const q = queriesSearch.toLowerCase(); + return queries.filter((row) => { + return String(row.query).toLowerCase().includes(q); + }); + }, [queries, queriesSearch]); + + const paginatedPages = useMemo( + () => filteredPages.slice(pagesPage * pageSize, (pagesPage + 1) * pageSize), + [filteredPages, pagesPage, pageSize] + ); + + const paginatedQueries = useMemo( + () => + filteredQueries.slice( + queriesPage * pageSize, + (queriesPage + 1) * pageSize + ), + [filteredQueries, queriesPage, pageSize] + ); + + const pagesPageCount = Math.ceil(filteredPages.length / pageSize) || 1; + const queriesPageCount = Math.ceil(filteredQueries.length / pageSize) || 1; + if (connectionQuery.isLoading) { return ( - -
+ +
@@ -85,138 +221,438 @@ function SeoPage() { if (!isConnected) { return ( - - -
-
- - - -
-

No SEO data yet

-

- Connect Google Search Console to track your search impressions, clicks, and keyword rankings. -

- -
-
+ + + ); } const overview = overviewQuery.data ?? []; - const pages = pagesQuery.data ?? []; - const queries = queriesQuery.data ?? []; + const prevOverview = previousOverviewQuery.data ?? []; + + const sumOverview = (rows: typeof overview) => + rows.reduce( + (acc, row) => ({ + clicks: acc.clicks + row.clicks, + impressions: acc.impressions + row.impressions, + ctr: acc.ctr + row.ctr, + position: acc.position + row.position, + }), + { clicks: 0, impressions: 0, ctr: 0, position: 0 } + ); + + const totals = sumOverview(overview); + const prevTotals = sumOverview(prevOverview); + const n = Math.max(overview.length, 1); + const pn = Math.max(prevOverview.length, 1); return ( + setInterval(v)} + range={range} + startDate={startDate} + /> + { + if (v !== 'custom') { + setStartDate(null); + setEndDate(null); + } + setInterval( + (getDefaultIntervalByRange(v) ?? 'day') as IInterval + ); + setRange(v); + }} + onEndDateChange={setEndDate} + onStartDateChange={setStartDate} + startDate={startDate} + value={range} + /> + + } description={`Search performance for ${connection.siteUrl}`} + title="SEO" />
- {/* Summary metrics */} -
- {(['clicks', 'impressions', 'ctr', 'position'] as const).map((metric) => { - const total = overview.reduce((sum, row) => { - if (metric === 'ctr' || metric === 'position') { - return sum + row[metric]; - } - return sum + row[metric]; - }, 0); - const display = - metric === 'ctr' - ? `${((total / Math.max(overview.length, 1)) * 100).toFixed(1)}%` - : metric === 'position' - ? (total / Math.max(overview.length, 1)).toFixed(1) - : total.toLocaleString(); - const label = - metric === 'ctr' - ? 'Avg CTR' - : metric === 'position' - ? 'Avg Position' - : metric.charAt(0).toUpperCase() + metric.slice(1); - - return ( -
-
{label}
-
{overviewQuery.isLoading ? : display}
-
- ); - })} +
+
+ ({ current: r.clicks, date: r.date }))} + id="clicks" + isLoading={overviewQuery.isLoading} + label="Clicks" + metric={{ current: totals.clicks, previous: prevTotals.clicks }} + /> + ({ + current: r.impressions, + date: r.date, + }))} + id="impressions" + isLoading={overviewQuery.isLoading} + label="Impressions" + metric={{ + current: totals.impressions, + previous: prevTotals.impressions, + }} + /> + ({ + current: r.ctr * 100, + date: r.date, + }))} + id="ctr" + isLoading={overviewQuery.isLoading} + label="Avg CTR" + metric={{ + current: (totals.ctr / n) * 100, + previous: (prevTotals.ctr / pn) * 100, + }} + unit="%" + /> + ({ + current: r.position, + date: r.date, + }))} + id="position" + inverted + isLoading={overviewQuery.isLoading} + label="Avg Position" + metric={{ + current: totals.position / n, + previous: prevTotals.position / pn, + }} + /> +
+ +
- {/* Clicks over time chart */} -
-

Clicks over time

- {overviewQuery.isLoading ? ( - - ) : ( - - - - - - - - - - - - - - - - )} + + +
+ +
- {/* Pages and Queries tables */}
p.clicks), 1)} + onNextPage={() => + setPagesPage((p) => Math.min(pagesPageCount - 1, p + 1)) + } + onPreviousPage={() => setPagesPage((p) => Math.max(0, p - 1))} + onRowClick={(value) => + pushModal('PageDetails', { type: 'page', projectId, value }) + } + onSearchChange={(v) => { + setPagesSearch(v); + setPagesPage(0); + }} + pageCount={pagesPageCount} + pageIndex={pagesPage} + pageSize={pageSize} + rows={paginatedPages} + searchPlaceholder="Search pages" + searchValue={pagesSearch} + title="Top pages" + totalCount={filteredPages.length} /> q.clicks), 1)} + onNextPage={() => + setQueriesPage((p) => Math.min(queriesPageCount - 1, p + 1)) + } + onPreviousPage={() => setQueriesPage((p) => Math.max(0, p - 1))} + onRowClick={(value) => + pushModal('PageDetails', { type: 'query', projectId, value }) + } + onSearchChange={(v) => { + setQueriesSearch(v); + setQueriesPage(0); + }} + pageCount={queriesPageCount} + pageIndex={queriesPage} + pageSize={pageSize} + rows={paginatedQueries} + searchPlaceholder="Search queries" + searchValue={queriesSearch} + title="Top queries" + totalCount={filteredQueries.length} + /> +
+ +
+ +
); } +function TrafficSourceWidget({ + title, + engines, + total, + previousTotal, + isLoading, + emptyMessage, +}: { + title: string; + engines: Array<{ name: string; sessions: number }>; + total: number; + previousTotal: number; + isLoading: boolean; + emptyMessage: string; +}) { + const displayed = + engines.length > 8 + ? [ + ...engines.slice(0, 7), + { + name: 'Others', + sessions: engines.slice(7).reduce((s, d) => s + d.sessions, 0), + }, + ] + : engines.slice(0, 8); + + const max = displayed[0]?.sessions ?? 1; + const pctChange = + previousTotal > 0 ? ((total - previousTotal) / previousTotal) * 100 : null; + + return ( +
+
+

{title}

+ {!isLoading && total > 0 && ( +
+ + {total.toLocaleString()} + + {pctChange !== null && ( + = 0 ? 'text-emerald-600 dark:text-emerald-400' : 'text-red-600 dark:text-red-400'}`} + > + {pctChange >= 0 ? '+' : ''} + {pctChange.toFixed(1)}% + + )} +
+ )} +
+
+ {isLoading && + [1, 2, 3, 4].map((i) => ( +
+
+
+
+
+ ))} + {!isLoading && engines.length === 0 && ( +

+ {emptyMessage} +

+ )} + {!isLoading && + displayed.map((engine) => { + const pct = total > 0 ? (engine.sessions / total) * 100 : 0; + const barPct = (engine.sessions / max) * 100; + return ( +
+
+
+ {engine.name !== 'Others' && ( + + )} + + {engine.name.replace(/\..+$/, '')} + + + {engine.sessions.toLocaleString()} + + + {pct.toFixed(0)}% + +
+
+ ); + })} +
+
+ ); +} + +function SearchEngines(props: { + engines: Array<{ name: string; sessions: number }>; + total: number; + previousTotal: number; + isLoading: boolean; +}) { + return ( + + ); +} + +function AiEngines(props: { + engines: Array<{ name: string; sessions: number }>; + total: number; + previousTotal: number; + isLoading: boolean; +}) { + return ( + + ); +} + +function GscChart({ + data, + isLoading, +}: { + data: Array<{ date: string; clicks: number; impressions: number }>; + isLoading: boolean; +}) { + const color = getChartColor(0); + const yAxisProps = useYAxisProps(); + + return ( +
+

Clicks & Impressions

+ {isLoading ? ( + + ) : ( + + + + + + + + + + + + + + v.slice(5)} + type="category" + /> + + + + + + + + + )} +
+ ); +} + interface GscTableRow { clicks: number; impressions: number; @@ -228,62 +664,201 @@ interface GscTableRow { function GscTable({ title, rows, - keyLabel, keyField, + keyLabel, + maxClicks, isLoading, + onRowClick, + searchValue, + onSearchChange, + searchPlaceholder, + totalCount, + pageIndex, + pageSize, + pageCount, + onPreviousPage, + onNextPage, }: { title: string; rows: GscTableRow[]; - keyLabel: string; keyField: string; + keyLabel: string; + maxClicks: number; isLoading: boolean; + onRowClick?: (value: string) => void; + searchValue?: string; + onSearchChange?: (value: string) => void; + searchPlaceholder?: string; + totalCount?: number; + pageIndex?: number; + pageSize?: number; + pageCount?: number; + onPreviousPage?: () => void; + onNextPage?: () => void; }) { - return ( -
-
-

{title}

+ const showPagination = + totalCount != null && + pageSize != null && + pageCount != null && + onPreviousPage != null && + onNextPage != null && + pageIndex != null; + const canPreviousPage = (pageIndex ?? 0) > 0; + const canNextPage = (pageIndex ?? 0) < (pageCount ?? 1) - 1; + const rangeStart = totalCount ? (pageIndex ?? 0) * (pageSize ?? 0) + 1 : 0; + const rangeEnd = Math.min( + (pageIndex ?? 0) * (pageSize ?? 0) + (pageSize ?? 0), + totalCount ?? 0 + ); + if (isLoading) { + return ( +
+
+

{title}

+
+ , + }, + { + name: 'Clicks', + width: '70px', + render: () => , + }, + { + name: 'Impr.', + width: '70px', + render: () => , + }, + { + name: 'CTR', + width: '60px', + render: () => , + }, + { + name: 'Pos.', + width: '55px', + render: () => , + }, + ]} + data={[1, 2, 3, 4, 5]} + getColumnPercentage={() => 0} + keyExtractor={(i) => String(i)} + />
- - - - {keyLabel} - Clicks - Impressions - CTR - Position - - - - {isLoading && - Array.from({ length: 5 }).map((_, i) => ( - - {Array.from({ length: 5 }).map((_, j) => ( - - - - ))} - - ))} - {!isLoading && rows.length === 0 && ( - - - No data yet - - + ); + } + + return ( +
+
+
+

{title}

+ {showPagination && ( +
+ + {totalCount === 0 + ? '0 results' + : `${rangeStart}-${rangeEnd} of ${totalCount}`} + + +
)} - {rows.map((row) => ( - - - {String(row[keyField])} - - {row.clicks.toLocaleString()} - {row.impressions.toLocaleString()} - {(row.ctr * 100).toFixed(1)}% - {row.position.toFixed(1)} - - ))} - -
+
+ {onSearchChange != null && ( +
+ + onSearchChange(e.target.value)} + placeholder={searchPlaceholder ?? 'Search'} + type="search" + value={searchValue ?? ''} + /> +
+ )} +
+ + +
+ ); + }, + }, + { + name: 'Clicks', + width: '70px', + getSortValue: (item) => item.clicks, + render(item) { + return ( + + {item.clicks.toLocaleString()} + + ); + }, + }, + { + name: 'Impr.', + width: '70px', + getSortValue: (item) => item.impressions, + render(item) { + return ( + + {item.impressions.toLocaleString()} + + ); + }, + }, + { + name: 'CTR', + width: '60px', + getSortValue: (item) => item.ctr, + render(item) { + return ( + + {(item.ctr * 100).toFixed(1)}% + + ); + }, + }, + { + name: 'Pos.', + width: '55px', + getSortValue: (item) => item.position, + render(item) { + return ( + + {item.position.toFixed(1)} + + ); + }, + }, + ]} + data={rows} + getColumnPercentage={(item) => item.clicks / maxClicks} + keyExtractor={(item) => String(item[keyField])} + />
); } diff --git a/apps/start/src/routes/_app.$organizationId.$projectId.settings._tabs.gsc.tsx b/apps/start/src/routes/_app.$organizationId.$projectId.settings._tabs.gsc.tsx index af18c356f..bd8f975ff 100644 --- a/apps/start/src/routes/_app.$organizationId.$projectId.settings._tabs.gsc.tsx +++ b/apps/start/src/routes/_app.$organizationId.$projectId.settings._tabs.gsc.tsx @@ -1,3 +1,9 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { createFileRoute } from '@tanstack/react-router'; +import { formatDistanceToNow } from 'date-fns'; +import { CheckCircleIcon, Loader2Icon, XCircleIcon } from 'lucide-react'; +import { useState } from 'react'; +import { toast } from 'sonner'; import { Skeleton } from '@/components/skeleton'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; @@ -10,12 +16,6 @@ import { } from '@/components/ui/select'; import { useAppParams } from '@/hooks/use-app-params'; import { useTRPC } from '@/integrations/trpc/react'; -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { createFileRoute } from '@tanstack/react-router'; -import { formatDistanceToNow } from 'date-fns'; -import { CheckCircleIcon, Loader2Icon, XCircleIcon } from 'lucide-react'; -import { useState } from 'react'; -import { toast } from 'sonner'; export const Route = createFileRoute( '/_app/$organizationId/$projectId/settings/_tabs/gsc' @@ -46,14 +46,7 @@ function GscSettings() { const initiateOAuth = useMutation( trpc.gsc.initiateOAuth.mutationOptions({ onSuccess: (data) => { - // Route through the API /gsc/initiate endpoint which sets cookies then redirects to Google - const apiUrl = (import.meta.env.VITE_API_URL as string) ?? ''; - const initiateUrl = new URL(`${apiUrl}/gsc/initiate`); - initiateUrl.searchParams.set('state', data.state); - initiateUrl.searchParams.set('code_verifier', data.codeVerifier); - initiateUrl.searchParams.set('project_id', data.projectId); - initiateUrl.searchParams.set('redirect', data.url); - window.location.href = initiateUrl.toString(); + window.location.href = data.url; }, onError: () => { toast.error('Failed to initiate Google Search Console connection'); @@ -102,19 +95,21 @@ function GscSettings() { return (
-

Google Search Console

-

- Connect your Google Search Console property to import search performance data. +

Google Search Console

+

+ Connect your Google Search Console property to import search + performance data.

-
-

- You will be redirected to Google to authorize access. Only read-only access to Search Console data is requested. +

+

+ You will be redirected to Google to authorize access. Only read-only + access to Search Console data is requested.

@@ -181,6 +179,56 @@ function GscSettings() { ); } + // Token expired — show reconnect prompt + if (connection.lastSyncStatus === 'token_expired') { + return ( +
+
+

Google Search Console

+

+ Connected to Google Search Console. +

+
+
+
+ + Authorization expired +
+

+ Your Google Search Console authorization has expired or been + revoked. Please reconnect to continue syncing data. +

+ {connection.lastSyncError && ( +

+ {connection.lastSyncError} +

+ )} + +
+ +
+ ); + } + // Fully connected const syncStatusIcon = connection.lastSyncStatus === 'success' ? ( @@ -199,28 +247,35 @@ function GscSettings() { return (
-

Google Search Console

-

+

Google Search Console

+

Connected to Google Search Console.

-
-
-
Property
-
+
+
+
Property
+
{connection.siteUrl}
{connection.backfillStatus && ( -
-
Backfill
- +
+
Backfill
+ {connection.backfillStatus === 'running' && ( )} @@ -230,16 +285,19 @@ function GscSettings() { )} {connection.lastSyncedAt && ( -
-
Last synced
+
+
Last synced
{connection.lastSyncStatus && ( - + {syncStatusIcon} {connection.lastSyncStatus} )} - + {formatDistanceToNow(new Date(connection.lastSyncedAt), { addSuffix: true, })} @@ -250,8 +308,10 @@ function GscSettings() { {connection.lastSyncError && (
-
Last error
-
+
+ Last error +
+
{connection.lastSyncError}
@@ -259,10 +319,10 @@ function GscSettings() {
diff --git a/apps/start/src/components/overview/overview-range.tsx b/apps/start/src/components/overview/overview-range.tsx index d30c08647..ee1c64537 100644 --- a/apps/start/src/components/overview/overview-range.tsx +++ b/apps/start/src/components/overview/overview-range.tsx @@ -2,17 +2,25 @@ import { useOverviewOptions } from '@/components/overview/useOverviewOptions'; import { TimeWindowPicker } from '@/components/time-window-picker'; export function OverviewRange() { - const { range, setRange, setStartDate, setEndDate, endDate, startDate } = - useOverviewOptions(); + const { + range, + setRange, + setStartDate, + setEndDate, + endDate, + startDate, + setInterval, + } = useOverviewOptions(); return ( ); } diff --git a/apps/start/src/components/report-chart/report-editor.tsx b/apps/start/src/components/report-chart/report-editor.tsx index dc1a04a98..af7bbeb16 100644 --- a/apps/start/src/components/report-chart/report-editor.tsx +++ b/apps/start/src/components/report-chart/report-editor.tsx @@ -1,4 +1,7 @@ -import { ReportChart } from '@/components/report-chart'; +import type { IServiceReport } from '@openpanel/db'; +import { GanttChartSquareIcon, ShareIcon } from 'lucide-react'; +import { useEffect } from 'react'; +import EditReportName from '../report/edit-report-name'; import { ReportChartType } from '@/components/report/ReportChartType'; import { ReportInterval } from '@/components/report/ReportInterval'; import { ReportLineType } from '@/components/report/ReportLineType'; @@ -14,18 +17,13 @@ import { setReport, } from '@/components/report/reportSlice'; import { ReportSidebar } from '@/components/report/sidebar/ReportSidebar'; +import { ReportChart } from '@/components/report-chart'; import { TimeWindowPicker } from '@/components/time-window-picker'; import { Button } from '@/components/ui/button'; import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet'; import { useAppParams } from '@/hooks/use-app-params'; import { pushModal } from '@/modals'; import { useDispatch, useSelector } from '@/redux'; -import { bind } from 'bind-event-listener'; -import { GanttChartSquareIcon, ShareIcon } from 'lucide-react'; -import { useEffect } from 'react'; - -import type { IServiceReport } from '@openpanel/db'; -import EditReportName from '../report/edit-report-name'; interface ReportEditorProps { report: IServiceReport | null; @@ -54,15 +52,15 @@ export default function ReportEditor({ return (
-
+
{initialReport?.id && ( @@ -71,9 +69,9 @@ export default function ReportEditor({
@@ -88,23 +86,26 @@ export default function ReportEditor({ /> { dispatch(changeDateRanges(value)); }} - value={report.range} - onStartDateChange={(date) => dispatch(changeStartDate(date))} onEndDateChange={(date) => dispatch(changeEndDate(date))} - endDate={report.endDate} + onIntervalChange={(interval) => + dispatch(changeInterval(interval)) + } + onStartDateChange={(date) => dispatch(changeStartDate(date))} startDate={report.startDate} + value={report.range} /> dispatch(changeInterval(newInterval))} range={report.range} - chartType={report.chartType} startDate={report.startDate} - endDate={report.endDate} />
@@ -114,7 +115,7 @@ export default function ReportEditor({
{report.ready && ( - + )}
diff --git a/apps/start/src/components/time-window-picker.tsx b/apps/start/src/components/time-window-picker.tsx index aed61ede3..e611e3e07 100644 --- a/apps/start/src/components/time-window-picker.tsx +++ b/apps/start/src/components/time-window-picker.tsx @@ -1,3 +1,9 @@ +import { timeWindows } from '@openpanel/constants'; +import type { IChartRange, IInterval } from '@openpanel/validation'; +import { bind } from 'bind-event-listener'; +import { endOfDay, format, startOfDay } from 'date-fns'; +import { CalendarIcon } from 'lucide-react'; +import { useCallback, useEffect, useRef } from 'react'; import { Button } from '@/components/ui/button'; import { DropdownMenu, @@ -11,24 +17,18 @@ import { } from '@/components/ui/dropdown-menu'; import { pushModal, useOnPushModal } from '@/modals'; import { cn } from '@/utils/cn'; -import { bind } from 'bind-event-listener'; -import { CalendarIcon } from 'lucide-react'; -import { useCallback, useEffect, useRef } from 'react'; - import { shouldIgnoreKeypress } from '@/utils/should-ignore-keypress'; -import { timeWindows } from '@openpanel/constants'; -import type { IChartRange } from '@openpanel/validation'; -import { endOfDay, format, startOfDay } from 'date-fns'; -type Props = { +interface Props { value: IChartRange; onChange: (value: IChartRange) => void; onStartDateChange: (date: string) => void; onEndDateChange: (date: string) => void; + onIntervalChange: (interval: IInterval) => void; endDate: string | null; startDate: string | null; className?: string; -}; +} export function TimeWindowPicker({ value, onChange, @@ -36,6 +36,7 @@ export function TimeWindowPicker({ onStartDateChange, endDate, onEndDateChange, + onIntervalChange, className, }: Props) { const isDateRangerPickerOpen = useRef(false); @@ -46,10 +47,11 @@ export function TimeWindowPicker({ const handleCustom = useCallback(() => { pushModal('DateRangerPicker', { - onChange: ({ startDate, endDate }) => { + onChange: ({ startDate, endDate, interval }) => { onStartDateChange(format(startOfDay(startDate), 'yyyy-MM-dd HH:mm:ss')); onEndDateChange(format(endOfDay(endDate), 'yyyy-MM-dd HH:mm:ss')); onChange('custom'); + onIntervalChange(interval); }, startDate: startDate ? new Date(startDate) : undefined, endDate: endDate ? new Date(endDate) : undefined, @@ -69,7 +71,7 @@ export function TimeWindowPicker({ } const match = Object.values(timeWindows).find( - (tw) => event.key === tw.shortcut.toLowerCase(), + (tw) => event.key === tw.shortcut.toLowerCase() ); if (match?.key === 'custom') { handleCustom(); @@ -84,9 +86,9 @@ export function TimeWindowPicker({ diff --git a/apps/start/src/components/ui/calendar.tsx b/apps/start/src/components/ui/calendar.tsx index c3742167f..0673f170c 100644 --- a/apps/start/src/components/ui/calendar.tsx +++ b/apps/start/src/components/ui/calendar.tsx @@ -9,7 +9,6 @@ import { DayPicker, getDefaultClassNames, } from 'react-day-picker'; - import { Button, buttonVariants } from '@/components/ui/button'; import { cn } from '@/lib/utils'; @@ -29,99 +28,93 @@ function Calendar({ return ( svg]:rotate-180`, String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`, - className, + className )} - captionLayout={captionLayout} - formatters={{ - formatMonthDropdown: (date) => - date.toLocaleString('default', { month: 'short' }), - ...formatters, - }} classNames={{ root: cn('w-fit', defaultClassNames.root), months: cn( - 'flex gap-4 flex-col sm:flex-row relative', - defaultClassNames.months, + 'relative flex flex-col gap-4 sm:flex-row', + defaultClassNames.months ), - month: cn('flex flex-col w-full gap-4', defaultClassNames.month), + month: cn('flex w-full flex-col gap-4', defaultClassNames.month), nav: cn( - 'flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between', - defaultClassNames.nav, + 'absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1', + defaultClassNames.nav ), button_previous: cn( buttonVariants({ variant: buttonVariant }), - 'size-(--cell-size) aria-disabled:opacity-50 p-0 select-none', - defaultClassNames.button_previous, + 'size-(--cell-size) select-none p-0 aria-disabled:opacity-50', + defaultClassNames.button_previous ), button_next: cn( buttonVariants({ variant: buttonVariant }), - 'size-(--cell-size) aria-disabled:opacity-50 p-0 select-none', - defaultClassNames.button_next, + 'size-(--cell-size) select-none p-0 aria-disabled:opacity-50', + defaultClassNames.button_next ), month_caption: cn( - 'flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)', - defaultClassNames.month_caption, + 'flex h-(--cell-size) w-full items-center justify-center px-(--cell-size)', + defaultClassNames.month_caption ), dropdowns: cn( - 'w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5', - defaultClassNames.dropdowns, + 'flex h-(--cell-size) w-full items-center justify-center gap-1.5 font-medium text-sm', + defaultClassNames.dropdowns ), dropdown_root: cn( - 'relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md', - defaultClassNames.dropdown_root, + 'relative rounded-md border border-input shadow-xs has-focus:border-ring has-focus:ring-[3px] has-focus:ring-ring/50', + defaultClassNames.dropdown_root ), dropdown: cn( - 'absolute bg-popover inset-0 opacity-0', - defaultClassNames.dropdown, + 'absolute inset-0 bg-popover opacity-0', + defaultClassNames.dropdown ), caption_label: cn( 'select-none font-medium', captionLayout === 'label' ? 'text-sm' - : 'rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5', - defaultClassNames.caption_label, + : 'flex h-8 items-center gap-1 rounded-md pr-1 pl-2 text-sm [&>svg]:size-3.5 [&>svg]:text-muted-foreground', + defaultClassNames.caption_label ), table: 'w-full border-collapse', weekdays: cn('flex', defaultClassNames.weekdays), weekday: cn( - 'text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none', - defaultClassNames.weekday, + 'flex-1 select-none rounded-md font-normal text-[0.8rem] text-muted-foreground', + defaultClassNames.weekday ), - week: cn('flex w-full mt-2', defaultClassNames.week), + week: cn('mt-2 flex w-full', defaultClassNames.week), week_number_header: cn( - 'select-none w-(--cell-size)', - defaultClassNames.week_number_header, + 'w-(--cell-size) select-none', + defaultClassNames.week_number_header ), week_number: cn( - 'text-[0.8rem] select-none text-muted-foreground', - defaultClassNames.week_number, + 'select-none text-[0.8rem] text-muted-foreground', + defaultClassNames.week_number ), day: cn( - 'relative w-full h-full p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none', - defaultClassNames.day, + 'group/day relative aspect-square h-full w-full select-none p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md', + defaultClassNames.day ), range_start: cn( 'rounded-l-md bg-accent', - defaultClassNames.range_start, + defaultClassNames.range_start ), range_middle: cn('rounded-none', defaultClassNames.range_middle), range_end: cn('rounded-r-md bg-accent', defaultClassNames.range_end), today: cn( - 'bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none', - defaultClassNames.today, + 'rounded-md bg-accent text-accent-foreground data-[selected=true]:rounded-none', + defaultClassNames.today ), outside: cn( 'text-muted-foreground aria-selected:text-muted-foreground', - defaultClassNames.outside, + defaultClassNames.outside ), disabled: cn( 'text-muted-foreground opacity-50', - defaultClassNames.disabled, + defaultClassNames.disabled ), hidden: cn('invisible', defaultClassNames.hidden), ...classNames, @@ -130,9 +123,9 @@ function Calendar({ Root: ({ className, rootRef, ...props }) => { return (
); @@ -169,6 +162,12 @@ function Calendar({ }, ...components, }} + formatters={{ + formatMonthDropdown: (date) => + date.toLocaleString('default', { month: 'short' }), + ...formatters, + }} + showOutsideDays={showOutsideDays} {...props} /> ); @@ -184,29 +183,31 @@ function CalendarDayButton({ const ref = React.useRef(null); React.useEffect(() => { - if (modifiers.focused) ref.current?.focus(); + if (modifiers.focused) { + ref.current?.focus(); + } }, [modifiers.focused]); return ( {startDate && endDate && (