diff --git a/.changeset/ai-platform-reports.md b/.changeset/ai-platform-reports.md
new file mode 100644
index 0000000..95ee5b8
--- /dev/null
+++ b/.changeset/ai-platform-reports.md
@@ -0,0 +1,6 @@
+---
+"@workspace/web": patch
+"@workspace/lib": patch
+---
+
+Add separate Bing and Google AI report imports with comparable history and guide users through their first monitoring run.
diff --git a/.changeset/beatapi-monitoring-platforms.md b/.changeset/beatapi-monitoring-platforms.md
new file mode 100644
index 0000000..ffc84b2
--- /dev/null
+++ b/.changeset/beatapi-monitoring-platforms.md
@@ -0,0 +1,8 @@
+---
+"@workspace/web": patch
+"@workspace/lib": patch
+"@workspace/config": patch
+"@workspace/worker": patch
+---
+
+Track platforms only through BeatBrowser or BeatAPI with a default model per platform, remove product analytics, and reorder the sidebar into SEO, GEO and Optimize.
diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml
deleted file mode 100644
index e86c868..0000000
--- a/.github/FUNDING.yml
+++ /dev/null
@@ -1 +0,0 @@
-# Sponsorship is not configured for BeatGEO.
diff --git a/.github/contributors.txt b/.github/contributors.txt
deleted file mode 100644
index 274873b..0000000
--- a/.github/contributors.txt
+++ /dev/null
@@ -1 +0,0 @@
-# BeatGEO does not require the upstream CLA. Contributor history is preserved in Git.
diff --git a/AGENTS.md b/AGENTS.md
index cdd1f09..187adb3 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -50,7 +50,7 @@ Before handing work back or opening a PR, run `pnpm lint` and get it passing; CI
## Environment
-`.env` must exist at **both** the repo root and `apps/web/.env` (Vite reads its project root; the worker reads `apps/web/.env` via `--env-file`). Minimum for the local workspace: `DATABASE_URL`, `BETTER_AUTH_SECRET`, `BEAT_GEO_ENCRYPTION_KEY` (`openssl rand -base64 32`), `APP_URL`, `DISABLE_TELEMETRY=1`, and `SCRAPE_TARGETS` plus the keys of the providers it names. Local workspaces establish a private owner/session automatically and expose no registration, login, password, team or organization-switching UI. Local setup remains accessible without a research key; industry starter questions and browser monitoring need no research provider. AI-assisted onboarding and Opportunities require a research provider: `BEATAPI_API_KEY` plus `BEATAPI_RESEARCH_MODEL`, or one direct LLM key (`OPENAI_API_KEY`, `OPENROUTER_API_KEY`, `ANTHROPIC_API_KEY`, or `MISTRAL_API_KEY`) for onboarding and the Opportunities report. For UI-only work, `SCRAPE_TARGETS=stub:stub` and `ONBOARDING_LLM_TARGET=stub:stub` need no keys at all.
+`.env` must exist at **both** the repo root and `apps/web/.env` (Vite reads its project root; the worker reads `apps/web/.env` via `--env-file`). Minimum for the local workspace: `DATABASE_URL`, `BETTER_AUTH_SECRET`, `BEAT_GEO_ENCRYPTION_KEY` (`openssl rand -base64 32`), `APP_URL` and `SCRAPE_TARGETS` plus the keys of the providers it names. Local workspaces establish a private owner/session automatically and expose no registration, login, password, team or organization-switching UI. Local setup remains accessible without a research key; industry starter questions and browser monitoring need no research provider. AI-assisted onboarding and Opportunities require `BEATAPI_API_KEY` plus `BEATAPI_RESEARCH_MODEL`. Monitoring reaches platforms only through BeatBrowser (`beat-browser`) or BeatAPI (`beatapi`); see `docs/providers.md`. For UI-only work, `SCRAPE_TARGETS=stub:stub` and `ONBOARDING_LLM_TARGET=stub:stub` need no keys at all.
## Git workflow
diff --git a/CLA.md b/CLA.md
deleted file mode 100644
index 9b39951..0000000
--- a/CLA.md
+++ /dev/null
@@ -1,3 +0,0 @@
-# Contributions
-
-BeatGEO uses the MIT license. See CONTRIBUTING.md and THIRD_PARTY_NOTICES.md. No separate upstream contributor agreement is required.
diff --git a/apps/web/package.json b/apps/web/package.json
index 2bb14f3..ad02583 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -49,7 +49,6 @@
"lucide-react": "^1.51.0",
"pg": "^8.23.1",
"pg-boss": "^12.36.0",
- "posthog-js": "^1.435.8",
"react": "^19.3.0",
"react-dom": "^19.3.0",
"react-icons": "^5.7.0",
diff --git a/apps/web/src/components/__tests__/response-markdown.test.tsx b/apps/web/src/components/__tests__/response-markdown.test.tsx
index 2fc39dd..7540f6a 100644
--- a/apps/web/src/components/__tests__/response-markdown.test.tsx
+++ b/apps/web/src/components/__tests__/response-markdown.test.tsx
@@ -1,12 +1,7 @@
/**
- * Every provider stores its answers in a different shape, and each one reaches
- * the prompt detail page through `extractTextContent` before it is rendered.
- * A provider whose extraction hands back flattened text or glued-together
- * blocks looks identical to a broken renderer from the page's side, so the two
- * halves are checked together here rather than in isolation.
- *
- * The shapes mirror the fixtures in each provider's own tests in
- * `packages/lib/src/providers/registry`.
+ * Stored answers reach the prompt detail page through `extractTextContent`
+ * before they are rendered, so extraction and rendering are checked together:
+ * flattened text looks identical to a broken renderer from the page's side.
*/
import { extractTextContent } from "@workspace/lib/text-extraction";
import { renderToStaticMarkup } from "react-dom/server";
@@ -14,79 +9,22 @@ import { describe, expect, it } from "vitest";
import { ResponseMarkdown } from "@/components/response-markdown";
const PROSE = "Here are the **top** trackers:";
-const TABLE = "| Tool | Price |\n| --- | --- |\n| Profound | $99 |";
+const TABLE = "| Tool | Price |\n| --- | --- |\n| Example Tracker | $99 |";
const ANSWER = `${PROSE}\n\n${TABLE}`;
const render = (rawOutput: unknown, provider: string) =>
renderToStaticMarkup({extractTextContent(rawOutput, provider)});
-/** One answer, one shape per provider surface. */
const SURFACES: [name: string, provider: string, rawOutput: unknown][] = [
- ["openai-api", "openai-api", { output: [{ type: "message", content: [{ type: "output_text", text: ANSWER }] }] }],
- ["anthropic-api", "anthropic-api", { content: [{ type: "text", text: ANSWER }] }],
- ["mistral-api", "mistral-api", { outputs: [{ content: [{ type: "text", text: ANSWER }] }] }],
- ["openrouter", "openrouter", { choices: [{ message: { content: ANSWER } }] }],
- ["olostep", "olostep", { json_content: JSON.stringify({ result: { markdown_content: ANSWER } }) }],
- ["brightdata chatbot", "brightdata", [{ answer_text_markdown: ANSWER, answer_text: "flattened" }]],
- ["brightdata AI Overview", "brightdata", [{ ai_overview: { markdown: ANSWER } }]],
- ["oxylabs ChatGPT", "oxylabs", { results: [{ content: { markdown_text: ANSWER } }] }],
- ["oxylabs Perplexity", "oxylabs", { results: [{ content: { answer_results_md: ANSWER } }] }],
- ["cloro chatbot", "cloro", { markdown: ANSWER, text: "flattened" }],
- ["cloro AI Overview", "cloro", { aioverview: { markdown: ANSWER, text: "flattened" } }],
- ["dataforseo scraper", "dataforseo", { tasks: [{ result: [{ markdown: ANSWER, sources: [] }] }] }],
- [
- "dataforseo LLM Responses",
- "dataforseo",
- { tasks: [{ result: [{ items: [{ type: "message", sections: [{ type: "text", text: ANSWER }] }] }] }] },
- ],
- [
- "dataforseo AI Overview",
- "dataforseo",
- { tasks: [{ result: [{ items: [{ type: "ai_overview", markdown: ANSWER }] }] }] },
- ],
- // Rows written before the provider column existed dispatch on the model.
- [
- "legacy row without a provider",
- "chatgpt",
- { output: [{ type: "message", content: [{ type: "output_text", text: ANSWER }] }] },
- ],
+ ["BeatBrowser observation", "beat-browser", { status: "completed", answer: ANSWER, citations: [] }],
+ ["BeatAPI answer", "beatapi", { choices: [{ finish_reason: "stop", message: { content: ANSWER } }] }],
];
describe("answer rendering across providers", () => {
- it.each(SURFACES)("renders %s answers as markdown", (_name, provider, rawOutput) => {
+ it.each(SURFACES)("renders %s as markdown", (_name, provider, rawOutput) => {
const html = render(rawOutput, provider);
expect(html).toContain("
");
expect(html).toContain("top");
- expect(html).not.toContain("flattened");
- });
-
- /**
- * These three surfaces arrive as a list of blocks rather than one string.
- * Joined with a single newline they render as one run-on paragraph, which
- * is what the extraction's blank-line join exists to prevent.
- */
- it.each([
- [
- "brightdata AI Overview",
- "brightdata",
- [{ ai_overview: { texts: [{ snippet: "First." }, { snippet: "Second." }] } }],
- ],
- [
- "dataforseo scraper items",
- "dataforseo",
- { tasks: [{ result: [{ sources: [], items: [{ markdown: "First." }, { markdown: "Second." }] }] }] },
- ],
- [
- "oxylabs AI Overview",
- "oxylabs",
- {
- results: [
- { content: { ai_overviews: [{ answer_text: [{ fragments: [{ text: "First." }, { text: "Second." }] }] }] } },
- ],
- },
- ],
- ])("keeps %s blocks as separate paragraphs", (_name, provider, rawOutput) => {
- expect(render(rawOutput, provider).match(/
+ {t(
+ "Export one table from the AI report, then provide its website, dates and filters. Supported: page, daily history, and Bing grounding queries. Use exact counts, not Topic Portfolio opportunity ranges. CSV only; extract ZIP exports first. Maximum 2 MB / 2,000 rows.",
+ )}
+
+ {t(
+ "Compare only equal-length, non-overlapping periods from the same source, dimension, website and filters. Missing rows are unknown, not zero. Daily reports remain separate observations. Row sums are not site totals; different platforms are never added together.",
+ )}
+
{result.startDate} – {result.endDate} · {t("Google Search, web results")}
+
+ {t("Last successful data fetch")}: {new Date(result.fetchedAt).toLocaleString()}
+
+ {t("Saved search data is not a live connection check or a separate AI report.")}
{result.totals && (
diff --git a/apps/web/src/components/monitoring-start.tsx b/apps/web/src/components/monitoring-start.tsx
new file mode 100644
index 0000000..709e356
--- /dev/null
+++ b/apps/web/src/components/monitoring-start.tsx
@@ -0,0 +1,45 @@
+import { Link } from "@tanstack/react-router";
+import { useBrandParams } from "@/hooks/use-route-params";
+import { useT } from "@/lib/i18n";
+export function MonitoringStart() {
+ const t = useT(),
+ params = useBrandParams();
+ return (
+
+
+ {t(
+ "Question monitoring measures completed answer samples. Platform reports record site impressions or citations. SEO research and referral visits are separate evidence.",
+ )}
+
+
+ {t("Import Bing or Google AI reports")}
+
+
+ );
+}
diff --git a/apps/web/src/components/prompt-suggestions.tsx b/apps/web/src/components/prompt-suggestions.tsx
index ef326cd..c800852 100644
--- a/apps/web/src/components/prompt-suggestions.tsx
+++ b/apps/web/src/components/prompt-suggestions.tsx
@@ -7,7 +7,6 @@ import { type Ref, type RefObject, useEffect, useRef, useState } from "react";
import { type EditablePrompt, newPromptEntry } from "@/components/prompts-list-editor";
import { usePolledBrandJob } from "@/hooks/use-polled-brand-job";
import { useT } from "@/lib/i18n";
-import { trackEvent } from "@/lib/posthog";
import {
cancelPromptSuggestionsFn,
getPromptSuggestionsStatusFn,
@@ -76,7 +75,6 @@ export function usePromptSuggestions({
start: () => {
setError(null);
setNothingNew(false);
- trackEvent("prompt_suggestions_requested");
job.run();
},
cancel: job.cancel,
diff --git a/apps/web/src/components/prompt-wizard.tsx b/apps/web/src/components/prompt-wizard.tsx
index 0bd8d25..894e99a 100644
--- a/apps/web/src/components/prompt-wizard.tsx
+++ b/apps/web/src/components/prompt-wizard.tsx
@@ -25,7 +25,6 @@ import { dashboardKeys } from "@/hooks/use-dashboard-summary";
import { usePolledBrandJob } from "@/hooks/use-polled-brand-job";
import { promptsSummaryKeys } from "@/hooks/use-prompts-summary";
import { useBrandParams } from "@/hooks/use-route-params";
-import { trackEvent } from "@/lib/posthog";
import { useWriteErrorMessage } from "@/lib/write-errors";
import {
cancelAnalyzeBrandFn,
@@ -159,10 +158,6 @@ export default function PromptWizard() {
),
});
setReviewing(true);
- trackEvent("onboarding_analyzed", {
- competitor_count: suggestion.competitors.length,
- prompt_count: suggestion.suggestedPrompts.length,
- });
},
onError: setError,
startErrorFallback: "Analysis failed",
@@ -222,12 +217,6 @@ export default function PromptWizard() {
},
});
- trackEvent("wizard_completed", {
- prompts_created: promptsPayload.length,
- competitors_created: competitorsPayload.length,
- skipped: false,
- });
-
// Deployments without an onboardingRedirectUrlTemplate (e.g. local mode) skip the full reload, so caches fetched while !onboarded must be busted explicitly.
queryClient.invalidateQueries({ queryKey: brandKeys.all });
queryClient.invalidateQueries({ queryKey: dashboardKeys.all });
diff --git a/apps/web/src/components/prompts-editor.tsx b/apps/web/src/components/prompts-editor.tsx
index 6c1ac2b..b5ea274 100644
--- a/apps/web/src/components/prompts-editor.tsx
+++ b/apps/web/src/components/prompts-editor.tsx
@@ -12,7 +12,6 @@ import { type EditablePrompt, PromptsListEditor } from "@/components/prompts-lis
import { UnsavedChangesBar } from "@/components/unsaved-changes-bar";
import { useInvalidatePromptsSummary } from "@/hooks/use-prompts-summary";
import { useT } from "@/lib/i18n";
-import { trackEvent } from "@/lib/posthog";
import { useWriteErrorMessage } from "@/lib/write-errors";
import { updatePromptsFn } from "@/server/prompts";
@@ -144,13 +143,6 @@ export function PromptsEditor({ initialPrompts, brandId, pageTitle, pageDescript
const saved = await updatePromptsFn({ data: { brandId, prompts: allPrompts } });
- trackEvent("prompts_updated", {
- added: addedCount,
- edited: editedCount,
- deleted: removedCount,
- suggested: validPrompts.filter((p) => p.suggested && !p.id).length,
- });
-
// Adopt the server's rows so newly inserted prompts carry their real
// ids — re-saving without them would insert duplicates.
const next = toEditablePrompts(saved);
diff --git a/apps/web/src/env.d.ts b/apps/web/src/env.d.ts
index cdd53a3..559e9b4 100644
--- a/apps/web/src/env.d.ts
+++ b/apps/web/src/env.d.ts
@@ -3,17 +3,6 @@
// Every var here must have an entry in packages/config/src/env-registry.ts
// (enforced by env-registry.test.ts).
-interface ImportMetaEnv {
- // Analytics
- readonly VITE_PLAUSIBLE_DOMAIN?: string;
- readonly VITE_CLARITY_PROJECT_ID?: string;
- readonly VITE_POSTHOG_KEY?: string;
-}
-
-interface ImportMeta {
- readonly env: ImportMetaEnv;
-}
-
// Server-side environment variables (accessed via process.env in server functions)
// and Vite define globals
declare global {
@@ -33,31 +22,17 @@ declare global {
readonly BEAT_BROWSER_ARGS_JSON?: string;
readonly BROWSER_RUNNER_TOKEN?: string;
- readonly OPENAI_API_KEY: string;
- readonly ANTHROPIC_API_KEY: string;
- readonly MISTRAL_API_KEY?: string;
- readonly OPENROUTER_API_KEY?: string;
- readonly SEARCHAPI_API_KEY?: string;
- readonly OLOSTEP_API_KEY?: string;
- readonly BRIGHTDATA_API_TOKEN?: string;
- readonly OXYLABS_USERNAME?: string;
- readonly OXYLABS_PASSWORD?: string;
- readonly CLORO_API_KEY?: string;
readonly JINA_API_KEY?: string;
readonly BEATAPI_API_KEY?: string;
readonly BEATAPI_RESEARCH_MODEL?: string;
readonly ONBOARDING_LLM_TARGET?: string;
readonly BEAT_GEO_ENCRYPTION_KEY?: string;
- readonly BEAT_GEO_POSTHOG_KEY?: string;
readonly BEAT_GEO_ENCRYPTION_KEY_OLD?: string;
- readonly DATAFORSEO_LOGIN: string;
- readonly DATAFORSEO_PASSWORD: string;
readonly BETTER_AUTH_SECRET?: string;
readonly ADMIN_API_KEYS?: string;
readonly DEFAULT_BRAND_DOMAINS?: string;
readonly ENVIRONMENT?: string;
- readonly DISABLE_TELEMETRY?: string;
}
}
}
diff --git a/apps/web/src/lib/__tests__/fanout-analysis.test.ts b/apps/web/src/lib/__tests__/fanout-analysis.test.ts
index 62b716a..ab91687 100644
--- a/apps/web/src/lib/__tests__/fanout-analysis.test.ts
+++ b/apps/web/src/lib/__tests__/fanout-analysis.test.ts
@@ -161,14 +161,14 @@ describe("computeFanoutAnalysis: 'unavailable' sentinel", () => {
// store ["unavailable"] in web_queries when a search happened but the strings
// aren't exposed. It's not a real fan-out query and must never reach the page.
const breakdown: FanoutBreakdownRow[] = [
- { prompt_id: "p1", model: "openrouter-claude", query: UNAVAILABLE_SENTINEL, count: 6, brand_mentions: 2 },
- { prompt_id: "p1", model: "openrouter-gpt", query: "Unavailable", count: 3, brand_mentions: 1 }, // mixed case
+ { prompt_id: "p1", model: "api-claude", query: UNAVAILABLE_SENTINEL, count: 6, brand_mentions: 2 },
+ { prompt_id: "p1", model: "api-gpt", query: "Unavailable", count: 3, brand_mentions: 1 }, // mixed case
{ prompt_id: "p1", model: "chatgpt", query: "best crm software 2026", count: 4, brand_mentions: 3 },
];
// The SQL filters the sentinel, so OpenRouter's model totals come back at 0.
const modelTotals: FanoutModelTotalRow[] = [
- { model: "openrouter-claude", runs: 8, fanout_runs: 0, total_queries: 0 },
- { model: "openrouter-gpt", runs: 5, fanout_runs: 0, total_queries: 0 },
+ { model: "api-claude", runs: 8, fanout_runs: 0, total_queries: 0 },
+ { model: "api-gpt", runs: 5, fanout_runs: 0, total_queries: 0 },
{ model: "chatgpt", runs: 4, fanout_runs: 4, total_queries: 4 },
];
const a = computeFanoutAnalysis(breakdown, modelTotals, promptMap);
@@ -184,8 +184,8 @@ describe("computeFanoutAnalysis: 'unavailable' sentinel", () => {
it("surfaces sentinel-only providers in the per-model breakdown with zero fan-out", () => {
const byModel = Object.fromEntries(a.byModel.map((m) => [m.model, m]));
- expect(byModel["openrouter-claude"]).toMatchObject({ totalQueries: 0, fanoutRuns: 0 });
- expect(byModel["openrouter-gpt"]).toMatchObject({ totalQueries: 0, fanoutRuns: 0 });
+ expect(byModel["api-claude"]).toMatchObject({ totalQueries: 0, fanoutRuns: 0 });
+ expect(byModel["api-gpt"]).toMatchObject({ totalQueries: 0, fanoutRuns: 0 });
expect(byModel.chatgpt.totalQueries).toBe(4);
});
});
diff --git a/apps/web/src/lib/__tests__/postgres-read.integration.test.ts b/apps/web/src/lib/__tests__/postgres-read.integration.test.ts
index d019a28..a3db398 100644
--- a/apps/web/src/lib/__tests__/postgres-read.integration.test.ts
+++ b/apps/web/src/lib/__tests__/postgres-read.integration.test.ts
@@ -243,12 +243,12 @@ describe("model filter", () => {
const grounded = await createRun(brandId, promptId, {
at,
brandMentioned: true,
- provider: "openai-api",
+ provider: "beatapi",
webSearch: true,
});
- const scraped = await createRun(brandId, promptId, { at, brandMentioned: false, provider: "brightdata" });
- await createRun(brandId, promptId, { at, brandMentioned: false, provider: "openai-api", webSearch: false });
- await createRun(brandId, promptId, { at, brandMentioned: true, model: "claude", provider: "anthropic-api" });
+ const scraped = await createRun(brandId, promptId, { at, brandMentioned: false, provider: "beat-browser" });
+ await createRun(brandId, promptId, { at, brandMentioned: false, provider: "beatapi", webSearch: false });
+ await createRun(brandId, promptId, { at, brandMentioned: true, model: "claude", provider: "beatapi" });
await createCitation(grounded, "https://a.example/1");
await createCitation(scraped, "https://b.example/1");
await createCitation(scraped, "https://b.example/2");
diff --git a/apps/web/src/lib/breadcrumbs.ts b/apps/web/src/lib/breadcrumbs.ts
index 3c7c3b9..ae02313 100644
--- a/apps/web/src/lib/breadcrumbs.ts
+++ b/apps/web/src/lib/breadcrumbs.ts
@@ -74,18 +74,17 @@ function pageCrumbs(label: string | undefined, pathname: string, hash: string):
if (label === "Opportunities" && ["search", "seo-actions", "all-actions"].includes(hash))
return [
{
- label: hash === "all-actions" ? "Analytics" : "SEO",
- href: pathname.replace(/opportunities$/, hash === "all-actions" ? "traffic" : "seo"),
+ label: hash === "all-actions" ? "Optimize" : "SEO",
+ href: hash === "all-actions" ? pathname : pathname.replace(/opportunities$/, "seo"),
},
{
- label:
- hash === "search" ? "Search diagnosis" : hash === "seo-actions" ? "SEO actions" : "All improvement actions",
+ label: hash === "search" ? "Search diagnosis" : hash === "seo-actions" ? "SEO actions" : "Actions & retests",
href: `${pathname}#${hash}`,
},
];
if (label === "Traffic analysis")
return [
- { label: "Analytics", href: pathname },
+ { label: "GEO", href: pathname.replace(/traffic$/, "") },
{ label, href: pathname },
];
if (label === "Search performance")
diff --git a/apps/web/src/lib/clarity.ts b/apps/web/src/lib/clarity.ts
deleted file mode 100644
index 0afb7cc..0000000
--- a/apps/web/src/lib/clarity.ts
+++ /dev/null
@@ -1,52 +0,0 @@
-import { onAnalyticsConsent } from "@workspace/ui/lib/cookie-consent";
-
-type ClarityApi = ((command: string, ...args: unknown[]) => void) & { q?: unknown[][] };
-
-declare global {
- interface Window {
- clarity?: ClarityApi;
- }
-}
-
-let injected = false;
-
-function load(projectId: string): void {
- if (injected) return;
- injected = true;
- // Clarity replays this queue once its tag loads, so a choice made while the
- // script is still in flight isn't lost.
- window.clarity ??= Object.assign(
- (...args: unknown[]) => {
- window.clarity?.q?.push(args);
- },
- { q: [] as unknown[][] },
- );
- const script = document.createElement("script");
- script.async = true;
- script.src = `https://www.clarity.ms/tag/${projectId}`;
- document.head.appendChild(script);
-}
-
-/**
- * Load Microsoft Clarity only while analytics consent is in effect.
- *
- * Clarity records session replays, so the tag is never fetched before the
- * visitor has answered — which is the whole reason it isn't a `