diff --git a/src/client/features/audit/results/export.ts b/src/client/features/audit/results/export.ts
index e3c8f24..bbd8ce0 100644
--- a/src/client/features/audit/results/export.ts
+++ b/src/client/features/audit/results/export.ts
@@ -1,5 +1,6 @@
import type { AuditResultsData } from "@/client/features/audit/results/types";
-import { buildCsv, downloadCsv } from "@/client/lib/csv";
+import { buildCsv, type CsvValue, downloadCsv } from "@/client/lib/csv";
+import { exportTableToSheets } from "@/client/lib/exportToSheets";
function downloadFile(content: string, filename: string, mime: string) {
const blob = new Blob([content], { type: `${mime};charset=utf-8;` });
@@ -11,22 +12,77 @@ function downloadFile(content: string, filename: string, mime: string) {
URL.revokeObjectURL(url);
}
+const PAGES_HEADERS = [
+ "URL",
+ "Status",
+ "Title",
+ "H1",
+ "Words",
+ "Images",
+ "Missing Alt",
+ "Response Time (ms)",
+];
+
+function pagesRows(pages: AuditResultsData["pages"]): CsvValue[][] {
+ return pages.map((page) => [
+ page.url,
+ page.statusCode,
+ page.title ?? "",
+ page.h1Count,
+ page.wordCount,
+ page.imagesTotal,
+ page.imagesMissingAlt,
+ page.responseTimeMs,
+ ]);
+}
+
+const PERFORMANCE_HEADERS = [
+ "URL",
+ "Device",
+ "Performance",
+ "Accessibility",
+ "SEO",
+ "LCP (ms)",
+ "CLS",
+ "INP (ms)",
+ "TTFB (ms)",
+];
+
+function performanceRows(
+ lighthouse: AuditResultsData["lighthouse"],
+ pages: AuditResultsData["pages"],
+): CsvValue[][] {
+ return lighthouse.map((result) => {
+ const page = pages.find((candidate) => candidate.id === result.pageId);
+ return [
+ page?.url ?? "",
+ result.strategy,
+ result.performanceScore,
+ result.accessibilityScore,
+ result.seoScore,
+ result.lcpMs,
+ result.cls,
+ result.inpMs,
+ result.ttfbMs,
+ ];
+ });
+}
+
export function exportPages(
pages: AuditResultsData["pages"],
- format: "csv" | "json",
+ format: "csv" | "json" | "sheets",
) {
- const rows = pages.map((page: AuditResultsData["pages"][number]) => ({
- url: page.url,
- statusCode: page.statusCode,
- title: page.title ?? "",
- h1Count: page.h1Count,
- wordCount: page.wordCount,
- imagesTotal: page.imagesTotal,
- imagesMissingAlt: page.imagesMissingAlt,
- responseTimeMs: page.responseTimeMs,
- }));
-
if (format === "json") {
+ const rows = pages.map((page) => ({
+ url: page.url,
+ statusCode: page.statusCode,
+ title: page.title ?? "",
+ h1Count: page.h1Count,
+ wordCount: page.wordCount,
+ imagesTotal: page.imagesTotal,
+ imagesMissingAlt: page.imagesMissingAlt,
+ responseTimeMs: page.responseTimeMs,
+ }));
downloadFile(
JSON.stringify(rows, null, 2),
"audit-pages.json",
@@ -35,41 +91,26 @@ export function exportPages(
return;
}
- const headers = [
- "URL",
- "Status",
- "Title",
- "H1",
- "Words",
- "Images",
- "Missing Alt",
- "Response Time (ms)",
- ];
- const lines = rows.map((row: (typeof rows)[number]) => [
- row.url,
- row.statusCode,
- row.title,
- row.h1Count,
- row.wordCount,
- row.imagesTotal,
- row.imagesMissingAlt,
- row.responseTimeMs,
- ]);
+ if (format === "sheets") {
+ void exportTableToSheets({
+ headers: PAGES_HEADERS,
+ rows: pagesRows(pages),
+ feature: "audit_pages",
+ });
+ return;
+ }
- downloadCsv("audit-pages.csv", buildCsv(headers, lines));
+ downloadCsv("audit-pages.csv", buildCsv(PAGES_HEADERS, pagesRows(pages)));
}
export function exportPerformance(
lighthouse: AuditResultsData["lighthouse"],
pages: AuditResultsData["pages"],
- format: "csv" | "json",
+ format: "csv" | "json" | "sheets",
) {
- const rows = lighthouse.map(
- (result: AuditResultsData["lighthouse"][number]) => {
- const page = pages.find(
- (candidate: AuditResultsData["pages"][number]) =>
- candidate.id === result.pageId,
- );
+ if (format === "json") {
+ const rows = lighthouse.map((result) => {
+ const page = pages.find((candidate) => candidate.id === result.pageId);
return {
url: page?.url ?? "",
strategy: result.strategy,
@@ -81,10 +122,7 @@ export function exportPerformance(
inpMs: result.inpMs,
ttfbMs: result.ttfbMs,
};
- },
- );
-
- if (format === "json") {
+ });
downloadFile(
JSON.stringify(rows, null, 2),
"audit-performance.json",
@@ -93,28 +131,16 @@ export function exportPerformance(
return;
}
- const headers = [
- "URL",
- "Device",
- "Performance",
- "Accessibility",
- "SEO",
- "LCP (ms)",
- "CLS",
- "INP (ms)",
- "TTFB (ms)",
- ];
- const lines = rows.map((row: (typeof rows)[number]) => [
- row.url,
- row.strategy,
- row.performance,
- row.accessibility,
- row.seo,
- row.lcpMs,
- row.cls,
- row.inpMs,
- row.ttfbMs,
- ]);
+ const rows = performanceRows(lighthouse, pages);
- downloadCsv("audit-performance.csv", buildCsv(headers, lines));
+ if (format === "sheets") {
+ void exportTableToSheets({
+ headers: PERFORMANCE_HEADERS,
+ rows,
+ feature: "audit_performance",
+ });
+ return;
+ }
+
+ downloadCsv("audit-performance.csv", buildCsv(PERFORMANCE_HEADERS, rows));
}
diff --git a/src/client/features/backlinks/BacklinksPageSections.tsx b/src/client/features/backlinks/BacklinksPageSections.tsx
index 53ecafb..6031932 100644
--- a/src/client/features/backlinks/BacklinksPageSections.tsx
+++ b/src/client/features/backlinks/BacklinksPageSections.tsx
@@ -1,5 +1,7 @@
+import { useMemo } from "react";
import { HeaderHelpLabel } from "@/client/features/keywords/components";
import { ArrowLeft, Download, SlidersHorizontal } from "lucide-react";
+import { ExportToSheetsButton } from "@/client/components/table/ExportToSheetsButton";
import {
BacklinksNewLostChart,
BacklinksTrendChart,
@@ -16,7 +18,7 @@ import {
TAB_DESCRIPTIONS,
formatRelativeTimestamp,
} from "./backlinksPageUtils";
-import { exportBacklinksTabCsv } from "./export";
+import { buildBacklinksTabExport, exportBacklinksTabCsv } from "./export";
import type { BacklinksFiltersState } from "./useBacklinksFilters";
export function BacklinksOverviewPanels({
@@ -82,6 +84,10 @@ export function BacklinksResultsCard({
exportTarget: string;
}) {
const currentFilterCount = filters[activeTab].activeFilterCount;
+ const exportTable = useMemo(
+ () => buildBacklinksTabExport({ tab: activeTab, rows: filteredData }),
+ [activeTab, filteredData],
+ );
return (
@@ -115,19 +121,27 @@ export function BacklinksResultsCard({
-
+
+
+
+
diff --git a/src/client/features/backlinks/export.ts b/src/client/features/backlinks/export.ts
index 279e3e0..33d231d 100644
--- a/src/client/features/backlinks/export.ts
+++ b/src/client/features/backlinks/export.ts
@@ -1,4 +1,4 @@
-import { buildCsv, downloadCsv } from "@/client/lib/csv";
+import { buildCsv, type CsvValue, downloadCsv } from "@/client/lib/csv";
import type {
BacklinksOverviewData,
BacklinksSearchState,
@@ -10,103 +10,115 @@ type BacklinksFilteredData = {
topPages: BacklinksOverviewData["topPages"];
};
+export function buildBacklinksTabExport(args: {
+ tab: BacklinksSearchState["tab"];
+ rows: BacklinksFilteredData;
+}): { headers: string[]; rows: CsvValue[][] } {
+ const { tab, rows } = args;
+
+ if (tab === "backlinks") {
+ return {
+ headers: [
+ "Domain",
+ "Source URL",
+ "Target URL",
+ "Anchor",
+ "Type",
+ "Dofollow",
+ "Rel Attributes",
+ "Domain Rank",
+ "Source Page Rank",
+ "Target Rank",
+ "Spam Score",
+ "First Seen",
+ "Last Seen",
+ "Lost",
+ "Broken",
+ "Links Count",
+ ],
+ rows: rows.backlinks.map((row) => [
+ row.domainFrom,
+ row.urlFrom,
+ row.urlTo,
+ row.anchor,
+ row.itemType,
+ row.isDofollow,
+ row.relAttributes.join(", "),
+ row.domainFromRank,
+ row.pageFromRank,
+ row.rank,
+ row.spamScore,
+ row.firstSeen,
+ row.lastSeen,
+ row.isLost,
+ row.isBroken,
+ row.linksCount,
+ ]),
+ };
+ }
+
+ if (tab === "domains") {
+ return {
+ headers: [
+ "Domain",
+ "Backlinks",
+ "Referring Pages",
+ "Rank",
+ "Spam Score",
+ "First Seen",
+ "Broken Backlinks",
+ "Broken Pages",
+ ],
+ rows: rows.referringDomains.map((row) => [
+ row.domain,
+ row.backlinks,
+ row.referringPages,
+ row.rank,
+ row.spamScore,
+ row.firstSeen,
+ row.brokenBacklinks,
+ row.brokenPages,
+ ]),
+ };
+ }
+
+ return {
+ headers: [
+ "Page",
+ "Backlinks",
+ "Referring Domains",
+ "Rank",
+ "Broken Backlinks",
+ ],
+ rows: rows.topPages.map((row) => [
+ row.page,
+ row.backlinks,
+ row.referringDomains,
+ row.rank,
+ row.brokenBacklinks,
+ ]),
+ };
+}
+
export function buildBacklinksTabCsvFile(args: {
tab: BacklinksSearchState["tab"];
target: string;
rows: BacklinksFilteredData;
}) {
- const { tab, target, rows } = args;
-
- if (tab === "backlinks") {
- const headers = [
- "Domain",
- "Source URL",
- "Target URL",
- "Anchor",
- "Type",
- "Dofollow",
- "Rel Attributes",
- "Domain Rank",
- "Source Page Rank",
- "Target Rank",
- "Spam Score",
- "First Seen",
- "Last Seen",
- "Lost",
- "Broken",
- "Links Count",
- ];
- const lines = rows.backlinks.map((row) => [
- row.domainFrom,
- row.urlFrom,
- row.urlTo,
- row.anchor,
- row.itemType,
- row.isDofollow,
- row.relAttributes.join(", "),
- row.domainFromRank,
- row.pageFromRank,
- row.rank,
- row.spamScore,
- row.firstSeen,
- row.lastSeen,
- row.isLost,
- row.isBroken,
- row.linksCount,
- ]);
-
- return {
- filename: buildFilename("backlinks", target),
- content: buildCsv(headers, lines),
- };
- }
-
- if (tab === "domains") {
- const headers = [
- "Domain",
- "Backlinks",
- "Referring Pages",
- "Rank",
- "Spam Score",
- "First Seen",
- "Broken Backlinks",
- "Broken Pages",
- ];
- const lines = rows.referringDomains.map((row) => [
- row.domain,
- row.backlinks,
- row.referringPages,
- row.rank,
- row.spamScore,
- row.firstSeen,
- row.brokenBacklinks,
- row.brokenPages,
- ]);
-
- return {
- filename: buildFilename("referring-domains", target),
- content: buildCsv(headers, lines),
- };
- }
-
- const headers = [
- "Page",
- "Backlinks",
- "Referring Domains",
- "Rank",
- "Broken Backlinks",
- ];
- const lines = rows.topPages.map((row) => [
- row.page,
- row.backlinks,
- row.referringDomains,
- row.rank,
- row.brokenBacklinks,
- ]);
+ const { headers, rows } = buildBacklinksTabExport({
+ tab: args.tab,
+ rows: args.rows,
+ });
+ const filenamePrefix =
+ args.tab === "backlinks"
+ ? "backlinks"
+ : args.tab === "domains"
+ ? "referring-domains"
+ : "top-pages";
return {
- filename: buildFilename("top-pages", target),
- content: buildCsv(headers, lines),
+ filename: buildFilename(filenamePrefix, args.target),
+ content: buildCsv(headers, rows),
};
}
diff --git a/src/client/features/domain/components/DomainResultsCard.tsx b/src/client/features/domain/components/DomainResultsCard.tsx
index 0545ab3..333ddb6 100644
--- a/src/client/features/domain/components/DomainResultsCard.tsx
+++ b/src/client/features/domain/components/DomainResultsCard.tsx
@@ -6,6 +6,7 @@ import {
FileSpreadsheet,
Save,
Search,
+ Sheet,
SlidersHorizontal,
} from "lucide-react";
import { toast } from "sonner";
@@ -13,11 +14,9 @@ import { DomainFilterPanel } from "@/client/features/domain/components/DomainFil
import { DomainKeywordsTable } from "@/client/features/domain/components/DomainKeywordsTable";
import { DomainPagesTable } from "@/client/features/domain/components/DomainPagesTable";
import type { useDomainFilters } from "@/client/features/domain/hooks/useDomainFilters";
-import {
- downloadCsv,
- keywordsToCsv,
- pagesToCsv,
-} from "@/client/features/domain/utils";
+import { keywordsToTable, pagesToTable } from "@/client/features/domain/utils";
+import { buildCsv, downloadCsv } from "@/client/lib/csv";
+import { exportTableToSheets } from "@/client/lib/exportToSheets";
import { captureClientEvent } from "@/client/lib/posthog";
import type {
DomainActiveTab,
@@ -75,8 +74,11 @@ export function DomainResultsCard({
onToggleKeyword,
onToggleAllVisible,
}: Props) {
- const currentRows =
- activeTab === "keywords" ? filteredKeywords : filteredPages;
+ const isKeywordsTab = activeTab === "keywords";
+ const currentRows = isKeywordsTab ? filteredKeywords : filteredPages;
+ const exportTable = isKeywordsTab
+ ? keywordsToTable(filteredKeywords)
+ : pagesToTable(filteredPages);
const handleCopy = async () => {
const text = JSON.stringify(currentRows, null, 2);
@@ -84,12 +86,19 @@ export function DomainResultsCard({
toast.success("Copied data");
};
+ const handleExportToSheets = () => {
+ void exportTableToSheets({
+ headers: exportTable.headers,
+ rows: exportTable.rows,
+ feature: "domain_overview",
+ });
+ };
+
const handleDownload = (extension: "csv" | "xls") => {
- const rows =
- activeTab === "keywords"
- ? keywordsToCsv(filteredKeywords)
- : pagesToCsv(filteredPages);
- downloadCsv(rows, `${overview.domain}-${activeTab}.${extension}`);
+ downloadCsv(
+ `${overview.domain}-${activeTab}.${extension}`,
+ buildCsv(exportTable.headers, exportTable.rows),
+ );
if (extension === "csv") {
captureClientEvent("data:export", {
@@ -99,8 +108,6 @@ export function DomainResultsCard({
}
};
- const isKeywordsTab = activeTab === "keywords";
-
return (
@@ -144,12 +151,18 @@ export function DomainResultsCard({
+ -
+
+
-
-
diff --git a/src/client/features/domain/utils.ts b/src/client/features/domain/utils.ts
index 6b2e0a7..c233f08 100644
--- a/src/client/features/domain/utils.ts
+++ b/src/client/features/domain/utils.ts
@@ -4,7 +4,6 @@ import type {
PageRow,
SortOrder,
} from "@/client/features/domain/types";
-import { buildCsv, downloadCsv as downloadCsvFile } from "@/client/lib/csv";
export function toSortMode(value: string | null): DomainSortMode | undefined {
if (
@@ -101,40 +100,32 @@ export function formatMetric(
return formatNumber(value);
}
-export function keywordsToCsv(rows: KeywordRow[]): string {
- const headers = [
- "Keyword",
- "Rank",
- "Volume",
- "Traffic",
- "CPC",
- "URL",
- "Score",
- ];
- const lines = rows.map((row) => [
- row.keyword,
- row.position,
- row.searchVolume,
- row.traffic,
- row.cpc,
- row.relativeUrl ?? row.url,
- row.keywordDifficulty,
- ]);
- return buildCsv(headers, lines);
+type ExportTable = { headers: string[]; rows: (string | number | null)[][] };
+
+export function keywordsToTable(rows: KeywordRow[]): ExportTable {
+ return {
+ headers: ["Keyword", "Rank", "Volume", "Traffic", "CPC", "URL", "Score"],
+ rows: rows.map((row) => [
+ row.keyword,
+ row.position,
+ row.searchVolume,
+ row.traffic,
+ row.cpc,
+ row.relativeUrl ?? row.url,
+ row.keywordDifficulty,
+ ]),
+ };
}
-export function pagesToCsv(rows: PageRow[]): string {
- const headers = ["Page", "Organic Traffic", "Keywords"];
- const lines = rows.map((row) => [
- row.relativePath ?? row.page,
- row.organicTraffic,
- row.keywords,
- ]);
- return buildCsv(headers, lines);
-}
-
-export function downloadCsv(content: string, filename: string) {
- downloadCsvFile(filename, content);
+export function pagesToTable(rows: PageRow[]): ExportTable {
+ return {
+ headers: ["Page", "Organic Traffic", "Keywords"],
+ rows: rows.map((row) => [
+ row.relativePath ?? row.page,
+ row.organicTraffic,
+ row.keywords,
+ ]),
+ };
}
export function resolveDomainPageHref(
diff --git a/src/client/features/keywords/components/SerpAnalysisCard.tsx b/src/client/features/keywords/components/SerpAnalysisCard.tsx
index 2b02159..8809b31 100644
--- a/src/client/features/keywords/components/SerpAnalysisCard.tsx
+++ b/src/client/features/keywords/components/SerpAnalysisCard.tsx
@@ -6,6 +6,7 @@ import {
TrendingDown,
TrendingUp,
} from "lucide-react";
+import { ExportToSheetsButton } from "@/client/components/table/ExportToSheetsButton";
import type { SerpResultItem } from "@/types/keywords";
import { formatNumber } from "../utils";
@@ -48,8 +49,33 @@ export function SerpAnalysisCard({
return (
-
- {items.length} organic results
+
+
+ {items.length} organic results
+
+
[
+ item.rank,
+ item.title ?? "",
+ item.url,
+ item.domain,
+ item.etv ?? "",
+ item.referringDomains ?? "",
+ item.backlinks ?? "",
+ item.isNew ? "new" : (item.rankChange ?? ""),
+ ])}
+ feature="serp_analysis"
+ />
0
@@ -141,13 +149,19 @@ function DesktopTableCard({ controller }: Props) {
Save Keywords
+
diff --git a/src/client/features/keywords/page/KeywordResearchMobileResults.tsx b/src/client/features/keywords/page/KeywordResearchMobileResults.tsx
index 06b07a5..4d656e7 100644
--- a/src/client/features/keywords/page/KeywordResearchMobileResults.tsx
+++ b/src/client/features/keywords/page/KeywordResearchMobileResults.tsx
@@ -1,4 +1,6 @@
import { FileDown, RotateCcw, Save, SlidersHorizontal } from "lucide-react";
+import { ExportToSheetsButton } from "@/client/components/table/ExportToSheetsButton";
+import { KEYWORD_RESEARCH_HEADERS } from "@/client/features/keywords/state/keywordControllerActions";
import {
KeywordCard,
SerpAnalysisCard,
@@ -58,8 +60,14 @@ export function KeywordResearchMobileResults({ controller }: Props) {
}
function MobileKeywordCards({ controller }: Props) {
- const { activeFilterCount, filteredRows, rows, selectedRows, showFilters } =
- controller;
+ const {
+ activeFilterCount,
+ filteredRows,
+ rows,
+ selectedRows,
+ sheetsExportRows,
+ showFilters,
+ } = controller;
const keywordCountLabel =
selectedRows.size > 0
@@ -105,10 +113,17 @@ function MobileKeywordCards({ controller }: Props) {
>
+
diff --git a/src/client/features/keywords/state/keywordControllerActions.ts b/src/client/features/keywords/state/keywordControllerActions.ts
index 64fc67b..749ee5e 100644
--- a/src/client/features/keywords/state/keywordControllerActions.ts
+++ b/src/client/features/keywords/state/keywordControllerActions.ts
@@ -1,5 +1,6 @@
+import { useMemo } from "react";
import { toast } from "sonner";
-import { buildCsv, downloadCsv } from "@/client/lib/csv";
+import { buildCsv, type CsvValue, downloadCsv } from "@/client/lib/csv";
import { getStandardErrorMessage } from "@/client/lib/error-messages";
import { captureClientEvent } from "@/client/lib/posthog";
import { getLanguageCode } from "@/client/features/keywords/utils";
@@ -8,6 +9,26 @@ import type { SaveKeywordsInput } from "@/types/schemas/keywords";
import type { SortDir, SortField } from "@/client/features/keywords/components";
import type { KeywordResearchControllerInput } from "./useKeywordResearchController";
+export const KEYWORD_RESEARCH_HEADERS = [
+ "Keyword",
+ "Volume",
+ "CPC",
+ "Competition",
+ "Score",
+ "Intent",
+];
+
+function keywordResearchRow(row: KeywordResearchRow): CsvValue[] {
+ return [
+ row.keyword,
+ row.searchVolume ?? "",
+ row.cpc ?? "",
+ row.competition ?? "",
+ row.keywordDifficulty ?? "",
+ row.intent,
+ ];
+}
+
type SaveExportActionParams = {
selectedRows: Set
;
rows: KeywordResearchRow[];
@@ -100,38 +121,37 @@ export function useSaveAndExportActions(params: SaveExportActionParams) {
);
};
- const exportCsv = () => {
- const source =
- selectedRows.size > 0
+ const sheetsExportRows: CsvValue[][] = useMemo(
+ () =>
+ (selectedRows.size > 0
? filteredRows.filter((row) => selectedRows.has(row.keyword))
- : filteredRows;
- if (source.length === 0) {
+ : filteredRows
+ ).map(keywordResearchRow),
+ [filteredRows, selectedRows],
+ );
+
+ const exportCsv = () => {
+ if (sheetsExportRows.length === 0) {
toast.error("No data to export");
return;
}
- const headers = [
- "Keyword",
- "Volume",
- "CPC",
- "Competition",
- "Difficulty",
- "Intent",
- ];
- const csvRows = source.map((row) => [
- row.keyword,
- row.searchVolume ?? "",
- row.cpc?.toFixed(2) ?? "",
- row.competition?.toFixed(2) ?? "",
- row.keywordDifficulty ?? "",
- row.intent,
- ]);
- const csv = buildCsv(headers, csvRows);
- downloadCsv("keyword-research.csv", csv);
+ // CSV file keeps cents-formatted CPC/competition for human readability.
+ const csvRows = sheetsExportRows.map((row) =>
+ row.map((cell, idx) =>
+ (idx === 2 || idx === 3) && typeof cell === "number"
+ ? cell.toFixed(2)
+ : cell,
+ ),
+ );
+ downloadCsv(
+ "keyword-research.csv",
+ buildCsv(KEYWORD_RESEARCH_HEADERS, csvRows),
+ );
captureClientEvent("data:export", {
source_feature: "keyword_research",
- result_count: source.length,
+ result_count: sheetsExportRows.length,
});
};
- return { handleSaveKeywords, confirmSave, exportCsv };
+ return { handleSaveKeywords, confirmSave, exportCsv, sheetsExportRows };
}
diff --git a/src/client/features/keywords/state/useKeywordResearchController.ts b/src/client/features/keywords/state/useKeywordResearchController.ts
index 64c2dca..da70e2d 100644
--- a/src/client/features/keywords/state/useKeywordResearchController.ts
+++ b/src/client/features/keywords/state/useKeywordResearchController.ts
@@ -75,7 +75,7 @@ export function useKeywordResearchController(
[input.sortDir, input.sortField, setSearchParams],
);
- const { handleSaveKeywords, confirmSave, exportCsv } =
+ const { handleSaveKeywords, confirmSave, exportCsv, sheetsExportRows } =
useSaveAndExportActions({
selectedRows: state.selectedRows,
rows: state.rows,
@@ -114,6 +114,7 @@ export function useKeywordResearchController(
confirmSave,
controlsForm: state.controlsForm,
exportCsv,
+ sheetsExportRows,
filteredRows: state.filteredRows,
filtersForm: state.filtersForm,
handleRowClick,
diff --git a/src/client/features/lighthouse/issues/LighthouseIssuesParts.tsx b/src/client/features/lighthouse/issues/LighthouseIssuesParts.tsx
index d8c0de3..9b75193 100644
--- a/src/client/features/lighthouse/issues/LighthouseIssuesParts.tsx
+++ b/src/client/features/lighthouse/issues/LighthouseIssuesParts.tsx
@@ -4,6 +4,7 @@ import {
Download,
FileWarning,
Info,
+ Sheet,
TriangleAlert,
} from "lucide-react";
import type {
@@ -88,6 +89,7 @@ export function LighthouseIssuesToolbar({
onCopy,
onExport,
onExportCsv,
+ onExportSheets,
}: {
category: CategoryTab;
categoryCounts: Record;
@@ -99,6 +101,10 @@ export function LighthouseIssuesToolbar({
onCopy: (data: ExportPayload, toastMessage: string) => void;
onExport: (data: ExportPayload) => void;
onExportCsv: (issues: LighthouseIssue[], variant: "all" | "current") => void;
+ onExportSheets: (
+ issues: LighthouseIssue[],
+ variant: "all" | "current",
+ ) => void;
}) {
const exportCurrentCategory: ExportPayload =
category === "all" ? { mode: "issues" } : { mode: "category", category };
@@ -121,6 +127,7 @@ export function LighthouseIssuesToolbar({
onCopy={onCopy}
onExport={onExport}
onExportCsv={onExportCsv}
+ onExportSheets={onExportSheets}
visibleIssues={visibleIssues}
/>
@@ -167,6 +174,7 @@ function ExportMenu({
onCopy,
onExport,
onExportCsv,
+ onExportSheets,
visibleIssues,
}: {
allIssues: LighthouseIssue[];
@@ -176,6 +184,10 @@ function ExportMenu({
onCopy: (data: ExportPayload, toastMessage: string) => void;
onExport: (data: ExportPayload) => void;
onExportCsv: (issues: LighthouseIssue[], variant: "all" | "current") => void;
+ onExportSheets: (
+ issues: LighthouseIssue[],
+ variant: "all" | "current",
+ ) => void;
visibleIssues: LighthouseIssue[];
}) {
return (
@@ -189,6 +201,27 @@ function ExportMenu({
tabIndex={0}
className="dropdown-content z-10 menu p-2 shadow-lg bg-base-100 border border-base-300 rounded-box w-72"
>
+ -
+ Export to Google Sheets
+
+ -
+
+
+ -
+
+
-
Copy
diff --git a/src/client/features/lighthouse/issues/LighthouseIssuesScreen.tsx b/src/client/features/lighthouse/issues/LighthouseIssuesScreen.tsx
index 1a050d9..0caf3dc 100644
--- a/src/client/features/lighthouse/issues/LighthouseIssuesScreen.tsx
+++ b/src/client/features/lighthouse/issues/LighthouseIssuesScreen.tsx
@@ -5,12 +5,14 @@ import {
exportAuditLighthouseIssues,
getAuditLighthouseIssues,
} from "@/serverFunctions/lighthouse";
+import { exportTableToSheets } from "@/client/lib/exportToSheets";
import type { CategoryTab, ExportPayload, LighthouseIssue } from "./types";
import {
categoryLabel,
categorySlug,
downloadTextFile,
issuesToCsv,
+ issuesToTable,
} from "./utils";
import {
LighthouseIssueList,
@@ -62,6 +64,7 @@ export function LighthouseIssuesScreen(props: LighthouseIssuesScreenProps) {
runCopy,
runExport,
runExportCsv,
+ runExportSheets,
selectedCategoryLabel,
severityCounts,
visibleIssues,
@@ -129,6 +132,7 @@ export function LighthouseIssuesScreen(props: LighthouseIssuesScreenProps) {
void runExport(data);
}}
onExportCsv={runExportCsv}
+ onExportSheets={runExportSheets}
/>
{
+ const table = issuesToTable(rows);
+ void exportTableToSheets({
+ headers: table.headers,
+ rows: table.rows,
+ feature: `lighthouse_issues_${variant}`,
+ });
+ };
+
const runCopy = async (data: ExportPayload, toastMessage: string) => {
try {
const exported = await exportMutation.mutateAsync(data);
@@ -202,6 +218,7 @@ function useLighthouseIssuesActions({
runCopy,
runExport,
runExportCsv,
+ runExportSheets,
selectedCategoryLabel,
severityCounts,
visibleIssues,
diff --git a/src/client/features/lighthouse/issues/utils.tsx b/src/client/features/lighthouse/issues/utils.tsx
index 5ca1fc2..1ee2885 100644
--- a/src/client/features/lighthouse/issues/utils.tsx
+++ b/src/client/features/lighthouse/issues/utils.tsx
@@ -1,6 +1,36 @@
-import { buildCsv } from "@/client/lib/csv";
+import { buildCsv, type CsvValue } from "@/client/lib/csv";
import type { CategoryTab, LighthouseIssue } from "./types";
+const ISSUE_HEADERS = [
+ "Category",
+ "Severity",
+ "Score",
+ "Title",
+ "Display Value",
+ "Description",
+ "Impact (ms)",
+ "Impact (bytes)",
+ "Affected Items",
+];
+
+function issuesToRows(issues: LighthouseIssue[]): CsvValue[][] {
+ return issues.map((issue) => [
+ issue.category,
+ issue.severity,
+ issue.score ?? "",
+ issue.title,
+ issue.displayValue ?? "",
+ issue.description ?? "",
+ issue.impactMs ?? "",
+ issue.impactBytes ?? "",
+ issue.items.length,
+ ]);
+}
+
+export function issuesToTable(issues: LighthouseIssue[]) {
+ return { headers: ISSUE_HEADERS, rows: issuesToRows(issues) };
+}
+
export function categoryLabel(category: CategoryTab) {
if (category === "best-practices") return "Best practices";
if (category === "all") return "All";
@@ -12,31 +42,7 @@ export function categorySlug(category: CategoryTab) {
}
export function issuesToCsv(issues: LighthouseIssue[]) {
- const headers = [
- "Category",
- "Severity",
- "Score",
- "Title",
- "Display Value",
- "Description",
- "Impact (ms)",
- "Impact (bytes)",
- "Affected Items",
- ];
-
- const rows = issues.map((issue) => [
- issue.category,
- issue.severity,
- issue.score ?? "",
- issue.title,
- issue.displayValue ?? "",
- issue.description ?? "",
- issue.impactMs ?? "",
- issue.impactBytes ?? "",
- issue.items.length,
- ]);
-
- return buildCsv(headers, rows);
+ return buildCsv(ISSUE_HEADERS, issuesToRows(issues));
}
export function downloadTextFile(
diff --git a/src/client/features/rank-tracking/ActionsMenu.tsx b/src/client/features/rank-tracking/ActionsMenu.tsx
index 7974a33..91b3811 100644
--- a/src/client/features/rank-tracking/ActionsMenu.tsx
+++ b/src/client/features/rank-tracking/ActionsMenu.tsx
@@ -1,9 +1,17 @@
import { useState } from "react";
-import { MoreHorizontal, Play, Download, Copy, RefreshCw } from "lucide-react";
+import {
+ Copy,
+ Download,
+ MoreHorizontal,
+ Play,
+ RefreshCw,
+ Sheet,
+} from "lucide-react";
export function ActionsMenu({
onCheckNow,
onExport,
+ onExportToSheets,
onCopyKeywords,
onRefreshMetrics,
isRunning,
@@ -13,6 +21,7 @@ export function ActionsMenu({
}: {
onCheckNow: () => void;
onExport: () => void;
+ onExportToSheets: () => void;
onCopyKeywords: () => void;
onRefreshMetrics: () => void;
isRunning: boolean;
@@ -59,6 +68,17 @@ export function ActionsMenu({
/>
{metricsRefreshing ? "Refreshing..." : "Refresh Metrics"}
+