feat: polish rank tracking UI (#115)

This commit is contained in:
Ben Senescu 2026-04-16 00:42:10 -04:00 committed by GitHub
parent 4595080356
commit aeafa5649d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
30 changed files with 3362 additions and 349 deletions

View File

@ -0,0 +1 @@
ALTER TABLE `rank_tracking_configs` ADD `serp_depth` integer NOT NULL;

File diff suppressed because it is too large Load Diff

View File

@ -57,6 +57,13 @@
"when": 1776208279781,
"tag": "0007_sour_risque",
"breakpoints": true
},
{
"idx": 8,
"version": "6",
"when": 1776288697036,
"tag": "0008_luxuriant_colossus",
"breakpoints": true
}
]
}

View File

@ -104,6 +104,7 @@ async function main() {
locationCode: 2840,
languageCode: "en",
devices: "mobile",
serpDepth: 20,
scheduleInterval: "weekly",
isActive: true,
lastCheckedAt: now,

View File

@ -0,0 +1,32 @@
import type { ReactNode } from "react";
interface SegmentedToggleItem<T extends string> {
value: T;
icon: ReactNode;
label: string;
}
export function SegmentedToggle<T extends string>({
items,
value,
onChange,
}: {
items: SegmentedToggleItem<T>[];
value: T;
onChange: (value: T) => void;
}) {
return (
<div className="inline-flex rounded-lg bg-base-300 p-0.5">
{items.map((item) => (
<button
key={item.value}
className={`btn btn-xs px-2 ${value === item.value ? "bg-primary/20 text-primary shadow-sm" : "btn-ghost text-base-content/40"}`}
onClick={() => onChange(item.value)}
title={item.label}
>
{item.icon}
</button>
))}
</div>
);
}

View File

@ -13,7 +13,11 @@ export function AddKeywordsPanel({
}: {
configId: string;
projectId: string;
onSuccess: (result: { added: number; addedIds?: string[] }) => void;
onSuccess: (result: {
added: number;
addedIds: string[];
checkTriggered: boolean;
}) => void;
onCancel: () => void;
}) {
const [keywordInput, setKeywordInput] = useState("");
@ -30,36 +34,32 @@ export function AddKeywordsPanel({
});
const isPending = mutation.isPending;
return (
<div className="card bg-base-100 border border-base-300">
<div className="card-body gap-3 p-4">
<div className="flex gap-2 items-end">
<textarea
className="textarea textarea-bordered textarea-sm flex-1"
rows={3}
placeholder="Enter keywords, one per line"
value={keywordInput}
onChange={(e) => setKeywordInput(e.target.value)}
/>
<div className="flex flex-col gap-1">
<button
className="btn btn-primary btn-sm"
onClick={() => {
const lines = keywordInput
.split("\n")
.map((l) => l.trim())
.filter(Boolean);
if (lines.length > 0) mutation.mutate(lines);
}}
disabled={isPending || !keywordInput.trim()}
>
{isPending && <Loader2 className="size-3 animate-spin" />}
Add
</button>
<button className="btn btn-ghost btn-sm" onClick={onCancel}>
Cancel
</button>
</div>
</div>
<div className="flex gap-2 items-end">
<textarea
className="textarea textarea-bordered textarea-sm flex-1"
rows={3}
placeholder="Enter keywords, one per line"
value={keywordInput}
onChange={(e) => setKeywordInput(e.target.value)}
/>
<div className="flex flex-col gap-1">
<button
className="btn btn-primary btn-sm"
onClick={() => {
const lines = keywordInput
.split("\n")
.map((l) => l.trim())
.filter(Boolean);
if (lines.length > 0) mutation.mutate(lines);
}}
disabled={isPending || !keywordInput.trim()}
>
{isPending && <Loader2 className="size-3 animate-spin" />}
Add
</button>
<button className="btn btn-ghost btn-sm" onClick={onCancel}>
Cancel
</button>
</div>
</div>
);

View File

@ -11,17 +11,23 @@ import {
export function CheckConfirmModal({
keywordCount,
devices,
serpDepth,
isPending,
onRunNow,
onCancel,
}: {
keywordCount: number;
devices: RankTrackingConfig["devices"];
serpDepth: number;
isPending: boolean;
onRunNow: () => void;
onCancel: () => void;
}) {
const { costUsd } = estimateRankCheckCredits(keywordCount, devices);
const { costUsd } = estimateRankCheckCredits(
keywordCount,
devices,
serpDepth,
);
const dc = devicesCount(devices);
const totalChecks = keywordCount * dc;
const liveTime =

View File

@ -0,0 +1,375 @@
import { useEffect, useState } from "react";
import { useMutation, useQuery } from "@tanstack/react-query";
import {
useReactTable,
getCoreRowModel,
getSortedRowModel,
flexRender,
type ColumnDef,
type RowSelectionState,
type SortingState,
} from "@tanstack/react-table";
import { Loader2, AlertCircle, X } from "lucide-react";
import { toast } from "sonner";
import { getDomainKeywordSuggestions } from "@/serverFunctions/domain";
import { addTrackingKeywords } from "@/serverFunctions/rank-tracking";
import { getStandardErrorMessage } from "@/client/lib/error-messages";
import { SortableHeader } from "./RankTrackingColumns";
type SuggestedKeyword = {
keyword: string;
position: number | null;
searchVolume: number | null;
traffic: number | null;
};
const PRE_SELECT_COUNT = 20;
const columns: ColumnDef<SuggestedKeyword>[] = [
{
id: "select",
size: 32,
enableSorting: false,
header: ({ table }) => (
<input
type="checkbox"
className="checkbox checkbox-xs"
checked={table.getIsAllRowsSelected()}
onChange={table.getToggleAllRowsSelectedHandler()}
/>
),
cell: ({ row }) => (
<input
type="checkbox"
className="checkbox checkbox-xs"
checked={row.getIsSelected()}
onClick={(e) => e.stopPropagation()}
onChange={row.getToggleSelectedHandler()}
/>
),
},
{
id: "keyword",
accessorKey: "keyword",
header: ({ column }) => (
<SortableHeader
column={column}
label="Keyword"
id="keyword"
tooltip="The search term this domain ranks for"
/>
),
cell: ({ getValue }) => (
<span className="font-medium">{getValue<string>()}</span>
),
sortingFn: "alphanumeric",
},
{
id: "position",
accessorKey: "position",
header: ({ column }) => (
<SortableHeader
column={column}
label="Position"
id="position"
tooltip="Current Google ranking position"
/>
),
cell: ({ getValue }) => {
const pos = getValue<number | null>();
return pos != null ? (
pos
) : (
<span className="text-base-content/40"></span>
);
},
sortingFn: (rowA, rowB) => {
const a = rowA.original.position ?? 999;
const b = rowB.original.position ?? 999;
return a - b;
},
},
{
id: "searchVolume",
accessorKey: "searchVolume",
header: ({ column }) => (
<SortableHeader
column={column}
label="Volume"
id="searchVolume"
tooltip="Monthly search volume"
/>
),
cell: ({ getValue }) => {
const vol = getValue<number | null>();
return vol != null ? (
vol.toLocaleString()
) : (
<span className="text-base-content/40"></span>
);
},
sortingFn: (rowA, rowB) => {
const a = rowA.original.searchVolume ?? 0;
const b = rowB.original.searchVolume ?? 0;
return a - b;
},
},
{
id: "traffic",
accessorKey: "traffic",
header: ({ column }) => (
<SortableHeader
column={column}
label="Traffic"
id="traffic"
tooltip="Estimated monthly organic traffic"
/>
),
cell: ({ getValue }) => {
const traffic = getValue<number | null>();
return traffic != null ? (
Math.round(traffic).toLocaleString()
) : (
<span className="text-base-content/40"></span>
);
},
sortingFn: (rowA, rowB) => {
const a = rowA.original.traffic ?? 0;
const b = rowB.original.traffic ?? 0;
return a - b;
},
},
];
type Props = {
configId: string;
projectId: string;
domain: string;
locationCode: number;
languageCode: string;
onDone: (configId: string) => void;
onClose: () => void;
};
export function KeywordSuggestionStep({
configId,
projectId,
domain,
locationCode,
languageCode,
onDone,
onClose,
}: Props) {
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const [hasInitialized, setHasInitialized] = useState(false);
const [sorting, setSorting] = useState<SortingState>([
{ id: "position", desc: false },
]);
const suggestionsQuery = useQuery({
queryKey: [
"domainKeywordSuggestions",
projectId,
domain,
locationCode,
languageCode,
],
queryFn: () =>
getDomainKeywordSuggestions({
data: { projectId, domain, locationCode, languageCode },
}),
});
const data = suggestionsQuery.data ?? [];
// Pre-select top 20 by position once data loads
useEffect(() => {
const items = suggestionsQuery.data;
if (items && items.length > 0 && !hasInitialized) {
// Data comes sorted by search volume from the API, but we display sorted
// by position. Pre-select the 20 with the best (lowest) positions.
const indexed = items.map((item, i) => ({
index: i,
position: item.position ?? 999,
}));
indexed.sort((a, b) => a.position - b.position);
const initial: RowSelectionState = {};
for (let i = 0; i < Math.min(PRE_SELECT_COUNT, indexed.length); i++) {
initial[indexed[i].index] = true;
}
setRowSelection(initial);
setHasInitialized(true);
}
}, [suggestionsQuery.data, hasInitialized]);
const table = useReactTable({
data,
columns,
state: { rowSelection, sorting },
onRowSelectionChange: setRowSelection,
onSortingChange: setSorting,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
enableRowSelection: true,
});
const selectedCount = Object.keys(rowSelection).filter(
(k) => rowSelection[k],
).length;
const addMutation = useMutation({
mutationFn: (keywords: string[]) =>
addTrackingKeywords({ data: { projectId, configId, keywords } }),
onSuccess: (result) => {
toast.success(`Added ${result.added} keywords for tracking`);
onDone(configId);
},
onError: (error) => {
toast.error(getStandardErrorMessage(error, "Failed to add keywords"));
},
});
const handleAdd = () => {
const selectedKeywords = table
.getSelectedRowModel()
.rows.map((row) => row.original.keyword);
if (selectedKeywords.length > 0) {
addMutation.mutate(selectedKeywords);
}
};
const sectionHeader = (title: string) => (
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">{title}</h2>
<button className="btn btn-ghost btn-sm btn-square" onClick={onClose}>
<X className="size-4" />
</button>
</div>
);
// Loading state
if (suggestionsQuery.isLoading) {
return (
<>
{sectionHeader("Finding your top keywords...")}
<div className="flex flex-col items-center justify-center gap-3 py-16">
<Loader2 className="size-8 animate-spin text-primary" />
<p className="text-xs text-base-content/50">
This usually takes a few seconds
</p>
</div>
</>
);
}
// Error state
if (suggestionsQuery.isError) {
return (
<>
{sectionHeader("Couldn't fetch keywords")}
<div className="flex flex-col items-center justify-center gap-3 py-16">
<AlertCircle className="size-8 text-error" />
<p className="text-xs text-base-content/50">
You can try again or add keywords manually later.
</p>
<div className="flex gap-2 mt-2">
<button className="btn btn-ghost btn-sm" onClick={onClose}>
Skip
</button>
<button
className="btn btn-primary btn-sm"
onClick={() => suggestionsQuery.refetch()}
>
Try Again
</button>
</div>
</div>
</>
);
}
// Empty state
if (data.length === 0) {
return (
<>
{sectionHeader("No rankings found")}
<div className="flex flex-col items-center justify-center gap-3 py-16">
<p className="text-xs text-base-content/50">
We couldn't find any keywords {domain} currently ranks for. You can
add keywords manually.
</p>
<button className="btn btn-primary btn-sm mt-2" onClick={onClose}>
Continue
</button>
</div>
</>
);
}
// Data loaded
return (
<div className="flex flex-col gap-3">
{sectionHeader("Choose keywords to track")}
<div className="flex items-center justify-between">
<p className="text-sm text-base-content/60">
We found {data.length} keywords {domain} ranks for.
</p>
<span className="text-xs text-base-content/50">
{selectedCount} of {data.length} selected
</span>
</div>
<div className="overflow-y-auto max-h-[400px] border border-base-300 rounded-lg">
<table className="table table-xs table-pin-rows w-full">
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th key={header.id} className="bg-base-200">
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</th>
))}
</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.map((row) => (
<tr
key={row.id}
className="hover:bg-base-200/50 cursor-pointer"
onClick={row.getToggleSelectedHandler()}
>
{row.getVisibleCells().map((cell) => (
<td key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
<div className="flex items-center justify-end gap-3 pt-1">
<button className="btn btn-ghost btn-sm" onClick={onClose}>
Skip
</button>
<button
className="btn btn-primary btn-sm"
disabled={selectedCount === 0 || addMutation.isPending}
onClick={handleAdd}
>
{addMutation.isPending && (
<Loader2 className="size-3.5 animate-spin" />
)}
Add {selectedCount} Keyword{selectedCount !== 1 ? "s" : ""}
</button>
</div>
</div>
);
}

View File

@ -2,18 +2,28 @@ import { useMemo } from "react";
import { ArrowUp, ArrowDown } from "lucide-react";
import type { ColumnDef, SortingFn } from "@tanstack/react-table";
import type { RankTrackingRow } from "@/types/schemas/rank-tracking";
import { comparePositions, DeviceRankCell } from "./RankTrackingTableParts";
import {
comparePositions,
DeviceRankCell,
DeviceUrlCell,
SerpFeatureTags,
} from "./RankTrackingTableParts";
const HEADER_TOOLTIPS: Record<string, string> = {
keyword: "The search term being tracked",
desktopPosition: "Google ranking details on desktop devices",
mobilePosition: "Google ranking details on mobile devices",
keyword: "The search term being tracked in Google",
desktopPosition:
"Current Google ranking position, showing change from the comparison period",
mobilePosition:
"Current Google ranking position, showing change from the comparison period",
url: "The page on your site that ranks for this keyword",
serp: "Special result features appearing on the search results page (e.g. AI Overview, People Also Ask)",
};
function SortableHeader({
export function SortableHeader({
column,
label,
id,
tooltip,
}: {
column: {
getIsSorted: () => false | "asc" | "desc";
@ -21,6 +31,7 @@ function SortableHeader({
};
label: string;
id: string;
tooltip?: string;
}) {
const sorted = column.getIsSorted();
return (
@ -28,7 +39,7 @@ function SortableHeader({
type="button"
className="inline-flex items-center gap-1 text-xs uppercase tracking-wide font-medium text-base-content/60 transition-colors hover:text-base-content"
onClick={column.getToggleSortingHandler()}
title={HEADER_TOOLTIPS[id]}
title={tooltip ?? HEADER_TOOLTIPS[id]}
aria-label={`Sort by ${label}`}
aria-pressed={!!sorted}
>
@ -86,25 +97,65 @@ const keywordColumn: ColumnDef<RankTrackingRow> = {
function makeDeviceColumn(
device: "desktop" | "mobile",
domain: string,
): ColumnDef<RankTrackingRow> {
const id = device === "desktop" ? "desktopPosition" : "mobilePosition";
const label = device === "desktop" ? "Desktop" : "Mobile";
return {
id,
accessorFn: (row) => row[device].position,
header: ({ column }) => (
<SortableHeader column={column} label={label} id={id} />
),
size: 176,
minSize: 176,
cell: ({ row }) => (
<DeviceRankCell result={row.original[device]} domain={domain} />
<SortableHeader column={column} label="Position" id={id} />
),
size: 120,
maxSize: 140,
cell: ({ row }) => <DeviceRankCell result={row.original[device]} />,
sortingFn: positionSort,
};
}
function makeUrlColumn(
device: "desktop" | "mobile",
domain: string,
): ColumnDef<RankTrackingRow> {
return {
id: device === "desktop" ? "desktopUrl" : "mobileUrl",
enableSorting: false,
header: () => (
<span
className="text-xs uppercase tracking-wide font-medium text-base-content/60 cursor-help"
title={HEADER_TOOLTIPS.url}
>
URL
</span>
),
size: 240,
cell: ({ row }) => (
<DeviceUrlCell result={row.original[device]} domain={domain} />
),
};
}
function makeSerpColumn(
device: "desktop" | "mobile",
): ColumnDef<RankTrackingRow> {
return {
id: device === "desktop" ? "desktopSerp" : "mobileSerp",
enableSorting: false,
header: () => (
<span
className="text-xs uppercase tracking-wide font-medium text-base-content/60 cursor-help"
title={HEADER_TOOLTIPS.serp}
>
SERP Features
</span>
),
cell: ({ row }) => {
const features = row.original[device].serpFeatures;
if (features.length === 0) return null;
return <SerpFeatureTags features={features} />;
},
};
}
export function useRankTrackingColumns(
showDesktop: boolean,
showMobile: boolean,
@ -112,8 +163,16 @@ export function useRankTrackingColumns(
): ColumnDef<RankTrackingRow>[] {
return useMemo(() => {
const cols: ColumnDef<RankTrackingRow>[] = [selectColumn, keywordColumn];
if (showDesktop) cols.push(makeDeviceColumn("desktop", domain));
if (showMobile) cols.push(makeDeviceColumn("mobile", domain));
if (showDesktop) {
cols.push(makeDeviceColumn("desktop"));
cols.push(makeUrlColumn("desktop", domain));
cols.push(makeSerpColumn("desktop"));
}
if (showMobile) {
cols.push(makeDeviceColumn("mobile"));
cols.push(makeUrlColumn("mobile", domain));
cols.push(makeSerpColumn("mobile"));
}
return cols;
}, [showDesktop, showMobile, domain]);
}

View File

@ -5,22 +5,29 @@ import {
createRankTrackingConfig,
updateRankTrackingConfig,
} from "@/serverFunctions/rank-tracking";
import { Loader2, X } from "lucide-react";
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 {
depthToPages,
pagesToDepth,
estimateRankCheckCredits,
} from "@/shared/rank-tracking";
import {
LOCATION_OPTIONS,
DEFAULT_LOCATION_CODE,
getLanguageCode,
} from "@/client/features/keywords/locations";
import { KeywordSuggestionStep } from "./KeywordSuggestionStep";
type Props = {
projectId: string;
existingConfig?: RankTrackingConfig | null;
onClose: () => void;
onSaved: () => void;
onSaved: (createdConfigId?: string) => void;
onConfigCreated?: () => void;
};
export function RankTrackingConfigModal({
@ -28,18 +35,22 @@ export function RankTrackingConfigModal({
existingConfig,
onClose,
onSaved,
onConfigCreated,
}: Props) {
const isEdit = !!existingConfig;
const [step, setStep] = useState<"config" | "keywords">("config");
const [domain, setDomain] = useState(existingConfig?.domain ?? "");
const [devices, setDevices] = useState<"both" | "desktop" | "mobile">(
existingConfig?.devices ?? "mobile",
existingConfig?.devices ?? "both",
);
const [locationCode, setLocationCode] = useState(
existingConfig?.locationCode ?? DEFAULT_LOCATION_CODE,
);
const [serpDepth, setSerpDepth] = useState(existingConfig?.serpDepth ?? 20);
const [schedule, setSchedule] = useState<"daily" | "weekly" | "manual">(
existingConfig?.scheduleInterval ?? "weekly",
);
const [createdConfigId, setCreatedConfigId] = useState<string | null>(null);
const createMutation = useMutation({
mutationFn: () =>
@ -48,15 +59,18 @@ export function RankTrackingConfigModal({
projectId,
domain,
devices,
serpDepth,
locationCode,
languageCode: getLanguageCode(locationCode),
scheduleInterval: schedule,
},
}),
onSuccess: () => {
onSuccess: (result) => {
captureClientEvent("rank_tracking:config_create");
toast.success("Domain added for rank tracking");
onSaved();
setCreatedConfigId(result.configId);
onConfigCreated?.();
setStep("keywords");
},
onError: (error) => {
toast.error(getStandardErrorMessage(error, "Failed to save config"));
@ -71,6 +85,7 @@ export function RankTrackingConfigModal({
configId: existingConfig!.id,
domain,
devices,
serpDepth,
locationCode,
languageCode: getLanguageCode(locationCode),
scheduleInterval: schedule,
@ -102,8 +117,24 @@ export function RankTrackingConfigModal({
const isPending = createMutation.isPending || updateMutation.isPending;
if (step === "keywords" && createdConfigId) {
return (
<Modal maxWidth="max-w-3xl">
<KeywordSuggestionStep
configId={createdConfigId}
projectId={projectId}
domain={domain}
locationCode={locationCode}
languageCode={getLanguageCode(locationCode)}
onDone={(id) => onSaved(id)}
onClose={() => onSaved(createdConfigId ?? undefined)}
/>
</Modal>
);
}
return (
<Modal>
<Modal maxWidth="max-w-lg">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">
{isEdit ? "Edit Domain Config" : "Add Domain"}
@ -166,6 +197,18 @@ export function RankTrackingConfigModal({
<option value="desktop">Desktop only</option>
<option value="mobile">Mobile only</option>
</select>
<div className="mt-1.5 text-xs text-base-content/50">
Most Google searches come from mobile, but select this based on your
customer.
</div>
{devices === "both" && (
<div className="mt-1.5 flex items-start gap-1.5 text-xs text-info">
<Info className="size-3.5 shrink-0 mt-0.5" />
<span>
Tracking both devices uses 2x credits per keyword check
</span>
</div>
)}
</div>
<div className="form-control">
@ -190,12 +233,62 @@ export function RankTrackingConfigModal({
<option value="weekly">Weekly</option>
<option value="manual">Manual only</option>
</select>
{schedule === "daily" && (
<div className="mt-1.5 flex items-start gap-1.5 text-xs text-warning">
<Info className="size-3.5 shrink-0 mt-0.5" />
<span>Daily checks use 7x more credits than weekly</span>
</div>
)}
</div>
<p className="text-xs text-base-content/60">
After adding a domain, manage tracked keywords from the domain detail
view.
</p>
<div className="form-control">
<label className="label">
<span className="label-text font-medium">Search Depth</span>
</label>
<select
className="select select-bordered w-full"
value={depthToPages(serpDepth)}
onChange={(e) => setSerpDepth(pagesToDepth(Number(e.target.value)))}
>
{Array.from({ length: 10 }, (_, i) => i + 1).map((pages) => (
<option key={pages} value={pages}>
{pages} {pages === 1 ? "page" : "pages"} (top {pages * 10}{" "}
results)
</option>
))}
</select>
<div className="mt-1.5 text-xs text-base-content/50">
10 pages is ~8x more expensive than 1 page
</div>
</div>
{(() => {
const { costUsd: costPerKeyword } = estimateRankCheckCredits(
1,
devices,
serpDepth,
);
const checksPerMonth = schedule === "daily" ? 30 : 4;
return (
<div className="rounded-lg bg-base-200/50 px-3 py-2.5 text-xs text-base-content/70 space-y-0.5">
<div>
<span className="font-mono font-semibold text-base-content">
~${costPerKeyword.toFixed(4)}
</span>{" "}
per keyword per check
</div>
{schedule !== "manual" && (
<div>
50 keywords would cost{" "}
<span className="font-mono font-semibold text-base-content">
~${(costPerKeyword * 50 * checksPerMonth).toFixed(2)}
</span>
/month
</div>
)}
</div>
);
})()}
<div className="flex justify-end gap-2 pt-2">
<button

View File

@ -9,9 +9,11 @@ import {
AlertTriangle,
ArrowLeft,
Loader2,
Monitor,
Plus,
Settings,
SlidersHorizontal,
Smartphone,
} from "lucide-react";
import { captureClientEvent } from "@/client/lib/posthog";
import { RankTrackingTable } from "./RankTrackingTable";
@ -32,11 +34,12 @@ import {
type Filters,
} from "./RankTrackingFilters";
import { CheckConfirmModal } from "./CheckConfirmModal";
import { SegmentedToggle } from "@/client/components/SegmentedToggle";
import { useRankCheckTrigger } from "./useRankCheckTrigger";
import { useRankRunPolling } from "./useRankRunPolling";
const COMPARE_PERIODS: ReadonlySet<string> = new Set([
"previous",
"1d",
"7d",
"30d",
"90d",
@ -60,7 +63,12 @@ export function RankTrackingDomainDetail({
const [showAddKeywords, setShowAddKeywords] = useState(false);
const [showFilters, setShowFilters] = useState(false);
const [filters, setFilters] = useState<Filters>(EMPTY_FILTERS);
const [comparePeriod, setComparePeriod] = useState<ComparePeriod>("previous");
const [comparePeriod, setComparePeriod] = useState<ComparePeriod>(
config.scheduleInterval === "daily" ? "1d" : "7d",
);
const [activeDevice, setActiveDevice] = useState<"desktop" | "mobile">(
config.devices === "mobile" ? "mobile" : "desktop",
);
const { data: resultsData, isLoading: resultsLoading } = useQuery({
queryKey: ["rankTrackingResults", projectId, config.id, comparePeriod],
@ -85,7 +93,8 @@ export function RankTrackingDomainDetail({
const handleKeywordsAdded = (result: {
added: number;
addedIds?: string[];
addedIds: string[];
checkTriggered: boolean;
}) => {
void queryClient.invalidateQueries({
queryKey: ["rankTrackingCostEstimate", projectId, config.id],
@ -93,13 +102,17 @@ export function RankTrackingDomainDetail({
void queryClient.invalidateQueries({
queryKey: ["rankTrackingResults", projectId, config.id],
});
void queryClient.invalidateQueries({
queryKey: ["rankTrackingLatestRun", projectId, config.id],
});
setShowAddKeywords(false);
captureClientEvent("rank_tracking:keywords_add");
toast.success(
`${result.added} keyword${result.added !== 1 ? "s" : ""} added`,
);
if (result.addedIds && result.addedIds.length > 0)
requestCheck(result.addedIds.length, result.addedIds);
if (!result.checkTriggered && result.added > 0) {
toast.info("Use 'Check Now' to check these keywords");
}
};
const isRunning =
@ -124,15 +137,19 @@ export function RankTrackingDomainDetail({
const rows = resultsData?.rows;
const run = resultsData?.run;
const showDesktop = config.devices !== "mobile";
const showMobile = config.devices !== "desktop";
const hasBothDevices = config.devices === "both";
const showDesktop = hasBothDevices
? activeDevice === "desktop"
: config.devices !== "mobile";
const showMobile = hasBothDevices
? activeDevice === "mobile"
: config.devices !== "desktop";
const filtered = useMemo(
() => applyFilters(rows ?? [], filters),
[rows, filters],
);
const activeFilterCount = countActiveFilters(filters);
const defaultSortId =
config.devices === "desktop" ? "desktopPosition" : "mobilePosition";
const defaultSortId = showDesktop ? "desktopPosition" : "mobilePosition";
return (
<div className="space-y-3">
@ -144,40 +161,6 @@ export function RankTrackingDomainDetail({
Back to domains
</button>
{/* Domain header */}
<div className="flex flex-col sm:flex-row sm:items-start justify-between gap-2">
<div>
<h2 className="text-lg font-semibold">{config.domain}</h2>
<p className="text-xs text-base-content/60">
{LOCATIONS[config.locationCode] ?? "US"} &middot;{" "}
{devicesLabel(config.devices)} &middot;{" "}
{scheduleLabel(config.scheduleInterval)}
{run && (
<>
{" "}
&middot; Last: {new Date(run.startedAt).toLocaleDateString()}
</>
)}
{costEstimate && costEstimate.keywordCount > 0 && (
<> &middot; ~${costEstimate.costUsd.toFixed(2)}/check</>
)}
</p>
</div>
<div className="flex gap-2">
<button className="btn btn-outline btn-sm gap-1" onClick={onEdit}>
<Settings className="size-3.5" />
Configure
</button>
<button
className="btn btn-primary btn-sm gap-1"
onClick={() => setShowAddKeywords(!showAddKeywords)}
>
<Plus className="size-3.5" />
Add Keywords
</button>
</div>
</div>
{config.lastSkipReason === "insufficient_credits" && (
<div className="alert alert-warning text-sm py-2">
<AlertTriangle className="size-4" />
@ -197,19 +180,56 @@ export function RankTrackingDomainDetail({
</div>
)}
{showAddKeywords && (
<AddKeywordsPanel
configId={config.id}
projectId={projectId}
onSuccess={handleKeywordsAdded}
onCancel={() => setShowAddKeywords(false)}
/>
)}
{/* Results card */}
<div className="flex-1 flex flex-col min-w-0 border border-base-300 rounded-xl bg-base-100 overflow-hidden">
{/* Domain header */}
<div className="flex flex-col sm:flex-row sm:items-start justify-between gap-2 px-4 pt-4 pb-3">
<div>
<h2 className="text-lg font-semibold">{config.domain}</h2>
<p className="text-xs text-base-content/60">
{LOCATIONS[config.locationCode] ?? "US"} &middot;{" "}
{devicesLabel(config.devices)} &middot;{" "}
{scheduleLabel(config.scheduleInterval)}
{run && (
<>
{" "}
&middot; Last:{" "}
{new Date(run.lastCheckedAt).toLocaleDateString()}
</>
)}
{costEstimate && costEstimate.keywordCount > 0 && (
<> &middot; ~${costEstimate.costUsd.toFixed(2)}/check</>
)}
</p>
</div>
<div className="flex gap-2">
<button className="btn btn-outline btn-sm gap-1" onClick={onEdit}>
<Settings className="size-3.5" />
Configure
</button>
<button
className="btn btn-primary btn-sm gap-1"
onClick={() => setShowAddKeywords(!showAddKeywords)}
>
<Plus className="size-3.5" />
Add Keywords
</button>
</div>
</div>
{showAddKeywords && (
<div className="px-4 pb-3">
<AddKeywordsPanel
configId={config.id}
projectId={projectId}
onSuccess={handleKeywordsAdded}
onCancel={() => setShowAddKeywords(false)}
/>
</div>
)}
{/* Table toolbar */}
<div className="shrink-0 flex items-center gap-2 px-4 py-2 border-b border-base-300">
<div className="shrink-0 flex items-center gap-2 px-4 py-2 border-y border-base-300">
<button
className={`btn btn-ghost btn-sm gap-1.5 ${showFilters ? "btn-active" : ""}`}
onClick={() => setShowFilters((c) => !c)}
@ -223,19 +243,6 @@ export function RankTrackingDomainDetail({
</span>
)}
</button>
<select
className="select select-bordered select-sm text-xs"
value={comparePeriod}
onChange={(e) => {
if (isComparePeriod(e.target.value))
setComparePeriod(e.target.value);
}}
>
<option value="previous">vs previous check</option>
<option value="7d">vs 7 days ago</option>
<option value="30d">vs 30 days ago</option>
<option value="90d">vs 90 days ago</option>
</select>
{isRunning && latestRun ? (
<div className="flex items-center gap-2 text-sm text-base-content/70">
@ -243,7 +250,7 @@ export function RankTrackingDomainDetail({
<span>
{latestRun.status === "pending"
? "Preparing..."
: "Checking keywords..."}{" "}
: `Getting rankings for ${latestRun.keywordsTotal || "?"} keyword${latestRun.keywordsTotal !== 1 ? "s" : ""}...`}{" "}
{latestRun.keywordsChecked}/{latestRun.keywordsTotal || "?"}
</span>
{latestRun.keywordsTotal > 0 && (
@ -262,6 +269,39 @@ export function RankTrackingDomainDetail({
<div className="flex-1" />
<select
className="select select-bordered select-sm text-xs w-auto"
value={comparePeriod}
onChange={(e) => {
if (isComparePeriod(e.target.value))
setComparePeriod(e.target.value);
}}
>
<option value="1d">Since yesterday</option>
<option value="7d">Since last week</option>
<option value="30d">Since last month</option>
<option value="90d">Since 90 days ago</option>
</select>
{hasBothDevices && (
<SegmentedToggle
items={[
{
value: "desktop" as const,
icon: <Monitor className="size-3.5" />,
label: "Desktop",
},
{
value: "mobile" as const,
icon: <Smartphone className="size-3.5" />,
label: "Mobile",
},
]}
value={activeDevice}
onChange={setActiveDevice}
/>
)}
<ActionsMenu
onCheckNow={() => {
const count = costEstimate?.keywordCount ?? rows?.length ?? 0;
@ -298,6 +338,7 @@ export function RankTrackingDomainDetail({
{/* Table */}
<div className="p-4">
<RankTrackingTable
key={defaultSortId}
totalCount={rows?.length ?? 0}
rows={filtered}
resultsLoading={resultsLoading}
@ -315,6 +356,7 @@ export function RankTrackingDomainDetail({
<CheckConfirmModal
keywordCount={pendingCheck.count}
devices={config.devices}
serpDepth={config.serpDepth}
isPending={isPending}
onRunNow={() =>
startCheck({

View File

@ -1,12 +1,24 @@
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useNavigate } from "@tanstack/react-router";
import { toast } from "sonner";
import { LOCATIONS } from "@/client/features/keywords/locations";
import { AlertTriangle, Globe, Plus, ChevronRight } from "lucide-react";
import { getRankTrackingConfigSummaries } from "@/serverFunctions/rank-tracking";
import {
AlertTriangle,
Archive,
Globe,
Plus,
ChevronRight,
} from "lucide-react";
import {
getRankTrackingConfigSummaries,
updateRankTrackingConfig,
} from "@/serverFunctions/rank-tracking";
import {
devicesLabel as getDevicesLabel,
scheduleLabel as getScheduleLabel,
} from "@/shared/rank-tracking";
import { Modal } from "@/client/components/Modal";
type ConfigSummary = Awaited<
ReturnType<typeof getRankTrackingConfigSummaries>
@ -20,11 +32,32 @@ export function RankTrackingDomainList({
onAddDomain: () => void;
}) {
const navigate = useNavigate();
const queryClient = useQueryClient();
const [archiveTarget, setArchiveTarget] = useState<ConfigSummary | null>(
null,
);
const { data: summaries } = useQuery({
queryKey: ["rankTrackingConfigSummaries", projectId],
queryFn: () => getRankTrackingConfigSummaries({ data: { projectId } }),
});
const archiveMutation = useMutation({
mutationFn: (configId: string) =>
updateRankTrackingConfig({
data: { projectId, configId, isActive: false },
}),
onSuccess: () => {
setArchiveTarget(null);
void queryClient.invalidateQueries({
queryKey: ["rankTrackingConfigSummaries", projectId],
});
void queryClient.invalidateQueries({
queryKey: ["rankTrackingConfigs", projectId],
});
toast.success("Domain archived");
},
});
return (
<div className="card bg-base-100 border border-base-300">
<div className="card-body gap-0 p-0">
@ -62,11 +95,40 @@ export function RankTrackingDomainList({
params: { projectId, configId: summary.id },
})
}
onArchive={() => setArchiveTarget(summary)}
/>
))
)}
</div>
</div>
{archiveTarget && (
<Modal>
<h3 className="text-lg font-semibold">
Archive {archiveTarget.domain}?
</h3>
<p className="text-sm text-base-content/70">
Scheduled checks will stop and this domain will be hidden from the
list. Ranking history is preserved.
</p>
<div className="flex justify-end gap-2">
<button
className="btn btn-ghost btn-sm"
onClick={() => setArchiveTarget(null)}
>
Cancel
</button>
<button
className="btn btn-error btn-sm gap-1"
onClick={() => archiveMutation.mutate(archiveTarget.id)}
disabled={archiveMutation.isPending}
>
<Archive className="size-3.5" />
Archive
</button>
</div>
</Modal>
)}
</div>
);
}
@ -74,22 +136,28 @@ export function RankTrackingDomainList({
function DomainRow({
summary,
onClick,
onArchive,
}: {
summary: ConfigSummary;
onClick: () => void;
onArchive: () => void;
}) {
const dl = getDevicesLabel(summary.devices);
const sl = getScheduleLabel(summary.scheduleInterval);
return (
<button
type="button"
className="flex w-full items-center gap-4 px-5 py-3.5 text-left transition-colors hover:bg-base-200/50"
<div
role="button"
tabIndex={0}
className="flex w-full items-center gap-4 px-5 py-3.5 text-left transition-colors hover:bg-base-200/50 cursor-pointer"
onClick={onClick}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onClick();
}
}}
>
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-base-200">
<Globe className="size-4 text-base-content/60" />
</div>
<div className="min-w-0 flex-1">
<p className="font-medium truncate">{summary.domain}</p>
<p className="text-xs text-base-content/60">
@ -119,7 +187,18 @@ function DomainRow({
</div>
)}
</div>
<button
type="button"
className="btn btn-ghost btn-xs text-base-content/40 hover:text-error"
title="Archive domain"
onClick={(e) => {
e.stopPropagation();
onArchive();
}}
>
<Archive className="size-4" />
</button>
<ChevronRight className="size-4 shrink-0 text-base-content/40" />
</button>
</div>
);
}

View File

@ -1,4 +1,4 @@
import { ArrowUp, ArrowDown, Minus, Sparkles } from "lucide-react";
import { Sparkles } from "lucide-react";
import { toast } from "sonner";
import { buildCsv, downloadCsv } from "@/client/lib/csv";
import { captureClientEvent } from "@/client/lib/posthog";
@ -7,50 +7,6 @@ import type {
RankTrackingRow,
} from "@/types/schemas/rank-tracking";
function PositionBadge({ position }: { position: number | null }) {
if (position === null) {
return <span className="text-base-content/40">-</span>;
}
return <span className="font-mono">{position}</span>;
}
function ChangeIndicator({
current,
previous,
}: {
current: number | null;
previous: number | null;
}) {
if (previous === null) {
return null;
}
if (current === null) {
return <span className="badge badge-xs badge-error">lost</span>;
}
const change = previous - current; // positive = improved (lower position number is better)
if (change > 0) {
return (
<span className="inline-flex items-center gap-0.5 text-xs text-success">
<ArrowUp className="size-3" />+{change}
</span>
);
}
if (change < 0) {
return (
<span className="inline-flex items-center gap-0.5 text-xs text-error">
<ArrowDown className="size-3" />
{change}
</span>
);
}
return (
<span className="text-base-content/40">
<Minus className="size-3 inline" />
</span>
);
}
const FEATURE_SHORT_LABELS: Record<string, string> = {
featured_snippet: "FS",
people_also_ask: "PAA",
@ -76,7 +32,7 @@ const FEATURE_TOOLTIPS: Record<string, string> = {
top_stories: "Top Stories — news articles carousel",
};
function SerpFeatureTags({ features }: { features: string[] }) {
export function SerpFeatureTags({ features }: { features: string[] }) {
const notable = features.filter((f) => f in FEATURE_SHORT_LABELS);
if (notable.length === 0) return null;
return (
@ -84,7 +40,7 @@ function SerpFeatureTags({ features }: { features: string[] }) {
{notable.map((f) => (
<span
key={f}
className="badge badge-outline badge-xs gap-0.5 cursor-help"
className="badge badge-xs gap-0.5 cursor-help bg-base-300 border-0 text-base-content/70"
title={FEATURE_TOOLTIPS[f] ?? f}
>
{f === "ai_overview" && <Sparkles className="size-2.5" />}
@ -95,51 +51,79 @@ function SerpFeatureTags({ features }: { features: string[] }) {
);
}
function PositionWithChange({
position,
previous,
export function DeviceRankCell({
result,
}: {
position: number | null;
previous: number | null;
result: RankTrackingDeviceResult;
}) {
const { position, previousPosition } = result;
// Nothing at all
if (position === null && previousPosition === null) {
return <span className="text-base-content/40">-</span>;
}
// Was ranking, now lost
if (position === null && previousPosition !== null) {
return (
<span className="inline-flex items-center gap-1.5">
<span className="font-mono text-xs text-base-content/40 w-6 text-right">
{previousPosition}
</span>
<span className="text-base-content/30"></span>
<span className="font-mono rounded px-1.5 py-0.5 text-xs font-semibold bg-error/20 text-error">
lost
</span>
</span>
);
}
// First check — no previous data
if (previousPosition === null) {
return <span className="font-mono">{position}</span>;
}
// Both exist — show old → new with colored badge
const change = previousPosition - position!;
let badgeClass = "bg-base-200 text-base-content";
if (change > 0) badgeClass = "bg-success/20 text-success";
if (change < 0) badgeClass = "bg-warning/20 text-warning";
return (
<span className="inline-flex w-full items-center justify-between px-3">
<PositionBadge position={position} />
<ChangeIndicator current={position} previous={previous} />
<span className="inline-flex items-center gap-1.5">
<span className="font-mono text-xs text-base-content/40 w-6 text-right">
{previousPosition}
</span>
<span className="text-base-content/30"></span>
<span
className={`font-mono rounded px-1.5 py-0.5 text-xs font-semibold ${badgeClass}`}
>
{position}
</span>
</span>
);
}
export function DeviceRankCell({
export function DeviceUrlCell({
result,
domain,
}: {
result: RankTrackingDeviceResult;
domain: string;
}) {
if (!result.rankingUrl) {
return <span className="text-base-content/40 text-xs">-</span>;
}
return (
<div className="min-w-44 space-y-1.5">
<PositionWithChange
position={result.position}
previous={result.previousPosition}
/>
{result.rankingUrl ? (
<a
href={toFullUrl(result.rankingUrl, domain)}
target="_blank"
rel="noopener noreferrer"
className="link link-hover block truncate px-3 text-xs"
title={result.rankingUrl}
>
{toPath(result.rankingUrl)}
</a>
) : null}
{result.serpFeatures.length > 0 ? (
<div className="px-3">
<SerpFeatureTags features={result.serpFeatures} />
</div>
) : null}
</div>
<a
href={toFullUrl(result.rankingUrl, domain)}
target="_blank"
rel="noopener noreferrer"
className="link link-hover block truncate text-xs"
title={result.rankingUrl}
>
{toPath(result.rankingUrl)}
</a>
);
}

View File

@ -120,6 +120,7 @@ export const rankTrackingConfigs = sqliteTable(
})
.notNull()
.default("both"),
serpDepth: integer("serp_depth").notNull(),
scheduleInterval: text("schedule_interval", {
enum: ["daily", "weekly", "manual"],
})

View File

@ -1,5 +1,5 @@
import { useState } from "react";
import { createFileRoute } from "@tanstack/react-router";
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { useQueryClient } from "@tanstack/react-query";
import { RankTrackingDomainList } from "@/client/features/rank-tracking/RankTrackingDomainList";
import { RankTrackingConfigModal } from "@/client/features/rank-tracking/RankTrackingConfigModal";
@ -10,6 +10,7 @@ export const Route = createFileRoute("/_project/p/$projectId/rank-tracking/")({
function RankTrackingIndex() {
const { projectId } = Route.useParams();
const navigate = useNavigate();
const queryClient = useQueryClient();
const [showConfigModal, setShowConfigModal] = useState(false);
@ -34,9 +35,16 @@ function RankTrackingIndex() {
projectId={projectId}
existingConfig={null}
onClose={() => setShowConfigModal(false)}
onSaved={() => {
onConfigCreated={invalidateConfigs}
onSaved={(createdConfigId) => {
setShowConfigModal(false);
invalidateConfigs();
if (createdConfigId) {
void navigate({
to: "/p/$projectId/rank-tracking/$configId",
params: { projectId, configId: createdConfigId },
});
}
}}
/>
)}

View File

@ -248,6 +248,89 @@ function derivePages(
}));
}
async function getSuggestedKeywords(
input: {
domain: string;
locationCode: number;
languageCode: string;
organizationId: string;
projectId: string;
},
billingCustomer: BillingCustomerContext,
): Promise<
Array<{
keyword: string;
position: number | null;
searchVolume: number | null;
traffic: number | null;
cpc: number | null;
keywordDifficulty: number | null;
}>
> {
const domain = input.domain.toLowerCase().trim();
const cacheKey = await buildCacheKey("domain:keyword-suggestions", {
organizationId: billingCustomer.organizationId,
projectId: input.projectId,
domain,
locationCode: input.locationCode,
languageCode: input.languageCode,
});
const cachedRaw = await getCached(cacheKey);
const cached = z
.array(
z.object({
keyword: z.string(),
position: z.number().nullable(),
searchVolume: z.number().nullable(),
traffic: z.number().nullable(),
cpc: z.number().nullable(),
keywordDifficulty: z.number().nullable(),
}),
)
.safeParse(cachedRaw);
if (cached.success && cached.data.length > 0) {
return cached.data;
}
const dataforseo = createDataforseoClient(billingCustomer);
const rankedItems = await dataforseo.domain.rankedKeywords({
target: domain,
locationCode: input.locationCode,
languageCode: input.languageCode,
limit: 100,
orderBy: ["keyword_data.keyword_info.search_volume,desc"],
});
const keywords = rankedItems
.map((item) => mapKeywordItem(item))
.filter(
(item): item is NonNullable<ReturnType<typeof mapKeywordItem>> =>
item != null,
)
.map((item) => ({
keyword: item.keyword,
position: item.position,
searchVolume: item.searchVolume,
traffic: item.traffic,
cpc: item.cpc,
keywordDifficulty: item.keywordDifficulty,
}));
if (keywords.length > 0) {
void setCached(cacheKey, keywords, DOMAIN_OVERVIEW_TTL_SECONDS).catch(
(error) => {
console.error("domain.keyword-suggestions.cache-write failed:", error);
},
);
}
return keywords;
}
export const DomainService = {
getOverview,
getSuggestedKeywords,
} as const;

View File

@ -1,4 +1,4 @@
import { and, asc, count, desc, eq, gte, inArray, lte, max } from "drizzle-orm";
import { and, count, desc, eq, inArray, lte, max } from "drizzle-orm";
import type { InferInsertModel } from "drizzle-orm";
import { db } from "@/db";
import {
@ -9,6 +9,11 @@ import {
rankTrackingKeywords,
projects,
} from "@/db/schema";
import {
getLatestSnapshotsForKeywords,
getSnapshotsBeforeDate,
getEarliestSnapshotsForKeywords,
} from "./snapshotQueries";
const DB_BATCH_SIZE = 100;
type BatchStatement = Parameters<typeof db.batch>[0][number];
@ -33,7 +38,12 @@ async function getConfigsForProject(projectId: string) {
return db
.select()
.from(rankTrackingConfigs)
.where(eq(rankTrackingConfigs.projectId, projectId))
.where(
and(
eq(rankTrackingConfigs.projectId, projectId),
eq(rankTrackingConfigs.isActive, true),
),
)
.orderBy(rankTrackingConfigs.createdAt);
}
@ -107,6 +117,7 @@ async function getDueConfigsWithOrganization(nowIso: string) {
locationCode: rankTrackingConfigs.locationCode,
languageCode: rankTrackingConfigs.languageCode,
devices: rankTrackingConfigs.devices,
serpDepth: rankTrackingConfigs.serpDepth,
scheduleInterval: rankTrackingConfigs.scheduleInterval,
nextCheckAt: rankTrackingConfigs.nextCheckAt,
organizationId: projects.organizationId,
@ -215,62 +226,6 @@ async function getSnapshotsForRun(runId: string) {
return db.select().from(rankSnapshots).where(eq(rankSnapshots.runId, runId));
}
async function getRecentCompletedRuns(configId: string, limit: number) {
return db
.select()
.from(rankCheckRuns)
.where(
and(
eq(rankCheckRuns.configId, configId),
eq(rankCheckRuns.status, "completed"),
eq(rankCheckRuns.isSubsetRun, false),
),
)
.orderBy(desc(rankCheckRuns.startedAt))
.limit(limit);
}
async function getClosestCompletedRun(configId: string, targetDate: string) {
const [beforeRows, afterRows] = await Promise.all([
db
.select()
.from(rankCheckRuns)
.where(
and(
eq(rankCheckRuns.configId, configId),
eq(rankCheckRuns.status, "completed"),
eq(rankCheckRuns.isSubsetRun, false),
lte(rankCheckRuns.startedAt, targetDate),
),
)
.orderBy(desc(rankCheckRuns.startedAt))
.limit(1),
db
.select()
.from(rankCheckRuns)
.where(
and(
eq(rankCheckRuns.configId, configId),
eq(rankCheckRuns.status, "completed"),
eq(rankCheckRuns.isSubsetRun, false),
gte(rankCheckRuns.startedAt, targetDate),
),
)
.orderBy(asc(rankCheckRuns.startedAt))
.limit(1),
]);
const before = beforeRows[0] ?? null;
const after = afterRows[0] ?? null;
if (!before) return after;
if (!after) return before;
const targetMs = new Date(targetDate).getTime();
const beforeDiff = Math.abs(targetMs - new Date(before.startedAt).getTime());
const afterDiff = Math.abs(new Date(after.startedAt).getTime() - targetMs);
return beforeDiff <= afterDiff ? before : after;
}
// ---------------------------------------------------------------------------
// Tracking keywords per config
// ---------------------------------------------------------------------------
@ -401,11 +356,12 @@ export const RankTrackingRepository = {
deleteRunLock,
insertSnapshots,
getSnapshotsForRun,
getRecentCompletedRuns,
getClosestCompletedRun,
getKeywordsForConfig,
addKeywordsToConfig,
removeKeywordsFromConfig,
getKeywordCountForConfig,
getConfigSummaries,
getLatestSnapshotsForKeywords,
getSnapshotsBeforeDate,
getEarliestSnapshotsForKeywords,
};

View File

@ -0,0 +1,149 @@
import { and, eq, inArray, lte, max, min } from "drizzle-orm";
import { db } from "@/db";
import { rankCheckRuns, rankSnapshots } from "@/db/schema";
/**
* Pick one snapshot per keyword+device from completed runs, using SQL GROUP BY
* + self-join instead of loading all snapshots into JS memory.
*
* No keywordIds needed scoped to the config via a completed-runs subquery,
* so subset runs are included automatically.
*/
export async function getSnapshotsForConfig(
configId: string,
opts: { beforeDate?: string; order: "latest" | "earliest" },
) {
const completedRunIds = db
.select({ id: rankCheckRuns.id })
.from(rankCheckRuns)
.where(
and(
eq(rankCheckRuns.configId, configId),
eq(rankCheckRuns.status, "completed"),
),
);
const aggFn = opts.order === "latest" ? max : min;
const conditions = [inArray(rankSnapshots.runId, completedRunIds)];
if (opts.beforeDate) {
conditions.push(lte(rankSnapshots.checkedAt, opts.beforeDate));
}
const grouped = db
.select({
trackingKeywordId: rankSnapshots.trackingKeywordId,
device: rankSnapshots.device,
targetCheckedAt: aggFn(rankSnapshots.checkedAt).as("target_checked_at"),
})
.from(rankSnapshots)
.where(and(...conditions))
.groupBy(rankSnapshots.trackingKeywordId, rankSnapshots.device)
.as("grouped");
return db
.select({
id: rankSnapshots.id,
runId: rankSnapshots.runId,
trackingKeywordId: rankSnapshots.trackingKeywordId,
keyword: rankSnapshots.keyword,
device: rankSnapshots.device,
position: rankSnapshots.position,
url: rankSnapshots.url,
serpFeatures: rankSnapshots.serpFeatures,
checkedAt: rankSnapshots.checkedAt,
})
.from(rankSnapshots)
.innerJoin(
grouped,
and(
eq(rankSnapshots.trackingKeywordId, grouped.trackingKeywordId),
eq(rankSnapshots.device, grouped.device),
eq(rankSnapshots.checkedAt, grouped.targetCheckedAt),
),
)
.where(inArray(rankSnapshots.runId, completedRunIds));
}
export async function getLatestSnapshotsForKeywords(configId: string) {
return getSnapshotsForConfig(configId, { order: "latest" });
}
export async function getSnapshotsBeforeDate(
configId: string,
beforeDate: string,
) {
return getSnapshotsForConfig(configId, { beforeDate, order: "latest" });
}
export async function getEarliestSnapshotsForKeywords(
configId: string,
keywordIds: string[],
) {
if (keywordIds.length === 0) return [];
const completedRunIds = db
.select({ id: rankCheckRuns.id })
.from(rankCheckRuns)
.where(
and(
eq(rankCheckRuns.configId, configId),
eq(rankCheckRuns.status, "completed"),
),
);
const CHUNK_SIZE = 900;
const allResults: Awaited<ReturnType<typeof getSnapshotsForConfig>> = [];
for (let i = 0; i < keywordIds.length; i += CHUNK_SIZE) {
const chunk = keywordIds.slice(i, i + CHUNK_SIZE);
const grouped = db
.select({
trackingKeywordId: rankSnapshots.trackingKeywordId,
device: rankSnapshots.device,
targetCheckedAt: min(rankSnapshots.checkedAt).as("target_checked_at"),
})
.from(rankSnapshots)
.where(
and(
inArray(rankSnapshots.runId, completedRunIds),
inArray(rankSnapshots.trackingKeywordId, chunk),
),
)
.groupBy(rankSnapshots.trackingKeywordId, rankSnapshots.device)
.as("grouped");
const rows = await db
.select({
id: rankSnapshots.id,
runId: rankSnapshots.runId,
trackingKeywordId: rankSnapshots.trackingKeywordId,
keyword: rankSnapshots.keyword,
device: rankSnapshots.device,
position: rankSnapshots.position,
url: rankSnapshots.url,
serpFeatures: rankSnapshots.serpFeatures,
checkedAt: rankSnapshots.checkedAt,
})
.from(rankSnapshots)
.innerJoin(
grouped,
and(
eq(rankSnapshots.trackingKeywordId, grouped.trackingKeywordId),
eq(rankSnapshots.device, grouped.device),
eq(rankSnapshots.checkedAt, grouped.targetCheckedAt),
),
)
.where(
and(
inArray(rankSnapshots.runId, completedRunIds),
inArray(rankSnapshots.trackingKeywordId, chunk),
),
);
allResults.push(...rows);
}
return allResults;
}

View File

@ -28,6 +28,7 @@ async function createConfig(input: {
locationCode?: number;
languageCode?: string;
devices?: RankTrackingConfig["devices"];
serpDepth: number;
scheduleInterval?: RankTrackingConfig["scheduleInterval"];
}) {
const normalizedDomain = normalizeDomain(input.domain);
@ -69,7 +70,8 @@ async function createConfig(input: {
domain: normalizedDomain,
locationCode: input.locationCode ?? 2840,
languageCode: input.languageCode ?? "en",
devices: input.devices ?? "mobile",
devices: input.devices ?? "both",
serpDepth: input.serpDepth,
scheduleInterval,
nextCheckAt,
});
@ -85,6 +87,7 @@ async function updateConfig(
locationCode?: number;
languageCode?: string;
devices?: RankTrackingConfig["devices"];
serpDepth?: number;
scheduleInterval?: RankTrackingConfig["scheduleInterval"];
isActive?: boolean;
},
@ -98,6 +101,7 @@ async function updateConfig(
if (input.languageCode !== undefined)
updates.languageCode = input.languageCode;
if (input.devices !== undefined) updates.devices = input.devices;
if (input.serpDepth !== undefined) updates.serpDepth = input.serpDepth;
if (input.isActive !== undefined) updates.isActive = input.isActive;
if (input.scheduleInterval !== undefined) {
@ -200,7 +204,7 @@ async function triggerCheck(input: {
organizationId: input.billingCustomer.organizationId,
projectId: input.billingCustomer.projectId,
},
keywordsTotal: keywords.length,
keywordsTotal: input.keywordIds ? input.keywordIds.length : keywords.length,
keywordIds: input.keywordIds,
trigger: "manual",
workflowStartErrorMessage: "Failed to start rank check workflow",
@ -239,6 +243,7 @@ async function estimateCost(configId: string, projectId: string) {
const { costUsd, costCredits } = estimateRankCheckCredits(
keywordCount,
config.devices,
config.serpDepth,
);
return {
costUsd,
@ -292,6 +297,7 @@ function formatRun(
status: run.status,
keywordsTotal: run.keywordsTotal,
keywordsChecked: run.keywordsChecked,
isSubsetRun: run.isSubsetRun,
errorMessage: run.errorMessage,
startedAt: run.startedAt,
completedAt: run.completedAt,

View File

@ -36,7 +36,7 @@ type RankCheckWorkflowStatus = {
type RankCheckConfigForStart = Pick<
RankTrackingConfig,
"id" | "domain" | "locationCode" | "languageCode" | "devices"
"id" | "domain" | "locationCode" | "languageCode" | "devices" | "serpDepth"
>;
const ACTIVE_WORKFLOW_STATUSES = new Set<RankCheckWorkflowStatus["status"]>([
@ -122,6 +122,7 @@ export async function beginRankCheckRun(input: {
locationCode: input.config.locationCode,
languageCode: input.config.languageCode,
devices: input.config.devices,
serpDepth: input.config.serpDepth,
trigger: input.trigger,
keywordIds: input.keywordIds,
},

View File

@ -7,10 +7,11 @@ import type {
} from "@/types/schemas/rank-tracking";
type SnapshotRow = Awaited<
ReturnType<typeof RankTrackingRepository.getSnapshotsForRun>
ReturnType<typeof RankTrackingRepository.getLatestSnapshotsForKeywords>
>[0];
const PERIOD_DAYS: Record<Exclude<ComparePeriod, "previous">, number> = {
const PERIOD_DAYS: Record<ComparePeriod, number> = {
"1d": 1,
"7d": 7,
"30d": 30,
"90d": 90,
@ -19,10 +20,10 @@ const PERIOD_DAYS: Record<Exclude<ComparePeriod, "previous">, number> = {
export async function getLatestResults(
configId: string,
projectId: string,
comparePeriod: ComparePeriod = "previous",
comparePeriod: ComparePeriod = "7d",
): Promise<{
rows: RankTrackingRow[];
run: { id: string; startedAt: string } | null;
run: { id: string; lastCheckedAt: string } | null;
}> {
const config = await RankTrackingRepository.getConfigById({
configId,
@ -32,54 +33,56 @@ export async function getLatestResults(
throw new AppError("INTERNAL_ERROR", "Rank tracking config not found");
}
const recentRuns = await RankTrackingRepository.getRecentCompletedRuns(
configId,
2,
);
const currentRun = recentRuns[0];
if (!currentRun) {
return { rows: [], run: null };
}
const currentSnapshots = await RankTrackingRepository.getSnapshotsForRun(
currentRun.id,
);
// Load comparison run's snapshots for delta computation
const previousPositions = new Map<string, number | null>();
let comparisonRun: typeof currentRun | null = null;
if (comparePeriod === "previous") {
comparisonRun = recentRuns[1] ?? null;
} else {
const days = PERIOD_DAYS[comparePeriod];
const targetDate = new Date(
Date.now() - days * 24 * 60 * 60 * 1000,
).toISOString();
const closest = await RankTrackingRepository.getClosestCompletedRun(
configId,
targetDate,
);
// Don't compare a run against itself
if (closest && closest.id !== currentRun.id) {
comparisonRun = closest;
}
}
if (comparisonRun) {
const prevSnapshots = await RankTrackingRepository.getSnapshotsForRun(
comparisonRun.id,
);
for (const snap of prevSnapshots) {
previousPositions.set(
`${snap.trackingKeywordId}:${snap.device}`,
snap.position,
);
}
}
const activeKeywords =
await RankTrackingRepository.getKeywordsForConfig(configId);
// Get the latest snapshot per keyword per device (across all completed runs)
const currentSnapshots =
await RankTrackingRepository.getLatestSnapshotsForKeywords(configId);
// Get comparison snapshots from before the target date
const days = PERIOD_DAYS[comparePeriod];
const targetDate = new Date(
Date.now() - days * 24 * 60 * 60 * 1000,
).toISOString();
const comparisonSnapshots =
await RankTrackingRepository.getSnapshotsBeforeDate(configId, targetDate);
const previousPositions = new Map<string, number | null>();
for (const snap of comparisonSnapshots) {
previousPositions.set(
`${snap.trackingKeywordId}:${snap.device}`,
snap.position,
);
}
// Fallback: for keyword+device combos with no comparison snapshot before
// the target date, use the earliest available snapshot as a baseline.
const missingKeywordIds: string[] = [];
for (const snap of currentSnapshots) {
const key = `${snap.trackingKeywordId}:${snap.device}`;
if (!previousPositions.has(key)) {
missingKeywordIds.push(snap.trackingKeywordId);
}
}
if (missingKeywordIds.length > 0) {
const uniqueMissingIds = [...new Set(missingKeywordIds)];
const earliestSnapshots =
await RankTrackingRepository.getEarliestSnapshotsForKeywords(
configId,
uniqueMissingIds,
);
for (const snap of earliestSnapshots) {
const key = `${snap.trackingKeywordId}:${snap.device}`;
if (!previousPositions.has(key)) {
previousPositions.set(key, snap.position);
}
}
}
// Build result rows
const rows = new Map<string, RankTrackingRow>(
activeKeywords.map((keyword) => [
keyword.id,
@ -96,6 +99,10 @@ export async function getLatestResults(
]),
);
// Determine the most recent snapshot time for the run info
let latestRunId: string | null = null;
let latestStartedAt: string | null = null;
for (const snapshot of currentSnapshots) {
const row = rows.get(snapshot.trackingKeywordId);
if (!row) continue;
@ -105,16 +112,22 @@ export async function getLatestResults(
`${snapshot.trackingKeywordId}:${snapshot.device}`,
) ?? null,
);
// Track the most recent run for the header display
if (!latestStartedAt || snapshot.checkedAt > latestStartedAt) {
latestRunId = snapshot.runId;
latestStartedAt = snapshot.checkedAt;
}
}
return {
rows: activeKeywords
.map((keyword) => rows.get(keyword.id))
.filter((row): row is RankTrackingRow => row != null),
run: {
id: currentRun.id,
startedAt: currentRun.startedAt,
},
run:
latestRunId && latestStartedAt
? { id: latestRunId, lastCheckedAt: latestStartedAt }
: null,
};
}

View File

@ -402,7 +402,9 @@ export async function fetchRankCheckSerpRaw(input: {
languageCode: string;
device: "desktop" | "mobile";
targetDomain: string;
depth: number;
}): Promise<DataforseoApiResponse<RankCheckResult>> {
const depth = Math.min(100, Math.max(10, input.depth));
const responseRaw = await postDataforseo(
"/v3/serp/google/organic/live/advanced",
[
@ -412,8 +414,7 @@ export async function fetchRankCheckSerpRaw(input: {
language_code: input.languageCode,
device: input.device,
os: input.device === "desktop" ? "windows" : "android",
depth: 20,
target: input.targetDomain,
depth,
},
],
);

View File

@ -207,6 +207,7 @@ export function createDataforseoClient(customer: BillingCustomerContext) {
languageCode: string;
device: "desktop" | "mobile";
targetDomain: string;
depth: number;
}) {
return meterDataforseoCall(
customer,

View File

@ -37,6 +37,7 @@ interface RankCheckParams {
locationCode: number;
languageCode: string;
devices: "both" | "desktop" | "mobile";
serpDepth: number;
trigger: "manual" | "scheduled";
keywordIds?: string[];
}
@ -46,6 +47,7 @@ async function prepareRankCheckKeywords(input: {
configId: string;
billingCustomer: BillingCustomerContext;
devices: RankCheckParams["devices"];
serpDepth: number;
keywordIds?: string[];
}) {
const ownsLock = await runOwnsRankCheckLock(input.configId, input.runId);
@ -77,6 +79,7 @@ async function prepareRankCheckKeywords(input: {
const { costCredits } = estimateRankCheckCredits(
trackingKeywords.length,
input.devices,
input.serpDepth,
);
const [monthlyCheck, topupCheck] = await Promise.all([
autumn.check({
@ -233,12 +236,34 @@ export class RankCheckWorkflow extends WorkflowEntrypoint<
locationCode,
languageCode,
devices,
serpDepth,
trigger,
keywordIds,
} = event.payload;
const client = createDataforseoClient(billingCustomer);
// Guard: skip if config was archived after the workflow was triggered
const configCheck = await step.do(
"check-active",
{ retries: { limit: 0, delay: "1 second" } },
async () => {
const cfg = await RankTrackingRepository.getConfigById({
configId,
projectId,
});
return { isActive: cfg?.isActive ?? false };
},
);
if (!configCheck.isActive) {
await failRunAndReleaseRankCheckLock(
configId,
runId,
"Config has been archived",
);
return;
}
try {
console.log(
`[rank-check] ${runId} starting (trigger=${trigger}, devices=${devices})`,
@ -253,6 +278,7 @@ export class RankCheckWorkflow extends WorkflowEntrypoint<
configId,
billingCustomer,
devices,
serpDepth,
keywordIds,
}),
);
@ -268,6 +294,7 @@ export class RankCheckWorkflow extends WorkflowEntrypoint<
client,
keywords,
devices,
serpDepth,
domain,
locationCode,
languageCode,

View File

@ -35,6 +35,7 @@ interface CheckContext {
client: ReturnType<typeof createDataforseoClient>;
keywords: KeywordEntry[];
devices: RankTrackingConfig["devices"];
serpDepth: number;
domain: string;
locationCode: number;
languageCode: string;
@ -74,6 +75,7 @@ export async function runLiveCheck(
languageCode: ctx.languageCode,
device,
targetDomain: ctx.domain,
depth: ctx.serpDepth,
})
.then((r) => ({ ...r, device })),
),
@ -93,6 +95,7 @@ export async function runLiveCheck(
await RankTrackingRepository.updateRun(ctx.runId, {
keywordsChecked: checked,
});
if (results.length > 0) {
await RankTrackingRepository.insertSnapshots(
mapResultsToSnapshotRows(ctx.runId, results),

View File

@ -1,6 +1,9 @@
import { createServerFn } from "@tanstack/react-start";
import { requireProjectContext } from "@/serverFunctions/middleware";
import { domainOverviewSchema } from "@/types/schemas/domain";
import {
domainOverviewSchema,
domainKeywordSuggestionsSchema,
} from "@/types/schemas/domain";
import { DomainService } from "@/server/features/domain/services/DomainService";
export const getDomainOverview = createServerFn({ method: "POST" })
@ -15,3 +18,17 @@ export const getDomainOverview = createServerFn({ method: "POST" })
context,
),
);
export const getDomainKeywordSuggestions = createServerFn({ method: "POST" })
.middleware(requireProjectContext)
.inputValidator((data: unknown) => domainKeywordSuggestionsSchema.parse(data))
.handler(async ({ data, context }) =>
DomainService.getSuggestedKeywords(
{
...data,
organizationId: context.organizationId,
projectId: context.projectId,
},
context,
),
);

View File

@ -3,6 +3,7 @@ import { waitUntil } from "cloudflare:workers";
import { RankTrackingRepository } from "@/server/features/rank-tracking/repositories/RankTrackingRepository";
import { RankTrackingService } from "@/server/features/rank-tracking/services/RankTrackingService";
import { getLatestResults } from "@/server/features/rank-tracking/services/rankTrackingResults";
import { asAppError } from "@/server/lib/errors";
import { captureServerEvent } from "@/server/lib/posthog";
import { requireProjectContext } from "@/serverFunctions/middleware";
import {
@ -41,6 +42,7 @@ export const createRankTrackingConfig = createServerFn({ method: "POST" })
locationCode: data.locationCode,
languageCode: data.languageCode,
devices: data.devices,
serpDepth: data.serpDepth,
scheduleInterval: data.scheduleInterval,
});
@ -70,6 +72,7 @@ export const updateRankTrackingConfig = createServerFn({ method: "POST" })
locationCode: data.locationCode,
languageCode: data.languageCode,
devices: data.devices,
serpDepth: data.serpDepth,
scheduleInterval: data.scheduleInterval,
isActive: data.isActive,
});
@ -134,11 +137,44 @@ export const addTrackingKeywords = createServerFn({ method: "POST" })
.middleware(requireProjectContext)
.inputValidator((data: unknown) => addKeywordsSchema.parse(data))
.handler(async ({ data, context }) => {
return RankTrackingService.addKeywords(
const result = await RankTrackingService.addKeywords(
data.configId,
context.projectId,
data.keywords,
);
let checkTriggered = false;
if (result.addedIds.length > 0) {
try {
const triggerResult = await RankTrackingService.triggerCheck({
configId: data.configId,
projectId: context.projectId,
billingCustomer: context,
keywordIds: result.addedIds,
});
checkTriggered = triggerResult.ok;
if (!triggerResult.ok) {
console.info(
"[rank-tracking] auto-check skipped: %s",
triggerResult.reason,
);
}
} catch (err) {
const appErr = asAppError(err);
if (appErr?.code === "INSUFFICIENT_CREDITS") {
console.info(
"[rank-tracking] auto-check skipped: insufficient credits",
);
} else {
console.error(
"[rank-tracking] auto-check after keyword add failed:",
err,
);
}
}
}
return { ...result, checkTriggered };
});
export const removeTrackingKeywords = createServerFn({ method: "POST" })

View File

@ -9,8 +9,11 @@ import type { RankTrackingConfig } from "@/types/schemas/rank-tracking";
// Cost constants
// ---------------------------------------------------------------------------
/** Per-SERP cost from DataForSEO Live API */
const COST_PER_SERP_USD = 0.002;
/** DataForSEO Live API: cost of first page (10 results) */
const BASE_PAGE_COST_USD = 0.002;
/** DataForSEO Live API: cost of each additional page (75% of base) */
const EXTRA_PAGE_COST_USD = 0.0015;
/** How many keywords are checked per batch */
export const KEYWORDS_PER_BATCH = 10;
@ -28,13 +31,28 @@ export const MAX_CONFIGS_PER_PROJECT = 20;
// Cost estimation
// ---------------------------------------------------------------------------
/** DataForSEO cost for a single SERP request at the given depth. */
function costPerSerpAtDepth(depth: number): number {
const pages = depth / 10;
return BASE_PAGE_COST_USD + (pages - 1) * EXTRA_PAGE_COST_USD;
}
export function depthToPages(depth: number): number {
return depth / 10;
}
export function pagesToDepth(pages: number): number {
return pages * 10;
}
export function estimateRankCheckCredits(
keywordCount: number,
devices: RankTrackingConfig["devices"],
depth: number,
) {
const totalChecks = keywordCount * devicesCount(devices);
const costUsd = roundUsdForBilling(
totalChecks * COST_PER_SERP_USD * SEO_DATA_COST_MARKUP,
totalChecks * costPerSerpAtDepth(depth) * SEO_DATA_COST_MARKUP,
);
const costCredits = Math.ceil(costUsd * AUTUMN_SEO_DATA_CREDITS_PER_USD);
return { costUsd, costCredits };

View File

@ -1,11 +1,14 @@
import { z } from "zod";
export const DOMAIN_REGEX =
/^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)*\.[a-zA-Z]{2,}$/;
const booleanSearchParamSchema = z
.union([z.boolean(), z.enum(["true", "false"])])
.transform((value) => value === true || value === "true");
export const domainOverviewSchema = z.object({
projectId: z.string().min(1),
projectId: z.string().uuid(),
domain: z.string().min(1, "Domain is required").max(255),
includeSubdomains: z.boolean().default(true),
locationCode: z.number().int().positive().default(2840),
@ -20,6 +23,17 @@ const domainSortModes = ["rank", "traffic", "volume", "score", "cpc"] as const;
const domainSortOrders = ["asc", "desc"] as const;
const domainTabs = ["keywords", "pages"] as const;
export const domainKeywordSuggestionsSchema = z.object({
projectId: z.string().uuid(),
domain: z
.string()
.min(1)
.max(253)
.regex(DOMAIN_REGEX, "Invalid domain format"),
locationCode: z.number().int().positive(),
languageCode: z.string().min(2).max(8),
});
export const domainSearchSchema = z.object({
domain: z.string().optional(),
subdomains: booleanSearchParamSchema.optional(),

View File

@ -1,6 +1,7 @@
import type { InferSelectModel } from "drizzle-orm";
import { z } from "zod";
import { rankTrackingConfigs } from "@/db/app.schema";
import { DOMAIN_REGEX } from "@/types/schemas/domain";
// ---------------------------------------------------------------------------
// DB-derived types
@ -53,13 +54,11 @@ export const createConfigSchema = z.object({
.string()
.min(1)
.max(253)
.regex(
/^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)*\.[a-zA-Z]{2,}$/,
"Invalid domain format",
),
.regex(DOMAIN_REGEX, "Invalid domain format"),
locationCode: z.number().int().positive().optional(),
languageCode: z.string().max(10).optional(),
devices: devicesEnum.optional(),
serpDepth: z.number().int().min(10).max(100).multipleOf(10),
scheduleInterval: scheduleEnum.optional(),
});
@ -70,14 +69,12 @@ export const updateConfigSchema = z.object({
.string()
.min(1)
.max(253)
.regex(
/^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)*\.[a-zA-Z]{2,}$/,
"Invalid domain format",
)
.regex(DOMAIN_REGEX, "Invalid domain format")
.optional(),
locationCode: z.number().int().positive().optional(),
languageCode: z.string().max(10).optional(),
devices: devicesEnum.optional(),
serpDepth: z.number().int().min(10).max(100).multipleOf(10).optional(),
scheduleInterval: scheduleEnum.optional(),
isActive: z.boolean().optional(),
});
@ -88,7 +85,7 @@ export const triggerCheckSchema = z.object({
keywordIds: z.array(z.string().uuid()).max(2000).optional(),
});
export const comparePeriodSchema = z.enum(["previous", "7d", "30d", "90d"]);
export const comparePeriodSchema = z.enum(["1d", "7d", "30d", "90d"]);
export type ComparePeriod = z.infer<typeof comparePeriodSchema>;
export const getLatestResultsSchema = z.object({