From 6f50850b5a72ef3b31676f0e2dbfef7fce42b691 Mon Sep 17 00:00:00 2001 From: Ben Senescu <44480372+bensenescu@users.noreply.github.com> Date: Wed, 6 May 2026 10:46:33 -0400 Subject: [PATCH] fix rank tracking unranked filters (#18) Co-authored-by: Granata005 --- .../rank-tracking/RankTrackingFilters.test.ts | 86 +++++++++++++++++++ .../rank-tracking/RankTrackingFilters.tsx | 49 +++++++---- 2 files changed, 118 insertions(+), 17 deletions(-) create mode 100644 src/client/features/rank-tracking/RankTrackingFilters.test.ts diff --git a/src/client/features/rank-tracking/RankTrackingFilters.test.ts b/src/client/features/rank-tracking/RankTrackingFilters.test.ts new file mode 100644 index 0000000..71910bc --- /dev/null +++ b/src/client/features/rank-tracking/RankTrackingFilters.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "vitest"; +import type { RankTrackingRow } from "@/types/schemas/rank-tracking"; +import { + applyFilters, + EMPTY_FILTERS, + matchesPositionFilter, + type Filters, +} from "./RankTrackingFilters"; + +function makeRow( + keyword: string, + desktopPosition: number | null, + mobilePosition: number | null, +): RankTrackingRow { + return { + trackingKeywordId: keyword, + keyword, + searchVolume: null, + keywordDifficulty: null, + cpc: null, + desktop: { + position: desktopPosition, + previousPosition: null, + rankingUrl: null, + serpFeatures: [], + }, + mobile: { + position: mobilePosition, + previousPosition: null, + rankingUrl: null, + serpFeatures: [], + }, + }; +} + +function withFilters(overrides: Partial): Filters { + return { ...EMPTY_FILTERS, ...overrides }; +} + +describe("matchesPositionFilter", () => { + it("matches only unranked positions when max is zero", () => { + expect(matchesPositionFilter(null, "", "0")).toBe(true); + expect(matchesPositionFilter(1, "", "0")).toBe(false); + expect(matchesPositionFilter(20, "10", "0")).toBe(false); + }); + + it("keeps regular rank ranges unchanged", () => { + expect(matchesPositionFilter(4, "1", "10")).toBe(true); + expect(matchesPositionFilter(11, "1", "10")).toBe(false); + expect(matchesPositionFilter(null, "1", "10")).toBe(false); + }); +}); + +describe("applyFilters", () => { + const rows = [ + makeRow("ranked both", 3, 6), + makeRow("desktop unranked", null, 5), + makeRow("mobile unranked", 7, null), + makeRow("unranked both", null, null), + ]; + + it("filters desktop unranked rows with desktop max zero", () => { + expect( + applyFilters(rows, withFilters({ maxDesktopPos: "0" })).map( + (row) => row.keyword, + ), + ).toEqual(["desktop unranked", "unranked both"]); + }); + + it("filters mobile unranked rows with mobile max zero", () => { + expect( + applyFilters(rows, withFilters({ maxMobilePos: "0" })).map( + (row) => row.keyword, + ), + ).toEqual(["mobile unranked", "unranked both"]); + }); + + it("requires both devices to be unranked when both max values are zero", () => { + expect( + applyFilters( + rows, + withFilters({ maxDesktopPos: "0", maxMobilePos: "0" }), + ).map((row) => row.keyword), + ).toEqual(["unranked both"]); + }); +}); diff --git a/src/client/features/rank-tracking/RankTrackingFilters.tsx b/src/client/features/rank-tracking/RankTrackingFilters.tsx index 08fdc07..33a334b 100644 --- a/src/client/features/rank-tracking/RankTrackingFilters.tsx +++ b/src/client/features/rank-tracking/RankTrackingFilters.tsx @@ -162,29 +162,44 @@ export function applyFilters( if (excludeTerms.some((t) => kw.includes(t))) return false; - if (filters.minDesktopPos || filters.maxDesktopPos) { - const min = filters.minDesktopPos ? Number(filters.minDesktopPos) : 0; - const max = filters.maxDesktopPos - ? Number(filters.maxDesktopPos) - : Infinity; - if (row.desktop.position === null) return false; - if (row.desktop.position < min || row.desktop.position > max) - return false; - } + if ( + !matchesPositionFilter( + row.desktop.position, + filters.minDesktopPos, + filters.maxDesktopPos, + ) + ) + return false; - if (filters.minMobilePos || filters.maxMobilePos) { - const min = filters.minMobilePos ? Number(filters.minMobilePos) : 0; - const max = filters.maxMobilePos - ? Number(filters.maxMobilePos) - : Infinity; - if (row.mobile.position === null) return false; - if (row.mobile.position < min || row.mobile.position > max) 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++;