Add combined SEO report: Search Console + Analytics + Site Audit in one view
Some checks failed
CI / ci (push) Has been cancelled
CI / docker-build (push) Has been cancelled
Publish Docker image / docker (push) Has been cancelled
Upload sourcemaps / upload (push) Has been cancelled

Per-project overview pulling together GSC performance totals/top queries,
GA4 organic overview metrics, and the latest completed site audit summary
into a single page under /p/$projectId/report. Each section degrades
independently (not connected / error / ok) so a missing integration never
blocks the rest of the report. Includes a "Download PDF" button that uses
window.print() against print-aware layout classes added to AppShell so the
app chrome is hidden and content flows naturally when printed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
metatroncubeswdev 2026-09-13 14:37:11 -04:00
parent 6d40767d95
commit 05e64ccd36
8 changed files with 567 additions and 7 deletions

View File

@ -0,0 +1,321 @@
import type { ReactNode } from "react";
import { useQuery } from "@tanstack/react-query";
import { AlertCircle, Download } from "lucide-react";
import { formatDate } from "@/client/features/audit/shared";
import { getProjectReport } from "@/serverFunctions/report";
type ProjectReport = Awaited<ReturnType<typeof getProjectReport>>;
function formatNumber(value: number): string {
return new Intl.NumberFormat("en-US").format(value);
}
function formatPercent(value: number): string {
return `${(value * 100).toFixed(1)}%`;
}
function SectionShell({
title,
children,
}: {
title: string;
children: ReactNode;
}) {
return (
<section className="card break-inside-avoid border border-base-300 bg-base-100 print:border print:shadow-none">
<div className="card-body gap-3">
<h2 className="card-title text-base">{title}</h2>
{children}
</div>
</section>
);
}
function NotConnectedNote({ what }: { what: string }) {
return (
<p className="text-sm text-base-content/60">
{what} isn&rsquo;t connected for this project yet.
</p>
);
}
function SectionError({ message }: { message: string }) {
return (
<div className="alert alert-error text-sm">
<AlertCircle className="size-4" />
<span>{message}</span>
</div>
);
}
function SearchConsoleSection({
section,
}: {
section: ProjectReport["searchConsole"];
}) {
if (section.status === "not_connected") {
return (
<SectionShell title="Search Console">
<NotConnectedNote what="Google Search Console" />
</SectionShell>
);
}
if (section.status === "error") {
return (
<SectionShell title="Search Console">
<SectionError message={section.message} />
</SectionShell>
);
}
const { totals, topQueries, siteUrl } = section.data;
return (
<SectionShell title="Search Console">
<p className="text-xs text-base-content/60">
{siteUrl} &middot; last 28 days
</p>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<Stat label="Clicks" value={formatNumber(totals.clicks)} />
<Stat label="Impressions" value={formatNumber(totals.impressions)} />
<Stat label="Avg. CTR" value={formatPercent(totals.ctr)} />
<Stat label="Avg. position" value={totals.position.toFixed(1)} />
</div>
{topQueries.length > 0 && (
<div className="overflow-x-auto">
<table className="table table-sm">
<thead>
<tr>
<th>Top queries</th>
<th>Clicks</th>
<th>Impressions</th>
<th>Position</th>
</tr>
</thead>
<tbody>
{topQueries.map((row) => (
<tr key={row.key}>
<td className="max-w-[240px] truncate">{row.key}</td>
<td>{formatNumber(row.clicks)}</td>
<td>{formatNumber(row.impressions)}</td>
<td>{row.position.toFixed(1)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</SectionShell>
);
}
function AnalyticsSection({
section,
}: {
section: ProjectReport["analytics"];
}) {
if (section.status === "not_connected") {
return (
<SectionShell title="Analytics">
<NotConnectedNote what="Google Analytics" />
</SectionShell>
);
}
if (section.status === "error") {
return (
<SectionShell title="Analytics">
<SectionError message={section.message} />
</SectionShell>
);
}
const { current, source } = section.data;
const metric = (key: string) => {
const value = current?.[key];
return typeof value === "number" ? value : null;
};
const sessions = metric("sessions");
const activeUsers = metric("activeUsers");
const engagementRate = metric("engagementRate");
const keyEvents = metric("keyEvents");
return (
<SectionShell title="Analytics">
<p className="text-xs text-base-content/60">
{source.propertyDisplayName} &middot; organic search &middot; last 28
days
</p>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<Stat
label="Sessions"
value={sessions === null ? "—" : formatNumber(sessions)}
/>
<Stat
label="Active users"
value={activeUsers === null ? "—" : formatNumber(activeUsers)}
/>
<Stat
label="Engagement rate"
value={engagementRate === null ? "—" : formatPercent(engagementRate)}
/>
<Stat
label="Key events"
value={keyEvents === null ? "—" : formatNumber(keyEvents)}
/>
</div>
</SectionShell>
);
}
function AuditSection({ section }: { section: ProjectReport["audit"] }) {
if (section.status === "not_connected") {
return (
<SectionShell title="Site Audit">
<p className="text-sm text-base-content/60">
No completed site audit yet.
</p>
</SectionShell>
);
}
if (section.status === "error") {
return (
<SectionShell title="Site Audit">
<SectionError message={section.message} />
</SectionShell>
);
}
const {
startUrl,
completedAt,
pagesCrawled,
criticalCount,
warningCount,
infoCount,
topIssues,
} = section.data;
return (
<SectionShell title="Site Audit">
<p className="text-xs text-base-content/60">
{startUrl} &middot; {pagesCrawled} pages &middot; completed{" "}
{completedAt ? formatDate(completedAt) : "—"}
</p>
<div className="grid grid-cols-3 gap-3">
<Stat label="Critical" value={String(criticalCount)} tone="error" />
<Stat label="Warnings" value={String(warningCount)} tone="warning" />
<Stat label="Info" value={String(infoCount)} />
</div>
{topIssues.length > 0 && (
<ul className="space-y-1 text-sm">
{topIssues.map((issue) => (
<li key={issue.issueType} className="flex justify-between gap-4">
<span>{issue.title}</span>
<span className="text-base-content/60">
{issue.count} {issue.count === 1 ? "page" : "pages"}
</span>
</li>
))}
</ul>
)}
</SectionShell>
);
}
function Stat({
label,
value,
tone,
}: {
label: string;
value: string;
tone?: "error" | "warning";
}) {
return (
<div>
<p className="text-xs text-base-content/60">{label}</p>
<p
className={
tone === "error"
? "text-xl font-semibold text-error"
: tone === "warning"
? "text-xl font-semibold text-warning"
: "text-xl font-semibold"
}
>
{value}
</p>
</div>
);
}
export function ReportView({ projectId }: { projectId: string }) {
const reportQuery = useQuery({
queryKey: ["project-report", projectId],
queryFn: () => getProjectReport({ data: { projectId } }),
});
if (reportQuery.isPending) {
return (
<div className="flex justify-center py-20">
<span className="loading loading-spinner loading-lg" />
</div>
);
}
if (reportQuery.isError) {
return (
<div className="px-4 py-6 md:px-6">
<div className="alert alert-error">
<AlertCircle className="size-5" />
<span>We couldn&rsquo;t build this report. Try again.</span>
</div>
</div>
);
}
const report = reportQuery.data;
return (
<div className="mx-auto max-w-4xl space-y-4 p-4 py-6 md:p-8 print:max-w-none print:p-0">
<div className="flex items-center justify-between gap-4 print:hidden">
<div>
<h1 className="text-2xl font-semibold">SEO Report</h1>
<p className="text-sm text-base-content/60">
{report.project.domain ?? report.project.name}
</p>
</div>
<button
type="button"
className="btn btn-primary btn-sm gap-2"
onClick={() => window.print()}
>
<Download className="size-4" />
Download PDF
</button>
</div>
{/* Print-only header the app chrome above is hidden on print
(AppShell adds print:hidden to the sidebar/topbar), so the report
needs its own header for the printed/PDF version. */}
<div className="hidden print:mb-4 print:block">
<h1 className="text-2xl font-semibold">
{report.project.name} SEO Report
</h1>
{report.project.domain && (
<p className="text-sm text-base-content/60">
{report.project.domain}
</p>
)}
<p className="text-xs text-base-content/50">
Generated {formatDate(report.generatedAt)}
</p>
</div>
<div className="space-y-4 print:space-y-3">
<SearchConsoleSection section={report.searchConsole} />
<AnalyticsSection section={report.analytics} />
<AuditSection section={report.audit} />
</div>
</div>
);
}

View File

@ -115,12 +115,12 @@ export function AuthenticatedAppLayout({
}, [shouldShowMissingSeoApiKeyModal]); }, [shouldShowMissingSeoApiKeyModal]);
return ( return (
<div className="flex h-[100dvh] bg-base-200"> <div className="flex h-[100dvh] bg-base-200 print:block print:h-auto">
<div className="hidden shrink-0 md:block"> <div className="hidden shrink-0 md:block print:hidden">
<Sidebar projectId={sidebarProjectId} /> <Sidebar projectId={sidebarProjectId} />
</div> </div>
<div className="flex min-w-0 flex-1 flex-col"> <div className="flex min-w-0 flex-1 flex-col print:block">
<MobileTopBar <MobileTopBar
drawerOpen={drawerOpen} drawerOpen={drawerOpen}
onOpenDrawer={() => setDrawerOpen(true)} onOpenDrawer={() => setDrawerOpen(true)}
@ -128,8 +128,8 @@ export function AuthenticatedAppLayout({
{/* PostHog-style cutout: the main content sits on a raised panel with a {/* PostHog-style cutout: the main content sits on a raised panel with a
thin strip of the sidebar background above it and a hairline border. */} thin strip of the sidebar background above it and a hairline border. */}
<div className="flex min-h-0 flex-1 flex-col md:pt-2"> <div className="flex min-h-0 flex-1 flex-col md:pt-2 print:block">
<div className="flex min-h-0 flex-1 flex-col overflow-hidden bg-base-100 md:rounded-tl-lg md:border-l md:border-t md:border-base-300"> <div className="flex min-h-0 flex-1 flex-col overflow-hidden bg-base-100 md:rounded-tl-lg md:border-l md:border-t md:border-base-300 print:block print:overflow-visible print:border-0 print:bg-transparent">
<SeoApiStatusBanners <SeoApiStatusBanners
shouldShowSeoApiWarning={shouldShowSeoApiWarning} shouldShowSeoApiWarning={shouldShowSeoApiWarning}
seoApiKeyStatusError={seoApiKeyStatusError} seoApiKeyStatusError={seoApiKeyStatusError}
@ -137,7 +137,9 @@ export function AuthenticatedAppLayout({
{banner} {banner}
<div className="min-h-0 flex-1 overflow-auto">{children}</div> <div className="min-h-0 flex-1 overflow-auto print:h-auto print:overflow-visible">
{children}
</div>
</div> </div>
</div> </div>
</div> </div>
@ -170,7 +172,7 @@ function MobileTopBar({
onOpenDrawer: () => void; onOpenDrawer: () => void;
}) { }) {
return ( return (
<div className="flex shrink-0 items-center gap-1 border-b border-base-300 bg-base-100 px-2 py-1.5 md:hidden"> <div className="flex shrink-0 items-center gap-1 border-b border-base-300 bg-base-100 px-2 py-1.5 md:hidden print:hidden">
<button <button
type="button" type="button"
className="btn btn-square btn-ghost btn-sm" className="btn btn-square btn-ghost btn-sm"

View File

@ -2,6 +2,7 @@ import {
Bookmark, Bookmark,
Bot, Bot,
ClipboardCheck, ClipboardCheck,
FileText,
Globe, Globe,
LayoutDashboard, LayoutDashboard,
Link2, Link2,
@ -57,6 +58,11 @@ const projectNavItems = [
label: "Site Audit", label: "Site Audit",
icon: ClipboardCheck, icon: ClipboardCheck,
}, },
{
to: "/p/$projectId/report" as const,
label: "SEO Report",
icon: FileText,
},
{ {
to: "/p/$projectId/brand-lookup" as const, to: "/p/$projectId/brand-lookup" as const,
label: "Brand Lookup", label: "Brand Lookup",
@ -120,6 +126,7 @@ export function getProjectNavGroups(projectId: string) {
byPath("/p/$projectId/rank-tracking"), byPath("/p/$projectId/rank-tracking"),
byPath("/p/$projectId/saved"), byPath("/p/$projectId/saved"),
byPath("/p/$projectId/audit"), byPath("/p/$projectId/audit"),
byPath("/p/$projectId/report"),
], ],
}, },
]; ];

View File

@ -51,6 +51,7 @@ import { Route as ProjectPProjectIdSettingsRouteImport } from './routes/_project
import { Route as ProjectPProjectIdSearchPerformanceRouteImport } from './routes/_project/p/$projectId/search-performance' import { Route as ProjectPProjectIdSearchPerformanceRouteImport } from './routes/_project/p/$projectId/search-performance'
import { Route as ProjectPProjectIdSavedRouteImport } from './routes/_project/p/$projectId/saved' import { Route as ProjectPProjectIdSavedRouteImport } from './routes/_project/p/$projectId/saved'
import { Route as ProjectPProjectIdSamRouteImport } from './routes/_project/p/$projectId/sam' import { Route as ProjectPProjectIdSamRouteImport } from './routes/_project/p/$projectId/sam'
import { Route as ProjectPProjectIdReportRouteImport } from './routes/_project/p/$projectId/report'
import { Route as ProjectPProjectIdRankTrackingRouteImport } from './routes/_project/p/$projectId/rank-tracking' import { Route as ProjectPProjectIdRankTrackingRouteImport } from './routes/_project/p/$projectId/rank-tracking'
import { Route as ProjectPProjectIdPromptExplorerRouteImport } from './routes/_project/p/$projectId/prompt-explorer' import { Route as ProjectPProjectIdPromptExplorerRouteImport } from './routes/_project/p/$projectId/prompt-explorer'
import { Route as ProjectPProjectIdKeywordsRouteImport } from './routes/_project/p/$projectId/keywords' import { Route as ProjectPProjectIdKeywordsRouteImport } from './routes/_project/p/$projectId/keywords'
@ -278,6 +279,11 @@ const ProjectPProjectIdSamRoute = ProjectPProjectIdSamRouteImport.update({
path: '/sam', path: '/sam',
getParentRoute: () => ProjectPProjectIdRouteRoute, getParentRoute: () => ProjectPProjectIdRouteRoute,
} as any) } as any)
const ProjectPProjectIdReportRoute = ProjectPProjectIdReportRouteImport.update({
id: '/report',
path: '/report',
getParentRoute: () => ProjectPProjectIdRouteRoute,
} as any)
const ProjectPProjectIdRankTrackingRoute = const ProjectPProjectIdRankTrackingRoute =
ProjectPProjectIdRankTrackingRouteImport.update({ ProjectPProjectIdRankTrackingRouteImport.update({
id: '/rank-tracking', id: '/rank-tracking',
@ -400,6 +406,7 @@ export interface FileRoutesByFullPath {
'/p/$projectId/keywords': typeof ProjectPProjectIdKeywordsRoute '/p/$projectId/keywords': typeof ProjectPProjectIdKeywordsRoute
'/p/$projectId/prompt-explorer': typeof ProjectPProjectIdPromptExplorerRoute '/p/$projectId/prompt-explorer': typeof ProjectPProjectIdPromptExplorerRoute
'/p/$projectId/rank-tracking': typeof ProjectPProjectIdRankTrackingRouteWithChildren '/p/$projectId/rank-tracking': typeof ProjectPProjectIdRankTrackingRouteWithChildren
'/p/$projectId/report': typeof ProjectPProjectIdReportRoute
'/p/$projectId/sam': typeof ProjectPProjectIdSamRoute '/p/$projectId/sam': typeof ProjectPProjectIdSamRoute
'/p/$projectId/saved': typeof ProjectPProjectIdSavedRoute '/p/$projectId/saved': typeof ProjectPProjectIdSavedRoute
'/p/$projectId/search-performance': typeof ProjectPProjectIdSearchPerformanceRoute '/p/$projectId/search-performance': typeof ProjectPProjectIdSearchPerformanceRoute
@ -450,6 +457,7 @@ export interface FileRoutesByTo {
'/p/$projectId/domain': typeof ProjectPProjectIdDomainRoute '/p/$projectId/domain': typeof ProjectPProjectIdDomainRoute
'/p/$projectId/keywords': typeof ProjectPProjectIdKeywordsRoute '/p/$projectId/keywords': typeof ProjectPProjectIdKeywordsRoute
'/p/$projectId/prompt-explorer': typeof ProjectPProjectIdPromptExplorerRoute '/p/$projectId/prompt-explorer': typeof ProjectPProjectIdPromptExplorerRoute
'/p/$projectId/report': typeof ProjectPProjectIdReportRoute
'/p/$projectId/sam': typeof ProjectPProjectIdSamRoute '/p/$projectId/sam': typeof ProjectPProjectIdSamRoute
'/p/$projectId/saved': typeof ProjectPProjectIdSavedRoute '/p/$projectId/saved': typeof ProjectPProjectIdSavedRoute
'/p/$projectId/search-performance': typeof ProjectPProjectIdSearchPerformanceRoute '/p/$projectId/search-performance': typeof ProjectPProjectIdSearchPerformanceRoute
@ -508,6 +516,7 @@ export interface FileRoutesById {
'/_project/p/$projectId/keywords': typeof ProjectPProjectIdKeywordsRoute '/_project/p/$projectId/keywords': typeof ProjectPProjectIdKeywordsRoute
'/_project/p/$projectId/prompt-explorer': typeof ProjectPProjectIdPromptExplorerRoute '/_project/p/$projectId/prompt-explorer': typeof ProjectPProjectIdPromptExplorerRoute
'/_project/p/$projectId/rank-tracking': typeof ProjectPProjectIdRankTrackingRouteWithChildren '/_project/p/$projectId/rank-tracking': typeof ProjectPProjectIdRankTrackingRouteWithChildren
'/_project/p/$projectId/report': typeof ProjectPProjectIdReportRoute
'/_project/p/$projectId/sam': typeof ProjectPProjectIdSamRoute '/_project/p/$projectId/sam': typeof ProjectPProjectIdSamRoute
'/_project/p/$projectId/saved': typeof ProjectPProjectIdSavedRoute '/_project/p/$projectId/saved': typeof ProjectPProjectIdSavedRoute
'/_project/p/$projectId/search-performance': typeof ProjectPProjectIdSearchPerformanceRoute '/_project/p/$projectId/search-performance': typeof ProjectPProjectIdSearchPerformanceRoute
@ -564,6 +573,7 @@ export interface FileRouteTypes {
| '/p/$projectId/keywords' | '/p/$projectId/keywords'
| '/p/$projectId/prompt-explorer' | '/p/$projectId/prompt-explorer'
| '/p/$projectId/rank-tracking' | '/p/$projectId/rank-tracking'
| '/p/$projectId/report'
| '/p/$projectId/sam' | '/p/$projectId/sam'
| '/p/$projectId/saved' | '/p/$projectId/saved'
| '/p/$projectId/search-performance' | '/p/$projectId/search-performance'
@ -614,6 +624,7 @@ export interface FileRouteTypes {
| '/p/$projectId/domain' | '/p/$projectId/domain'
| '/p/$projectId/keywords' | '/p/$projectId/keywords'
| '/p/$projectId/prompt-explorer' | '/p/$projectId/prompt-explorer'
| '/p/$projectId/report'
| '/p/$projectId/sam' | '/p/$projectId/sam'
| '/p/$projectId/saved' | '/p/$projectId/saved'
| '/p/$projectId/search-performance' | '/p/$projectId/search-performance'
@ -671,6 +682,7 @@ export interface FileRouteTypes {
| '/_project/p/$projectId/keywords' | '/_project/p/$projectId/keywords'
| '/_project/p/$projectId/prompt-explorer' | '/_project/p/$projectId/prompt-explorer'
| '/_project/p/$projectId/rank-tracking' | '/_project/p/$projectId/rank-tracking'
| '/_project/p/$projectId/report'
| '/_project/p/$projectId/sam' | '/_project/p/$projectId/sam'
| '/_project/p/$projectId/saved' | '/_project/p/$projectId/saved'
| '/_project/p/$projectId/search-performance' | '/_project/p/$projectId/search-performance'
@ -1002,6 +1014,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ProjectPProjectIdSamRouteImport preLoaderRoute: typeof ProjectPProjectIdSamRouteImport
parentRoute: typeof ProjectPProjectIdRouteRoute parentRoute: typeof ProjectPProjectIdRouteRoute
} }
'/_project/p/$projectId/report': {
id: '/_project/p/$projectId/report'
path: '/report'
fullPath: '/p/$projectId/report'
preLoaderRoute: typeof ProjectPProjectIdReportRouteImport
parentRoute: typeof ProjectPProjectIdRouteRoute
}
'/_project/p/$projectId/rank-tracking': { '/_project/p/$projectId/rank-tracking': {
id: '/_project/p/$projectId/rank-tracking' id: '/_project/p/$projectId/rank-tracking'
path: '/rank-tracking' path: '/rank-tracking'
@ -1212,6 +1231,7 @@ interface ProjectPProjectIdRouteRouteChildren {
ProjectPProjectIdKeywordsRoute: typeof ProjectPProjectIdKeywordsRoute ProjectPProjectIdKeywordsRoute: typeof ProjectPProjectIdKeywordsRoute
ProjectPProjectIdPromptExplorerRoute: typeof ProjectPProjectIdPromptExplorerRoute ProjectPProjectIdPromptExplorerRoute: typeof ProjectPProjectIdPromptExplorerRoute
ProjectPProjectIdRankTrackingRoute: typeof ProjectPProjectIdRankTrackingRouteWithChildren ProjectPProjectIdRankTrackingRoute: typeof ProjectPProjectIdRankTrackingRouteWithChildren
ProjectPProjectIdReportRoute: typeof ProjectPProjectIdReportRoute
ProjectPProjectIdSamRoute: typeof ProjectPProjectIdSamRoute ProjectPProjectIdSamRoute: typeof ProjectPProjectIdSamRoute
ProjectPProjectIdSavedRoute: typeof ProjectPProjectIdSavedRoute ProjectPProjectIdSavedRoute: typeof ProjectPProjectIdSavedRoute
ProjectPProjectIdSearchPerformanceRoute: typeof ProjectPProjectIdSearchPerformanceRoute ProjectPProjectIdSearchPerformanceRoute: typeof ProjectPProjectIdSearchPerformanceRoute
@ -1229,6 +1249,7 @@ const ProjectPProjectIdRouteRouteChildren: ProjectPProjectIdRouteRouteChildren =
ProjectPProjectIdPromptExplorerRoute: ProjectPProjectIdPromptExplorerRoute, ProjectPProjectIdPromptExplorerRoute: ProjectPProjectIdPromptExplorerRoute,
ProjectPProjectIdRankTrackingRoute: ProjectPProjectIdRankTrackingRoute:
ProjectPProjectIdRankTrackingRouteWithChildren, ProjectPProjectIdRankTrackingRouteWithChildren,
ProjectPProjectIdReportRoute: ProjectPProjectIdReportRoute,
ProjectPProjectIdSamRoute: ProjectPProjectIdSamRoute, ProjectPProjectIdSamRoute: ProjectPProjectIdSamRoute,
ProjectPProjectIdSavedRoute: ProjectPProjectIdSavedRoute, ProjectPProjectIdSavedRoute: ProjectPProjectIdSavedRoute,
ProjectPProjectIdSearchPerformanceRoute: ProjectPProjectIdSearchPerformanceRoute:

View File

@ -0,0 +1,11 @@
import { createFileRoute } from "@tanstack/react-router";
import { ReportView } from "@/client/features/reports/ReportView";
export const Route = createFileRoute("/_project/p/$projectId/report")({
component: ReportRoute,
});
function ReportRoute() {
const { projectId } = Route.useParams();
return <ReportView projectId={projectId} />;
}

View File

@ -0,0 +1,175 @@
import { GscNotConnectedError } from "@/server/lib/gscErrors";
import { Ga4ReportError } from "@/server/lib/ga4Errors";
import { GscService } from "@/server/features/gsc/services/GscService";
import { Ga4OrganicOverviewService } from "@/server/features/ga4/services/Ga4OrganicOverviewService";
import {
sumSearchTotals,
toDimensionRows,
} from "@/server/features/gsc/searchPerformanceReport";
import { AuditService } from "@/server/features/audit/services/AuditService";
import { getIssueDescriptor } from "@/shared/audit-issues";
// One combined report per project — Search Console, Google Analytics, and the
// latest completed site audit. Each section is fetched independently and
// degrades on its own: a project missing one integration still gets a report
// for the other two, rather than a single missing connection failing the
// whole page. Read-only; nothing here writes or spends API credits (it reuses
// each domain's existing report data, not a fresh audit/crawl).
const TOP_QUERY_LIMIT = 10;
type SectionStatus<T> =
| { status: "ok"; data: T }
| { status: "not_connected" }
| { status: "error"; message: string };
async function buildSearchConsoleSection(projectId: string): Promise<
SectionStatus<{
siteUrl: string;
totals: ReturnType<typeof sumSearchTotals>;
topQueries: ReturnType<typeof toDimensionRows>;
}>
> {
try {
const result = await GscService.getPerformance({
projectId,
dimensions: ["query"],
dateRange: "last_28_days",
rowLimit: TOP_QUERY_LIMIT,
});
const topQueries = toDimensionRows(result.rows);
topQueries.sort((a, b) => b.clicks - a.clicks);
return {
status: "ok",
data: {
siteUrl: result.siteUrl,
totals: sumSearchTotals(result.rows),
topQueries: topQueries.slice(0, TOP_QUERY_LIMIT),
},
};
} catch (error) {
if (error instanceof GscNotConnectedError)
return { status: "not_connected" };
return {
status: "error",
message: error instanceof Error ? error.message : "Unknown error",
};
}
}
async function buildAnalyticsSection(
projectId: string,
): Promise<
SectionStatus<
Pick<
Awaited<ReturnType<typeof Ga4OrganicOverviewService.getOrganicOverview>>,
"source" | "current" | "previous" | "comparison"
>
>
> {
try {
const result = await Ga4OrganicOverviewService.getOrganicOverview({
projectId,
});
return {
status: "ok",
data: {
source: result.source,
current: result.current,
previous: result.previous,
comparison: result.comparison,
},
};
} catch (error) {
if (error instanceof Ga4ReportError && error.code === "ga4_not_connected") {
return { status: "not_connected" };
}
return {
status: "error",
message: error instanceof Error ? error.message : "Unknown error",
};
}
}
type AuditSectionData = {
auditId: string;
startUrl: string;
completedAt: string | null;
pagesCrawled: number;
criticalCount: number;
warningCount: number;
infoCount: number;
topIssues: Array<{ issueType: string; title: string; count: number }>;
};
async function buildAuditSection(
projectId: string,
): Promise<SectionStatus<AuditSectionData>> {
try {
const history = await AuditService.getHistory(projectId);
const latest = history.find((audit) => audit.status === "completed");
if (!latest) return { status: "not_connected" };
const { audit, issues } = await AuditService.getResults(
latest.id,
projectId,
);
if (!audit) return { status: "not_connected" };
const countByType = new Map<string, number>();
for (const issue of issues) {
countByType.set(
issue.issueType,
(countByType.get(issue.issueType) ?? 0) + 1,
);
}
const issueCounts = [...countByType.entries()];
issueCounts.sort((a, b) => b[1] - a[1]);
const topIssues = issueCounts.slice(0, 5).map(([issueType, count]) => ({
issueType,
title: getIssueDescriptor(issueType)?.title ?? issueType,
count,
}));
return {
status: "ok",
data: {
auditId: audit.id,
startUrl: audit.startUrl,
completedAt: audit.completedAt,
pagesCrawled: audit.pagesCrawled,
criticalCount: issues.filter((i) => i.severity === "critical").length,
warningCount: issues.filter((i) => i.severity === "warning").length,
infoCount: issues.filter((i) => i.severity === "info").length,
topIssues,
},
};
} catch (error) {
return {
status: "error",
message: error instanceof Error ? error.message : "Unknown error",
};
}
}
async function buildProjectReport(input: {
projectId: string;
projectName: string;
projectDomain: string | null;
}) {
const [searchConsole, analytics, audit] = await Promise.all([
buildSearchConsoleSection(input.projectId),
buildAnalyticsSection(input.projectId),
buildAuditSection(input.projectId),
]);
return {
project: { name: input.projectName, domain: input.projectDomain },
generatedAt: new Date().toISOString(),
searchConsole,
analytics,
audit,
};
}
export const ProjectReportService = { buildProjectReport };

View File

@ -0,0 +1,18 @@
import { createServerFn } from "@tanstack/react-start";
import { ProjectReportService } from "@/server/features/reports/services/ProjectReportService";
import { requireProjectContext } from "@/serverFunctions/middleware";
import { getProjectReportSchema } from "@/types/schemas/report";
// The combined Search Console + Analytics + Site Audit report for one
// project. Read-only — reuses each domain's existing report data, so it costs
// nothing to (re)generate.
export const getProjectReport = createServerFn({ method: "POST" })
.middleware(requireProjectContext)
.validator(getProjectReportSchema)
.handler(async ({ context }) => {
return ProjectReportService.buildProjectReport({
projectId: context.projectId,
projectName: context.project.name,
projectDomain: context.project.domain,
});
});

View File

@ -0,0 +1,5 @@
import { z } from "zod";
export const getProjectReportSchema = z.object({
projectId: z.string().min(1),
});