diff --git a/src/client/features/ai-mcp/AvailableTools.tsx b/src/client/features/ai-mcp/AvailableTools.tsx index 9d9b6d2..dc0e95e 100644 --- a/src/client/features/ai-mcp/AvailableTools.tsx +++ b/src/client/features/ai-mcp/AvailableTools.tsx @@ -1,5 +1,3 @@ -import { GA4_OAUTH_APP_PENDING } from "@/shared/ga4"; - type McpTool = { name: string; title: string; @@ -254,14 +252,10 @@ const toolCategories: ToolCategory[] = [ }, ]; -const visibleCategories = GA4_OAUTH_APP_PENDING - ? toolCategories.filter((cat) => cat.label !== "Google Analytics") - : toolCategories; - export function AvailableTools() { return (
- {visibleCategories.map((cat) => ( + {toolCategories.map((cat) => (

{cat.label} diff --git a/src/client/features/dashboard/DashboardPage.tsx b/src/client/features/dashboard/DashboardPage.tsx index 2b2b20a..d559e92 100644 --- a/src/client/features/dashboard/DashboardPage.tsx +++ b/src/client/features/dashboard/DashboardPage.tsx @@ -4,7 +4,6 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { ChevronLeft, ChevronRight, Check } from "lucide-react"; import { captureClientEvent } from "@/client/lib/posthog"; -import { Ga4ConnectCard } from "@/client/features/dashboard/Ga4ConnectCard"; import { computeNextStep, isStepDone, @@ -15,6 +14,7 @@ import { BacklinkPulseCard, GscCard, } from "@/client/features/dashboard/DashboardCards"; +import { Ga4Card } from "@/client/features/dashboard/Ga4Card"; import { McpConnectCard } from "@/client/features/dashboard/McpConnectCard"; import { WorkspaceMergeBanner } from "@/client/features/dashboard/WorkspaceMergeBanner"; import { getStandardErrorMessage } from "@/client/lib/error-messages"; @@ -26,8 +26,6 @@ import { refreshDashboardBacklinkSnapshot, } from "@/serverFunctions/dashboard"; import { setProjectDomain } from "@/serverFunctions/projects"; -import { useSession } from "@/lib/auth-client"; -import { isGa4ConnectAvailable } from "@/shared/ga4"; import type { DashboardHeroStep } from "@/types/schemas/dashboard"; const HERO_COPY: Record< @@ -236,7 +234,6 @@ function OnboardingChecklist({ export function DashboardPage({ projectId }: { projectId: string }) { const queryClient = useQueryClient(); - const { data: session } = useSession(); const activationQuery = useQuery({ queryKey: ["dashboardActivation", projectId], @@ -338,17 +335,13 @@ export function DashboardPage({ projectId }: { projectId: string }) { hasData: gscConnected, node: , }, - ...(isGa4ConnectAvailable(session?.user?.email) && - (ga4Connected || !activation.ga4.cardDismissedAt) + ...(ga4Connected || !activation.ga4.cardDismissedAt ? [ { key: "ga4", hasData: ga4Connected, node: ( - + ), }, ] diff --git a/src/client/features/dashboard/Ga4Card.tsx b/src/client/features/dashboard/Ga4Card.tsx new file mode 100644 index 0000000..7590410 --- /dev/null +++ b/src/client/features/dashboard/Ga4Card.tsx @@ -0,0 +1,185 @@ +import { Link } from "@tanstack/react-router"; +import { useQuery } from "@tanstack/react-query"; +import { + Area, + AreaChart, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; +import { + CardShell, + moreDetailsClass, + PercentDelta, + Stat, +} from "@/client/features/dashboard/cardParts"; +import { Ga4ConnectCard } from "@/client/features/dashboard/Ga4ConnectCard"; +import { + formatCount, + formatCtr, +} from "@/client/features/search-performance/SearchPerformanceColumns"; +import { getGa4DashboardReport } from "@/serverFunctions/ga4"; + +function formatTrendDay(date: string): string { + // Construct in local time: Date.parse("2026-08-01") is UTC midnight, which + // toLocaleDateString would render as the previous day west of Greenwich. + const [year, month, day] = date.split("-").map(Number); + return new Date(year, month - 1, day).toLocaleDateString(undefined, { + month: "short", + day: "numeric", + }); +} + +function statValue( + value: number | null, + format: (value: number) => string, +): string { + return value === null ? "—" : format(value); +} + +function statDelta(current: number | null, previous: number | null) { + return current !== null && previous !== null ? ( + + ) : undefined; +} + +function SessionsTooltip({ + active, + payload, + label, +}: { + active?: boolean; + payload?: Array<{ value: number }>; + label?: string; +}) { + if (!active || !payload?.length) return null; + return ( +
+

+ {label ? formatTrendDay(label) : ""} +

+

+ {formatCount(payload[0].value)} sessions +

+
+ ); +} + +export function Ga4Card({ + projectId, + connected, +}: { + projectId: string; + connected: boolean; +}) { + const reportQuery = useQuery({ + queryKey: ["dashboardGa4Report", projectId], + queryFn: () => getGa4DashboardReport({ data: { projectId } }), + enabled: connected, + }); + + // Not connected (or a dead grant discovered by the report call): the + // connection card sells and runs the whole flow itself. + if (!connected || (reportQuery.data && !reportQuery.data.connected)) { + return ; + } + + const report = reportQuery.data; + + return ( + + Manage + + } + > + {reportQuery.isPending ? ( +
+
+ {Array.from({ length: 4 }, (_, i) => ( +
+ ))} +
+
+
+ ) : reportQuery.isError ? ( +

+ Couldn’t load Google Analytics data. Try again shortly. +

+ ) : report?.connected ? ( + // Covers null (no report row) and 0: a zero-session period would + // otherwise render an all-zero flatline chart in an empty box. + !report.totals.sessions ? ( +

+ No organic search traffic recorded in the last 28 days yet. +

+ ) : ( +
+
+ + + + +
+
+ + + + + } + cursor={{ stroke: "currentColor", strokeOpacity: 0.2 }} + /> + + + +
+
+ ) + ) : null} + + ); +} diff --git a/src/client/features/ga4/GoogleAnalyticsConnectionCard.tsx b/src/client/features/ga4/GoogleAnalyticsConnectionCard.tsx index 849b6df..673f4d4 100644 --- a/src/client/features/ga4/GoogleAnalyticsConnectionCard.tsx +++ b/src/client/features/ga4/GoogleAnalyticsConnectionCard.tsx @@ -19,11 +19,7 @@ import { listGa4Properties, setGa4Property, } from "@/serverFunctions/ga4"; -import { useSession } from "@/lib/auth-client"; -import { - GA4_SELF_HOSTED_SETUP_DOCS_URL, - isGa4ConnectAvailable, -} from "@/shared/ga4"; +import { GA4_SELF_HOSTED_SETUP_DOCS_URL } from "@/shared/ga4"; export function GoogleAnalyticsConnectionCard({ projectId, @@ -38,7 +34,6 @@ export function GoogleAnalyticsConnectionCard({ }) { const hosted = isHostedClientAuthMode(); const queryClient = useQueryClient(); - const { data: session } = useSession(); const [picking, setPicking] = React.useState(false); const [selection, setSelection] = React.useState( null, @@ -50,14 +45,6 @@ export function GoogleAnalyticsConnectionCard({ }); const connection = connectionQuery.data; const connected = Boolean(connection?.connected); - // Hide the hosted connect surface while the OAuth app awaits Google's - // approval, but keep the card for users who already hold a grant so they - // can finish property selection or disconnect. - const hiddenPendingApproval = - !isGa4ConnectAvailable(session?.user?.email) && - hosted && - !connected && - !connection?.currentUserHasGrant; const selfHostedNeedsSetup = !hosted && connectionQuery.isSuccess && !connection?.googleOAuthConfigured; const showPicker = picking || (connection?.currentUserHasGrant && !connected); @@ -92,6 +79,9 @@ export function GoogleAnalyticsConnectionCard({ void queryClient.invalidateQueries({ queryKey: ["dashboardActivation", projectId], }); + void queryClient.invalidateQueries({ + queryKey: ["dashboardGa4Report", projectId], + }); }; const setPropertyMutation = useMutation({ mutationFn: (selected: Ga4PropertySelection) => @@ -116,8 +106,6 @@ export function GoogleAnalyticsConnectionCard({ }); const handleConnect = () => void startGoogleLink("ga4", window.location.href); - if (hiddenPendingApproval) return null; - return ( <> {heading} diff --git a/src/server/mcp/server.test.ts b/src/server/mcp/server.test.ts deleted file mode 100644 index 7071add..0000000 --- a/src/server/mcp/server.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { createOpenSeoMcpServer } from "./server"; - -const mcpServerMocks = vi.hoisted(() => ({ - registerTool: vi.fn<(name: string, ...args: unknown[]) => void>(), -})); - -vi.mock("@modelcontextprotocol/server", () => ({ - McpServer: class { - registerTool(name: string, ...args: unknown[]) { - mcpServerMocks.registerTool(name, ...args); - } - }, -})); - -vi.mock("cloudflare:workers", () => ({ - DurableObject: vi.fn(), - env: {}, - waitUntil: vi.fn(), -})); - -vi.mock("@/server/mcp/instrumentation", () => ({ - instrumentMcpToolHandler: ( - _name: string, - _outputSchema: unknown, - handler: unknown, - ) => handler, -})); - -describe("createOpenSeoMcpServer", () => { - beforeEach(() => { - mcpServerMocks.registerTool.mockClear(); - }); - - it("does not expose GA4-backed tools while OAuth approval is pending", () => { - createOpenSeoMcpServer({ - openSeoAuth: { - userId: "user-1", - userEmail: "user@example.com", - organizationId: "org-1", - baseUrl: "https://example.com", - }, - }); - - const registeredToolNames = mcpServerMocks.registerTool.mock.calls.map( - ([name]) => name, - ); - - expect(registeredToolNames).toContain("whoami"); - expect(registeredToolNames).toContain("get_search_console_performance"); - expect(registeredToolNames).not.toContain( - "get_google_analytics_organic_landing_pages", - ); - expect(registeredToolNames).not.toContain( - "get_google_analytics_page_performance", - ); - expect(registeredToolNames).not.toContain( - "get_google_analytics_key_events", - ); - expect(registeredToolNames).not.toContain("get_search_opportunities"); - expect(registeredToolNames).not.toContain( - "get_google_analytics_organic_overview", - ); - expect(registeredToolNames).not.toContain( - "get_google_analytics_traffic_acquisition", - ); - expect(registeredToolNames).not.toContain( - "get_google_analytics_measurement_health", - ); - expect(registeredToolNames).not.toContain( - "get_google_analytics_ecommerce_performance", - ); - expect(registeredToolNames).not.toContain( - "get_google_analytics_site_search", - ); - expect(registeredToolNames).not.toContain( - "get_google_analytics_audience_breakdown", - ); - }); -}); diff --git a/src/server/mcp/server.ts b/src/server/mcp/server.ts index 6a61f79..bc5f858 100644 --- a/src/server/mcp/server.ts +++ b/src/server/mcp/server.ts @@ -6,7 +6,6 @@ import { import type { z } from "zod"; import { createMcpToolContext, - MCP_AUTH_CONTEXT_PROP, type McpProps, type ToolContext, } from "@/server/mcp/context"; @@ -63,7 +62,6 @@ import { getSearchConsolePerformanceTool, inspectUrlsTool, } from "@/server/mcp/tools/search-console-tools"; -import { GA4_OAUTH_APP_PENDING, isGa4ConnectAvailable } from "@/shared/ga4"; import { getAuditIssuesTool, getAuditPagesTool, @@ -186,21 +184,16 @@ export function createOpenSeoMcpServer(authProps: McpProps) { register(getKeywordMetricsTool); register(getSearchConsolePerformanceTool); register(inspectUrlsTool); - if ( - !GA4_OAUTH_APP_PENDING || - isGa4ConnectAvailable(authProps[MCP_AUTH_CONTEXT_PROP].userEmail) - ) { - register(getGoogleAnalyticsOrganicLandingPagesTool); - register(getGoogleAnalyticsPagePerformanceTool); - register(getGoogleAnalyticsKeyEventsTool); - register(getSearchOpportunitiesTool); - register(getGoogleAnalyticsOrganicOverviewTool); - register(getGoogleAnalyticsTrafficAcquisitionTool); - register(getGoogleAnalyticsMeasurementHealthTool); - register(getGoogleAnalyticsEcommercePerformanceTool); - register(getGoogleAnalyticsSiteSearchTool); - register(getGoogleAnalyticsAudienceBreakdownTool); - } + register(getGoogleAnalyticsOrganicLandingPagesTool); + register(getGoogleAnalyticsPagePerformanceTool); + register(getGoogleAnalyticsKeyEventsTool); + register(getSearchOpportunitiesTool); + register(getGoogleAnalyticsOrganicOverviewTool); + register(getGoogleAnalyticsTrafficAcquisitionTool); + register(getGoogleAnalyticsMeasurementHealthTool); + register(getGoogleAnalyticsEcommercePerformanceTool); + register(getGoogleAnalyticsSiteSearchTool); + register(getGoogleAnalyticsAudienceBreakdownTool); register(runSiteAuditTool); register(getAuditStatusTool); register(getAuditIssuesTool); diff --git a/src/serverFunctions/ga4.ts b/src/serverFunctions/ga4.ts index d2a04d8..03532bf 100644 --- a/src/serverFunctions/ga4.ts +++ b/src/serverFunctions/ga4.ts @@ -2,7 +2,10 @@ import { createServerFn } from "@tanstack/react-start"; import { getRequest } from "@tanstack/react-start/server"; import { waitUntil } from "cloudflare:workers"; import { z } from "zod"; +import { shiftGa4Date } from "@/server/features/ga4/services/Ga4Dates"; +import { Ga4OrganicOverviewService } from "@/server/features/ga4/services/Ga4OrganicOverviewService"; import { Ga4Service } from "@/server/features/ga4/services/Ga4Service"; +import { Ga4ReportError } from "@/server/lib/ga4Errors"; import { hasSelfHostedGoogleOAuthConfig } from "@/server/features/google/oauth-config"; import { createSelfHostedGoogleAuthorizationUrl, @@ -49,6 +52,81 @@ export const getGa4Connection = createServerFn({ method: "POST" }) }; }); +function overviewMetric( + row: Record | null, + name: string, +): number | null { + const value = row?.[name]; + return typeof value === "number" ? value : null; +} + +/** Zero-fill the daily trend across the resolved range: GA4 omits days with + * no organic sessions, which would silently shrink the chart's x-axis. */ +function fillDailySessions( + rows: Array>, + range: { startDate: string; endDate: string }, +): Array<{ date: string; sessions: number }> { + const sessionsByDate = new Map(); + for (const row of rows) { + // GA4's `date` dimension is YYYYMMDD; the range dates are YYYY-MM-DD. + if (typeof row.date !== "string" || typeof row.sessions !== "number") { + continue; + } + const iso = `${row.date.slice(0, 4)}-${row.date.slice(4, 6)}-${row.date.slice(6, 8)}`; + sessionsByDate.set(iso, row.sessions); + } + const days: Array<{ date: string; sessions: number }> = []; + for ( + let date = range.startDate; + date <= range.endDate; + date = shiftGa4Date(date, 1) + ) { + days.push({ date, sessions: sessionsByDate.get(date) ?? 0 }); + } + return days; +} + +/** The dashboard's GA4 card: organic totals vs the previous period plus a + * daily sessions trend, over the default (last 28 complete days) range. */ +export const getGa4DashboardReport = createServerFn({ method: "POST" }) + .middleware(requireProjectContext) + .validator(projectScopedSchema) + .handler(async ({ context }) => { + try { + const overview = await Ga4OrganicOverviewService.getOrganicOverview({ + projectId: context.projectId, + }); + const totals = (row: Record | null) => ({ + sessions: overviewMetric(row, "sessions"), + activeUsers: overviewMetric(row, "activeUsers"), + engagementRate: overviewMetric(row, "engagementRate"), + keyEvents: overviewMetric(row, "keyEvents"), + }); + return { + connected: true as const, + totals: totals(overview.current), + prevTotals: totals(overview.previous), + trend: fillDailySessions( + overview.trend, + overview.request.resolvedDateRange, + ), + }; + } catch (error) { + // Not connected, a dead grant, or a lost/deleted property: the dashboard + // card falls back to the connect card instead of retrying a report that + // can never succeed. Other report errors are real faults. + if ( + error instanceof Ga4ReportError && + (error.code === "ga4_not_connected" || + error.code === "ga4_reconnect_required" || + error.code === "ga4_property_inaccessible") + ) { + return { connected: false as const }; + } + throw error; + } + }); + export const listGa4Properties = createServerFn({ method: "POST" }) .middleware(requireProjectContext) .validator(projectScopedSchema) diff --git a/src/shared/ga4.ts b/src/shared/ga4.ts index 3048e6a..2044762 100644 --- a/src/shared/ga4.ts +++ b/src/shared/ga4.ts @@ -1,25 +1,6 @@ /** Better Auth provider ID for the dedicated Google Analytics grant. */ export const GA4_OAUTH_PROVIDER_ID = "google-analytics"; -// Google hasn't approved the GA4 OAuth app yet, so hosted connect attempts -// show Google's "unverified app" warning. Gates every GA4 connect surface; -// flip to false once the app is approved. Self-hosted deployments use their -// own OAuth app, so only hosted mode is gated. -export const GA4_OAUTH_APP_PENDING = true; - -// Google's OAuth verification reviewer tests with this account, so the GA4 -// connect/disconnect surfaces stay visible for it while the app is pending -// approval. MCP tool registration stays gated for everyone until approval. -const GA4_OAUTH_REVIEWER_EMAILS = new Set(["walkthrough@everyapp.dev"]); - -/** Whether GA4 connect surfaces are visible to this user despite the pending gate. */ -export function isGa4ConnectAvailable( - email: string | null | undefined, -): boolean { - if (!GA4_OAUTH_APP_PENDING) return true; - return email != null && GA4_OAUTH_REVIEWER_EMAILS.has(email); -} - export const GA4_OAUTH_SCOPES = [ "openid", "email",