feat(keywords): add search intent filter to Keyword Research (#76)

Add a multi-select intent filter (Informational, Commercial,
Transactional, Navigational, Unknown) to the Keyword Research table on
both the desktop and mobile layouts (closes #67).

Selected intents are stored as a comma-separated string in a new
`intents` filter field, so they fit the existing all-strings filter
shape and get active-filter counting, persistence, and clear-all for
free. Sorting, pagination, save, and export already derive from the
filtered row set, so they respect the intent filter automatically. The
persistence schema defaults `intents` to "" so filter blobs saved before
this change still load. Intent classification and URL params are
untouched.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maria Zuheros 2026-07-13 18:25:31 +01:00 committed by GitHub
parent 65f7baf8a1
commit 927e931e51
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 286 additions and 10 deletions

View File

@ -18,32 +18,41 @@ const SHORT_LABELS: Record<KeywordIntent, string> = {
unknown: "?", unknown: "?",
}; };
/** Full intent labels, shared with the keyword filters so both stay in sync. */
export const INTENT_LABELS: Record<KeywordIntent, string> = {
informational: "Informational",
commercial: "Commercial",
transactional: "Transactional",
navigational: "Navigational",
unknown: "Unknown",
};
const DESCRIPTIONS: Record< const DESCRIPTIONS: Record<
KeywordIntent, KeywordIntent,
{ label: string; description: string } { label: string; description: string }
> = { > = {
informational: { informational: {
label: "Informational", label: INTENT_LABELS.informational,
description: description:
"The searcher wants information or answers. Use this for educational content, guides, and comparison-light explainers.", "The searcher wants information or answers. Use this for educational content, guides, and comparison-light explainers.",
}, },
commercial: { commercial: {
label: "Commercial", label: INTENT_LABELS.commercial,
description: description:
"The searcher is researching options before a purchase. Treat this as buying intent for comparisons, alternatives, and product-led pages.", "The searcher is researching options before a purchase. Treat this as buying intent for comparisons, alternatives, and product-led pages.",
}, },
transactional: { transactional: {
label: "Transactional", label: INTENT_LABELS.transactional,
description: description:
"The searcher is ready to complete an action, often a purchase. Prioritize clear offers, pricing, trials, or conversion paths.", "The searcher is ready to complete an action, often a purchase. Prioritize clear offers, pricing, trials, or conversion paths.",
}, },
navigational: { navigational: {
label: "Navigational", label: INTENT_LABELS.navigational,
description: description:
"The searcher is looking for a specific site, brand, or page. These queries usually reward matching the expected destination.", "The searcher is looking for a specific site, brand, or page. These queries usually reward matching the expected destination.",
}, },
unknown: { unknown: {
label: "Unknown", label: INTENT_LABELS.unknown,
description: description:
"Intent was not available for this keyword, so avoid making content strategy decisions from this badge alone.", "Intent was not available for this keyword, so avoid making content strategy decisions from this badge alone.",
}, },

View File

@ -0,0 +1,107 @@
import { describe, expect, it } from "vitest";
import type { KeywordIntent, KeywordResearchRow } from "@/types/keywords";
import {
EMPTY_FILTERS,
parseIntentFilter,
toggleIntentFilter,
type KeywordFilterValues,
} from "@/client/features/keywords/keywordResearchTypes";
import { applyKeywordFiltersAndSort } from "./useKeywordFiltering";
function makeRow(keyword: string, intent: KeywordIntent): KeywordResearchRow {
return {
keyword,
searchVolume: 100,
trend: [],
keywordDifficulty: 10,
cpc: 1,
competition: 0.5,
intent,
};
}
function filter(
rows: KeywordResearchRow[],
overrides: Partial<KeywordFilterValues>,
): KeywordResearchRow[] {
return applyKeywordFiltersAndSort({
rows,
filters: { ...EMPTY_FILTERS, ...overrides },
sortField: "keyword",
sortDir: "asc",
});
}
const rows: KeywordResearchRow[] = [
makeRow("buy running shoes", "transactional"),
makeRow("best running shoes", "commercial"),
makeRow("how to run", "informational"),
makeRow("nike store", "navigational"),
makeRow("mystery term", "unknown"),
];
describe("parseIntentFilter", () => {
it("returns an empty list for an empty string", () => {
expect(parseIntentFilter("")).toEqual([]);
});
it("parses a comma-separated string in canonical order", () => {
expect(parseIntentFilter("transactional,informational")).toEqual([
"informational",
"transactional",
]);
});
it("drops unknown tokens and de-duplicates", () => {
expect(parseIntentFilter("commercial,bogus,commercial")).toEqual([
"commercial",
]);
});
});
describe("toggleIntentFilter", () => {
it("adds an intent when absent and keeps canonical order", () => {
expect(toggleIntentFilter("transactional", "informational")).toBe(
"informational,transactional",
);
});
it("removes an intent when already present", () => {
expect(
toggleIntentFilter("informational,transactional", "informational"),
).toBe("transactional");
});
});
describe("applyKeywordFiltersAndSort — intent filtering", () => {
it("returns every row when no intent is selected", () => {
expect(filter(rows, { intents: "" })).toHaveLength(rows.length);
});
it("keeps only rows matching a single selected intent", () => {
const result = filter(rows, { intents: "transactional" });
expect(result.map((r) => r.keyword)).toEqual(["buy running shoes"]);
});
it("keeps rows matching any of multiple selected intents", () => {
const result = filter(rows, { intents: "transactional,commercial" });
expect(result.map((r) => r.keyword).toSorted()).toEqual([
"best running shoes",
"buy running shoes",
]);
});
it("combines the intent filter with other filters (AND)", () => {
// "running" narrows to the two shoe rows; intent narrows to the commercial one.
const result = filter(rows, {
include: "running",
intents: "commercial",
});
expect(result.map((r) => r.keyword)).toEqual(["best running shoes"]);
});
it("ignores invalid intent tokens (treated as no intent match constraint)", () => {
const result = filter(rows, { intents: "bogus" });
expect(result).toHaveLength(rows.length);
});
});

View File

@ -2,10 +2,13 @@ import { useMemo } from "react";
import { sortBy } from "remeda"; import { sortBy } from "remeda";
import { parseTerms } from "@/client/features/keywords/utils"; import { parseTerms } from "@/client/features/keywords/utils";
import type { KeywordResearchRow } from "@/types/keywords"; import type { KeywordResearchRow } from "@/types/keywords";
import type { KeywordFilterValues } from "@/client/features/keywords/keywordResearchTypes"; import {
parseIntentFilter,
type KeywordFilterValues,
} from "@/client/features/keywords/keywordResearchTypes";
import type { SortDir, SortField } from "@/client/features/keywords/components"; import type { SortDir, SortField } from "@/client/features/keywords/components";
function applyKeywordFiltersAndSort(params: { export function applyKeywordFiltersAndSort(params: {
rows: KeywordResearchRow[]; rows: KeywordResearchRow[];
filters: KeywordFilterValues; filters: KeywordFilterValues;
sortField: SortField; sortField: SortField;
@ -13,6 +16,7 @@ function applyKeywordFiltersAndSort(params: {
}): KeywordResearchRow[] { }): KeywordResearchRow[] {
const includeTerms = parseTerms(params.filters.include); const includeTerms = parseTerms(params.filters.include);
const excludeTerms = parseTerms(params.filters.exclude); const excludeTerms = parseTerms(params.filters.exclude);
const selectedIntents = parseIntentFilter(params.filters.intents);
const filtered = params.rows.filter((row) => { const filtered = params.rows.filter((row) => {
const haystack = row.keyword.toLowerCase(); const haystack = row.keyword.toLowerCase();
@ -26,6 +30,10 @@ function applyKeywordFiltersAndSort(params: {
return false; return false;
} }
if (selectedIntents.length > 0 && !selectedIntents.includes(row.intent)) {
return false;
}
const vol = row.searchVolume ?? 0; const vol = row.searchVolume ?? 0;
const cpc = row.cpc ?? 0; const cpc = row.cpc ?? 0;
const kd = row.keywordDifficulty ?? 0; const kd = row.keywordDifficulty ?? 0;

View File

@ -0,0 +1,35 @@
import { describe, expect, it } from "vitest";
import { EMPTY_FILTERS } from "@/client/features/keywords/keywordResearchTypes";
import { filterValuesSchema } from "./useLocalKeywordFilters";
describe("filterValuesSchema — persistence migration", () => {
it("defaults intents to '' for blobs persisted before the intent filter existed", () => {
// A filter blob saved by an older build has no `intents` key.
const legacyBlob = {
include: "shoes",
exclude: "",
minVol: "100",
maxVol: "",
minCpc: "",
maxCpc: "",
minKd: "",
maxKd: "",
};
const parsed = filterValuesSchema.parse(legacyBlob);
expect(parsed.intents).toBe("");
// The rest of the user's saved filters survive the migration.
expect(parsed.include).toBe("shoes");
expect(parsed.minVol).toBe("100");
});
it("preserves a stored intents value", () => {
const parsed = filterValuesSchema.parse({
...EMPTY_FILTERS,
intents: "transactional,commercial",
});
expect(parsed.intents).toBe("transactional,commercial");
});
});

View File

@ -8,7 +8,7 @@ import {
const STORAGE_KEY = "keyword-default-filters"; const STORAGE_KEY = "keyword-default-filters";
const filterValuesSchema = z.object({ export const filterValuesSchema = z.object({
include: z.string(), include: z.string(),
exclude: z.string(), exclude: z.string(),
minVol: z.string(), minVol: z.string(),
@ -17,6 +17,9 @@ const filterValuesSchema = z.object({
maxCpc: z.string(), maxCpc: z.string(),
minKd: z.string(), minKd: z.string(),
maxKd: z.string(), maxKd: z.string(),
// Defaulted so filter blobs persisted before the intent filter existed still
// parse (missing key -> "") instead of discarding the user's saved filters.
intents: z.string().default(""),
}); });
function loadFiltersFromStorage(): KeywordFilterValues { function loadFiltersFromStorage(): KeywordFilterValues {
@ -63,6 +66,7 @@ export function useLocalKeywordFilters() {
"maxCpc", "maxCpc",
"minKd", "minKd",
"maxKd", "maxKd",
"intents",
]; ];
for (const key of keys) { for (const key of keys) {

View File

@ -1,3 +1,5 @@
import type { KeywordIntent } from "@/types/keywords";
export const MAX_KEYWORDS_PER_SUBMIT = 5; export const MAX_KEYWORDS_PER_SUBMIT = 5;
export type ResultLimit = 150 | 300 | 500; export type ResultLimit = 150 | 300 | 500;
@ -17,6 +19,14 @@ export type KeywordFilterValues = {
maxCpc: string; maxCpc: string;
minKd: string; minKd: string;
maxKd: string; maxKd: string;
/**
* Selected search intents, stored as a comma-separated string (e.g.
* "transactional,commercial") so it fits the all-strings filter shape used
* for form state, persistence, and active-filter counting. Empty = no intent
* filter. Use {@link parseIntentFilter} / {@link toggleIntentFilter} to read
* and edit it rather than parsing the string ad hoc.
*/
intents: string;
}; };
export const EMPTY_FILTERS: KeywordFilterValues = { export const EMPTY_FILTERS: KeywordFilterValues = {
@ -28,4 +38,43 @@ export const EMPTY_FILTERS: KeywordFilterValues = {
maxCpc: "", maxCpc: "",
minKd: "", minKd: "",
maxKd: "", maxKd: "",
intents: "",
}; };
/** Canonical intent order for stable serialization and UI display. */
export const KEYWORD_INTENT_ORDER: KeywordIntent[] = [
"informational",
"commercial",
"transactional",
"navigational",
"unknown",
];
const KEYWORD_INTENT_SET = new Set<string>(KEYWORD_INTENT_ORDER);
/** Parses the stored intents string into a list of valid, de-duplicated intents. */
export function parseIntentFilter(value: string): KeywordIntent[] {
if (!value) return [];
const selected = new Set(
value
.split(",")
.map((part) => part.trim())
.filter((part): part is KeywordIntent => KEYWORD_INTENT_SET.has(part)),
);
// Emit in canonical order so persistence and comparisons are deterministic.
return KEYWORD_INTENT_ORDER.filter((intent) => selected.has(intent));
}
/** Toggles one intent in the stored string, preserving canonical order. */
export function toggleIntentFilter(
value: string,
intent: KeywordIntent,
): string {
const selected = new Set(parseIntentFilter(value));
if (selected.has(intent)) {
selected.delete(intent);
} else {
selected.add(intent);
}
return KEYWORD_INTENT_ORDER.filter((item) => selected.has(item)).join(",");
}

View File

@ -23,9 +23,10 @@ import {
import type { KeywordResearchRow } from "@/types/keywords"; import type { KeywordResearchRow } from "@/types/keywords";
import type { KeywordResearchControllerState } from "./types"; import type { KeywordResearchControllerState } from "./types";
import { import {
FilterIntentSelect,
FilterRangeInputs, FilterRangeInputs,
FilterTextInput, FilterTextInput,
} from "./keywordResearchDesktopFilters"; } from "./keywordResearchFilters";
import { KeywordResearchDesktopTable } from "./KeywordResearchDesktopTable"; import { KeywordResearchDesktopTable } from "./KeywordResearchDesktopTable";
import { import {
KeywordResearchPagination, KeywordResearchPagination,
@ -330,6 +331,8 @@ function DesktopFilters({ controller }: Props) {
maxName="maxKd" maxName="maxKd"
/> />
</div> </div>
<FilterIntentSelect form={filtersForm} />
</div> </div>
); );
} }

View File

@ -19,7 +19,7 @@ import {
import { DifficultyBadge } from "@/client/features/domain/components/DifficultyBadge"; import { DifficultyBadge } from "@/client/features/domain/components/DifficultyBadge";
import { formatNumber } from "@/client/features/keywords/utils"; import { formatNumber } from "@/client/features/keywords/utils";
import type { KeywordResearchRow } from "@/types/keywords"; import type { KeywordResearchRow } from "@/types/keywords";
import { EmptyFilterResults } from "./keywordResearchDesktopFilters"; import { EmptyFilterResults } from "./keywordResearchFilters";
type Props = { type Props = {
activeFilterCount: number; activeFilterCount: number;

View File

@ -15,6 +15,7 @@ import {
import { exportTableToSheets } from "@/client/lib/exportToSheets"; import { exportTableToSheets } from "@/client/lib/exportToSheets";
import { captureClientEvent } from "@/client/lib/posthog"; import { captureClientEvent } from "@/client/lib/posthog";
import { SerpAnalysisCard } from "@/client/features/keywords/components"; import { SerpAnalysisCard } from "@/client/features/keywords/components";
import { FilterIntentSelect } from "./keywordResearchFilters";
import { KeywordResearchDesktopTable } from "./KeywordResearchDesktopTable"; import { KeywordResearchDesktopTable } from "./KeywordResearchDesktopTable";
import { import {
KeywordResearchPagination, KeywordResearchPagination,
@ -322,6 +323,8 @@ function MobileFilters({ controller }: Props) {
placeholder="Max difficulty" placeholder="Max difficulty"
/> />
</div> </div>
<FilterIntentSelect form={filtersForm} />
</div> </div>
); );
} }

View File

@ -1,5 +1,63 @@
import {
KEYWORD_INTENT_ORDER,
parseIntentFilter,
toggleIntentFilter,
} from "@/client/features/keywords/keywordResearchTypes";
import { INTENT_LABELS } from "@/client/features/keywords/components/IntentBadge";
import type { KeywordResearchControllerState } from "./types"; import type { KeywordResearchControllerState } from "./types";
export function FilterIntentSelect({
form,
}: {
form: KeywordResearchControllerState["filtersForm"];
}) {
return (
<div
role="group"
aria-labelledby="keyword-intent-filter-label"
className="rounded-lg border border-base-300 bg-base-100 p-2.5 space-y-2"
>
<p
id="keyword-intent-filter-label"
className="text-[11px] font-semibold uppercase tracking-wide text-base-content/60"
>
Intent
</p>
<form.Field name="intents">
{(field) => {
const selected = parseIntentFilter(field.state.value);
return (
<div className="flex flex-wrap gap-1.5">
{KEYWORD_INTENT_ORDER.map((intent) => {
const isActive = selected.includes(intent);
return (
<button
key={intent}
type="button"
aria-pressed={isActive}
className={`btn btn-xs ${
isActive
? "btn-primary"
: "btn-ghost border border-base-300"
}`}
onClick={() =>
field.handleChange(
toggleIntentFilter(field.state.value, intent),
)
}
>
{INTENT_LABELS[intent]}
</button>
);
})}
</div>
);
}}
</form.Field>
</div>
);
}
export function FilterTextInput({ export function FilterTextInput({
form, form,
name, name,