feat(rank-tracking): add city/region-level location targeting (#62)
This commit is contained in:
parent
c3caf2009f
commit
ffa6ec5e70
4
drizzle-pg/0006_location_name.sql
Normal file
4
drizzle-pg/0006_location_name.sql
Normal file
@ -0,0 +1,4 @@
|
||||
DROP INDEX "rank_tracking_configs_project_domain_location_idx";--> statement-breakpoint
|
||||
ALTER TABLE "rank_tracking_configs" ADD COLUMN "location_name" text;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "rank_tracking_configs_national_idx" ON "rank_tracking_configs" USING btree ("project_id","domain","location_code") WHERE "rank_tracking_configs"."location_name" IS NULL;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "rank_tracking_configs_local_idx" ON "rank_tracking_configs" USING btree ("project_id","domain","location_code","location_name") WHERE "rank_tracking_configs"."location_name" IS NOT NULL;
|
||||
3098
drizzle-pg/meta/0006_snapshot.json
Normal file
3098
drizzle-pg/meta/0006_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -43,6 +43,13 @@
|
||||
"when": 1783117905771,
|
||||
"tag": "0005_talented_wild_pack",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 6,
|
||||
"version": "7",
|
||||
"when": 1783438455708,
|
||||
"tag": "0006_location_name",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
4
drizzle/0029_location_name.sql
Normal file
4
drizzle/0029_location_name.sql
Normal file
@ -0,0 +1,4 @@
|
||||
DROP INDEX `rank_tracking_configs_project_domain_location_idx`;--> statement-breakpoint
|
||||
ALTER TABLE `rank_tracking_configs` ADD `location_name` text;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `rank_tracking_configs_national_idx` ON `rank_tracking_configs` (`project_id`,`domain`,`location_code`) WHERE "rank_tracking_configs"."location_name" IS NULL;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `rank_tracking_configs_local_idx` ON `rank_tracking_configs` (`project_id`,`domain`,`location_code`,`location_name`) WHERE "rank_tracking_configs"."location_name" IS NOT NULL;
|
||||
2820
drizzle/meta/0029_snapshot.json
Normal file
2820
drizzle/meta/0029_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -204,6 +204,13 @@
|
||||
"when": 1783117904905,
|
||||
"tag": "0028_empty_beyonder",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 29,
|
||||
"version": "6",
|
||||
"when": 1783438453855,
|
||||
"tag": "0029_location_name",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
78
specs/0008-local-rank-tracking-locations.md
Normal file
78
specs/0008-local-rank-tracking-locations.md
Normal file
@ -0,0 +1,78 @@
|
||||
# 0008 — Local Rank Tracking: Location Data & Search
|
||||
|
||||
How city/region-level rank tracking stores and searches DataForSEO's location
|
||||
registry, and why it works the way it does. Shipped July 2026; future
|
||||
directions are listed at the end.
|
||||
|
||||
## The feature
|
||||
|
||||
Rank tracking configs take an optional `location_name` — a canonical DataForSEO
|
||||
location string like `Enid,Oklahoma,United States`. Null means the existing
|
||||
country-level behavior, unchanged. Uniqueness is enforced by two partial
|
||||
indexes: one config per (project, domain, country) for national trackers, one
|
||||
per (project, domain, country, location) for local ones.
|
||||
|
||||
When set, the location flows through verbatim:
|
||||
|
||||
- **SERP checks** (live and queued task-post) send `location_name` instead of
|
||||
`location_code`, so positions reflect what a searcher in that city sees.
|
||||
SERP pricing is location-independent, so cost estimates are unchanged.
|
||||
- **Keyword metrics** come city-scoped: volume / CPC / competition from Google
|
||||
Ads `search_volume` (the only DataForSEO source that accepts sub-country
|
||||
geotargets), merged per keyword with national KD / intent from Labs (which
|
||||
is country-only). This matters: "rv storage near me" is 135K/mo nationally
|
||||
but 70/mo in Pittsburgh — a national number on a local tracker overstates
|
||||
demand by orders of magnitude. Keywords Google Ads collapses away get
|
||||
explicit nulls rather than a leaked national value; the UI column reads
|
||||
"Local volume" and exports name the city. Adds ~$0.09 per metrics refresh.
|
||||
- **The picker** is a debounced combobox in the config modal, searching the
|
||||
country's registry and storing the selected canonical name. Local mode
|
||||
requires a selection; switching country clears it.
|
||||
|
||||
## Location data: how search works
|
||||
|
||||
The registry endpoint (`/v3/serp/google/locations/{iso}`) is free but has no
|
||||
search parameter and returns the full country list per call — 9.5 MB / 60k
|
||||
entries for the US. Slimmed to the five types users target (City, County,
|
||||
Municipality, DMA Region, Region) it is ~23k entries / 1.5 MB. The data
|
||||
changes roughly quarterly (Google geotarget updates).
|
||||
|
||||
The search path: combobox (350 ms debounce) → `searchSerpLocations` server fn
|
||||
→ per-country list from **KV** (`serp-locations:{iso}`, 30-day
|
||||
`expirationTtl`, hot reads edge-cached with `cacheTtl: 86400`) → substring
|
||||
filter, top 10. A KV miss triggers the origin fetch + slim + store, with
|
||||
concurrent cold fills coalesced in-isolate so the prewarm and a fast first
|
||||
keystroke can't both download the 9.5 MB payload.
|
||||
|
||||
Selecting **Local** in the modal fires `prewarmSerpLocations` (a `useQuery`
|
||||
keyed on country, `staleTime: Infinity`), so the one slow cold fill (~3 s)
|
||||
usually happens before the first keystroke. Warm searches are tens of ms.
|
||||
|
||||
## Why KV
|
||||
|
||||
| Alternative | Why not |
|
||||
| ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Workers Cache API layer | Documented no-op on workers.dev (self-hosters), per-colo only, no persistence guarantees. KV's `cacheTtl` provides the same hot-read caching, managed. |
|
||||
| R2 (+ cache in front) | Works (an earlier iteration shipped it) but needs a second layer for read latency; KV is one primitive with the same profile. |
|
||||
| D1 / Postgres table | Schema + migrations on two providers for quarterly-static reference data; revisit if server-side validation or MCP location search justify a real table. |
|
||||
| Durable Object per country | Pins to its first-request region forever; new binding for self-host; buys coordination this read-only data doesn't need. |
|
||||
| Static assets, client search | Refresh requires redeploy — self-hosters would be pinned to release-time snapshots. |
|
||||
| "Just accept zip codes" | Doesn't avoid the registry: DataForSEO only accepts canonical values, and zips are registry entries themselves (~32k for the US). |
|
||||
|
||||
Cost is noise either way: KV bills per operation, so storage for all supported
|
||||
countries is ~$0.02/month and each search read is fractions of a cent.
|
||||
|
||||
## Future directions (not built)
|
||||
|
||||
- **Picker quality**: prominence-ranked results (offline GeoNames/Census tier
|
||||
table — the registry has no population data, so "Portland" currently ranks
|
||||
the Maine DMA above Portland, OR), cities-first with a type filter,
|
||||
recently-used/suggested locations (must come from our own config history —
|
||||
GSC has no city dimension), and a selection-confirmation line.
|
||||
- **ZIP fast path**: numeric queries search a separate cached Postal Code
|
||||
blob; useful for sub-metro service areas inside large cities.
|
||||
- **Multi-city fan-out**: multi-select in the picker creating one config per
|
||||
city with a shared keyword set — the agency 3–5-metro workflow.
|
||||
- **Server-side `location_name` validation** at config save, closing the gap
|
||||
where a hand-crafted request can store an arbitrary string (fails at
|
||||
DataForSEO at cost 0 today, so client-side validation suffices).
|
||||
222
src/client/components/SerpLocationCombobox.tsx
Normal file
222
src/client/components/SerpLocationCombobox.tsx
Normal file
@ -0,0 +1,222 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Loader2, Search } from "lucide-react";
|
||||
import { searchSerpLocations } from "@/serverFunctions/serp-locations";
|
||||
import { formatLocationLabel } from "@/shared/keyword-locations";
|
||||
import type { SerpLocationResult } from "@/server/lib/dataforseo/serp-locations";
|
||||
|
||||
type Props = {
|
||||
value: string | undefined;
|
||||
onChange: (locationName: string | undefined) => void;
|
||||
/** ISO 3166-1 alpha-2 country code, e.g. "us". */
|
||||
countryCode: string;
|
||||
placeholder?: string;
|
||||
};
|
||||
|
||||
function useDebounce(value: string, delayMs: number): string {
|
||||
const [debounced, setDebounced] = useState(value);
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setDebounced(value), delayMs);
|
||||
return () => clearTimeout(timer);
|
||||
}, [value, delayMs]);
|
||||
return debounced;
|
||||
}
|
||||
|
||||
export function SerpLocationCombobox({
|
||||
value,
|
||||
onChange,
|
||||
countryCode,
|
||||
placeholder = "Search cities...",
|
||||
}: Props) {
|
||||
const [inputValue, setInputValue] = useState(
|
||||
value ? formatLocationLabel(value) : "",
|
||||
);
|
||||
const [results, setResults] = useState<SerpLocationResult[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isError, setIsError] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const listRef = useRef<HTMLUListElement>(null);
|
||||
// Selecting a result sets the input to its display label; that change must
|
||||
// not itself trigger a search for the label text.
|
||||
const skipNextFetchRef = useRef(false);
|
||||
|
||||
const debouncedQuery = useDebounce(inputValue, 350);
|
||||
|
||||
// Sync display when value prop changes externally (e.g. mode reset)
|
||||
useEffect(() => {
|
||||
if (!value) {
|
||||
setInputValue("");
|
||||
setResults([]);
|
||||
setOpen(false);
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
// Fetch results when debounced query changes
|
||||
useEffect(() => {
|
||||
const trimmed = debouncedQuery.trim();
|
||||
if (!trimmed) {
|
||||
setResults([]);
|
||||
setOpen(false);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
if (skipNextFetchRef.current) {
|
||||
skipNextFetchRef.current = false;
|
||||
// Any fetch this change superseded was cancelled before its own
|
||||
// finally could clear the spinner, so clear it here.
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setIsLoading(true);
|
||||
setIsError(false);
|
||||
searchSerpLocations({ data: { query: trimmed, countryCode } })
|
||||
.then((data) => {
|
||||
if (cancelled) return;
|
||||
setResults(data);
|
||||
setOpen(true);
|
||||
setActiveIndex(0);
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return;
|
||||
setIsError(true);
|
||||
setOpen(true);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setIsLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [debouncedQuery, countryCode]);
|
||||
|
||||
// Close on outside click
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handlePointerDown = (e: PointerEvent) => {
|
||||
if (
|
||||
e.target instanceof Node &&
|
||||
!containerRef.current?.contains(e.target)
|
||||
) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("pointerdown", handlePointerDown);
|
||||
return () => document.removeEventListener("pointerdown", handlePointerDown);
|
||||
}, [open]);
|
||||
|
||||
// Scroll active item into view
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
listRef.current?.children[activeIndex]?.scrollIntoView({
|
||||
block: "nearest",
|
||||
});
|
||||
}, [activeIndex, open]);
|
||||
|
||||
const select = (loc: SerpLocationResult) => {
|
||||
onChange(loc.locationName);
|
||||
skipNextFetchRef.current = true;
|
||||
setInputValue(loc.displayLabel);
|
||||
setResults([]);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const v = e.target.value;
|
||||
setInputValue(v);
|
||||
if (!v.trim()) {
|
||||
onChange(undefined);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (!open) return;
|
||||
switch (e.key) {
|
||||
case "ArrowDown":
|
||||
e.preventDefault();
|
||||
setActiveIndex((i) => Math.min(i + 1, results.length - 1));
|
||||
break;
|
||||
case "ArrowUp":
|
||||
e.preventDefault();
|
||||
setActiveIndex((i) => Math.max(i - 1, 0));
|
||||
break;
|
||||
case "Enter": {
|
||||
e.preventDefault();
|
||||
const loc = results[activeIndex];
|
||||
if (loc) select(loc);
|
||||
break;
|
||||
}
|
||||
case "Escape":
|
||||
e.preventDefault();
|
||||
setOpen(false);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative w-full">
|
||||
<label className="flex items-center gap-2 input input-bordered w-full pr-3">
|
||||
{isLoading ? (
|
||||
<Loader2 className="size-4 shrink-0 text-base-content/50 animate-spin" />
|
||||
) : (
|
||||
<Search className="size-4 shrink-0 text-base-content/50" />
|
||||
)}
|
||||
<input
|
||||
type="text"
|
||||
className="grow min-w-0 bg-transparent outline-none placeholder:text-base-content/40"
|
||||
placeholder={placeholder}
|
||||
value={inputValue}
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={() => {
|
||||
if (results.length > 0) setOpen(true);
|
||||
}}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{open && (
|
||||
<div className="absolute z-30 mt-1 w-full rounded-box border border-base-300 bg-base-100 shadow-lg p-1">
|
||||
{isError ? (
|
||||
<p className="px-3 py-2 text-sm text-error">
|
||||
Unable to load locations
|
||||
</p>
|
||||
) : results.length === 0 ? (
|
||||
<p className="px-3 py-2 text-sm text-base-content/50">
|
||||
No locations found for "{debouncedQuery.trim()}"
|
||||
</p>
|
||||
) : (
|
||||
<ul
|
||||
ref={listRef}
|
||||
role="listbox"
|
||||
className="menu max-h-56 w-full flex-nowrap overflow-y-auto p-0"
|
||||
>
|
||||
{results.map((loc, index) => (
|
||||
<li
|
||||
key={loc.locationCode}
|
||||
role="option"
|
||||
aria-selected={loc.locationName === value}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={`w-full flex items-center justify-between gap-2 ${index === activeIndex ? "menu-focus" : ""}`}
|
||||
onClick={() => select(loc)}
|
||||
onMouseEnter={() => setActiveIndex(index)}
|
||||
>
|
||||
<span className="truncate text-left">
|
||||
{loc.displayLabel}
|
||||
</span>
|
||||
<span className="badge badge-xs bg-base-300 border-0 text-base-content/60 shrink-0">
|
||||
{loc.locationType}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -8,6 +8,7 @@ import { captureClientEvent } from "@/client/lib/posthog";
|
||||
import { getRankKeywordHistory } from "@/serverFunctions/rank-tracking";
|
||||
import type { RankKeywordHistoryPoint } from "@/serverFunctions/rank-tracking";
|
||||
import { LOCATIONS } from "@/client/features/keywords/locations";
|
||||
import { formatLocationLabel } from "@/shared/keyword-locations";
|
||||
import { csvChange, DeviceRankCell } from "./RankTrackingTableParts";
|
||||
import {
|
||||
RankTrendChart,
|
||||
@ -34,6 +35,7 @@ export function KeywordTrendModal({
|
||||
configId,
|
||||
domain,
|
||||
locationCode,
|
||||
locationName,
|
||||
serpDepth,
|
||||
onClose,
|
||||
}: {
|
||||
@ -42,6 +44,7 @@ export function KeywordTrendModal({
|
||||
configId: string;
|
||||
domain: string;
|
||||
locationCode: number;
|
||||
locationName?: string;
|
||||
serpDepth: number;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
@ -145,8 +148,11 @@ export function KeywordTrendModal({
|
||||
{target.keyword}
|
||||
</h3>
|
||||
<p className="text-xs text-base-content/60">
|
||||
{domain} · {LOCATIONS[locationCode] ?? "US"} ·
|
||||
Position over time
|
||||
{domain} ·{" "}
|
||||
{locationName
|
||||
? formatLocationLabel(locationName, 2)
|
||||
: (LOCATIONS[locationCode] ?? "US")}{" "}
|
||||
· Position over time
|
||||
</p>
|
||||
</div>
|
||||
<TrendRangeToggle value={sinceDays} onChange={setSinceDays} />
|
||||
|
||||
@ -3,6 +3,7 @@ import { ArrowUp, ArrowDown } from "lucide-react";
|
||||
import type { ColumnDef, SortingFn } 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";
|
||||
import {
|
||||
CpcCell,
|
||||
DeviceRankCell,
|
||||
@ -69,16 +70,30 @@ const nullsLastNumeric: SortingFn<RankTrackingRow> = (rowA, rowB, columnId) => {
|
||||
return a - b;
|
||||
};
|
||||
|
||||
const volumeColumn: ColumnDef<RankTrackingRow> = {
|
||||
// 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",
|
||||
header: ({ column }) => (
|
||||
<SortableHeader column={column} label="Volume" id="volume" />
|
||||
<SortableHeader
|
||||
column={column}
|
||||
label={locationLabel ? "Local volume" : "Volume"}
|
||||
id="volume"
|
||||
tooltip={
|
||||
locationLabel
|
||||
? `Estimated monthly searches in ${locationLabel} from Google Ads`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
),
|
||||
size: 90,
|
||||
cell: ({ getValue }) => <VolumeCell value={getValue<number | null>()} />,
|
||||
sortingFn: nullsLastNumeric,
|
||||
};
|
||||
}
|
||||
|
||||
const kdColumn: ColumnDef<RankTrackingRow> = {
|
||||
id: "kd",
|
||||
@ -184,13 +199,25 @@ function makeSerpColumn(
|
||||
};
|
||||
}
|
||||
|
||||
export function useRankTrackingColumns(
|
||||
showDesktop: boolean,
|
||||
showMobile: boolean,
|
||||
domain: string,
|
||||
selectAnchorRef: MutableRefObject<SelectionAnchor | null>,
|
||||
onKeywordClick: (row: RankTrackingRow) => void,
|
||||
): ColumnDef<RankTrackingRow>[] {
|
||||
export function useRankTrackingColumns(options: {
|
||||
showDesktop: boolean;
|
||||
showMobile: boolean;
|
||||
domain: string;
|
||||
selectAnchorRef: MutableRefObject<SelectionAnchor | null>;
|
||||
onKeywordClick: (row: RankTrackingRow) => void;
|
||||
locationName?: string | null;
|
||||
}): ColumnDef<RankTrackingRow>[] {
|
||||
const {
|
||||
showDesktop,
|
||||
showMobile,
|
||||
domain,
|
||||
selectAnchorRef,
|
||||
onKeywordClick,
|
||||
locationName,
|
||||
} = options;
|
||||
const locationLabel = locationName
|
||||
? formatLocationLabel(locationName, 2)
|
||||
: undefined;
|
||||
return useMemo(() => {
|
||||
const cols: ColumnDef<RankTrackingRow>[] = [
|
||||
makeSelectionColumn<RankTrackingRow>(selectAnchorRef),
|
||||
@ -204,7 +231,7 @@ export function useRankTrackingColumns(
|
||||
cols.push(makeDeviceColumn("mobile"));
|
||||
cols.push(makeUrlColumn("mobile", domain));
|
||||
}
|
||||
cols.push(volumeColumn, kdColumn, cpcColumn);
|
||||
cols.push(makeVolumeColumn(locationLabel), kdColumn, cpcColumn);
|
||||
if (showDesktop) {
|
||||
cols.push(makeSerpColumn("desktop"));
|
||||
}
|
||||
@ -212,5 +239,12 @@ export function useRankTrackingColumns(
|
||||
cols.push(makeSerpColumn("mobile"));
|
||||
}
|
||||
return cols;
|
||||
}, [showDesktop, showMobile, domain, selectAnchorRef, onKeywordClick]);
|
||||
}, [
|
||||
showDesktop,
|
||||
showMobile,
|
||||
domain,
|
||||
selectAnchorRef,
|
||||
onKeywordClick,
|
||||
locationLabel,
|
||||
]);
|
||||
}
|
||||
|
||||
@ -1,14 +1,7 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import {
|
||||
createRankTrackingConfig,
|
||||
updateRankTrackingConfig,
|
||||
} from "@/serverFunctions/rank-tracking";
|
||||
import { Info, Loader2, X } from "lucide-react";
|
||||
import { Modal } from "@/client/components/Modal";
|
||||
import { getStandardErrorMessage } from "@/client/lib/error-messages";
|
||||
import { captureClientEvent } from "@/client/lib/posthog";
|
||||
import type { RankTrackingConfig } from "@/types/schemas/rank-tracking";
|
||||
import { domainField, normalizeDomain } from "@/types/schemas/domain";
|
||||
import {
|
||||
@ -21,8 +14,11 @@ import {
|
||||
getLanguageCode,
|
||||
getLanguageOptions,
|
||||
} from "@/client/features/keywords/locations";
|
||||
import { getIsoCountryCode } from "@/shared/keyword-locations";
|
||||
import { LocationSelect } from "@/client/components/LocationSelect";
|
||||
import { SearchTargetingField } from "./SearchTargetingField";
|
||||
import { KeywordSuggestionStep } from "./KeywordSuggestionStep";
|
||||
import { useSaveConfigMutations } from "./useSaveConfigMutations";
|
||||
|
||||
type Props = {
|
||||
projectId: string;
|
||||
@ -60,55 +56,37 @@ export function RankTrackingConfigModal({
|
||||
const [schedule, setSchedule] = useState<
|
||||
RankTrackingConfig["scheduleInterval"]
|
||||
>(existingConfig?.scheduleInterval ?? "weekly");
|
||||
const [targetingMode, setTargetingMode] = useState<"national" | "local">(
|
||||
existingConfig?.locationName ? "local" : "national",
|
||||
);
|
||||
const [locationName, setLocationName] = useState<string | undefined>(
|
||||
existingConfig?.locationName ?? undefined,
|
||||
);
|
||||
const [createdConfigId, setCreatedConfigId] = useState<string | null>(null);
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (normalizedDomain: string) =>
|
||||
createRankTrackingConfig({
|
||||
data: {
|
||||
const selectedCountryCode = useMemo(
|
||||
() => getIsoCountryCode(locationCode),
|
||||
[locationCode],
|
||||
);
|
||||
|
||||
const { createMutation, updateMutation } = useSaveConfigMutations({
|
||||
projectId,
|
||||
domain: normalizedDomain,
|
||||
existingConfig,
|
||||
fields: {
|
||||
devices,
|
||||
serpDepth,
|
||||
locationCode,
|
||||
languageCode,
|
||||
scheduleInterval: schedule,
|
||||
targetingMode,
|
||||
locationName,
|
||||
schedule,
|
||||
},
|
||||
}),
|
||||
onSuccess: (result) => {
|
||||
captureClientEvent("rank_tracking:config_create");
|
||||
toast.success("Domain added for rank tracking");
|
||||
setCreatedConfigId(result.configId);
|
||||
onCreated: (configId) => {
|
||||
setCreatedConfigId(configId);
|
||||
onConfigCreated?.();
|
||||
setStep("keywords");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(getStandardErrorMessage(error, "Failed to save config"));
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (normalizedDomain: string) =>
|
||||
updateRankTrackingConfig({
|
||||
data: {
|
||||
projectId,
|
||||
configId: existingConfig!.id,
|
||||
domain: normalizedDomain,
|
||||
devices,
|
||||
serpDepth,
|
||||
locationCode,
|
||||
languageCode,
|
||||
scheduleInterval: schedule,
|
||||
},
|
||||
}),
|
||||
onSuccess: () => {
|
||||
captureClientEvent("rank_tracking:config_update");
|
||||
toast.success("Configuration updated");
|
||||
onSaved();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(getStandardErrorMessage(error, "Failed to update config"));
|
||||
},
|
||||
onUpdated: () => onSaved(),
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
@ -118,6 +96,10 @@ export function RankTrackingConfigModal({
|
||||
toast.error("Please enter a domain");
|
||||
return;
|
||||
}
|
||||
if (targetingMode === "local" && !locationName) {
|
||||
toast.error("Please select a city or region for local targeting");
|
||||
return;
|
||||
}
|
||||
const parsedDomain = domainField.safeParse(domain);
|
||||
if (!parsedDomain.success) {
|
||||
toast.error("Please enter a valid domain");
|
||||
@ -202,10 +184,20 @@ export function RankTrackingConfigModal({
|
||||
onChange={(newLocationCode) => {
|
||||
setLocationCode(newLocationCode);
|
||||
setLanguageCode(getLanguageCode(newLocationCode));
|
||||
// A picked city belongs to the previous country.
|
||||
setLocationName(undefined);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SearchTargetingField
|
||||
mode={targetingMode}
|
||||
onModeChange={setTargetingMode}
|
||||
locationName={locationName}
|
||||
onLocationNameChange={setLocationName}
|
||||
countryCode={selectedCountryCode}
|
||||
/>
|
||||
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text font-medium">Language</span>
|
||||
|
||||
@ -2,6 +2,7 @@ import { Monitor, Plus, Settings, Smartphone } from "lucide-react";
|
||||
import { SegmentedToggle } from "@/client/components/SegmentedToggle";
|
||||
import { LOCATIONS } from "@/client/features/keywords/locations";
|
||||
import { devicesLabel, scheduleLabel } from "@/shared/rank-tracking";
|
||||
import { formatLocationLabel } from "@/shared/keyword-locations";
|
||||
import type {
|
||||
ComparePeriod,
|
||||
RankTrackingConfig,
|
||||
@ -45,8 +46,10 @@ export function RankTrackingDetailHeader({
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">{config.domain}</h2>
|
||||
<p className="text-xs text-base-content/60">
|
||||
{LOCATIONS[config.locationCode] ?? "US"} ·{" "}
|
||||
{devicesLabel(config.devices)} ·{" "}
|
||||
{config.locationName
|
||||
? formatLocationLabel(config.locationName, 2)
|
||||
: (LOCATIONS[config.locationCode] ?? "US")}{" "}
|
||||
· {devicesLabel(config.devices)} ·{" "}
|
||||
{scheduleLabel(config.scheduleInterval)}
|
||||
{run && (
|
||||
<>
|
||||
|
||||
@ -270,10 +270,16 @@ export function RankTrackingDomainDetail({
|
||||
showDesktop,
|
||||
showMobile,
|
||||
config.domain,
|
||||
config.locationName,
|
||||
)
|
||||
}
|
||||
onExportToSheets={() =>
|
||||
exportRankTrackingToSheets(filtered, showDesktop, showMobile)
|
||||
exportRankTrackingToSheets(
|
||||
filtered,
|
||||
showDesktop,
|
||||
showMobile,
|
||||
config.locationName,
|
||||
)
|
||||
}
|
||||
onCopyKeywords={() => {
|
||||
void navigator.clipboard.writeText(
|
||||
@ -326,6 +332,7 @@ export function RankTrackingDomainDetail({
|
||||
configId={config.id}
|
||||
projectId={projectId}
|
||||
locationCode={config.locationCode}
|
||||
locationName={config.locationName}
|
||||
serpDepth={config.serpDepth}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -16,6 +16,7 @@ import {
|
||||
updateRankTrackingConfig,
|
||||
} from "@/serverFunctions/rank-tracking";
|
||||
import { devicesLabel, scheduleLabel } from "@/shared/rank-tracking";
|
||||
import { formatLocationLabel } from "@/shared/keyword-locations";
|
||||
import { Modal } from "@/client/components/Modal";
|
||||
import {
|
||||
applyDomainListFilters,
|
||||
@ -205,8 +206,10 @@ function DomainRow({
|
||||
<div className="min-w-0 flex-1 pointer-events-none">
|
||||
<p className="font-medium truncate">{summary.domain}</p>
|
||||
<p className="text-xs text-base-content/60">
|
||||
{LOCATIONS[summary.locationCode] ?? "US"} ·{" "}
|
||||
{devicesLabel(summary.devices)} ·{" "}
|
||||
{summary.locationName
|
||||
? formatLocationLabel(summary.locationName, 2)
|
||||
: (LOCATIONS[summary.locationCode] ?? "US")}{" "}
|
||||
· {devicesLabel(summary.devices)} ·{" "}
|
||||
{scheduleLabel(summary.scheduleInterval)}
|
||||
{summary.lastRunCompletedAt && (
|
||||
<>
|
||||
|
||||
@ -38,6 +38,7 @@ export function RankTrackingTable({
|
||||
configId,
|
||||
projectId,
|
||||
locationCode,
|
||||
locationName,
|
||||
serpDepth,
|
||||
}: {
|
||||
totalCount: number;
|
||||
@ -50,6 +51,7 @@ export function RankTrackingTable({
|
||||
configId: string;
|
||||
projectId: string;
|
||||
locationCode: number;
|
||||
locationName?: string | null;
|
||||
serpDepth: number;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
@ -68,13 +70,14 @@ export function RankTrackingTable({
|
||||
[],
|
||||
);
|
||||
|
||||
const columns = useRankTrackingColumns(
|
||||
const columns = useRankTrackingColumns({
|
||||
showDesktop,
|
||||
showMobile,
|
||||
domain,
|
||||
selectAnchorRef,
|
||||
handleKeywordClick,
|
||||
);
|
||||
onKeywordClick: handleKeywordClick,
|
||||
locationName,
|
||||
});
|
||||
|
||||
const table = useAppTable({
|
||||
data: rows,
|
||||
@ -239,6 +242,7 @@ export function RankTrackingTable({
|
||||
configId={configId}
|
||||
domain={domain}
|
||||
locationCode={locationCode}
|
||||
locationName={locationName ?? undefined}
|
||||
serpDepth={serpDepth}
|
||||
onClose={() => setTrendTarget(null)}
|
||||
/>
|
||||
|
||||
@ -3,6 +3,7 @@ import { toast } from "sonner";
|
||||
import { buildCsv, downloadCsv } from "@/client/lib/csv";
|
||||
import { exportTableToSheets } from "@/client/lib/exportToSheets";
|
||||
import { captureClientEvent } from "@/client/lib/posthog";
|
||||
import { formatLocationLabel } from "@/shared/keyword-locations";
|
||||
import type {
|
||||
RankTrackingDeviceResult,
|
||||
RankTrackingRow,
|
||||
@ -173,10 +174,14 @@ export function buildRankTrackingExport(
|
||||
sorted: RankTrackingRow[],
|
||||
showDesktop: boolean,
|
||||
showMobile: boolean,
|
||||
locationName?: string | null,
|
||||
): { headers: string[]; rows: (string | number)[][] } {
|
||||
const headers = [
|
||||
"Keyword",
|
||||
"Volume",
|
||||
// Exports lack the table's tooltip, so name the city inline.
|
||||
locationName
|
||||
? `Local volume (${formatLocationLabel(locationName, 2)})`
|
||||
: "Volume",
|
||||
"KD",
|
||||
"CPC",
|
||||
...(showDesktop
|
||||
@ -227,11 +232,13 @@ export function exportRankTrackingToSheets(
|
||||
sorted: RankTrackingRow[],
|
||||
showDesktop: boolean,
|
||||
showMobile: boolean,
|
||||
locationName?: string | null,
|
||||
) {
|
||||
const { headers, rows } = buildRankTrackingExport(
|
||||
sorted,
|
||||
showDesktop,
|
||||
showMobile,
|
||||
locationName,
|
||||
);
|
||||
void exportTableToSheets({ headers, rows, feature: "rank_tracking" });
|
||||
}
|
||||
@ -241,6 +248,7 @@ export function exportRankTrackingCsv(
|
||||
showDesktop: boolean,
|
||||
showMobile: boolean,
|
||||
domain: string,
|
||||
locationName?: string | null,
|
||||
) {
|
||||
if (sorted.length === 0) {
|
||||
toast.error("No data to export");
|
||||
@ -250,6 +258,7 @@ export function exportRankTrackingCsv(
|
||||
sorted,
|
||||
showDesktop,
|
||||
showMobile,
|
||||
locationName,
|
||||
);
|
||||
// CSV file download keeps cents-formatted CPC for human readability;
|
||||
// clipboard/Sheets export uses raw numbers (see buildRankTrackingExport).
|
||||
|
||||
83
src/client/features/rank-tracking/SearchTargetingField.tsx
Normal file
83
src/client/features/rank-tracking/SearchTargetingField.tsx
Normal file
@ -0,0 +1,83 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { SerpLocationCombobox } from "@/client/components/SerpLocationCombobox";
|
||||
import { prewarmSerpLocations } from "@/serverFunctions/serp-locations";
|
||||
|
||||
type TargetingMode = "national" | "local";
|
||||
|
||||
export function SearchTargetingField({
|
||||
mode,
|
||||
onModeChange,
|
||||
locationName,
|
||||
onLocationNameChange,
|
||||
countryCode,
|
||||
}: {
|
||||
mode: TargetingMode;
|
||||
onModeChange: (mode: TargetingMode) => void;
|
||||
locationName: string | undefined;
|
||||
onLocationNameChange: (locationName: string | undefined) => void;
|
||||
countryCode: string;
|
||||
}) {
|
||||
// Warm the server-side location cache the moment Local targeting is in
|
||||
// play, so the country list is hot before the first keystroke. Best-effort:
|
||||
// a failed warm just means the first search is slower, so no retries, and
|
||||
// staleTime keeps one warm per country per session.
|
||||
useQuery({
|
||||
queryKey: ["serp-locations-prewarm", countryCode],
|
||||
queryFn: () => prewarmSerpLocations({ data: { countryCode } }),
|
||||
enabled: mode === "local",
|
||||
staleTime: Infinity,
|
||||
retry: false,
|
||||
});
|
||||
return (
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text font-medium">Search Targeting</span>
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
className="radio radio-sm"
|
||||
checked={mode === "national"}
|
||||
onChange={() => {
|
||||
onModeChange("national");
|
||||
onLocationNameChange(undefined);
|
||||
}}
|
||||
/>
|
||||
<span className="text-sm">National</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
className="radio radio-sm"
|
||||
checked={mode === "local"}
|
||||
onChange={() => onModeChange("local")}
|
||||
/>
|
||||
<span className="text-sm">Local</span>
|
||||
</label>
|
||||
</div>
|
||||
<p className="text-xs text-base-content/50 mt-1.5">
|
||||
{mode === "local" ? (
|
||||
<>
|
||||
<span className="text-success font-medium">Best for:</span> "near
|
||||
me" queries, city/county keywords, service-area pages.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Local targeting can understate rankings for non-geo-modified terms.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
{mode === "local" && (
|
||||
<div className="mt-2">
|
||||
<SerpLocationCombobox
|
||||
value={locationName}
|
||||
onChange={onLocationNameChange}
|
||||
countryCode={countryCode}
|
||||
placeholder="Search cities..."
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
83
src/client/features/rank-tracking/useSaveConfigMutations.ts
Normal file
83
src/client/features/rank-tracking/useSaveConfigMutations.ts
Normal file
@ -0,0 +1,83 @@
|
||||
import { toast } from "sonner";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import {
|
||||
createRankTrackingConfig,
|
||||
updateRankTrackingConfig,
|
||||
} from "@/serverFunctions/rank-tracking";
|
||||
import { getStandardErrorMessage } from "@/client/lib/error-messages";
|
||||
import { captureClientEvent } from "@/client/lib/posthog";
|
||||
import type { RankTrackingConfig } from "@/types/schemas/rank-tracking";
|
||||
|
||||
type ConfigFields = {
|
||||
devices: "both" | "desktop" | "mobile";
|
||||
serpDepth: number;
|
||||
locationCode: number;
|
||||
languageCode: string;
|
||||
targetingMode: "national" | "local";
|
||||
locationName: string | undefined;
|
||||
schedule: RankTrackingConfig["scheduleInterval"];
|
||||
};
|
||||
|
||||
export function useSaveConfigMutations(input: {
|
||||
projectId: string;
|
||||
existingConfig?: RankTrackingConfig | null;
|
||||
fields: ConfigFields;
|
||||
onCreated: (configId: string) => void;
|
||||
onUpdated: () => void;
|
||||
}) {
|
||||
const { projectId, existingConfig, fields, onCreated, onUpdated } = input;
|
||||
const common = {
|
||||
devices: fields.devices,
|
||||
serpDepth: fields.serpDepth,
|
||||
locationCode: fields.locationCode,
|
||||
languageCode: fields.languageCode,
|
||||
scheduleInterval: fields.schedule,
|
||||
};
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (normalizedDomain: string) =>
|
||||
createRankTrackingConfig({
|
||||
data: {
|
||||
projectId,
|
||||
domain: normalizedDomain,
|
||||
...common,
|
||||
locationName:
|
||||
fields.targetingMode === "local" ? fields.locationName : undefined,
|
||||
},
|
||||
}),
|
||||
onSuccess: (result) => {
|
||||
captureClientEvent("rank_tracking:config_create");
|
||||
toast.success("Domain added for rank tracking");
|
||||
onCreated(result.configId);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(getStandardErrorMessage(error, "Failed to save config"));
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (normalizedDomain: string) =>
|
||||
updateRankTrackingConfig({
|
||||
data: {
|
||||
projectId,
|
||||
configId: existingConfig!.id,
|
||||
domain: normalizedDomain,
|
||||
...common,
|
||||
// null clears a previously-set local target; undefined would leave
|
||||
// the old location_name in the DB and silently keep city targeting.
|
||||
locationName:
|
||||
fields.targetingMode === "local" ? fields.locationName : null,
|
||||
},
|
||||
}),
|
||||
onSuccess: () => {
|
||||
captureClientEvent("rank_tracking:config_update");
|
||||
toast.success("Configuration updated");
|
||||
onUpdated();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(getStandardErrorMessage(error, "Failed to update config"));
|
||||
},
|
||||
});
|
||||
|
||||
return { createMutation, updateMutation };
|
||||
}
|
||||
@ -225,6 +225,7 @@ export const rankTrackingConfigs = sqliteTable(
|
||||
})
|
||||
.notNull()
|
||||
.default("weekly"),
|
||||
locationName: text("location_name"),
|
||||
isActive: integer("is_active", { mode: "boolean" }).notNull().default(true),
|
||||
lastCheckedAt: text("last_checked_at"),
|
||||
nextCheckAt: text("next_check_at"),
|
||||
@ -234,11 +235,12 @@ export const rankTrackingConfigs = sqliteTable(
|
||||
.default(sql`(current_timestamp)`),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("rank_tracking_configs_project_domain_location_idx").on(
|
||||
table.projectId,
|
||||
table.domain,
|
||||
table.locationCode,
|
||||
),
|
||||
uniqueIndex("rank_tracking_configs_national_idx")
|
||||
.on(table.projectId, table.domain, table.locationCode)
|
||||
.where(sql`${table.locationName} IS NULL`),
|
||||
uniqueIndex("rank_tracking_configs_local_idx")
|
||||
.on(table.projectId, table.domain, table.locationCode, table.locationName)
|
||||
.where(sql`${table.locationName} IS NOT NULL`),
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@ -228,6 +228,7 @@ export const rankTrackingConfigs = pgTable(
|
||||
})
|
||||
.notNull()
|
||||
.default("weekly"),
|
||||
locationName: text("location_name"),
|
||||
isActive: boolean("is_active").notNull().default(true),
|
||||
lastCheckedAt: timestampColumn("last_checked_at"),
|
||||
nextCheckAt: timestampColumn("next_check_at"),
|
||||
@ -235,11 +236,12 @@ export const rankTrackingConfigs = pgTable(
|
||||
createdAt: timestampColumn("created_at").notNull().default(isoNow),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("rank_tracking_configs_project_domain_location_idx").on(
|
||||
table.projectId,
|
||||
table.domain,
|
||||
table.locationCode,
|
||||
),
|
||||
uniqueIndex("rank_tracking_configs_national_idx")
|
||||
.on(table.projectId, table.domain, table.locationCode)
|
||||
.where(sql`${table.locationName} IS NULL`),
|
||||
uniqueIndex("rank_tracking_configs_local_idx")
|
||||
.on(table.projectId, table.domain, table.locationCode, table.locationName)
|
||||
.where(sql`${table.locationName} IS NOT NULL`),
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@ -59,6 +59,7 @@ async function getConfigByProjectDomainLocation(
|
||||
projectId: string,
|
||||
domain: string,
|
||||
locationCode: number,
|
||||
locationName: string | null,
|
||||
) {
|
||||
const rows = await db
|
||||
.select()
|
||||
@ -68,6 +69,12 @@ async function getConfigByProjectDomainLocation(
|
||||
eq(rankTrackingConfigs.projectId, projectId),
|
||||
eq(rankTrackingConfigs.domain, domain),
|
||||
eq(rankTrackingConfigs.locationCode, locationCode),
|
||||
// National (NULL) and per-city configs are distinct rows — mirrors
|
||||
// the partial unique indexes, so a national config and any number of
|
||||
// city configs can coexist for the same domain.
|
||||
locationName === null
|
||||
? isNull(rankTrackingConfigs.locationName)
|
||||
: eq(rankTrackingConfigs.locationName, locationName),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
@ -104,6 +111,7 @@ async function getDueConfigsWithOrganization(nowIso: string) {
|
||||
domain: rankTrackingConfigs.domain,
|
||||
locationCode: rankTrackingConfigs.locationCode,
|
||||
languageCode: rankTrackingConfigs.languageCode,
|
||||
locationName: rankTrackingConfigs.locationName,
|
||||
devices: rankTrackingConfigs.devices,
|
||||
serpDepth: rankTrackingConfigs.serpDepth,
|
||||
scheduleInterval: rankTrackingConfigs.scheduleInterval,
|
||||
|
||||
@ -84,6 +84,35 @@ describe("RankTrackingService.createConfig", () => {
|
||||
expect(mocks.createConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keys the duplicate check on locationName so national and city configs coexist", async () => {
|
||||
mocks.getConfigByProjectDomainLocation.mockResolvedValue(null);
|
||||
mocks.getConfigsForProject.mockResolvedValue([]);
|
||||
mocks.createConfig.mockResolvedValue(undefined);
|
||||
const { RankTrackingService } = await import("./RankTrackingService");
|
||||
|
||||
// Local config: the lookup must be scoped to this exact city, so an
|
||||
// existing national row for the same domain doesn't collide.
|
||||
await RankTrackingService.createConfig({
|
||||
...baseInput,
|
||||
locationName: "Enid,Oklahoma,United States",
|
||||
});
|
||||
expect(mocks.getConfigByProjectDomainLocation).toHaveBeenCalledWith(
|
||||
"project_1",
|
||||
"acme.com",
|
||||
2840,
|
||||
"Enid,Oklahoma,United States",
|
||||
);
|
||||
|
||||
// National config: the lookup is scoped to NULL locationName.
|
||||
await RankTrackingService.createConfig(baseInput);
|
||||
expect(mocks.getConfigByProjectDomainLocation).toHaveBeenLastCalledWith(
|
||||
"project_1",
|
||||
"acme.com",
|
||||
2840,
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects reactivating an archived config when the project is at the active-config cap", async () => {
|
||||
const { MAX_CONFIGS_PER_PROJECT } = await import("@/shared/rank-tracking");
|
||||
mocks.getConfigByProjectDomainLocation.mockResolvedValue(archivedConfig);
|
||||
|
||||
@ -32,6 +32,7 @@ async function createConfig(input: {
|
||||
domain: string;
|
||||
locationCode?: number;
|
||||
languageCode?: string;
|
||||
locationName?: string;
|
||||
devices?: RankTrackingConfig["devices"];
|
||||
serpDepth: number;
|
||||
scheduleInterval?: RankTrackingConfig["scheduleInterval"];
|
||||
@ -44,11 +45,13 @@ async function createConfig(input: {
|
||||
? computeNextCheckAt(scheduleInterval)
|
||||
: null;
|
||||
|
||||
const locationName = input.locationName ?? null;
|
||||
const existing =
|
||||
await RankTrackingRepository.getConfigByProjectDomainLocation(
|
||||
input.projectId,
|
||||
normalizedDomain,
|
||||
locationCode,
|
||||
locationName,
|
||||
);
|
||||
// The (project, domain, location) row still exists when a domain is
|
||||
// archived — archiving only flips isActive to false. So re-adding an
|
||||
@ -58,7 +61,9 @@ async function createConfig(input: {
|
||||
if (existing?.isActive) {
|
||||
throw new AppError(
|
||||
"VALIDATION_ERROR",
|
||||
"This domain + country combination is already being tracked",
|
||||
locationName
|
||||
? "This domain + city combination is already being tracked"
|
||||
: "This domain + country combination is already being tracked",
|
||||
);
|
||||
}
|
||||
|
||||
@ -98,6 +103,7 @@ async function createConfig(input: {
|
||||
domain: normalizedDomain,
|
||||
locationCode,
|
||||
languageCode: input.languageCode ?? "en",
|
||||
locationName,
|
||||
devices: input.devices ?? "both",
|
||||
serpDepth: input.serpDepth,
|
||||
scheduleInterval,
|
||||
@ -114,6 +120,7 @@ async function updateConfig(
|
||||
domain?: string;
|
||||
locationCode?: number;
|
||||
languageCode?: string;
|
||||
locationName?: string | null;
|
||||
devices?: RankTrackingConfig["devices"];
|
||||
serpDepth?: number;
|
||||
scheduleInterval?: RankTrackingConfig["scheduleInterval"];
|
||||
@ -128,6 +135,8 @@ async function updateConfig(
|
||||
updates.locationCode = input.locationCode;
|
||||
if (input.languageCode !== undefined)
|
||||
updates.languageCode = input.languageCode;
|
||||
if (input.locationName !== undefined)
|
||||
updates.locationName = input.locationName;
|
||||
if (input.devices !== undefined) updates.devices = input.devices;
|
||||
if (input.serpDepth !== undefined) updates.serpDepth = input.serpDepth;
|
||||
if (input.isActive !== undefined) updates.isActive = input.isActive;
|
||||
@ -280,6 +289,9 @@ async function refreshKeywordMetrics(
|
||||
keywords: keywords.map((kw) => kw.keyword),
|
||||
locationCode: config.locationCode,
|
||||
languageCode: config.languageCode,
|
||||
// Local configs get volume/CPC scoped to the tracked city; national
|
||||
// numbers can overstate local demand by orders of magnitude.
|
||||
locationName: config.locationName ?? undefined,
|
||||
creditFeature: "rank_tracking",
|
||||
});
|
||||
const byKeyword = new Map(
|
||||
|
||||
@ -36,7 +36,13 @@ type RankCheckWorkflowStatus = {
|
||||
|
||||
type RankCheckConfigForStart = Pick<
|
||||
RankTrackingConfig,
|
||||
"id" | "domain" | "locationCode" | "languageCode" | "devices" | "serpDepth"
|
||||
| "id"
|
||||
| "domain"
|
||||
| "locationCode"
|
||||
| "languageCode"
|
||||
| "locationName"
|
||||
| "devices"
|
||||
| "serpDepth"
|
||||
>;
|
||||
|
||||
const ACTIVE_WORKFLOW_STATUSES = new Set<RankCheckWorkflowStatus["status"]>([
|
||||
@ -164,6 +170,7 @@ export async function beginRankCheckRun(input: {
|
||||
domain: input.config.domain,
|
||||
locationCode: input.config.locationCode,
|
||||
languageCode: input.config.languageCode,
|
||||
locationName: input.config.locationName ?? undefined,
|
||||
devices: input.config.devices,
|
||||
serpDepth: input.config.serpDepth,
|
||||
trigger: input.trigger,
|
||||
|
||||
@ -30,11 +30,20 @@ export async function fetchAdsSearchVolume(input: {
|
||||
keywords: string[];
|
||||
locationCode: number;
|
||||
languageCode: string;
|
||||
/**
|
||||
* Canonical DataForSEO location_name (e.g. "Pittsburgh,Pennsylvania,United
|
||||
* States"). Google Ads accepts any geotarget, so this scopes volume / CPC /
|
||||
* competition to a city or region instead of the whole country.
|
||||
*/
|
||||
locationName?: string;
|
||||
}): Promise<DataforseoApiResponse<AdsKeywordItem[]>> {
|
||||
const locationParams = input.locationName
|
||||
? { location_name: input.locationName }
|
||||
: { location_code: input.locationCode };
|
||||
const response = await keywordsDataApi().googleAdsSearchVolumeLive([
|
||||
new KeywordsDataGoogleAdsSearchVolumeLiveRequestInfo({
|
||||
keywords: input.keywords,
|
||||
location_code: input.locationCode,
|
||||
...locationParams,
|
||||
language_code: input.languageCode,
|
||||
}),
|
||||
]);
|
||||
|
||||
@ -129,4 +129,64 @@ describe("fetchKeywordMetricsForList", () => {
|
||||
expect(keywordOverview).toHaveBeenCalledTimes(3); // 700 + 700 + 100
|
||||
expect(rows).toHaveLength(1500);
|
||||
});
|
||||
|
||||
it("merges city-scoped Ads volume with national Labs KD for local requests", async () => {
|
||||
const adsSearchVolume = vi.fn().mockResolvedValue([
|
||||
{
|
||||
keyword: "plumber near me",
|
||||
search_volume: 260,
|
||||
cpc: 7.54,
|
||||
competition: "MEDIUM",
|
||||
competition_index: 55,
|
||||
monthly_searches: [],
|
||||
},
|
||||
// "emergency plumber near me" collapsed away by Google Ads normalization.
|
||||
]);
|
||||
const keywordOverview = vi.fn().mockResolvedValue([
|
||||
{
|
||||
keyword: "plumber near me",
|
||||
keyword_info: { search_volume: 135000, cpc: 11.29 },
|
||||
keyword_properties: { keyword_difficulty: 76 },
|
||||
search_intent_info: { main_intent: "transactional" },
|
||||
},
|
||||
{
|
||||
keyword: "emergency plumber near me",
|
||||
keyword_info: { search_volume: 135000, cpc: 5.93 },
|
||||
keyword_properties: { keyword_difficulty: 17 },
|
||||
search_intent_info: { main_intent: "transactional" },
|
||||
},
|
||||
]);
|
||||
const client = fakeClient({ adsSearchVolume, keywordOverview });
|
||||
|
||||
const rows = await fetchKeywordMetricsForList(client, {
|
||||
keywords: ["plumber near me", "emergency plumber near me"],
|
||||
locationCode: 2840,
|
||||
languageCode: "en",
|
||||
locationName: "Springfield,Illinois,United States",
|
||||
creditFeature: "rank_tracking",
|
||||
});
|
||||
|
||||
expect(adsSearchVolume).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
locationName: "Springfield,Illinois,United States",
|
||||
}),
|
||||
);
|
||||
// Local volume/CPC from Ads, national KD/intent from Labs.
|
||||
expect(rows[0]).toMatchObject({
|
||||
keyword: "plumber near me",
|
||||
searchVolume: 260,
|
||||
cpc: 7.54,
|
||||
keywordDifficulty: 76,
|
||||
intent: "transactional",
|
||||
});
|
||||
// Keyword Ads dropped: KD/intent survive, but the national volume/CPC
|
||||
// must NOT leak into a local request.
|
||||
expect(rows[1]).toMatchObject({
|
||||
keyword: "emergency plumber near me",
|
||||
searchVolume: null,
|
||||
cpc: null,
|
||||
keywordDifficulty: 17,
|
||||
intent: "transactional",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -94,6 +94,14 @@ function normalizeAdsKeyword(
|
||||
// Hydrate a keyword list with fresh metrics: route by location (Labs vs Google
|
||||
// Ads), batch under the per-call cap, and drop items DataForSEO returns without
|
||||
// a keyword. `creditFeature` is required so spend is always attributed.
|
||||
//
|
||||
// `locationName` (canonical DataForSEO string, e.g. a city) scopes volume /
|
||||
// CPC / competition to that location via Google Ads — the only source that
|
||||
// accepts sub-country geotargets. Labs is country-only, so for Labs countries
|
||||
// a local request runs both calls and merges: local volume from Google Ads,
|
||||
// national KD / intent from Labs. National volume is never silently shown for
|
||||
// a local request — keywords Google Ads doesn't return keep KD / intent but
|
||||
// null volume / CPC.
|
||||
export async function fetchKeywordMetricsForList(
|
||||
client: KeywordMetricsClient,
|
||||
params: {
|
||||
@ -102,6 +110,7 @@ export async function fetchKeywordMetricsForList(
|
||||
languageCode: string;
|
||||
creditFeature: CreditFeature;
|
||||
includeClickstreamData?: boolean;
|
||||
locationName?: string;
|
||||
},
|
||||
): Promise<KeywordMetricRow[]> {
|
||||
const useGoogleAds =
|
||||
@ -115,13 +124,44 @@ export async function fetchKeywordMetricsForList(
|
||||
const items = await client.keywords.adsSearchVolume({
|
||||
keywords,
|
||||
locationCode: params.locationCode,
|
||||
locationName: params.locationName,
|
||||
languageCode: params.languageCode,
|
||||
creditFeature: params.creditFeature,
|
||||
});
|
||||
const covered = new Set<string>();
|
||||
for (const item of items) {
|
||||
if (!item.keyword) continue;
|
||||
covered.add(item.keyword.toLowerCase());
|
||||
rows.push(normalizeAdsKeyword(item, item.keyword));
|
||||
}
|
||||
if (params.locationName) {
|
||||
// A local request must overwrite whatever scope the stored metrics
|
||||
// had — keywords Ads collapsed away get explicit nulls so stale
|
||||
// (possibly national) numbers can't survive under a local label.
|
||||
rows.push(
|
||||
...keywords
|
||||
.filter((keyword) => !covered.has(keyword.toLowerCase()))
|
||||
.map(nullMetricRow),
|
||||
);
|
||||
}
|
||||
} else if (params.locationName) {
|
||||
const [adsItems, labsItems] = await Promise.all([
|
||||
client.keywords.adsSearchVolume({
|
||||
keywords,
|
||||
locationCode: params.locationCode,
|
||||
locationName: params.locationName,
|
||||
languageCode: params.languageCode,
|
||||
creditFeature: params.creditFeature,
|
||||
}),
|
||||
client.labs.keywordOverview({
|
||||
keywords,
|
||||
locationCode: params.locationCode,
|
||||
languageCode: params.languageCode,
|
||||
includeClickstreamData: params.includeClickstreamData ?? false,
|
||||
creditFeature: params.creditFeature,
|
||||
}),
|
||||
]);
|
||||
rows.push(...mergeLocalAndNationalRows(keywords, adsItems, labsItems));
|
||||
} else {
|
||||
const items = await client.labs.keywordOverview({
|
||||
keywords,
|
||||
@ -139,3 +179,57 @@ export async function fetchKeywordMetricsForList(
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
function nullMetricRow(keyword: string): KeywordMetricRow {
|
||||
return {
|
||||
keyword,
|
||||
searchVolume: null,
|
||||
cpc: null,
|
||||
competition: null,
|
||||
competitionLevel: null,
|
||||
keywordDifficulty: null,
|
||||
intent: null,
|
||||
monthlySearches: [],
|
||||
};
|
||||
}
|
||||
|
||||
function mergeLocalAndNationalRows(
|
||||
keywords: string[],
|
||||
adsItems: AdsKeywordItem[],
|
||||
labsItems: KeywordOverviewItem[],
|
||||
): KeywordMetricRow[] {
|
||||
const labsByKeyword = new Map(
|
||||
labsItems
|
||||
.filter((item) => item.keyword)
|
||||
.map((item) => [item.keyword!.toLowerCase(), item]),
|
||||
);
|
||||
const rows: KeywordMetricRow[] = [];
|
||||
const covered = new Set<string>();
|
||||
|
||||
for (const item of adsItems) {
|
||||
if (!item.keyword) continue;
|
||||
covered.add(item.keyword.toLowerCase());
|
||||
const row = normalizeAdsKeyword(item, item.keyword);
|
||||
const labs = labsByKeyword.get(item.keyword.toLowerCase());
|
||||
row.keywordDifficulty =
|
||||
labs?.keyword_properties?.keyword_difficulty ?? null;
|
||||
row.intent = labs?.search_intent_info?.main_intent ?? null;
|
||||
rows.push(row);
|
||||
}
|
||||
|
||||
// Google Ads occasionally collapses near-duplicate keywords into one item.
|
||||
// Keep the national KD / intent for the missing ones but leave volume / CPC
|
||||
// null rather than substituting the (misleading) national numbers.
|
||||
for (const keyword of keywords) {
|
||||
if (covered.has(keyword.toLowerCase())) continue;
|
||||
const labs = labsByKeyword.get(keyword.toLowerCase());
|
||||
if (!labs) continue;
|
||||
rows.push({
|
||||
...nullMetricRow(keyword),
|
||||
keywordDifficulty: labs.keyword_properties?.keyword_difficulty ?? null,
|
||||
intent: labs.search_intent_info?.main_intent ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
110
src/server/lib/dataforseo/serp-locations.ts
Normal file
110
src/server/lib/dataforseo/serp-locations.ts
Normal file
@ -0,0 +1,110 @@
|
||||
import { env } from "cloudflare:workers";
|
||||
import { z } from "zod";
|
||||
import { serpApi } from "@/server/lib/dataforseo/core";
|
||||
import { assertOk } from "@/server/lib/dataforseo/envelope";
|
||||
import { formatLocationLabel } from "@/shared/keyword-locations";
|
||||
|
||||
export interface SerpLocationResult {
|
||||
locationCode: number;
|
||||
locationName: string;
|
||||
locationType: string;
|
||||
displayLabel: string;
|
||||
}
|
||||
|
||||
// Sub-country granularities users actually target. Deliberately excludes
|
||||
// Postal Code (~32k extra rows for the US alone), State (national-ish), and
|
||||
// long-tail types like Airport / University.
|
||||
const INCLUDED_LOCATION_TYPES = new Set([
|
||||
"City",
|
||||
"County",
|
||||
"Municipality",
|
||||
"DMA Region",
|
||||
"Region",
|
||||
]);
|
||||
|
||||
const locationItemSchema = z.object({
|
||||
location_code: z.number(),
|
||||
location_name: z.string(),
|
||||
location_type: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
const cachedLocationsSchema = z.array(
|
||||
z.object({
|
||||
locationCode: z.number(),
|
||||
locationName: z.string(),
|
||||
locationType: z.string(),
|
||||
displayLabel: z.string(),
|
||||
}),
|
||||
);
|
||||
|
||||
/** Google refreshes geotargets roughly quarterly; 30 days keeps us current. */
|
||||
const KV_TTL_SECONDS = 30 * 24 * 60 * 60;
|
||||
/** Edge-cache hot reads so repeat searches skip the central KV store. */
|
||||
const KV_HOT_READ_TTL_SECONDS = 24 * 60 * 60;
|
||||
|
||||
function cacheKey(iso: string): string {
|
||||
return `serp-locations:${iso}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full sub-country location list for one country. `countryCode` is ISO
|
||||
* 3166-1 alpha-2 ("us", "gb") — the endpoint rejects country *names* with a
|
||||
* task-level Invalid Field error, which assertOk surfaces.
|
||||
*
|
||||
* The DataForSEO response is ~9.5MB for the US and the endpoint has no search
|
||||
* parameter, so the slimmed list (~1.5MB) is cached in KV (30d TTL, hot reads
|
||||
* edge-cached via cacheTtl); only a miss pays the origin fetch. The endpoint
|
||||
* is free (cost 0), so no billing envelope.
|
||||
*/
|
||||
export async function fetchSerpLocationsForCountry(
|
||||
countryCode: string,
|
||||
): Promise<SerpLocationResult[]> {
|
||||
const iso = countryCode.toLowerCase();
|
||||
|
||||
const cached = await env.KV.get(cacheKey(iso), {
|
||||
type: "json",
|
||||
cacheTtl: KV_HOT_READ_TTL_SECONDS,
|
||||
});
|
||||
const hit = cachedLocationsSchema.safeParse(cached);
|
||||
if (hit.success) return hit.data;
|
||||
|
||||
return fillFromOrigin(iso);
|
||||
}
|
||||
|
||||
// Coalesce concurrent cold fills within an isolate: the prewarm fired on
|
||||
// selecting Local and the user's first debounced search otherwise both miss
|
||||
// the cache and each fetch + parse the ~9.5MB origin payload. Entries are
|
||||
// deleted on settle so the parsed array isn't retained past the fill (and a
|
||||
// failed fill — e.g. the owning request got cancelled — isn't sticky).
|
||||
const inflightFills = new Map<string, Promise<SerpLocationResult[]>>();
|
||||
|
||||
function fillFromOrigin(iso: string): Promise<SerpLocationResult[]> {
|
||||
const inflight = inflightFills.get(iso);
|
||||
if (inflight) return inflight;
|
||||
|
||||
const fill = fetchFromDataforseo(iso)
|
||||
.then(async (fresh) => {
|
||||
await env.KV.put(cacheKey(iso), JSON.stringify(fresh), {
|
||||
expirationTtl: KV_TTL_SECONDS,
|
||||
});
|
||||
return fresh;
|
||||
})
|
||||
.finally(() => inflightFills.delete(iso));
|
||||
inflightFills.set(iso, fill);
|
||||
return fill;
|
||||
}
|
||||
|
||||
async function fetchFromDataforseo(iso: string): Promise<SerpLocationResult[]> {
|
||||
const response = await serpApi().googleLocationsCountry(iso);
|
||||
const task = assertOk(response);
|
||||
return (task.result ?? [])
|
||||
.map((item) => locationItemSchema.safeParse(item))
|
||||
.flatMap((parsed) => (parsed.success ? [parsed.data] : []))
|
||||
.filter((item) => INCLUDED_LOCATION_TYPES.has(item.location_type ?? ""))
|
||||
.map((item) => ({
|
||||
locationCode: item.location_code,
|
||||
locationName: item.location_name,
|
||||
displayLabel: formatLocationLabel(item.location_name),
|
||||
locationType: item.location_type ?? "",
|
||||
}));
|
||||
}
|
||||
@ -141,15 +141,19 @@ export async function fetchRankCheckSerp(input: {
|
||||
keywordId: string;
|
||||
locationCode: number;
|
||||
languageCode: string;
|
||||
locationName?: string;
|
||||
device: "desktop" | "mobile";
|
||||
targetDomain: string;
|
||||
depth: number;
|
||||
}): Promise<DataforseoApiResponse<RankCheckResult>> {
|
||||
const depth = clampSerpDepth(input.depth);
|
||||
const locationParams = input.locationName
|
||||
? { location_name: input.locationName }
|
||||
: { location_code: input.locationCode };
|
||||
const response = await serpApi().googleOrganicLiveAdvanced([
|
||||
new SerpGoogleOrganicLiveAdvancedRequestInfo({
|
||||
keyword: input.keyword,
|
||||
location_code: input.locationCode,
|
||||
...locationParams,
|
||||
language_code: input.languageCode,
|
||||
device: input.device,
|
||||
os: input.device === "desktop" ? "windows" : "android",
|
||||
@ -197,6 +201,7 @@ export async function postRankCheckTasks(input: {
|
||||
tasks: RankCheckTaskInput[];
|
||||
locationCode: number;
|
||||
languageCode: string;
|
||||
locationName?: string;
|
||||
depth: number;
|
||||
targetDomain: string;
|
||||
}): Promise<DataforseoApiResponse<PostedRankCheckTask[]>> {
|
||||
@ -207,12 +212,15 @@ export async function postRankCheckTasks(input: {
|
||||
);
|
||||
}
|
||||
const depth = clampSerpDepth(input.depth);
|
||||
const locationParams = input.locationName
|
||||
? { location_name: input.locationName }
|
||||
: { location_code: input.locationCode };
|
||||
const response = await serpApi().googleOrganicTaskPost(
|
||||
input.tasks.map(
|
||||
(task) =>
|
||||
new SerpGoogleOrganicTaskPostRequestInfo({
|
||||
keyword: task.keyword,
|
||||
location_code: input.locationCode,
|
||||
...locationParams,
|
||||
language_code: input.languageCode,
|
||||
device: task.device,
|
||||
os: task.device === "desktop" ? "windows" : "android",
|
||||
|
||||
@ -38,6 +38,7 @@ interface RankCheckParams {
|
||||
domain: string;
|
||||
locationCode: number;
|
||||
languageCode: string;
|
||||
locationName?: string;
|
||||
devices: "both" | "desktop" | "mobile";
|
||||
serpDepth: number;
|
||||
trigger: "manual" | "scheduled";
|
||||
@ -269,6 +270,7 @@ export class RankCheckWorkflow extends WorkflowEntrypoint<
|
||||
domain,
|
||||
locationCode,
|
||||
languageCode,
|
||||
locationName,
|
||||
devices,
|
||||
serpDepth,
|
||||
trigger,
|
||||
@ -332,6 +334,7 @@ export class RankCheckWorkflow extends WorkflowEntrypoint<
|
||||
domain,
|
||||
locationCode,
|
||||
languageCode,
|
||||
locationName,
|
||||
runId,
|
||||
};
|
||||
// Scheduled checks use DataForSEO's task queue (~30% of live cost);
|
||||
|
||||
@ -48,6 +48,7 @@ interface CheckContext {
|
||||
domain: string;
|
||||
locationCode: number;
|
||||
languageCode: string;
|
||||
locationName?: string;
|
||||
runId: string;
|
||||
}
|
||||
|
||||
@ -90,6 +91,7 @@ async function checkBatchLive(
|
||||
keywordId: task.keywordId,
|
||||
locationCode: ctx.locationCode,
|
||||
languageCode: ctx.languageCode,
|
||||
locationName: ctx.locationName,
|
||||
device: task.device,
|
||||
targetDomain: ctx.domain,
|
||||
depth: ctx.serpDepth,
|
||||
@ -294,6 +296,7 @@ export async function runQueuedCheck(
|
||||
tasks: chunk,
|
||||
locationCode: ctx.locationCode,
|
||||
languageCode: ctx.languageCode,
|
||||
locationName: ctx.locationName,
|
||||
depth: ctx.serpDepth,
|
||||
targetDomain: ctx.domain,
|
||||
}),
|
||||
|
||||
@ -80,6 +80,7 @@ export const createRankTrackingConfig = createServerFn({ method: "POST" })
|
||||
domain: data.domain,
|
||||
locationCode: data.locationCode,
|
||||
languageCode: data.languageCode,
|
||||
locationName: data.locationName,
|
||||
devices: data.devices,
|
||||
serpDepth: data.serpDepth,
|
||||
scheduleInterval: data.scheduleInterval,
|
||||
@ -110,6 +111,7 @@ export const updateRankTrackingConfig = createServerFn({ method: "POST" })
|
||||
domain: data.domain,
|
||||
locationCode: data.locationCode,
|
||||
languageCode: data.languageCode,
|
||||
locationName: data.locationName,
|
||||
devices: data.devices,
|
||||
serpDepth: data.serpDepth,
|
||||
scheduleInterval: data.scheduleInterval,
|
||||
|
||||
36
src/serverFunctions/serp-locations.ts
Normal file
36
src/serverFunctions/serp-locations.ts
Normal file
@ -0,0 +1,36 @@
|
||||
import { createServerFn } from "@tanstack/react-start";
|
||||
import { z } from "zod";
|
||||
import { requireAuthenticatedContext } from "@/serverFunctions/middleware";
|
||||
import { fetchSerpLocationsForCountry } from "@/server/lib/dataforseo/serp-locations";
|
||||
|
||||
/** ISO 3166-1 alpha-2, e.g. "us" — DataForSEO rejects country names. */
|
||||
const countryCodeField = z.string().regex(/^[a-z]{2}$/i);
|
||||
|
||||
const searchSerpLocationsSchema = z.object({
|
||||
query: z.string().min(1).max(100),
|
||||
countryCode: countryCodeField,
|
||||
});
|
||||
|
||||
export const searchSerpLocations = createServerFn({ method: "POST" })
|
||||
.middleware(requireAuthenticatedContext)
|
||||
.validator(searchSerpLocationsSchema)
|
||||
.handler(async ({ data }) => {
|
||||
const all = await fetchSerpLocationsForCountry(data.countryCode);
|
||||
const needle = data.query.trim().toLowerCase();
|
||||
return all
|
||||
.filter((loc) => loc.displayLabel.toLowerCase().includes(needle))
|
||||
.slice(0, 10);
|
||||
});
|
||||
|
||||
/**
|
||||
* Warm the per-country location cache so the first real search is fast.
|
||||
* Fired when the user switches to Local targeting; the first search per
|
||||
* country otherwise pays the full ~9.5MB DataForSEO fetch (~3s).
|
||||
*/
|
||||
export const prewarmSerpLocations = createServerFn({ method: "POST" })
|
||||
.middleware(requireAuthenticatedContext)
|
||||
.validator(z.object({ countryCode: countryCodeField }))
|
||||
.handler(async ({ data }) => {
|
||||
await fetchSerpLocationsForCountry(data.countryCode);
|
||||
return { warmed: true };
|
||||
});
|
||||
@ -2,6 +2,8 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
LABS_LOCATION_OPTIONS,
|
||||
LOCATION_OPTIONS,
|
||||
formatLocationLabel,
|
||||
getIsoCountryCode,
|
||||
getKeywordDataProvider,
|
||||
getLanguageCode,
|
||||
isLabsLocationCode,
|
||||
@ -63,3 +65,32 @@ describe("keyword locations", () => {
|
||||
expect(new Set(codes).size).toBe(codes.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getIsoCountryCode", () => {
|
||||
it("lowercases the shortLabel for standard countries", () => {
|
||||
expect(getIsoCountryCode(2840)).toBe("us");
|
||||
expect(getIsoCountryCode(2036)).toBe("au");
|
||||
});
|
||||
|
||||
it("maps the UK display label to its ISO code gb", () => {
|
||||
expect(getIsoCountryCode(2826)).toBe("gb");
|
||||
});
|
||||
|
||||
it("falls back to us for unknown location codes", () => {
|
||||
expect(getIsoCountryCode(999999)).toBe("us");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatLocationLabel", () => {
|
||||
it("trims uneven spacing around canonical name segments", () => {
|
||||
expect(formatLocationLabel("Portland-Auburn, ME,United States")).toBe(
|
||||
"Portland-Auburn, ME, United States",
|
||||
);
|
||||
});
|
||||
|
||||
it("truncates to maxSegments for compact display", () => {
|
||||
expect(formatLocationLabel("Springfield,Illinois,United States", 2)).toBe(
|
||||
"Springfield, Illinois",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -21,6 +21,37 @@
|
||||
*/
|
||||
export const DEFAULT_LOCATION_CODE = 2840;
|
||||
|
||||
/**
|
||||
* Human-readable form of a canonical DataForSEO location_name, whose segments
|
||||
* are comma-separated with inconsistent spacing ("Portland-Auburn, ME,United
|
||||
* States"). Trims each segment; `maxSegments` truncates for compact display
|
||||
* ("Enid, Oklahoma").
|
||||
*/
|
||||
export function formatLocationLabel(
|
||||
locationName: string,
|
||||
maxSegments?: number,
|
||||
): string {
|
||||
const parts = locationName.split(",").map((part) => part.trim());
|
||||
return (maxSegments ? parts.slice(0, maxSegments) : parts).join(", ");
|
||||
}
|
||||
|
||||
/**
|
||||
* shortLabel is a *display* label; the one entry that diverges from ISO
|
||||
* 3166-1 alpha-2 is the United Kingdom ("UK" reads better, ISO is "GB").
|
||||
*/
|
||||
const ISO_COUNTRY_OVERRIDES: Record<string, string> = { UK: "GB" };
|
||||
|
||||
/**
|
||||
* Lowercase ISO 3166-1 alpha-2 code for a country location_code — the format
|
||||
* DataForSEO's per-country endpoints (e.g. SERP locations) require.
|
||||
*/
|
||||
export function getIsoCountryCode(locationCode: number): string {
|
||||
const shortLabel =
|
||||
LOCATION_OPTIONS.find((option) => option.code === locationCode)
|
||||
?.shortLabel ?? "US";
|
||||
return (ISO_COUNTRY_OVERRIDES[shortLabel] ?? shortLabel).toLowerCase();
|
||||
}
|
||||
|
||||
type KeywordDataProvider = "labs" | "google_ads";
|
||||
|
||||
type LocationOption = {
|
||||
|
||||
@ -57,6 +57,7 @@ export const createConfigSchema = z.object({
|
||||
domain: domainField,
|
||||
locationCode: z.number().int().positive().optional(),
|
||||
languageCode: z.string().max(10).optional(),
|
||||
locationName: z.string().min(1).max(200).optional(),
|
||||
devices: devicesEnum.optional(),
|
||||
serpDepth: z.number().int().min(10).max(100).multipleOf(10),
|
||||
scheduleInterval: scheduleEnum.optional(),
|
||||
@ -68,6 +69,7 @@ export const updateConfigSchema = z.object({
|
||||
domain: domainField.optional(),
|
||||
locationCode: z.number().int().positive().optional(),
|
||||
languageCode: z.string().max(10).optional(),
|
||||
locationName: z.string().min(1).max(200).nullable().optional(),
|
||||
devices: devicesEnum.optional(),
|
||||
serpDepth: z.number().int().min(10).max(100).multipleOf(10).optional(),
|
||||
scheduleInterval: scheduleEnum.optional(),
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user