Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 18 additions & 21 deletions src/client/features/rank-tracking/RankTrackingColumns.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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}
Expand All @@ -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(
Expand Down Expand Up @@ -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",
};
}

Expand Down
236 changes: 236 additions & 0 deletions src/client/features/rank-tracking/RankTrackingFilters.logic.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading
Loading