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: "?",
};
/** 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<
KeywordIntent,
{ label: string; description: string }
> = {
informational: {
label: "Informational",
label: INTENT_LABELS.informational,
description:
"The searcher wants information or answers. Use this for educational content, guides, and comparison-light explainers.",
},
commercial: {
label: "Commercial",
label: INTENT_LABELS.commercial,
description:
"The searcher is researching options before a purchase. Treat this as buying intent for comparisons, alternatives, and product-led pages.",
},
transactional: {
label: "Transactional",
label: INTENT_LABELS.transactional,
description:
"The searcher is ready to complete an action, often a purchase. Prioritize clear offers, pricing, trials, or conversion paths.",
},
navigational: {
label: "Navigational",
label: INTENT_LABELS.navigational,
description:
"The searcher is looking for a specific site, brand, or page. These queries usually reward matching the expected destination.",
},
unknown: {
label: "Unknown",
label: INTENT_LABELS.unknown,
description:
"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 { parseTerms } from "@/client/features/keywords/utils";
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";
function applyKeywordFiltersAndSort(params: {
export function applyKeywordFiltersAndSort(params: {
rows: KeywordResearchRow[];
filters: KeywordFilterValues;
sortField: SortField;
@ -13,6 +16,7 @@ function applyKeywordFiltersAndSort(params: {
}): KeywordResearchRow[] {
const includeTerms = parseTerms(params.filters.include);
const excludeTerms = parseTerms(params.filters.exclude);
const selectedIntents = parseIntentFilter(params.filters.intents);
const filtered = params.rows.filter((row) => {
const haystack = row.keyword.toLowerCase();
@ -26,6 +30,10 @@ function applyKeywordFiltersAndSort(params: {
return false;
}
if (selectedIntents.length > 0 && !selectedIntents.includes(row.intent)) {
return false;
}
const vol = row.searchVolume ?? 0;
const cpc = row.cpc ?? 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 filterValuesSchema = z.object({
export const filterValuesSchema = z.object({
include: z.string(),
exclude: z.string(),
minVol: z.string(),
@ -17,6 +17,9 @@ const filterValuesSchema = z.object({
maxCpc: z.string(),
minKd: 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 {
@ -63,6 +66,7 @@ export function useLocalKeywordFilters() {
"maxCpc",
"minKd",
"maxKd",
"intents",
];
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 type ResultLimit = 150 | 300 | 500;
@ -17,6 +19,14 @@ export type KeywordFilterValues = {
maxCpc: string;
minKd: 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 = {
@ -28,4 +38,43 @@ export const EMPTY_FILTERS: KeywordFilterValues = {
maxCpc: "",
minKd: "",
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 { KeywordResearchControllerState } from "./types";
import {
FilterIntentSelect,
FilterRangeInputs,
FilterTextInput,
} from "./keywordResearchDesktopFilters";
} from "./keywordResearchFilters";
import { KeywordResearchDesktopTable } from "./KeywordResearchDesktopTable";
import {
KeywordResearchPagination,
@ -330,6 +331,8 @@ function DesktopFilters({ controller }: Props) {
maxName="maxKd"
/>
</div>
<FilterIntentSelect form={filtersForm} />
</div>
);
}

View File

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

View File

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