diff --git a/src/client/features/expired-domains/ExpiredDomainsPanel.tsx b/src/client/features/expired-domains/ExpiredDomainsPanel.tsx index ca539a77cc..01e5426dd7 100644 --- a/src/client/features/expired-domains/ExpiredDomainsPanel.tsx +++ b/src/client/features/expired-domains/ExpiredDomainsPanel.tsx @@ -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"; @@ -104,6 +109,18 @@ export function ExpiredDomainsPanel({ const result = searchQuery.data ?? restored?.result ?? null; const isRestored = !searchQuery.data && restored != null; + const [statusFilter, setStatusFilter] = useState("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 (
{result.rows.length > 0 ? ( +
+ {( + [ + ["all", "All"], + ["expired", "Expired only"], + ["critical", "Expires soon"], + ["warning", "This quarter"], + ] as const + ).map(([value, label]) => ( + + ))} + setQuery(event.target.value)} + placeholder="Filter by domain" + aria-label="Filter by domain" + className="input input-sm input-bordered ml-auto w-48" + /> +
+ ) : null} + + {visibleRows.length > 0 ? (
@@ -178,7 +227,7 @@ export function ExpiredDomainsPanel({ - {result.rows.map((row) => ( + {visibleRows.map((row) => (
{row.domain} @@ -194,6 +243,11 @@ export function ExpiredDomainsPanel({
+ ) : result.rows.length > 0 ? ( + // Filtered to nothing is a different message from found nothing. +

+ None of the {result.rows.length} results match this filter. +

) : ( // Shows its work. "Nothing found" over 50 checked domains is a // real, informative answer; a blank card is not. @@ -227,6 +281,19 @@ export function ExpiredDomainsPanel({ ) : 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. +

+ Not searched:{" "} + {result.sourcesSkipped + .map((skip) => `${skip.source} (${skip.reason})`) + .join("; ")} + . +

+ ) : null} + {result.sourceErrors.length > 0 ? ( // A source that failed is named rather than silently reducing // coverage -- otherwise the counts above would overstate what was diff --git a/src/server/features/expired-domains/ExpiredDomainsService.test.ts b/src/server/features/expired-domains/ExpiredDomainsService.test.ts index a314d787b4..95377304ef 100644 --- a/src/server/features/expired-domains/ExpiredDomainsService.test.ts +++ b/src/server/features/expired-domains/ExpiredDomainsService.test.ts @@ -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", () => { @@ -141,6 +146,7 @@ describe("runExpiredDomainFinder", () => { const failing: CandidateSource = { name: "link-gap", metered: true, + unavailableReason: () => null, collect: () => Promise.reject(new Error("BACKLINKS_BILLING_ISSUE")), }; diff --git a/src/server/features/expired-domains/ExpiredDomainsService.ts b/src/server/features/expired-domains/ExpiredDomainsService.ts index 6de2e71c7f..93d80ae8a5 100644 --- a/src/server/features/expired-domains/ExpiredDomainsService.ts +++ b/src/server/features/expired-domains/ExpiredDomainsService.ts @@ -34,6 +34,7 @@ type FinderResult = { summary: FinderSummary; sourcesUsed: string[]; sourceErrors: { source: string; code: string }[]; + sourcesSkipped: { source: string; reason: string }[]; }; /** @@ -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, }; } @@ -119,5 +121,6 @@ export async function runExpiredDomainFinder(input: { summary, sourcesUsed: collected.sourcesUsed, sourceErrors: collected.sourceErrors, + sourcesSkipped: collected.sourcesSkipped, }; } diff --git a/src/server/features/expired-domains/candidateSources.test.ts b/src/server/features/expired-domains/candidateSources.test.ts index 3a6af1d39b..330d1b9a87 100644 --- a/src/server/features/expired-domains/candidateSources.test.ts +++ b/src/server/features/expired-domains/candidateSources.test.ts @@ -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(); }); }); @@ -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(); }); }); @@ -165,6 +168,7 @@ describe("collectCandidates", () => { const good = { name: "competitors", metered: false, + unavailableReason: () => null, collect: vi.fn().mockResolvedValue([ { domain: "rivala.com", @@ -180,6 +184,7 @@ describe("collectCandidates", () => { const bad = { name: "link-gap", metered: true, + unavailableReason: () => null, collect: vi.fn().mockRejectedValue(new Error("BACKLINKS_BILLING_ISSUE")), }; @@ -196,6 +201,7 @@ describe("collectCandidates", () => { const source = (name: string) => ({ name, metered: false, + unavailableReason: () => null, collect: vi.fn().mockResolvedValue([]), }); @@ -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(); + }); }); diff --git a/src/server/features/expired-domains/candidateSources.ts b/src/server/features/expired-domains/candidateSources.ts index 0930016bdb..0f8f7de5dd 100644 --- a/src/server/features/expired-domains/candidateSources.ts +++ b/src/server/features/expired-domains/candidateSources.ts @@ -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; }; @@ -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) => ({ @@ -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], @@ -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, @@ -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 }[]; }; /** @@ -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); @@ -192,5 +222,5 @@ export async function collectCandidates( } } - return { lists, sourcesUsed, sourceErrors }; + return { lists, sourcesUsed, sourceErrors, sourcesSkipped }; } diff --git a/src/shared/expiredDomains.test.ts b/src/shared/expiredDomains.test.ts index 3b9af6f916..3c084161af 100644 --- a/src/shared/expiredDomains.test.ts +++ b/src/shared/expiredDomains.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { buildFinderRows, + filterFinderRows, mergeCandidates, rankAndCap, scoreCandidate, @@ -321,3 +322,62 @@ describe("buildFinderRows", () => { expect(summary.failed).toBe(1); }); }); + +describe("filterFinderRows", () => { + function row( + domain: string, + status: NonNullable, + ) { + return { + domain, + sources: ["serp-rivals"], + evidence: { + linksToCompetitors: [], + ranksForKeywords: [], + isKnownCompetitor: false, + }, + score: 0, + status, + expiration: expiration(status, 10), + available: null, + }; + } + + const rows = [ + row("gone.com", "expired"), + row("soon.com", "critical"), + row("later.com", "warning"), + ]; + + it("returns everything when no filter is applied", () => { + expect(filterFinderRows(rows, { status: "all", query: "" })).toHaveLength( + 3, + ); + }); + + // The point of the feature: "which of these can I actually buy today". + it("narrows to genuinely expired domains", () => { + const filtered = filterFinderRows(rows, { status: "expired", query: "" }); + expect(filtered.map((r) => r.domain)).toEqual(["gone.com"]); + }); + + it("matches a domain substring case-insensitively", () => { + expect( + filterFinderRows(rows, { status: "all", query: "SOON" }).map( + (r) => r.domain, + ), + ).toEqual(["soon.com"]); + }); + + it("applies status and query together", () => { + expect( + filterFinderRows(rows, { status: "expired", query: "soon" }), + ).toEqual([]); + }); + + it("ignores surrounding whitespace in the query", () => { + expect( + filterFinderRows(rows, { status: "all", query: " gone " }), + ).toHaveLength(1); + }); +}); diff --git a/src/shared/expiredDomains.ts b/src/shared/expiredDomains.ts index f5cca95dcc..242f3e0013 100644 --- a/src/shared/expiredDomains.ts +++ b/src/shared/expiredDomains.ts @@ -231,3 +231,25 @@ export function buildFinderRows( summary: { checked: candidates.length, surfaced: sorted.length, failed }, }; } + +/** What the table is narrowed to. `expired` is the "can I buy this" view. */ +export type FinderStatusFilter = "all" | "expired" | "critical" | "warning"; + +/** + * Narrow the table client-side. + * + * Filtering happens AFTER the run, over rows already paid for -- changing a + * filter must never re-request anything. That is why this takes rows rather + * than run parameters. + */ +export function filterFinderRows( + rows: FinderRow[], + filter: { status: FinderStatusFilter; query: string }, +): FinderRow[] { + const query = filter.query.trim().toLowerCase(); + return rows.filter((row) => { + if (filter.status !== "all" && row.status !== filter.status) return false; + if (query && !row.domain.toLowerCase().includes(query)) return false; + return true; + }); +} diff --git a/src/types/schemas/expiredDomains.ts b/src/types/schemas/expiredDomains.ts index d3899f94b7..3bbfccc633 100644 --- a/src/types/schemas/expiredDomains.ts +++ b/src/types/schemas/expiredDomains.ts @@ -51,4 +51,8 @@ export const expiredDomainsResultSchema = z.object({ }), sourcesUsed: z.array(z.string()), sourceErrors: z.array(z.object({ source: z.string(), code: z.string() })), + // Older runs predate this field; default keeps them restorable. + sourcesSkipped: z + .array(z.object({ source: z.string(), reason: z.string() })) + .default([]), });