Add Rank Tracking metric filters and fix null-last sort ordering (#75)

This commit is contained in:
A Sivasubramanian Manoj 2026-07-13 22:55:14 +05:30 committed by GitHub
parent 172126aec7
commit 65f7baf8a1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 352 additions and 205 deletions

View File

@ -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<RankTrackingRow> = (rowA, rowB, columnId) => {
const a = rowA.getValue<number | null>(columnId);
const b = rowB.getValue<number | null>(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<RankTrackingRow> {
return {
id: "volume",
accessorKey: "searchVolume",
accessorFn: (row) => row.searchVolume ?? undefined,
header: ({ column }) => (
<SortableHeader
column={column}
@ -90,29 +81,35 @@ function makeVolumeColumn(locationLabel?: string): ColumnDef<RankTrackingRow> {
/>
),
size: 90,
cell: ({ getValue }) => <VolumeCell value={getValue<number | null>()} />,
sortingFn: nullsLastNumeric,
cell: ({ getValue }) => (
<VolumeCell value={getValue<number | undefined>() ?? null} />
),
sortUndefined: "last",
};
}
const kdColumn: ColumnDef<RankTrackingRow> = {
id: "kd",
accessorKey: "keywordDifficulty",
accessorFn: (row) => row.keywordDifficulty ?? undefined,
header: ({ column }) => <SortableHeader column={column} label="KD" id="kd" />,
size: 70,
cell: ({ getValue }) => <DifficultyCell value={getValue<number | null>()} />,
sortingFn: nullsLastNumeric,
cell: ({ getValue }) => (
<DifficultyCell value={getValue<number | undefined>() ?? null} />
),
sortUndefined: "last",
};
const cpcColumn: ColumnDef<RankTrackingRow> = {
id: "cpc",
accessorKey: "cpc",
accessorFn: (row) => row.cpc ?? undefined,
header: ({ column }) => (
<SortableHeader column={column} label="CPC" id="cpc" />
),
size: 80,
cell: ({ getValue }) => <CpcCell value={getValue<number | null>()} />,
sortingFn: nullsLastNumeric,
cell: ({ getValue }) => (
<CpcCell value={getValue<number | undefined>() ?? 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 }) => (
<SortableHeader column={column} label="Position" id={id} />
),
size: 120,
maxSize: 140,
cell: ({ row }) => <DeviceRankCell result={row.original[device]} />,
sortingFn: nullsLastNumeric,
sortUndefined: "last",
};
}

View File

@ -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<T extends DomainFilterableConfig>(
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<number, string>();
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;
}

View File

@ -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"]);
});
});

View File

@ -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)}
/>
</div>
<div className="grid grid-cols-1 gap-3 lg:grid-cols-3">
<RangeFilter
title="Volume"
minValue={filters.minVolume}
maxValue={filters.maxVolume}
onMinChange={(v) => update("minVolume", v)}
onMaxChange={(v) => update("maxVolume", v)}
/>
<RangeFilter
title="Keyword difficulty"
minValue={filters.minKd}
maxValue={filters.maxKd}
onMinChange={(v) => update("minKd", v)}
onMaxChange={(v) => update("maxKd", v)}
/>
<RangeFilter
title="CPC"
minValue={filters.minCpc}
maxValue={filters.maxCpc}
onMinChange={(v) => update("minCpc", v)}
onMaxChange={(v) => update("maxCpc", v)}
/>
</div>
</div>
);
}
@ -262,138 +241,3 @@ function RangeFilter({
</div>
);
}
export function applyDomainListFilters<T extends DomainFilterableConfig>(
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<number, string>();
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;
}