diff --git a/src/client/features/rank-tracking/RankTrackingDomainList.tsx b/src/client/features/rank-tracking/RankTrackingDomainList.tsx index 7959b5d..4f6d0c0 100644 --- a/src/client/features/rank-tracking/RankTrackingDomainList.tsx +++ b/src/client/features/rank-tracking/RankTrackingDomainList.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useMemo, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Link } from "@tanstack/react-router"; import { toast } from "sonner"; @@ -9,6 +9,7 @@ import { Globe, Plus, ChevronRight, + Search, } from "lucide-react"; import { getRankTrackingConfigSummaries, @@ -16,11 +17,24 @@ import { } from "@/serverFunctions/rank-tracking"; import { devicesLabel, scheduleLabel } from "@/shared/rank-tracking"; import { Modal } from "@/client/components/Modal"; +import { + applyDomainListFilters, + countActiveDomainListFilters, + DomainListFilterBar, + EMPTY_DOMAIN_LIST_FILTERS, + getDomainListFilterOptions, + type DomainListFilters, +} from "./RankTrackingFilters"; type ConfigSummary = Awaited< ReturnType >[number]; +// Below this many domains the list is short enough to scan by eye, so the +// filter controls are more chrome than help. Still shown if filters are active +// (e.g. archiving dropped the count) so they never get orphaned. +const FILTER_BAR_MIN_DOMAINS = 6; + export function RankTrackingDomainList({ projectId, onAddDomain, @@ -32,10 +46,23 @@ export function RankTrackingDomainList({ const [archiveTarget, setArchiveTarget] = useState( null, ); + const [filters, setFilters] = useState( + EMPTY_DOMAIN_LIST_FILTERS, + ); const { data: summaries } = useQuery({ queryKey: ["rankTrackingConfigSummaries", projectId], queryFn: () => getRankTrackingConfigSummaries({ data: { projectId } }), }); + const allSummaries = useMemo(() => summaries ?? [], [summaries]); + const filteredSummaries = useMemo( + () => applyDomainListFilters(allSummaries, filters), + [allSummaries, filters], + ); + const filterOptions = useMemo( + () => getDomainListFilterOptions(allSummaries), + [allSummaries], + ); + const activeFilterCount = countActiveDomainListFilters(filters); const archiveMutation = useMutation({ mutationFn: (configId: string) => @@ -67,8 +94,18 @@ export function RankTrackingDomainList({ Add Domain -
- {(summaries ?? []).length === 0 ? ( + {(allSummaries.length >= FILTER_BAR_MIN_DOMAINS || + activeFilterCount > 0) && ( + setFilters(EMPTY_DOMAIN_LIST_FILTERS)} + /> + )} +
+ {allSummaries.length === 0 ? (
@@ -80,8 +117,29 @@ export function RankTrackingDomainList({ Add a domain to start monitoring keyword rankings over time.

+ ) : filteredSummaries.length === 0 ? ( +
+
+ +
+
+

+ No matching tracked domains +

+

+ Try clearing search or adjusting filters. +

+
+ +
) : ( - (summaries ?? []).map((summary) => ( + filteredSummaries.map((summary) => ( ): Filters { return { ...EMPTY_FILTERS, ...overrides }; } +function makeSummary( + id: string, + domain: string, + devices: DomainSummary["devices"], + locationCode: number, +): DomainSummary { + return { id, domain, devices, locationCode }; +} + +function withDomainFilters( + overrides: Partial, +): DomainListFilters { + return { ...EMPTY_DOMAIN_LIST_FILTERS, ...overrides }; +} + describe("matchesPositionFilter", () => { it("matches only unranked positions when max is zero", () => { expect(matchesPositionFilter(null, "", "0")).toBe(true); @@ -84,3 +111,102 @@ describe("applyFilters", () => { ).toEqual(["unranked both"]); }); }); + +describe("applyDomainListFilters", () => { + const summaries = [ + makeSummary("alpha-us-mobile", "alpha.example.com", "mobile", 2840), + makeSummary("alpha-fr-desktop", "alpha.example.com", "desktop", 2250), + makeSummary("alpha-fr-mobile", "alpha.example.com", "mobile", 2250), + makeSummary("bravo-fr-both", "bravo.example.com", "both", 2250), + makeSummary("charlie-uk-desktop", "charlie.example.com", "desktop", 2826), + ]; + + it("narrows by text query and restores all when cleared", () => { + expect( + applyDomainListFilters( + summaries, + withDomainFilters({ query: "ALPHA" }), + ).map((summary) => summary.id), + ).toEqual(["alpha-us-mobile", "alpha-fr-desktop", "alpha-fr-mobile"]); + + expect( + applyDomainListFilters(summaries, EMPTY_DOMAIN_LIST_FILTERS).map( + (summary) => summary.id, + ), + ).toEqual(summaries.map((summary) => summary.id)); + }); + + it("filters by device", () => { + expect( + applyDomainListFilters( + summaries, + withDomainFilters({ device: "mobile" }), + ).map((summary) => summary.id), + ).toEqual(["alpha-us-mobile", "alpha-fr-mobile"]); + }); + + it("filters by country", () => { + expect( + applyDomainListFilters( + summaries, + withDomainFilters({ locationCode: "2250" }), + ).map((summary) => summary.id), + ).toEqual(["alpha-fr-desktop", "alpha-fr-mobile", "bravo-fr-both"]); + }); + + it("combines domain, device, and country filters with AND semantics", () => { + expect( + applyDomainListFilters( + summaries, + withDomainFilters({ + query: "alpha", + device: "mobile", + locationCode: "2250", + }), + ).map((summary) => summary.id), + ).toEqual(["alpha-fr-mobile"]); + }); + + it("returns an empty list when filters match nothing", () => { + expect( + applyDomainListFilters( + summaries, + withDomainFilters({ query: "missing", device: "mobile" }), + ), + ).toEqual([]); + }); +}); + +describe("getDomainListFilterOptions", () => { + it("derives distinct device and country options from available summaries", () => { + const options = getDomainListFilterOptions([ + makeSummary("a", "a.com", "mobile", 2250), + makeSummary("b", "b.com", "desktop", 2250), + makeSummary("c", "c.com", "mobile", 2826), + ]); + + expect(options.devices).toEqual([ + { value: "desktop", label: "Desktop" }, + { value: "mobile", label: "Mobile" }, + ]); + expect(options.locations).toEqual([ + { value: "2250", label: "FR" }, + { value: "2826", label: "UK" }, + ]); + }); +}); + +describe("countActiveDomainListFilters", () => { + it("counts non-empty domain list filters", () => { + expect(countActiveDomainListFilters(EMPTY_DOMAIN_LIST_FILTERS)).toBe(0); + expect( + countActiveDomainListFilters( + withDomainFilters({ + query: "alpha", + device: "desktop", + locationCode: "2840", + }), + ), + ).toBe(3); + }); +}); diff --git a/src/client/features/rank-tracking/RankTrackingFilters.tsx b/src/client/features/rank-tracking/RankTrackingFilters.tsx index 33a334b..16febb2 100644 --- a/src/client/features/rank-tracking/RankTrackingFilters.tsx +++ b/src/client/features/rank-tracking/RankTrackingFilters.tsx @@ -1,5 +1,10 @@ import { RotateCcw } from "lucide-react"; -import type { RankTrackingRow } from "@/types/schemas/rank-tracking"; +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; @@ -10,6 +15,22 @@ export type Filters = { maxMobilePos: 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: "", @@ -19,6 +40,18 @@ export const EMPTY_FILTERS: Filters = { 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, @@ -97,6 +130,101 @@ export function FilterPanel({ ); } +export function DomainListFilterBar({ + filters, + options, + activeFilterCount, + onChange, + onReset, +}: { + filters: DomainListFilters; + options: { + devices: DomainListFilterOption[]; + locations: DomainListFilterOption[]; + }; + activeFilterCount: number; + onChange: (filters: DomainListFilters) => void; + onReset: () => void; +}) { + return ( +
+
+ + + + {activeFilterCount > 0 && ( + + )} +
+
+ ); +} + function RangeFilter({ title, minValue, @@ -135,6 +263,57 @@ 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, @@ -208,3 +387,13 @@ export function countActiveFilters(filters: Filters): number { 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; +}