Refine rank tracking onboarding and add ahrefs backlinks filter (#253)

This commit is contained in:
Ben Senescu 2026-06-09 22:17:13 -04:00 committed by GitHub
parent c38b453ab0
commit 2db6fdec71
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 283 additions and 110 deletions

View File

@ -106,8 +106,10 @@ function CompactRangeInput({
function BacklinksTabFilters({
form,
showAhrefsDrFilter,
}: {
form: BacklinksFiltersState["backlinks"]["form"];
showAhrefsDrFilter: boolean;
}) {
return (
<>
@ -133,6 +135,14 @@ function BacklinksTabFilters({
minName="minDomainRank"
maxName="maxDomainRank"
/>
{showAhrefsDrFilter ? (
<FilterRangeInputs
form={form}
title="Ahrefs DR"
minName="minAhrefsDr"
maxName="maxAhrefsDr"
/>
) : null}
<FilterRangeInputs
form={form}
title="Link Authority"
@ -219,8 +229,10 @@ function BacklinksTabFilters({
function ReferringDomainsFilters({
form,
showAhrefsDrFilter,
}: {
form: BacklinksFiltersState["domains"]["form"];
showAhrefsDrFilter: boolean;
}) {
return (
<>
@ -252,6 +264,14 @@ function ReferringDomainsFilters({
minName="minRank"
maxName="maxRank"
/>
{showAhrefsDrFilter ? (
<FilterRangeInputs
form={form}
title="Ahrefs DR"
minName="minAhrefsDr"
maxName="maxAhrefsDr"
/>
) : null}
<FilterRangeInputs
form={form}
title="Spam Score"
@ -313,9 +333,13 @@ function TopPagesFilters({
export function BacklinksFilterPanel({
activeTab,
filters,
showAhrefsDrFilter,
activeFilterCount,
}: {
activeTab: BacklinksTab;
filters: BacklinksFiltersState;
showAhrefsDrFilter: boolean;
activeFilterCount: number;
}) {
const current = filters[activeTab];
@ -324,9 +348,9 @@ export function BacklinksFilterPanel({
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2">
<p className="text-sm font-semibold">Refine results</p>
{current.activeFilterCount > 0 ? (
{activeFilterCount > 0 ? (
<span className="badge badge-xs badge-primary border-0 text-primary-content">
{current.activeFilterCount} active
{activeFilterCount} active
</span>
) : null}
</div>
@ -334,7 +358,7 @@ export function BacklinksFilterPanel({
type="button"
className="btn btn-xs btn-ghost gap-1"
onClick={current.reset}
disabled={current.activeFilterCount === 0}
disabled={activeFilterCount === 0}
>
<RotateCcw className="size-3" />
Clear all
@ -342,10 +366,16 @@ export function BacklinksFilterPanel({
</div>
{activeTab === "backlinks" ? (
<BacklinksTabFilters form={filters.backlinks.form} />
<BacklinksTabFilters
form={filters.backlinks.form}
showAhrefsDrFilter={showAhrefsDrFilter}
/>
) : null}
{activeTab === "domains" ? (
<ReferringDomainsFilters form={filters.domains.form} />
<ReferringDomainsFilters
form={filters.domains.form}
showAhrefsDrFilter={showAhrefsDrFilter}
/>
) : null}
{activeTab === "pages" ? (
<TopPagesFilters form={filters.pages.form} />

View File

@ -23,6 +23,10 @@ import {
BacklinksExportMenu,
} from "./BacklinksToolbarMenus";
import { buildBacklinksTabExport } from "./export";
import {
filterBacklinkRows,
filterReferringDomainRows,
} from "./backlinksFiltering";
import type { BacklinksFiltersState } from "./useBacklinksFilters";
import { useAhrefsDomainRatings } from "./useAhrefsDomainRatings";
@ -101,21 +105,49 @@ export function BacklinksResultsCard({
exportTarget: string;
onTabChange: (tab: BacklinksSearchState["tab"]) => void;
}) {
const currentFilterCount = filters[activeTab].activeFilterCount;
const exportTable = useMemo(
() => buildBacklinksTabExport({ tab: activeTab, rows: filteredData }),
[activeTab, filteredData],
);
const {
ratings: domainRatings,
isLoading: isLoadingRatings,
loadRatings,
} = useAhrefsDomainRatings(projectId);
const showAhrefsDrFilter = domainRatings !== null && activeTab !== "pages";
const currentFilterCount = countVisibleFilters(
filters[activeTab].values,
showAhrefsDrFilter,
);
const visibleFilteredData = useMemo(
() => ({
backlinks: filterBacklinkRows(
filteredData.backlinks,
filters.backlinks.values,
domainRatings,
),
referringDomains: filterReferringDomainRows(
filteredData.referringDomains,
filters.domains.values,
domainRatings,
),
topPages: filteredData.topPages,
}),
[
domainRatings,
filteredData.backlinks,
filteredData.referringDomains,
filteredData.topPages,
filters.backlinks.values,
filters.domains.values,
],
);
const exportTable = useMemo(
() =>
buildBacklinksTabExport({ tab: activeTab, rows: visibleFilteredData }),
[activeTab, visibleFilteredData],
);
// Domains keyed by both tables that the DR column can enrich. The Referring
// Domains list loads lazily, so this grows once that tab is opened.
const ratableDomains = useMemo(
() => collectRatableDomains(filteredData),
[filteredData],
() => collectRatableDomains(visibleFilteredData),
[visibleFilteredData],
);
// Once the user has opted in, keep newly loaded domains enriched without a
// re-click (e.g. after switching to the lazily-loaded Referring Domains tab).
@ -152,7 +184,7 @@ export function BacklinksResultsCard({
<BacklinksExportMenu
activeTab={activeTab}
exportTarget={exportTarget}
filteredData={filteredData}
filteredData={visibleFilteredData}
headers={exportTable.headers}
rows={exportTable.rows}
/>
@ -183,7 +215,12 @@ export function BacklinksResultsCard({
</div>
{filters.showFilters ? (
<BacklinksFilterPanel activeTab={activeTab} filters={filters} />
<BacklinksFilterPanel
activeTab={activeTab}
filters={filters}
showAhrefsDrFilter={showAhrefsDrFilter}
activeFilterCount={currentFilterCount}
/>
) : null}
<div className="p-4">
@ -194,7 +231,7 @@ export function BacklinksResultsCard({
) : null}
{activeTab === "backlinks" ? (
<BacklinksTable
rows={filteredData.backlinks}
rows={visibleFilteredData.backlinks}
domainRatings={domainRatings}
/>
) : null}
@ -203,7 +240,7 @@ export function BacklinksResultsCard({
) : null}
{activeTab === "domains" && !isTabLoading && !tabErrorMessage ? (
<ReferringDomainsTable
rows={filteredData.referringDomains}
rows={visibleFilteredData.referringDomains}
domainRatings={domainRatings}
/>
) : null}
@ -235,6 +272,21 @@ function collectRatableDomains(filteredData: {
];
}
function countVisibleFilters(
values: Record<string, string>,
showAhrefsDrFilter: boolean,
) {
return Object.entries(values).filter(([key, value]) => {
if (
!showAhrefsDrFilter &&
(key === "minAhrefsDr" || key === "maxAhrefsDr")
) {
return false;
}
return value.trim() !== "";
}).length;
}
function OverviewGrid({
data,
summaryStats,

View File

@ -3,6 +3,8 @@ export type BacklinksTabFilterValues = {
exclude: string;
minDomainRank: string;
maxDomainRank: string;
minAhrefsDr: string;
maxAhrefsDr: string;
minLinkAuthority: string;
maxLinkAuthority: string;
minSpamScore: string;
@ -19,6 +21,8 @@ export type ReferringDomainsFilterValues = {
maxBacklinks: string;
minRank: string;
maxRank: string;
minAhrefsDr: string;
maxAhrefsDr: string;
minSpamScore: string;
maxSpamScore: string;
};
@ -39,6 +43,8 @@ export const EMPTY_BACKLINKS_FILTERS: BacklinksTabFilterValues = {
exclude: "",
minDomainRank: "",
maxDomainRank: "",
minAhrefsDr: "",
maxAhrefsDr: "",
minLinkAuthority: "",
maxLinkAuthority: "",
minSpamScore: "",
@ -55,6 +61,8 @@ export const EMPTY_REFERRING_DOMAINS_FILTERS: ReferringDomainsFilterValues = {
maxBacklinks: "",
minRank: "",
maxRank: "",
minAhrefsDr: "",
maxAhrefsDr: "",
minSpamScore: "",
maxSpamScore: "",
};

View File

@ -1,10 +1,39 @@
import { describe, expect, it } from "vitest";
import type { BacklinksOverviewData } from "./backlinksPageTypes";
import { EMPTY_REFERRING_DOMAINS_FILTERS } from "./backlinksFilterTypes";
import { filterReferringDomainRows } from "./backlinksFiltering";
import {
EMPTY_BACKLINKS_FILTERS,
EMPTY_REFERRING_DOMAINS_FILTERS,
} from "./backlinksFilterTypes";
import {
filterBacklinkRows,
filterReferringDomainRows,
} from "./backlinksFiltering";
type BacklinkRow = BacklinksOverviewData["backlinks"][number];
type ReferringDomainRow = BacklinksOverviewData["referringDomains"][number];
function makeBacklinkRow(overrides: Partial<BacklinkRow> = {}): BacklinkRow {
return {
urlFrom: "https://example.com/post",
urlTo: "https://target.example/page",
domainFrom: "example.com",
anchor: "Example",
itemType: "anchor",
rank: 10,
domainFromRank: 20,
pageFromRank: 10,
spamScore: 2,
relAttributes: [],
firstSeen: null,
lastSeen: null,
linksCount: 1,
isDofollow: true,
isLost: false,
isBroken: false,
...overrides,
};
}
function makeReferringDomainRow(
overrides: Partial<ReferringDomainRow> = {},
): ReferringDomainRow {
@ -21,6 +50,57 @@ function makeReferringDomainRow(
};
}
describe("filterBacklinkRows", () => {
it("ignores Ahrefs DR range until ratings are loaded for the backlinks table", () => {
const rows = [
makeBacklinkRow({ domainFrom: "low.example" }),
makeBacklinkRow({ domainFrom: "high.example" }),
];
expect(
filterBacklinkRows(rows, {
...EMPTY_BACKLINKS_FILTERS,
minAhrefsDr: "50",
}),
).toEqual(rows);
});
it("filters by loaded Ahrefs DR range for the backlinks table", () => {
const rows = [
makeBacklinkRow({ domainFrom: "low.example" }),
makeBacklinkRow({ domainFrom: "www.high.example" }),
makeBacklinkRow({ domainFrom: "unknown.example" }),
];
const ratings = {
"low.example": 12,
"high.example": 64,
"unknown.example": null,
};
expect(
filterBacklinkRows(
rows,
{
...EMPTY_BACKLINKS_FILTERS,
minAhrefsDr: "50",
},
ratings,
),
).toEqual([rows[1], rows[2]]);
expect(
filterBacklinkRows(
rows,
{
...EMPTY_BACKLINKS_FILTERS,
maxAhrefsDr: "50",
},
ratings,
),
).toEqual([rows[0], rows[2]]);
});
});
describe("filterReferringDomainRows", () => {
it("filters by spam score range", () => {
const rows = [
@ -43,4 +123,53 @@ describe("filterReferringDomainRows", () => {
}),
).toEqual([rows[1], rows[2]]);
});
it("ignores Ahrefs DR range until ratings are loaded for referring domains", () => {
const rows = [
makeReferringDomainRow({ domain: "low.example" }),
makeReferringDomainRow({ domain: "high.example" }),
];
expect(
filterReferringDomainRows(rows, {
...EMPTY_REFERRING_DOMAINS_FILTERS,
minAhrefsDr: "50",
}),
).toEqual(rows);
});
it("filters by loaded Ahrefs DR range for referring domains", () => {
const rows = [
makeReferringDomainRow({ domain: "low.example" }),
makeReferringDomainRow({ domain: "high.example" }),
makeReferringDomainRow({ domain: "unknown.example" }),
];
const ratings = {
"low.example": 12,
"high.example": 64,
"unknown.example": null,
};
expect(
filterReferringDomainRows(
rows,
{
...EMPTY_REFERRING_DOMAINS_FILTERS,
minAhrefsDr: "50",
},
ratings,
),
).toEqual([rows[1], rows[2]]);
expect(
filterReferringDomainRows(
rows,
{
...EMPTY_REFERRING_DOMAINS_FILTERS,
maxAhrefsDr: "50",
},
ratings,
),
).toEqual([rows[0], rows[2]]);
});
});

View File

@ -5,6 +5,7 @@ import type {
ReferringDomainsFilterValues,
TopPagesFilterValues,
} from "./backlinksFilterTypes";
import type { DomainRatings } from "./useAhrefsDomainRatings";
function passesNumericFilter(
value: number | null | undefined,
@ -40,6 +41,7 @@ function passesTextFilter(
export function filterBacklinkRows(
rows: BacklinksOverviewData["backlinks"],
filters: BacklinksTabFilterValues,
domainRatings?: DomainRatings | null,
): BacklinksOverviewData["backlinks"] {
const includeTerms = parseTerms(filters.include);
const excludeTerms = parseTerms(filters.exclude);
@ -58,6 +60,17 @@ export function filterBacklinkRows(
)
)
return false;
if (
domainRatings &&
!passesNumericFilter(
row.domainFrom
? domainRatings[row.domainFrom.replace(/^www\./, "")]
: null,
filters.minAhrefsDr,
filters.maxAhrefsDr,
)
)
return false;
if (
!passesNumericFilter(
row.rank,
@ -90,6 +103,7 @@ export function filterBacklinkRows(
export function filterReferringDomainRows(
rows: BacklinksOverviewData["referringDomains"],
filters: ReferringDomainsFilterValues,
domainRatings?: DomainRatings | null,
): BacklinksOverviewData["referringDomains"] {
const includeTerms = parseTerms(filters.include);
const excludeTerms = parseTerms(filters.exclude);
@ -107,6 +121,15 @@ export function filterReferringDomainRows(
return false;
if (!passesNumericFilter(row.rank, filters.minRank, filters.maxRank))
return false;
if (
domainRatings &&
!passesNumericFilter(
row.domain ? domainRatings[row.domain] : null,
filters.minAhrefsDr,
filters.maxAhrefsDr,
)
)
return false;
if (
!passesNumericFilter(
row.spamScore,

View File

@ -5,11 +5,8 @@ import {
type RowSelectionState,
type SortingState,
} from "@tanstack/react-table";
import { Loader2, AlertCircle, FileDown, Sheet, X } from "lucide-react";
import { Loader2, AlertCircle, X } from "lucide-react";
import { toast } from "sonner";
import { buildCsv, downloadCsv } from "@/client/lib/csv";
import { exportTableToSheets } from "@/client/lib/exportToSheets";
import { captureClientEvent } from "@/client/lib/posthog";
import { getDomainKeywordSuggestions } from "@/serverFunctions/domain";
import { addTrackingKeywords } from "@/serverFunctions/rank-tracking";
import { getStandardErrorMessage } from "@/client/lib/error-messages";
@ -18,11 +15,6 @@ import {
makeSelectionColumn,
useAppTable,
} from "@/client/components/table/AppDataTable";
import {
TableBulkActionBar,
TableBulkActionButton,
TableBulkExportMenu,
} from "@/client/components/table/TableBulkActionBar";
import { SortableHeader } from "./RankTrackingColumns";
import {
applyShiftRangeSelection,
@ -37,12 +29,6 @@ type SuggestedKeyword = {
};
const PRE_SELECT_COUNT = 20;
const SUGGESTED_KEYWORD_EXPORT_HEADERS = [
"Keyword",
"Position",
"Volume",
"Traffic",
];
const baseColumns: ColumnDef<SuggestedKeyword>[] = [
{
@ -240,32 +226,6 @@ export function KeywordSuggestionStep({
addMutation.mutate(selectedKeywords);
}
};
const selectedSuggestionRows = table
.getSelectedRowModel()
.rows.map((row) => row.original);
const selectedSuggestionExportRows = selectedSuggestionRows.map((row) => [
row.keyword,
row.position ?? "",
row.searchVolume ?? "",
row.traffic ?? "",
]);
const handleExportSelectionToSheets = () => {
void exportTableToSheets({
headers: SUGGESTED_KEYWORD_EXPORT_HEADERS,
rows: selectedSuggestionExportRows,
feature: "rank_tracking",
});
};
const handleExportSelectionCsv = () => {
downloadCsv(
`rank-tracking-suggestions-${domain}.csv`,
buildCsv(SUGGESTED_KEYWORD_EXPORT_HEADERS, selectedSuggestionExportRows),
);
captureClientEvent("rank_tracking:suggestions_export_csv", {
result_count: selectedSuggestionRows.length,
scope: "selection",
});
};
const sectionHeader = (title: string) => (
<div className="flex items-center justify-between">
@ -301,18 +261,12 @@ export function KeywordSuggestionStep({
<div className="flex flex-col items-center justify-center gap-3 py-16">
<AlertCircle className="size-8 text-error" />
<p className="text-xs text-base-content/50">
You can try again or add keywords manually later.
You can skip this step and add keywords manually later.
</p>
<div className="flex gap-2 mt-2">
<button className="btn btn-ghost btn-sm" onClick={onClose}>
<button className="btn btn-primary btn-sm" onClick={onClose}>
Skip
</button>
<button
className="btn btn-primary btn-sm"
onClick={() => suggestionsQuery.refetch()}
>
Try Again
</button>
</div>
</div>
</>
@ -330,7 +284,7 @@ export function KeywordSuggestionStep({
add keywords manually.
</p>
<button className="btn btn-primary btn-sm mt-2" onClick={onClose}>
Continue
Skip
</button>
</div>
</>
@ -365,44 +319,25 @@ export function KeywordSuggestionStep({
/>
<div className="flex items-center justify-between gap-3 pt-1">
<button className="btn btn-ghost btn-sm" onClick={onClose}>
Skip
</button>
<TableBulkActionBar
selectedCount={selectedCount}
selectedLabel={`of ${data.length} selected`}
onClear={() => table.resetRowSelection()}
placement="inline"
actions={
<div className="flex items-center px-1.5">
<TableBulkActionButton
icon={
addMutation.isPending ? (
<Loader2 className="size-3.5 animate-spin" />
) : undefined
}
onClick={handleAdd}
disabled={addMutation.isPending}
>
Add Keyword{selectedCount !== 1 ? "s" : ""}
</TableBulkActionButton>
<TableBulkExportMenu
actions={[
{
label: "Export to Sheets",
icon: <Sheet className="size-4" />,
onClick: handleExportSelectionToSheets,
},
{
label: "Export CSV",
icon: <FileDown className="size-4" />,
onClick: handleExportSelectionCsv,
},
]}
/>
</div>
}
/>
<p className="text-xs text-base-content/60">
{selectedCount} of {data.length} selected
</p>
<div className="flex items-center gap-2">
<button className="btn btn-ghost btn-sm" onClick={onClose}>
Skip
</button>
<button
type="button"
className="btn btn-primary btn-sm"
onClick={handleAdd}
disabled={addMutation.isPending || selectedCount === 0}
>
{addMutation.isPending && (
<Loader2 className="size-3.5 animate-spin" />
)}
Save Keyword{selectedCount !== 1 ? "s" : ""}
</button>
</div>
</div>
</div>
);

View File

@ -256,7 +256,6 @@ function RankTrackingDomainDetailInner({
{/* Portfolio overview */}
{(rows?.length ?? 0) > 0 && (
<RankTrackingOverview
rows={rows ?? []}
device={activeDevice}
projectId={projectId}
configId={config.id}

View File

@ -11,7 +11,6 @@ import {
} from "recharts";
import type { TooltipContentProps } from "recharts";
import { getRankConfigTrend } from "@/serverFunctions/rank-tracking";
import type { RankTrackingRow } from "@/types/schemas/rank-tracking";
import {
formatDateTick,
TrendRangeToggle,
@ -32,12 +31,10 @@ interface PayloadEntry {
}
export function RankTrackingOverview({
rows,
device,
projectId,
configId,
}: {
rows: RankTrackingRow[];
device: "desktop" | "mobile";
projectId: string;
configId: string;