From 05e64ccd36d986d3e842995d4dc0d56bf4024596 Mon Sep 17 00:00:00 2001 From: metatroncubeswdev Date: Sun, 13 Sep 2026 14:37:11 -0400 Subject: [PATCH] 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 --- src/client/features/reports/ReportView.tsx | 321 ++++++++++++++++++ src/client/layout/AppShell.tsx | 16 +- src/client/navigation/items.ts | 7 + src/routeTree.gen.ts | 21 ++ src/routes/_project/p/$projectId/report.tsx | 11 + .../reports/services/ProjectReportService.ts | 175 ++++++++++ src/serverFunctions/report.ts | 18 + src/types/schemas/report.ts | 5 + 8 files changed, 567 insertions(+), 7 deletions(-) create mode 100644 src/client/features/reports/ReportView.tsx create mode 100644 src/routes/_project/p/$projectId/report.tsx create mode 100644 src/server/features/reports/services/ProjectReportService.ts create mode 100644 src/serverFunctions/report.ts create mode 100644 src/types/schemas/report.ts diff --git a/src/client/features/reports/ReportView.tsx b/src/client/features/reports/ReportView.tsx new file mode 100644 index 0000000..5efbe69 --- /dev/null +++ b/src/client/features/reports/ReportView.tsx @@ -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>; + +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 ( +
+
+

{title}

+ {children} +
+
+ ); +} + +function NotConnectedNote({ what }: { what: string }) { + return ( +

+ {what} isn’t connected for this project yet. +

+ ); +} + +function SectionError({ message }: { message: string }) { + return ( +
+ + {message} +
+ ); +} + +function SearchConsoleSection({ + section, +}: { + section: ProjectReport["searchConsole"]; +}) { + if (section.status === "not_connected") { + return ( + + + + ); + } + if (section.status === "error") { + return ( + + + + ); + } + + const { totals, topQueries, siteUrl } = section.data; + return ( + +

+ {siteUrl} · last 28 days +

+
+ + + + +
+ {topQueries.length > 0 && ( +
+ + + + + + + + + + + {topQueries.map((row) => ( + + + + + + + ))} + +
Top queriesClicksImpressionsPosition
{row.key}{formatNumber(row.clicks)}{formatNumber(row.impressions)}{row.position.toFixed(1)}
+
+ )} +
+ ); +} + +function AnalyticsSection({ + section, +}: { + section: ProjectReport["analytics"]; +}) { + if (section.status === "not_connected") { + return ( + + + + ); + } + if (section.status === "error") { + return ( + + + + ); + } + + 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 ( + +

+ {source.propertyDisplayName} · organic search · last 28 + days +

+
+ + + + +
+
+ ); +} + +function AuditSection({ section }: { section: ProjectReport["audit"] }) { + if (section.status === "not_connected") { + return ( + +

+ No completed site audit yet. +

+
+ ); + } + if (section.status === "error") { + return ( + + + + ); + } + + const { + startUrl, + completedAt, + pagesCrawled, + criticalCount, + warningCount, + infoCount, + topIssues, + } = section.data; + + return ( + +

+ {startUrl} · {pagesCrawled} pages · completed{" "} + {completedAt ? formatDate(completedAt) : "—"} +

+
+ + + +
+ {topIssues.length > 0 && ( +
    + {topIssues.map((issue) => ( +
  • + {issue.title} + + {issue.count} {issue.count === 1 ? "page" : "pages"} + +
  • + ))} +
+ )} +
+ ); +} + +function Stat({ + label, + value, + tone, +}: { + label: string; + value: string; + tone?: "error" | "warning"; +}) { + return ( +
+

{label}

+

+ {value} +

+
+ ); +} + +export function ReportView({ projectId }: { projectId: string }) { + const reportQuery = useQuery({ + queryKey: ["project-report", projectId], + queryFn: () => getProjectReport({ data: { projectId } }), + }); + + if (reportQuery.isPending) { + return ( +
+ +
+ ); + } + + if (reportQuery.isError) { + return ( +
+
+ + We couldn’t build this report. Try again. +
+
+ ); + } + + const report = reportQuery.data; + + return ( +
+
+
+

SEO Report

+

+ {report.project.domain ?? report.project.name} +

+
+ +
+ + {/* 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. */} +
+

+ {report.project.name} — SEO Report +

+ {report.project.domain && ( +

+ {report.project.domain} +

+ )} +

+ Generated {formatDate(report.generatedAt)} +

+
+ +
+ + + +
+
+ ); +} diff --git a/src/client/layout/AppShell.tsx b/src/client/layout/AppShell.tsx index ced7521..b873d0d 100644 --- a/src/client/layout/AppShell.tsx +++ b/src/client/layout/AppShell.tsx @@ -115,12 +115,12 @@ export function AuthenticatedAppLayout({ }, [shouldShowMissingSeoApiKeyModal]); return ( -
-
+
+
-
+
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. */} -
-
+
+
{children}
+
+ {children} +
@@ -170,7 +172,7 @@ function MobileTopBar({ onOpenDrawer: () => void; }) { return ( -
+