Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 68 additions & 1 deletion src/client/features/expired-domains/ExpiredDomainsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,13 @@ import {
import { InlineQueryError } from "@/client/components/InlineQueryError";
import { getStandardErrorMessage } from "@/client/lib/error-messages";
import type { DomainExpirationStatus } from "@/shared/domainExpiration";
import {
filterFinderRows,
type FinderStatusFilter,
} from "@/shared/expiredDomains";
import { Button } from "@cloudflare/kumo/components/button";
import { Loader } from "@cloudflare/kumo/components/loader";
import { useMemo, useState } from "react";
import { useAutoRestoredRun } from "@/client/features/analysis-runs/useAutoRestoredRun";
import { RUN_FEATURES } from "@/shared/analysis-run-features";
import { expiredDomainsResultSchema } from "@/types/schemas/expiredDomains";
Expand Down Expand Up @@ -104,6 +109,18 @@ export function ExpiredDomainsPanel({
const result = searchQuery.data ?? restored?.result ?? null;
const isRestored = !searchQuery.data && restored != null;

const [statusFilter, setStatusFilter] = useState<FinderStatusFilter>("all");
const [query, setQuery] = useState("");
// Filtering is client-side over rows already paid for -- changing a filter
// must never re-request anything.
const visibleRows = useMemo(
() =>
result
? filterFinderRows(result.rows, { status: statusFilter, query })
: [],
[result, statusFilter, query],
);

return (
<div
data-testid="expired-domains-panel"
Expand Down Expand Up @@ -166,6 +183,38 @@ export function ExpiredDomainsPanel({
) : result ? (
<>
{result.rows.length > 0 ? (
<div className="flex flex-wrap items-center gap-2">
{(
[
["all", "All"],
["expired", "Expired only"],
["critical", "Expires soon"],
["warning", "This quarter"],
] as const
).map(([value, label]) => (
<Button
key={value}
type="button"
size="sm"
variant={statusFilter === value ? "primary" : "ghost"}
aria-pressed={statusFilter === value}
onClick={() => setStatusFilter(value)}
>
{label}
</Button>
))}
<input
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Filter by domain"
aria-label="Filter by domain"
className="input input-sm input-bordered ml-auto w-48"
/>
</div>
) : null}

{visibleRows.length > 0 ? (
<div className="overflow-x-auto">
<table className="table table-sm">
<thead>
Expand All @@ -178,7 +227,7 @@ export function ExpiredDomainsPanel({
</tr>
</thead>
<tbody>
{result.rows.map((row) => (
{visibleRows.map((row) => (
<tr key={row.domain}>
<td className="font-medium">{row.domain}</td>
<td className={STATUS_CLASSES[row.status]}>
Expand All @@ -194,6 +243,11 @@ export function ExpiredDomainsPanel({
</tbody>
</table>
</div>
) : result.rows.length > 0 ? (
// Filtered to nothing is a different message from found nothing.
<p className="py-4 text-base-content/70">
None of the {result.rows.length} results match this filter.
</p>
) : (
// Shows its work. "Nothing found" over 50 checked domains is a
// real, informative answer; a blank card is not.
Expand Down Expand Up @@ -227,6 +281,19 @@ export function ExpiredDomainsPanel({
</Button>
) : null}

{result.sourcesSkipped.length > 0 ? (
// The bug this fixes: a source that returned nothing was counted
// as searched, so a run on a project with no competitors reported
// full coverage and simply looked weak.
<p className="text-xs text-warning">
Not searched:{" "}
{result.sourcesSkipped
.map((skip) => `${skip.source} (${skip.reason})`)
.join("; ")}
.
</p>
) : null}

{result.sourceErrors.length > 0 ? (
// A source that failed is named rather than silently reducing
// coverage -- otherwise the counts above would overstate what was
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,12 @@ function expiration(
}

function sourceOf(name: string, candidates: Candidate[]): CandidateSource {
return { name, metered: false, collect: () => Promise.resolve(candidates) };
return {
name,
metered: false,
unavailableReason: () => null,
collect: () => Promise.resolve(candidates),
};
}

describe("estimateFinderCost", () => {
Expand Down Expand Up @@ -141,6 +146,7 @@ describe("runExpiredDomainFinder", () => {
const failing: CandidateSource = {
name: "link-gap",
metered: true,
unavailableReason: () => null,
collect: () => Promise.reject(new Error("BACKLINKS_BILLING_ISSUE")),
};

Expand Down
3 changes: 3 additions & 0 deletions src/server/features/expired-domains/ExpiredDomainsService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ type FinderResult = {
summary: FinderSummary;
sourcesUsed: string[];
sourceErrors: { source: string; code: string }[];
sourcesSkipped: { source: string; reason: string }[];
};

/**
Expand Down Expand Up @@ -83,6 +84,7 @@ export async function runExpiredDomainFinder(input: {
summary: { checked: 0, surfaced: 0, failed: 0 },
sourcesUsed: collected.sourcesUsed,
sourceErrors: collected.sourceErrors,
sourcesSkipped: collected.sourcesSkipped,
};
}

Expand Down Expand Up @@ -119,5 +121,6 @@ export async function runExpiredDomainFinder(input: {
summary,
sourcesUsed: collected.sourcesUsed,
sourceErrors: collected.sourceErrors,
sourcesSkipped: collected.sourcesSkipped,
};
}
53 changes: 40 additions & 13 deletions src/server/features/expired-domains/candidateSources.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,15 +102,20 @@ describe("createLinkGapSource", () => {
});
});

it("returns nothing rather than calling out when there are no competitors", async () => {
const fetchIntersection = vi.fn();
const candidates = await createLinkGapSource(fetchIntersection).collect({
// The guard lives in `unavailableReason`, not in `collect`, so the run can
// TELL the user link gap did not search rather than silently counting it.
it("declares itself unavailable when there are no competitors", () => {
const source = createLinkGapSource(vi.fn());
const reason = source.unavailableReason({
...CONTEXT,
competitorDomains: [],
});

expect(candidates).toEqual([]);
expect(fetchIntersection).not.toHaveBeenCalled();
expect(reason).toMatch(/no competitors/i);
// The message has to point at the fix, since this is the source that finds
// adjacent sites and its absence is why a run looks thin.
expect(reason).toMatch(/Competitors tab/i);
expect(source.unavailableReason(CONTEXT)).toBeNull();
});
});

Expand Down Expand Up @@ -148,15 +153,13 @@ describe("createSerpRivalsSource", () => {
]);
});

it("returns nothing rather than calling out when there are no keywords", async () => {
const fetchSerp = vi.fn();
const candidates = await createSerpRivalsSource(fetchSerp).collect({
...CONTEXT,
keywords: [],
});
it("declares itself unavailable when there are no keywords", () => {
const source = createSerpRivalsSource(vi.fn());

expect(candidates).toEqual([]);
expect(fetchSerp).not.toHaveBeenCalled();
expect(source.unavailableReason({ ...CONTEXT, keywords: [] })).toMatch(
/rank-tracked keywords/i,
);
expect(source.unavailableReason(CONTEXT)).toBeNull();
});
});

Expand All @@ -165,6 +168,7 @@ describe("collectCandidates", () => {
const good = {
name: "competitors",
metered: false,
unavailableReason: () => null,
collect: vi.fn().mockResolvedValue([
{
domain: "rivala.com",
Expand All @@ -180,6 +184,7 @@ describe("collectCandidates", () => {
const bad = {
name: "link-gap",
metered: true,
unavailableReason: () => null,
collect: vi.fn().mockRejectedValue(new Error("BACKLINKS_BILLING_ISSUE")),
};

Expand All @@ -196,6 +201,7 @@ describe("collectCandidates", () => {
const source = (name: string) => ({
name,
metered: false,
unavailableReason: () => null,
collect: vi.fn().mockResolvedValue([]),
});

Expand All @@ -207,4 +213,25 @@ describe("collectCandidates", () => {
expect(result.sourcesUsed).toEqual(["competitors", "link-gap"]);
expect(result.sourceErrors).toEqual([]);
});

// The bug this pins: link gap returned [] on a project with no competitors,
// was counted as "used", and the run reported coverage it never had.
it("records an unavailable source as skipped, never as used", async () => {
const skipped = {
name: "link-gap",
metered: true,
unavailableReason: () => "no competitors saved",
collect: vi.fn().mockResolvedValue([]),
};

const result = await collectCandidates([skipped], CONTEXT);

expect(result.sourcesUsed).toEqual([]);
expect(result.sourcesSkipped).toEqual([
{ source: "link-gap", reason: "no competitors saved" },
]);
// And it must not have been called at all -- a skipped metered source that
// still fires would be a billed no-op.
expect(skipped.collect).not.toHaveBeenCalled();
});
});
44 changes: 37 additions & 7 deletions src/server/features/expired-domains/candidateSources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,16 @@ export type CandidateSource = {
readonly name: string;
/** True when collecting from this source costs DataForSEO credits. */
readonly metered: boolean;
/**
* Why this source cannot run for this project, or null when it can.
*
* A source that silently returns nothing is worse than one that errors: it
* gets counted as searched, and the summary then claims coverage it never
* had. That is exactly what happened on a project with no saved competitors
* -- link gap contributed nothing, the run reported "50 checked", and the
* user reasonably concluded the feature was just weak.
*/
unavailableReason(context: FinderContext): string | null;
collect(context: FinderContext): Promise<Candidate[]>;
};

Expand Down Expand Up @@ -58,6 +68,10 @@ export function createCompetitorsSource(
return {
name: "competitors",
metered: false,
unavailableReason: (context) =>
context.competitorDomains.length === 0
? "no competitors saved for this project"
: null,
async collect(context) {
const domains = await listCompetitors(context);
return domains.map((domain) => ({
Expand Down Expand Up @@ -86,11 +100,15 @@ export function createLinkGapSource(
return {
name: "link-gap",
metered: true,
// This is the source that reaches ADJACENT domains -- food and nutrition
// sites that link to a vending competitor, say. Without competitors it can
// do nothing, and the run collapses to whatever SERP rivals finds, which is
// by definition more of the same vertical.
unavailableReason: (context) =>
context.competitorDomains.length === 0
? "no competitors saved — link gap is what finds adjacent sites, so add a few on the Competitors tab"
: null,
async collect(context) {
// No competitors means no intersection to compute -- return early rather
// than spend a billed call on an empty target list.
if (context.competitorDomains.length === 0) return [];

const response = await fetchIntersection({
targets: context.competitorDomains,
excludeTargets: [context.projectDomain],
Expand Down Expand Up @@ -121,10 +139,12 @@ export function createSerpRivalsSource(
return {
name: "serp-rivals",
metered: true,
unavailableReason: (context) =>
context.keywords.length === 0
? "no rank-tracked keywords for this project"
: null,
async collect(context) {
const keywords = context.keywords.slice(0, MAX_SERP_KEYWORDS);
if (keywords.length === 0) return [];

const response = await fetchSerp({
keywords,
locationCode: context.locationCode,
Expand Down Expand Up @@ -161,6 +181,8 @@ type CollectedCandidates = {
lists: Candidate[][];
sourcesUsed: string[];
sourceErrors: { source: string; code: string }[];
/** Sources that could not run at all, and why. Surfaced to the user. */
sourcesSkipped: { source: string; reason: string }[];
};

/**
Expand All @@ -179,8 +201,16 @@ export async function collectCandidates(
const lists: Candidate[][] = [];
const sourcesUsed: string[] = [];
const sourceErrors: { source: string; code: string }[] = [];
const sourcesSkipped: { source: string; reason: string }[] = [];

for (const source of sources) {
const reason = source.unavailableReason(context);
if (reason !== null) {
// Recorded, NOT counted as used. The summary must never imply a source
// searched when it could not.
sourcesSkipped.push({ source: source.name, reason });
continue;
}
try {
lists.push(await source.collect(context));
sourcesUsed.push(source.name);
Expand All @@ -192,5 +222,5 @@ export async function collectCandidates(
}
}

return { lists, sourcesUsed, sourceErrors };
return { lists, sourcesUsed, sourceErrors, sourcesSkipped };
}
Loading
Loading