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]);
|
}, [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"
|
||||||
|
|||||||
@ -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"),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
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