From 37876db0af49e8ca88f29801a3d22373b3b95c6f Mon Sep 17 00:00:00 2001 From: A Sivasubramanian Manoj Date: Fri, 10 Jul 2026 12:04:56 +0000 Subject: [PATCH] Add Rank Tracking metric filters and fix null-last sort ordering --- .../rank-tracking/RankTrackingColumns.tsx | 39 ++- .../RankTrackingFilters.logic.ts | 236 ++++++++++++++++++ .../rank-tracking/RankTrackingFilters.test.ts | 76 +++++- .../rank-tracking/RankTrackingFilters.tsx | 206 ++------------- 4 files changed, 352 insertions(+), 205 deletions(-) create mode 100644 src/client/features/rank-tracking/RankTrackingFilters.logic.ts diff --git a/src/client/features/rank-tracking/RankTrackingColumns.tsx b/src/client/features/rank-tracking/RankTrackingColumns.tsx index a96220ef..2250ef88 100644 --- a/src/client/features/rank-tracking/RankTrackingColumns.tsx +++ b/src/client/features/rank-tracking/RankTrackingColumns.tsx @@ -1,6 +1,6 @@ import { useMemo, type MutableRefObject } from "react"; import { ArrowUp, ArrowDown } from "lucide-react"; -import type { ColumnDef, SortingFn } from "@tanstack/react-table"; +import type { ColumnDef } from "@tanstack/react-table"; import { makeSelectionColumn } from "@/client/components/table/AppDataTable"; import type { RankTrackingRow } from "@/types/schemas/rank-tracking"; import { formatLocationLabel } from "@/shared/keyword-locations"; @@ -61,22 +61,13 @@ export function SortableHeader({ ); } -const nullsLastNumeric: SortingFn = (rowA, rowB, columnId) => { - const a = rowA.getValue(columnId); - const b = rowB.getValue(columnId); - if (a == null && b == null) return 0; - if (a == null) return 1; - if (b == null) return -1; - return a - b; -}; - // Local configs fetch volume scoped to the tracked city, so the header must // say which number the user is looking at — national volume can overstate // local demand by orders of magnitude. function makeVolumeColumn(locationLabel?: string): ColumnDef { return { id: "volume", - accessorKey: "searchVolume", + accessorFn: (row) => row.searchVolume ?? undefined, header: ({ column }) => ( { /> ), size: 90, - cell: ({ getValue }) => ()} />, - sortingFn: nullsLastNumeric, + cell: ({ getValue }) => ( + () ?? null} /> + ), + sortUndefined: "last", }; } const kdColumn: ColumnDef = { id: "kd", - accessorKey: "keywordDifficulty", + accessorFn: (row) => row.keywordDifficulty ?? undefined, header: ({ column }) => , size: 70, - cell: ({ getValue }) => ()} />, - sortingFn: nullsLastNumeric, + cell: ({ getValue }) => ( + () ?? null} /> + ), + sortUndefined: "last", }; const cpcColumn: ColumnDef = { id: "cpc", - accessorKey: "cpc", + accessorFn: (row) => row.cpc ?? undefined, header: ({ column }) => ( ), size: 80, - cell: ({ getValue }) => ()} />, - sortingFn: nullsLastNumeric, + cell: ({ getValue }) => ( + () ?? null} /> + ), + sortUndefined: "last", }; function makeKeywordColumn( @@ -144,14 +141,14 @@ function makeDeviceColumn( const id = device === "desktop" ? "desktopPosition" : "mobilePosition"; return { id, - accessorFn: (row) => row[device].position, + accessorFn: (row) => row[device].position ?? undefined, header: ({ column }) => ( ), size: 120, maxSize: 140, cell: ({ row }) => , - sortingFn: nullsLastNumeric, + sortUndefined: "last", }; } diff --git a/src/client/features/rank-tracking/RankTrackingFilters.logic.ts b/src/client/features/rank-tracking/RankTrackingFilters.logic.ts new file mode 100644 index 00000000..865a1a01 --- /dev/null +++ b/src/client/features/rank-tracking/RankTrackingFilters.logic.ts @@ -0,0 +1,236 @@ +import { LOCATIONS } from "@/client/features/keywords/locations"; +import { devicesLabel } from "@/shared/rank-tracking"; +import type { + RankTrackingConfig, + RankTrackingRow, +} from "@/types/schemas/rank-tracking"; + +export type Filters = { + include: string; + exclude: string; + minDesktopPos: string; + maxDesktopPos: string; + minMobilePos: string; + maxMobilePos: string; + minVolume: string; + maxVolume: string; + minKd: string; + maxKd: string; + minCpc: string; + maxCpc: string; +}; + +type DomainFilterableConfig = Pick< + RankTrackingConfig, + "domain" | "devices" | "locationCode" +>; + +export type DomainListFilters = { + query: string; + device: "all" | RankTrackingConfig["devices"]; + locationCode: string; +}; + +type DomainListFilterOption = { + value: string; + label: string; +}; + +export const EMPTY_FILTERS: Filters = { + include: "", + exclude: "", + minDesktopPos: "", + maxDesktopPos: "", + minMobilePos: "", + maxMobilePos: "", + minVolume: "", + maxVolume: "", + minKd: "", + maxKd: "", + minCpc: "", + maxCpc: "", +}; + +export const EMPTY_DOMAIN_LIST_FILTERS: DomainListFilters = { + query: "", + device: "all", + locationCode: "all", +}; + +const DEVICE_FILTER_ORDER: RankTrackingConfig["devices"][] = [ + "both", + "desktop", + "mobile", +]; + +export function applyDomainListFilters( + configs: T[], + filters: DomainListFilters, +): T[] { + const query = filters.query.trim().toLowerCase(); + const locationCode = + filters.locationCode === "all" ? null : Number(filters.locationCode); + + return configs.filter((config) => { + if (query && !config.domain.toLowerCase().includes(query)) return false; + + if (filters.device !== "all" && config.devices !== filters.device) { + return false; + } + + if (locationCode !== null && config.locationCode !== locationCode) { + return false; + } + + return true; + }); +} + +export function getDomainListFilterOptions(configs: DomainFilterableConfig[]): { + devices: DomainListFilterOption[]; + locations: DomainListFilterOption[]; +} { + const deviceValues = new Set(configs.map((config) => config.devices)); + const devices = DEVICE_FILTER_ORDER.filter((device) => + deviceValues.has(device), + ).map((device) => ({ + value: device, + label: devicesLabel(device), + })); + + const locationMap = new Map(); + for (const config of configs) { + locationMap.set( + config.locationCode, + LOCATIONS[config.locationCode] ?? String(config.locationCode), + ); + } + + const locations = Array.from(locationMap, ([code, label]) => ({ + value: String(code), + label, + })).toSorted((a, b) => a.label.localeCompare(b.label)); + + return { devices, locations }; +} + +export function applyFilters( + rows: RankTrackingRow[], + filters: Filters, +): RankTrackingRow[] { + const includeTerms = filters.include + ? filters.include + .toLowerCase() + .split(",") + .map((t) => t.trim()) + .filter(Boolean) + : []; + const excludeTerms = filters.exclude + ? filters.exclude + .toLowerCase() + .split(",") + .map((t) => t.trim()) + .filter(Boolean) + : []; + + return rows.filter((row) => { + const kw = row.keyword.toLowerCase(); + + if (includeTerms.length > 0 && !includeTerms.some((t) => kw.includes(t))) + return false; + + if (excludeTerms.some((t) => kw.includes(t))) return false; + + if ( + !matchesPositionFilter( + row.desktop.position, + filters.minDesktopPos, + filters.maxDesktopPos, + ) + ) + return false; + + if ( + !matchesPositionFilter( + row.mobile.position, + filters.minMobilePos, + filters.maxMobilePos, + ) + ) + return false; + + if ( + !matchesMetricRangeFilter( + row.searchVolume, + filters.minVolume, + filters.maxVolume, + ) + ) + return false; + + if ( + !matchesMetricRangeFilter( + row.keywordDifficulty, + filters.minKd, + filters.maxKd, + ) + ) + return false; + + if (!matchesMetricRangeFilter(row.cpc, filters.minCpc, filters.maxCpc)) + return false; + + return true; + }); +} + +export function matchesPositionFilter( + position: number | null, + minValue: string, + maxValue: string, +): boolean { + if (!minValue && !maxValue) return true; + + const max = maxValue === "" ? Infinity : Number(maxValue); + if (max === 0) return position === null; + + if (position === null) return false; + + const min = minValue === "" ? 0 : Number(minValue); + return position >= min && position <= max; +} + +export function matchesMetricRangeFilter( + value: number | null, + minValue: string, + maxValue: string, +): boolean { + if (!minValue && !maxValue) return true; + if (value === null) return false; + + const min = minValue === "" ? -Infinity : Number(minValue); + const max = maxValue === "" ? Infinity : Number(maxValue); + return value >= min && value <= max; +} + +export function countActiveFilters(filters: Filters): number { + let count = 0; + if (filters.include) count++; + if (filters.exclude) count++; + if (filters.minDesktopPos || filters.maxDesktopPos) count++; + if (filters.minMobilePos || filters.maxMobilePos) count++; + if (filters.minVolume || filters.maxVolume) count++; + if (filters.minKd || filters.maxKd) count++; + if (filters.minCpc || filters.maxCpc) count++; + return count; +} + +export function countActiveDomainListFilters( + filters: DomainListFilters, +): number { + let count = 0; + if (filters.query.trim()) count++; + if (filters.device !== "all") count++; + if (filters.locationCode !== "all") count++; + return count; +} diff --git a/src/client/features/rank-tracking/RankTrackingFilters.test.ts b/src/client/features/rank-tracking/RankTrackingFilters.test.ts index 9b09dd69..bbf8a296 100644 --- a/src/client/features/rank-tracking/RankTrackingFilters.test.ts +++ b/src/client/features/rank-tracking/RankTrackingFilters.test.ts @@ -7,6 +7,7 @@ import { EMPTY_DOMAIN_LIST_FILTERS, EMPTY_FILTERS, getDomainListFilterOptions, + matchesMetricRangeFilter, matchesPositionFilter, type DomainListFilters, type Filters, @@ -23,13 +24,18 @@ function makeRow( keyword: string, desktopPosition: number | null, mobilePosition: number | null, + metrics: { + volume?: number | null; + kd?: number | null; + cpc?: number | null; + } = {}, ): RankTrackingRow { return { trackingKeywordId: keyword, keyword, - searchVolume: null, - keywordDifficulty: null, - cpc: null, + searchVolume: metrics.volume ?? null, + keywordDifficulty: metrics.kd ?? null, + cpc: metrics.cpc ?? null, desktop: { position: desktopPosition, previousPosition: null, @@ -210,3 +216,67 @@ describe("countActiveDomainListFilters", () => { ).toBe(3); }); }); + +describe("matchesMetricRangeFilter", () => { + it("matches everything when no bounds are set", () => { + expect(matchesMetricRangeFilter(null, "", "")).toBe(true); + expect(matchesMetricRangeFilter(500, "", "")).toBe(true); + }); + + it("excludes null values once a bound is set", () => { + expect(matchesMetricRangeFilter(null, "10", "")).toBe(false); + expect(matchesMetricRangeFilter(null, "", "100")).toBe(false); + }); + + it("treats zero as a real value, not 'no data'", () => { + expect(matchesMetricRangeFilter(0, "0", "10")).toBe(true); + expect(matchesMetricRangeFilter(0, "1", "10")).toBe(false); + }); + + it("respects min-only and max-only bounds", () => { + expect(matchesMetricRangeFilter(50, "20", "")).toBe(true); + expect(matchesMetricRangeFilter(10, "20", "")).toBe(false); + expect(matchesMetricRangeFilter(50, "", "40")).toBe(false); + expect(matchesMetricRangeFilter(50, "", "60")).toBe(true); + }); +}); + +describe("applyFilters metric ranges", () => { + const rows = [ + makeRow("high volume", 3, 6, { volume: 5000, kd: 40, cpc: 2.5 }), + makeRow("low volume", 3, 6, { volume: 10, kd: 80, cpc: 0.1 }), + makeRow("no data", 3, 6, {}), + ]; + + it("filters by volume range", () => { + expect( + applyFilters(rows, withFilters({ minVolume: "100" })).map( + (row) => row.keyword, + ), + ).toEqual(["high volume"]); + }); + + it("filters by KD range", () => { + expect( + applyFilters(rows, withFilters({ maxKd: "50" })).map( + (row) => row.keyword, + ), + ).toEqual(["high volume"]); + }); + + it("filters by CPC range", () => { + expect( + applyFilters(rows, withFilters({ minCpc: "1" })).map( + (row) => row.keyword, + ), + ).toEqual(["high volume"]); + }); + + it("excludes rows without metric data once a metric filter is active", () => { + expect( + applyFilters(rows, withFilters({ minVolume: "0" })).map( + (row) => row.keyword, + ), + ).toEqual(["high volume", "low volume"]); + }); +}); diff --git a/src/client/features/rank-tracking/RankTrackingFilters.tsx b/src/client/features/rank-tracking/RankTrackingFilters.tsx index 16febb24..06c58614 100644 --- a/src/client/features/rank-tracking/RankTrackingFilters.tsx +++ b/src/client/features/rank-tracking/RankTrackingFilters.tsx @@ -1,57 +1,13 @@ import { RotateCcw } from "lucide-react"; -import { LOCATIONS } from "@/client/features/keywords/locations"; -import { devicesLabel } from "@/shared/rank-tracking"; -import type { - RankTrackingConfig, - RankTrackingRow, -} from "@/types/schemas/rank-tracking"; +import type { DomainListFilters, Filters } from "./RankTrackingFilters.logic"; -export type Filters = { - include: string; - exclude: string; - minDesktopPos: string; - maxDesktopPos: string; - minMobilePos: string; - maxMobilePos: string; -}; - -type DomainFilterableConfig = Pick< - RankTrackingConfig, - "domain" | "devices" | "locationCode" ->; - -export type DomainListFilters = { - query: string; - device: "all" | RankTrackingConfig["devices"]; - locationCode: string; -}; +export * from "./RankTrackingFilters.logic"; type DomainListFilterOption = { value: string; label: string; }; -export const EMPTY_FILTERS: Filters = { - include: "", - exclude: "", - minDesktopPos: "", - maxDesktopPos: "", - minMobilePos: "", - maxMobilePos: "", -}; - -export const EMPTY_DOMAIN_LIST_FILTERS: DomainListFilters = { - query: "", - device: "all", - locationCode: "all", -}; - -const DEVICE_FILTER_ORDER: RankTrackingConfig["devices"][] = [ - "both", - "desktop", - "mobile", -]; - export function FilterPanel({ filters, setFilters, @@ -126,6 +82,29 @@ export function FilterPanel({ onMaxChange={(v) => update("maxMobilePos", v)} /> +
+ update("minVolume", v)} + onMaxChange={(v) => update("maxVolume", v)} + /> + update("minKd", v)} + onMaxChange={(v) => update("maxKd", v)} + /> + update("minCpc", v)} + onMaxChange={(v) => update("maxCpc", v)} + /> +
); } @@ -262,138 +241,3 @@ function RangeFilter({ ); } - -export function applyDomainListFilters( - configs: T[], - filters: DomainListFilters, -): T[] { - const query = filters.query.trim().toLowerCase(); - const locationCode = - filters.locationCode === "all" ? null : Number(filters.locationCode); - - return configs.filter((config) => { - if (query && !config.domain.toLowerCase().includes(query)) return false; - - if (filters.device !== "all" && config.devices !== filters.device) { - return false; - } - - if (locationCode !== null && config.locationCode !== locationCode) { - return false; - } - - return true; - }); -} - -export function getDomainListFilterOptions(configs: DomainFilterableConfig[]): { - devices: DomainListFilterOption[]; - locations: DomainListFilterOption[]; -} { - const deviceValues = new Set(configs.map((config) => config.devices)); - const devices = DEVICE_FILTER_ORDER.filter((device) => - deviceValues.has(device), - ).map((device) => ({ - value: device, - label: devicesLabel(device), - })); - - const locationMap = new Map(); - for (const config of configs) { - locationMap.set( - config.locationCode, - LOCATIONS[config.locationCode] ?? String(config.locationCode), - ); - } - - const locations = Array.from(locationMap, ([code, label]) => ({ - value: String(code), - label, - })).toSorted((a, b) => a.label.localeCompare(b.label)); - - return { devices, locations }; -} - -export function applyFilters( - rows: RankTrackingRow[], - filters: Filters, -): RankTrackingRow[] { - const includeTerms = filters.include - ? filters.include - .toLowerCase() - .split(",") - .map((t) => t.trim()) - .filter(Boolean) - : []; - const excludeTerms = filters.exclude - ? filters.exclude - .toLowerCase() - .split(",") - .map((t) => t.trim()) - .filter(Boolean) - : []; - - return rows.filter((row) => { - const kw = row.keyword.toLowerCase(); - - if (includeTerms.length > 0 && !includeTerms.some((t) => kw.includes(t))) - return false; - - if (excludeTerms.some((t) => kw.includes(t))) return false; - - if ( - !matchesPositionFilter( - row.desktop.position, - filters.minDesktopPos, - filters.maxDesktopPos, - ) - ) - return false; - - if ( - !matchesPositionFilter( - row.mobile.position, - filters.minMobilePos, - filters.maxMobilePos, - ) - ) - return false; - - return true; - }); -} - -export function matchesPositionFilter( - position: number | null, - minValue: string, - maxValue: string, -): boolean { - if (!minValue && !maxValue) return true; - - const max = maxValue === "" ? Infinity : Number(maxValue); - if (max === 0) return position === null; - - if (position === null) return false; - - const min = minValue === "" ? 0 : Number(minValue); - return position >= min && position <= max; -} - -export function countActiveFilters(filters: Filters): number { - let count = 0; - if (filters.include) count++; - if (filters.exclude) count++; - if (filters.minDesktopPos || filters.maxDesktopPos) count++; - if (filters.minMobilePos || filters.maxMobilePos) count++; - return count; -} - -export function countActiveDomainListFilters( - filters: DomainListFilters, -): number { - let count = 0; - if (filters.query.trim()) count++; - if (filters.device !== "all") count++; - if (filters.locationCode !== "all") count++; - return count; -}