Add combined SEO report: Search Console + Analytics + Site Audit in one view
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:
parent
6d40767d95
commit
05e64ccd36
321
src/client/features/reports/ReportView.tsx
Normal file
321
src/client/features/reports/ReportView.tsx
Normal 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’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} · 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} · organic search · 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} · {pagesCrawled} pages · 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’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>
|
||||
);
|
||||
}
|
||||
@ -115,12 +115,12 @@ export function AuthenticatedAppLayout({
|
||||
}, [shouldShowMissingSeoApiKeyModal]);
|
||||
|
||||
return (
|
||||
<div className="flex h-[100dvh] bg-base-200">
|
||||
<div className="hidden shrink-0 md:block">
|
||||
<div className="flex h-[100dvh] bg-base-200 print:block print:h-auto">
|
||||
<div className="hidden shrink-0 md:block print:hidden">
|
||||
<Sidebar projectId={sidebarProjectId} />
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<div className="flex min-w-0 flex-1 flex-col print:block">
|
||||
<MobileTopBar
|
||||
drawerOpen={drawerOpen}
|
||||
onOpenDrawer={() => setDrawerOpen(true)}
|
||||
@ -128,8 +128,8 @@ export function AuthenticatedAppLayout({
|
||||
|
||||
{/* 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. */}
|
||||
<div className="flex min-h-0 flex-1 flex-col md:pt-2">
|
||||
<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 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 print:block print:overflow-visible print:border-0 print:bg-transparent">
|
||||
<SeoApiStatusBanners
|
||||
shouldShowSeoApiWarning={shouldShowSeoApiWarning}
|
||||
seoApiKeyStatusError={seoApiKeyStatusError}
|
||||
@ -137,7 +137,9 @@ export function AuthenticatedAppLayout({
|
||||
|
||||
{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>
|
||||
@ -170,7 +172,7 @@ function MobileTopBar({
|
||||
onOpenDrawer: () => void;
|
||||
}) {
|
||||
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
|
||||
type="button"
|
||||
className="btn btn-square btn-ghost btn-sm"
|
||||
|
||||
@ -2,6 +2,7 @@ import {
|
||||
Bookmark,
|
||||
Bot,
|
||||
ClipboardCheck,
|
||||
FileText,
|
||||
Globe,
|
||||
LayoutDashboard,
|
||||
Link2,
|
||||
@ -57,6 +58,11 @@ const projectNavItems = [
|
||||
label: "Site Audit",
|
||||
icon: ClipboardCheck,
|
||||
},
|
||||
{
|
||||
to: "/p/$projectId/report" as const,
|
||||
label: "SEO Report",
|
||||
icon: FileText,
|
||||
},
|
||||
{
|
||||
to: "/p/$projectId/brand-lookup" as const,
|
||||
label: "Brand Lookup",
|
||||
@ -120,6 +126,7 @@ export function getProjectNavGroups(projectId: string) {
|
||||
byPath("/p/$projectId/rank-tracking"),
|
||||
byPath("/p/$projectId/saved"),
|
||||
byPath("/p/$projectId/audit"),
|
||||
byPath("/p/$projectId/report"),
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@ -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 ProjectPProjectIdSavedRouteImport } from './routes/_project/p/$projectId/saved'
|
||||
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 ProjectPProjectIdPromptExplorerRouteImport } from './routes/_project/p/$projectId/prompt-explorer'
|
||||
import { Route as ProjectPProjectIdKeywordsRouteImport } from './routes/_project/p/$projectId/keywords'
|
||||
@ -278,6 +279,11 @@ const ProjectPProjectIdSamRoute = ProjectPProjectIdSamRouteImport.update({
|
||||
path: '/sam',
|
||||
getParentRoute: () => ProjectPProjectIdRouteRoute,
|
||||
} as any)
|
||||
const ProjectPProjectIdReportRoute = ProjectPProjectIdReportRouteImport.update({
|
||||
id: '/report',
|
||||
path: '/report',
|
||||
getParentRoute: () => ProjectPProjectIdRouteRoute,
|
||||
} as any)
|
||||
const ProjectPProjectIdRankTrackingRoute =
|
||||
ProjectPProjectIdRankTrackingRouteImport.update({
|
||||
id: '/rank-tracking',
|
||||
@ -400,6 +406,7 @@ export interface FileRoutesByFullPath {
|
||||
'/p/$projectId/keywords': typeof ProjectPProjectIdKeywordsRoute
|
||||
'/p/$projectId/prompt-explorer': typeof ProjectPProjectIdPromptExplorerRoute
|
||||
'/p/$projectId/rank-tracking': typeof ProjectPProjectIdRankTrackingRouteWithChildren
|
||||
'/p/$projectId/report': typeof ProjectPProjectIdReportRoute
|
||||
'/p/$projectId/sam': typeof ProjectPProjectIdSamRoute
|
||||
'/p/$projectId/saved': typeof ProjectPProjectIdSavedRoute
|
||||
'/p/$projectId/search-performance': typeof ProjectPProjectIdSearchPerformanceRoute
|
||||
@ -450,6 +457,7 @@ export interface FileRoutesByTo {
|
||||
'/p/$projectId/domain': typeof ProjectPProjectIdDomainRoute
|
||||
'/p/$projectId/keywords': typeof ProjectPProjectIdKeywordsRoute
|
||||
'/p/$projectId/prompt-explorer': typeof ProjectPProjectIdPromptExplorerRoute
|
||||
'/p/$projectId/report': typeof ProjectPProjectIdReportRoute
|
||||
'/p/$projectId/sam': typeof ProjectPProjectIdSamRoute
|
||||
'/p/$projectId/saved': typeof ProjectPProjectIdSavedRoute
|
||||
'/p/$projectId/search-performance': typeof ProjectPProjectIdSearchPerformanceRoute
|
||||
@ -508,6 +516,7 @@ export interface FileRoutesById {
|
||||
'/_project/p/$projectId/keywords': typeof ProjectPProjectIdKeywordsRoute
|
||||
'/_project/p/$projectId/prompt-explorer': typeof ProjectPProjectIdPromptExplorerRoute
|
||||
'/_project/p/$projectId/rank-tracking': typeof ProjectPProjectIdRankTrackingRouteWithChildren
|
||||
'/_project/p/$projectId/report': typeof ProjectPProjectIdReportRoute
|
||||
'/_project/p/$projectId/sam': typeof ProjectPProjectIdSamRoute
|
||||
'/_project/p/$projectId/saved': typeof ProjectPProjectIdSavedRoute
|
||||
'/_project/p/$projectId/search-performance': typeof ProjectPProjectIdSearchPerformanceRoute
|
||||
@ -564,6 +573,7 @@ export interface FileRouteTypes {
|
||||
| '/p/$projectId/keywords'
|
||||
| '/p/$projectId/prompt-explorer'
|
||||
| '/p/$projectId/rank-tracking'
|
||||
| '/p/$projectId/report'
|
||||
| '/p/$projectId/sam'
|
||||
| '/p/$projectId/saved'
|
||||
| '/p/$projectId/search-performance'
|
||||
@ -614,6 +624,7 @@ export interface FileRouteTypes {
|
||||
| '/p/$projectId/domain'
|
||||
| '/p/$projectId/keywords'
|
||||
| '/p/$projectId/prompt-explorer'
|
||||
| '/p/$projectId/report'
|
||||
| '/p/$projectId/sam'
|
||||
| '/p/$projectId/saved'
|
||||
| '/p/$projectId/search-performance'
|
||||
@ -671,6 +682,7 @@ export interface FileRouteTypes {
|
||||
| '/_project/p/$projectId/keywords'
|
||||
| '/_project/p/$projectId/prompt-explorer'
|
||||
| '/_project/p/$projectId/rank-tracking'
|
||||
| '/_project/p/$projectId/report'
|
||||
| '/_project/p/$projectId/sam'
|
||||
| '/_project/p/$projectId/saved'
|
||||
| '/_project/p/$projectId/search-performance'
|
||||
@ -1002,6 +1014,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof ProjectPProjectIdSamRouteImport
|
||||
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': {
|
||||
id: '/_project/p/$projectId/rank-tracking'
|
||||
path: '/rank-tracking'
|
||||
@ -1212,6 +1231,7 @@ interface ProjectPProjectIdRouteRouteChildren {
|
||||
ProjectPProjectIdKeywordsRoute: typeof ProjectPProjectIdKeywordsRoute
|
||||
ProjectPProjectIdPromptExplorerRoute: typeof ProjectPProjectIdPromptExplorerRoute
|
||||
ProjectPProjectIdRankTrackingRoute: typeof ProjectPProjectIdRankTrackingRouteWithChildren
|
||||
ProjectPProjectIdReportRoute: typeof ProjectPProjectIdReportRoute
|
||||
ProjectPProjectIdSamRoute: typeof ProjectPProjectIdSamRoute
|
||||
ProjectPProjectIdSavedRoute: typeof ProjectPProjectIdSavedRoute
|
||||
ProjectPProjectIdSearchPerformanceRoute: typeof ProjectPProjectIdSearchPerformanceRoute
|
||||
@ -1229,6 +1249,7 @@ const ProjectPProjectIdRouteRouteChildren: ProjectPProjectIdRouteRouteChildren =
|
||||
ProjectPProjectIdPromptExplorerRoute: ProjectPProjectIdPromptExplorerRoute,
|
||||
ProjectPProjectIdRankTrackingRoute:
|
||||
ProjectPProjectIdRankTrackingRouteWithChildren,
|
||||
ProjectPProjectIdReportRoute: ProjectPProjectIdReportRoute,
|
||||
ProjectPProjectIdSamRoute: ProjectPProjectIdSamRoute,
|
||||
ProjectPProjectIdSavedRoute: ProjectPProjectIdSavedRoute,
|
||||
ProjectPProjectIdSearchPerformanceRoute:
|
||||
|
||||
11
src/routes/_project/p/$projectId/report.tsx
Normal file
11
src/routes/_project/p/$projectId/report.tsx
Normal 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} />;
|
||||
}
|
||||
175
src/server/features/reports/services/ProjectReportService.ts
Normal file
175
src/server/features/reports/services/ProjectReportService.ts
Normal 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 };
|
||||
18
src/serverFunctions/report.ts
Normal file
18
src/serverFunctions/report.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
5
src/types/schemas/report.ts
Normal file
5
src/types/schemas/report.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const getProjectReportSchema = z.object({
|
||||
projectId: z.string().min(1),
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user