Add Rank Tracking metric filters and fix null-last sort ordering (#75)
This commit is contained in:
parent
172126aec7
commit
65f7baf8a1
@ -1,6 +1,6 @@
|
|||||||
import { useMemo, type MutableRefObject } from "react";
|
import { useMemo, type MutableRefObject } from "react";
|
||||||
import { ArrowUp, ArrowDown } from "lucide-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 { makeSelectionColumn } from "@/client/components/table/AppDataTable";
|
||||||
import type { RankTrackingRow } from "@/types/schemas/rank-tracking";
|
import type { RankTrackingRow } from "@/types/schemas/rank-tracking";
|
||||||
import { formatLocationLabel } from "@/shared/keyword-locations";
|
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
|
// 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
|
// say which number the user is looking at — national volume can overstate
|
||||||
// local demand by orders of magnitude.
|
// local demand by orders of magnitude.
|
||||||
function makeVolumeColumn(locationLabel?: string): ColumnDef<RankTrackingRow> {
|
function makeVolumeColumn(locationLabel?: string): ColumnDef<RankTrackingRow> {
|
||||||
return {
|
return {
|
||||||
id: "volume",
|
id: "volume",
|
||||||
accessorKey: "searchVolume",
|
accessorFn: (row) => row.searchVolume ?? undefined,
|
||||||
header: ({ column }) => (
|
header: ({ column }) => (
|
||||||
<SortableHeader
|
<SortableHeader
|
||||||
column={column}
|
column={column}
|
||||||
@ -90,29 +81,35 @@ function makeVolumeColumn(locationLabel?: string): ColumnDef<RankTrackingRow> {
|
|||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
size: 90,
|
size: 90,
|
||||||
cell: ({ getValue }) => <VolumeCell value={getValue<number | null>()} />,
|
cell: ({ getValue }) => (
|
||||||
sortingFn: nullsLastNumeric,
|
<VolumeCell value={getValue<number | undefined>() ?? null} />
|
||||||
|
),
|
||||||
|
sortUndefined: "last",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const kdColumn: ColumnDef<RankTrackingRow> = {
|
const kdColumn: ColumnDef<RankTrackingRow> = {
|
||||||
id: "kd",
|
id: "kd",
|
||||||
accessorKey: "keywordDifficulty",
|
accessorFn: (row) => row.keywordDifficulty ?? undefined,
|
||||||
header: ({ column }) => <SortableHeader column={column} label="KD" id="kd" />,
|
header: ({ column }) => <SortableHeader column={column} label="KD" id="kd" />,
|
||||||
size: 70,
|
size: 70,
|
||||||
cell: ({ getValue }) => <DifficultyCell value={getValue<number | null>()} />,
|
cell: ({ getValue }) => (
|
||||||
sortingFn: nullsLastNumeric,
|
<DifficultyCell value={getValue<number | undefined>() ?? null} />
|
||||||
|
),
|
||||||
|
sortUndefined: "last",
|
||||||
};
|
};
|
||||||
|
|
||||||
const cpcColumn: ColumnDef<RankTrackingRow> = {
|
const cpcColumn: ColumnDef<RankTrackingRow> = {
|
||||||
id: "cpc",
|
id: "cpc",
|
||||||
accessorKey: "cpc",
|
accessorFn: (row) => row.cpc ?? undefined,
|
||||||
header: ({ column }) => (
|
header: ({ column }) => (
|
||||||
<SortableHeader column={column} label="CPC" id="cpc" />
|
<SortableHeader column={column} label="CPC" id="cpc" />
|
||||||
),
|
),
|
||||||
size: 80,
|
size: 80,
|
||||||
cell: ({ getValue }) => <CpcCell value={getValue<number | null>()} />,
|
cell: ({ getValue }) => (
|
||||||
sortingFn: nullsLastNumeric,
|
<CpcCell value={getValue<number | undefined>() ?? null} />
|
||||||
|
),
|
||||||
|
sortUndefined: "last",
|
||||||
};
|
};
|
||||||
|
|
||||||
function makeKeywordColumn(
|
function makeKeywordColumn(
|
||||||
@ -144,14 +141,14 @@ function makeDeviceColumn(
|
|||||||
const id = device === "desktop" ? "desktopPosition" : "mobilePosition";
|
const id = device === "desktop" ? "desktopPosition" : "mobilePosition";
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
accessorFn: (row) => row[device].position,
|
accessorFn: (row) => row[device].position ?? undefined,
|
||||||
header: ({ column }) => (
|
header: ({ column }) => (
|
||||||
<SortableHeader column={column} label="Position" id={id} />
|
<SortableHeader column={column} label="Position" id={id} />
|
||||||
),
|
),
|
||||||
size: 120,
|
size: 120,
|
||||||
maxSize: 140,
|
maxSize: 140,
|
||||||
cell: ({ row }) => <DeviceRankCell result={row.original[device]} />,
|
cell: ({ row }) => <DeviceRankCell result={row.original[device]} />,
|
||||||
sortingFn: nullsLastNumeric,
|
sortUndefined: "last",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
236
src/client/features/rank-tracking/RankTrackingFilters.logic.ts
Normal file
236
src/client/features/rank-tracking/RankTrackingFilters.logic.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -7,6 +7,7 @@ import {
|
|||||||
EMPTY_DOMAIN_LIST_FILTERS,
|
EMPTY_DOMAIN_LIST_FILTERS,
|
||||||
EMPTY_FILTERS,
|
EMPTY_FILTERS,
|
||||||
getDomainListFilterOptions,
|
getDomainListFilterOptions,
|
||||||
|
matchesMetricRangeFilter,
|
||||||
matchesPositionFilter,
|
matchesPositionFilter,
|
||||||
type DomainListFilters,
|
type DomainListFilters,
|
||||||
type Filters,
|
type Filters,
|
||||||
@ -23,13 +24,18 @@ function makeRow(
|
|||||||
keyword: string,
|
keyword: string,
|
||||||
desktopPosition: number | null,
|
desktopPosition: number | null,
|
||||||
mobilePosition: number | null,
|
mobilePosition: number | null,
|
||||||
|
metrics: {
|
||||||
|
volume?: number | null;
|
||||||
|
kd?: number | null;
|
||||||
|
cpc?: number | null;
|
||||||
|
} = {},
|
||||||
): RankTrackingRow {
|
): RankTrackingRow {
|
||||||
return {
|
return {
|
||||||
trackingKeywordId: keyword,
|
trackingKeywordId: keyword,
|
||||||
keyword,
|
keyword,
|
||||||
searchVolume: null,
|
searchVolume: metrics.volume ?? null,
|
||||||
keywordDifficulty: null,
|
keywordDifficulty: metrics.kd ?? null,
|
||||||
cpc: null,
|
cpc: metrics.cpc ?? null,
|
||||||
desktop: {
|
desktop: {
|
||||||
position: desktopPosition,
|
position: desktopPosition,
|
||||||
previousPosition: null,
|
previousPosition: null,
|
||||||
@ -210,3 +216,67 @@ describe("countActiveDomainListFilters", () => {
|
|||||||
).toBe(3);
|
).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"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -1,57 +1,13 @@
|
|||||||
import { RotateCcw } from "lucide-react";
|
import { RotateCcw } from "lucide-react";
|
||||||
import { LOCATIONS } from "@/client/features/keywords/locations";
|
import type { DomainListFilters, Filters } from "./RankTrackingFilters.logic";
|
||||||
import { devicesLabel } from "@/shared/rank-tracking";
|
|
||||||
import type {
|
|
||||||
RankTrackingConfig,
|
|
||||||
RankTrackingRow,
|
|
||||||
} from "@/types/schemas/rank-tracking";
|
|
||||||
|
|
||||||
export type Filters = {
|
export * from "./RankTrackingFilters.logic";
|
||||||
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;
|
|
||||||
};
|
|
||||||
|
|
||||||
type DomainListFilterOption = {
|
type DomainListFilterOption = {
|
||||||
value: string;
|
value: string;
|
||||||
label: 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({
|
export function FilterPanel({
|
||||||
filters,
|
filters,
|
||||||
setFilters,
|
setFilters,
|
||||||
@ -126,6 +82,29 @@ export function FilterPanel({
|
|||||||
onMaxChange={(v) => update("maxMobilePos", v)}
|
onMaxChange={(v) => update("maxMobilePos", v)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -262,138 +241,3 @@ function RangeFilter({
|
|||||||
</div>
|
</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;
|
|
||||||
}
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user