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",