feat: Rank Tracking MVP (#91)

This commit is contained in:
Ben Senescu 2026-04-14 19:19:54 -04:00 committed by Ben Senescu
parent fb1291537c
commit 53a83996ef
41 changed files with 6495 additions and 159 deletions

View File

@ -11,7 +11,8 @@
".output",
"web",
"src/routeTree.gen.ts",
"worker-configuration.d.ts"
"worker-configuration.d.ts",
"scripts"
],
"rules": {
"react/react-in-jsx-scope": "off",
@ -36,7 +37,7 @@
"eslint/complexity": ["error", { "max": 40 }],
"eslint/max-lines": [
"error",
{ "max": 350, "skipBlankLines": true, "skipComments": true }
{ "max": 400, "skipBlankLines": true, "skipComments": true }
],
"eslint/max-lines-per-function": [
"error",

View File

@ -30,6 +30,8 @@ OpenSEO is an SEO tool for _the people_. If tools like Semrush or Ahrefs are too
- Keyword research
- Find topics worth targeting, estimate demand, and prioritize what to write next.
- Rank tracking
- Monitor keyword positions across desktop and mobile over time, with SERP feature detection.
- Domain insights
- Understand where your domain is gaining or losing visibility so you can focus on the pages that move revenue.
- Backlinks
@ -41,8 +43,9 @@ OpenSEO is an SEO tool for _the people_. If tools like Semrush or Ahrefs are too
Top priorities:
- Rank tracking
- AI SEO like LLM Citations
- AI SEO, GEO, LLM Visibility
- MCP for Claude
- Making the best agentic workflows for SEO
If something important is missing, please join the [Discord](https://discord.gg/c9uGs3cFXr) or email me at ben@everyapp.dev and request it.
@ -241,15 +244,25 @@ That means you can try OpenSEO for free with the starter credit, then decide if/
### Pricing sources
- DataForSEO SERP API pricing: https://dataforseo.com/apis/serp-api/pricing
- DataForSEO Labs pricing: https://dataforseo.com/pricing/dataforseo-labs/dataforseo-google-api
- DataForSEO Backlinks pricing: https://dataforseo.com/pricing/backlinks/backlinks
- DataForSEO Lighthouse API docs: https://docs.dataforseo.com/v3/on_page/lighthouse/overview/
### 1) Site audit
### 1) Rank tracking
- No paid API calls in the current implementation.
- Uses the SERP Task API with `depth: 100` + `stop_crawl_on_match` (stops early when your domain is found).
- Cost per keyword per device: `$0.0006` (ranks top 10) to `$0.00465` (not ranking).
- Typical cost per keyword (both devices): `~$0.002` assuming most keywords rank in the top 20.
- Planning examples:
- 100 keywords, both devices, weekly: `~$0.80/month`
- 500 keywords, both devices, weekly: `~$4.00/month`
### 2) Keyword research (`related` mode)
### 2) Site audit
- $0.01 per 20 pages audited with Lighthouse
### 3) Keyword research (`related` mode)
- Current billed cost pattern (from account usage logs):
- `0.02 + (0.0001 x returned_keywords)` USD
@ -259,13 +272,13 @@ That means you can try OpenSEO for free with the starter credit, then decide if/
- 300 results = `$0.05`
- 500 results = `$0.07`
### 3) Domain overview
### 4) Domain overview
- Standard domain overview request (with top 200 ranked keywords): `$0.0401` per domain.
- General formula if needed:
- `0.0201 + (0.0001 x ranked_keywords_returned)` USD
### 4) Backlinks search
### 5) Backlinks search
- Backlinks search costs about `$0.06` for a domain or `$0.04` for a page.
- Opening extra tabs like `Referring Domains` or `Top Pages` adds about `+$0.02` each.

View File

@ -0,0 +1,67 @@
CREATE TABLE `rank_check_locks` (
`config_id` text PRIMARY KEY NOT NULL,
`run_id` text NOT NULL,
`acquired_at` text DEFAULT (current_timestamp) NOT NULL,
FOREIGN KEY (`config_id`) REFERENCES `rank_tracking_configs`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `rank_check_locks_run_idx` ON `rank_check_locks` (`run_id`);--> statement-breakpoint
CREATE TABLE `rank_check_runs` (
`id` text PRIMARY KEY NOT NULL,
`config_id` text NOT NULL,
`project_id` text NOT NULL,
`status` text DEFAULT 'pending' NOT NULL,
`keywords_total` integer DEFAULT 0 NOT NULL,
`keywords_checked` integer DEFAULT 0 NOT NULL,
`is_subset_run` integer DEFAULT false NOT NULL,
`error_message` text,
`started_at` text DEFAULT (current_timestamp) NOT NULL,
`completed_at` text,
FOREIGN KEY (`config_id`) REFERENCES `rank_tracking_configs`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `rank_check_runs_config_idx` ON `rank_check_runs` (`config_id`,`started_at`);--> statement-breakpoint
CREATE INDEX `rank_check_runs_project_idx` ON `rank_check_runs` (`project_id`,`started_at`);--> statement-breakpoint
CREATE TABLE `rank_snapshots` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`run_id` text NOT NULL,
`tracking_keyword_id` text NOT NULL,
`keyword` text NOT NULL,
`device` text NOT NULL,
`position` integer,
`url` text,
`serp_features` text,
`checked_at` text DEFAULT (current_timestamp) NOT NULL,
FOREIGN KEY (`run_id`) REFERENCES `rank_check_runs`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `rank_snapshots_run_idx` ON `rank_snapshots` (`run_id`);--> statement-breakpoint
CREATE INDEX `rank_snapshots_keyword_device_idx` ON `rank_snapshots` (`tracking_keyword_id`,`device`,`checked_at`);--> statement-breakpoint
CREATE UNIQUE INDEX `rank_snapshots_run_keyword_device_idx` ON `rank_snapshots` (`run_id`,`tracking_keyword_id`,`device`);--> statement-breakpoint
CREATE TABLE `rank_tracking_configs` (
`id` text PRIMARY KEY NOT NULL,
`project_id` text NOT NULL,
`domain` text NOT NULL,
`location_code` integer DEFAULT 2840 NOT NULL,
`language_code` text DEFAULT 'en' NOT NULL,
`devices` text DEFAULT 'both' NOT NULL,
`schedule_interval` text DEFAULT 'weekly' NOT NULL,
`is_active` integer DEFAULT true NOT NULL,
`last_checked_at` text,
`next_check_at` text,
`last_skip_reason` text,
`created_at` text DEFAULT (current_timestamp) NOT NULL,
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `rank_tracking_configs_project_domain_location_idx` ON `rank_tracking_configs` (`project_id`,`domain`,`location_code`);--> statement-breakpoint
CREATE TABLE `rank_tracking_keywords` (
`id` text PRIMARY KEY NOT NULL,
`config_id` text NOT NULL,
`keyword` text NOT NULL,
`created_at` text DEFAULT (current_timestamp) NOT NULL,
FOREIGN KEY (`config_id`) REFERENCES `rank_tracking_configs`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `rank_tracking_keywords_config_keyword_idx` ON `rank_tracking_keywords` (`config_id`,`keyword`);

File diff suppressed because it is too large Load Diff

View File

@ -50,6 +50,13 @@
"when": 1774320825595,
"tag": "0006_magical_alex_wilder",
"breakpoints": true
},
{
"idx": 7,
"version": "6",
"when": 1776208279781,
"tag": "0007_sour_risque",
"breakpoints": true
}
]
}

View File

@ -0,0 +1,19 @@
import type { ReactNode } from "react";
export function Modal({
maxWidth = "max-w-sm",
children,
}: {
maxWidth?: string;
children: ReactNode;
}) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div
className={`card bg-base-100 border border-base-300 w-full ${maxWidth} shadow-xl`}
>
<div className="card-body gap-4">{children}</div>
</div>
</div>
);
}

View File

@ -6,6 +6,7 @@ import {
type SortDirection,
type TopPagesTableSort,
} from "./backlinksTableSorting";
export function ReferringDomainsTableHeader({
sort,
onSortChange,

View File

@ -1,6 +1,7 @@
import type { BacklinksOverviewData } from "./backlinksPageTypes";
export type SortDirection = "asc" | "desc";
export type ReferringDomainsTableSortField =
| "domain"
| "backlinks"
@ -50,6 +51,7 @@ export function getNextSort<TField extends string>(
direction: current.direction === "asc" ? "desc" : "asc",
};
}
export function sortReferringDomainRows(
rows: BacklinksOverviewData["referringDomains"],
sort: ReferringDomainsTableSort,

View File

@ -0,0 +1,68 @@
import { useState } from "react";
import { MoreHorizontal, Play, Download, Copy } from "lucide-react";
export function ActionsMenu({
onCheckNow,
onExport,
onCopyKeywords,
isRunning,
hasData,
}: {
onCheckNow: () => void;
onExport: () => void;
onCopyKeywords: () => void;
isRunning: boolean;
hasData: boolean;
}) {
const [open, setOpen] = useState(false);
return (
<div className="relative">
<button
className="btn btn-ghost btn-sm gap-1"
onClick={() => setOpen((c) => !c)}
>
<MoreHorizontal className="size-4" />
</button>
{open && (
<>
<div className="fixed inset-0 z-40" onClick={() => setOpen(false)} />
<div className="absolute right-0 top-full mt-1 z-50 rounded-lg border border-base-300 bg-base-100 shadow-lg py-1 min-w-[160px]">
<button
className="flex w-full items-center gap-2 px-3 py-2 text-sm hover:bg-base-200"
onClick={() => {
onCheckNow();
setOpen(false);
}}
disabled={isRunning}
>
<Play className="size-3.5" />
{isRunning ? "Running..." : "Check Now"}
</button>
<button
className="flex w-full items-center gap-2 px-3 py-2 text-sm hover:bg-base-200"
onClick={() => {
onExport();
setOpen(false);
}}
disabled={!hasData}
>
<Download className="size-3.5" />
Export CSV
</button>
<button
className="flex w-full items-center gap-2 px-3 py-2 text-sm hover:bg-base-200"
onClick={() => {
onCopyKeywords();
setOpen(false);
}}
disabled={!hasData}
>
<Copy className="size-3.5" />
Copy Keywords
</button>
</div>
</>
)}
</div>
);
}

View File

@ -0,0 +1,66 @@
import { useState } from "react";
import { toast } from "sonner";
import { useMutation } from "@tanstack/react-query";
import { addTrackingKeywords } from "@/serverFunctions/rank-tracking";
import { getStandardErrorMessage } from "@/client/lib/error-messages";
import { Loader2 } from "lucide-react";
export function AddKeywordsPanel({
configId,
projectId,
onSuccess,
onCancel,
}: {
configId: string;
projectId: string;
onSuccess: (result: { added: number; addedIds?: string[] }) => void;
onCancel: () => void;
}) {
const [keywordInput, setKeywordInput] = useState("");
const mutation = useMutation({
mutationFn: (kws: string[]) =>
addTrackingKeywords({ data: { projectId, configId, keywords: kws } }),
onSuccess: (result) => {
setKeywordInput("");
onSuccess(result);
},
onError: (error) => {
toast.error(getStandardErrorMessage(error, "Failed to add keywords"));
},
});
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>
</div>
);
}

View File

@ -0,0 +1,69 @@
import { Loader2, Zap } from "lucide-react";
import { Modal } from "@/client/components/Modal";
import type { RankTrackingConfig } from "@/types/schemas/rank-tracking";
import {
estimateRankCheckCredits,
devicesCount,
KEYWORDS_PER_BATCH,
SECONDS_PER_BATCH,
} from "@/shared/rank-tracking";
export function CheckConfirmModal({
keywordCount,
devices,
isPending,
onRunNow,
onCancel,
}: {
keywordCount: number;
devices: RankTrackingConfig["devices"];
isPending: boolean;
onRunNow: () => void;
onCancel: () => void;
}) {
const { costUsd } = estimateRankCheckCredits(keywordCount, devices);
const dc = devicesCount(devices);
const totalChecks = keywordCount * dc;
const liveTime =
Math.ceil(totalChecks / KEYWORDS_PER_BATCH) * SECONDS_PER_BATCH;
return (
<Modal maxWidth="max-w-md">
<div>
<h3 className="text-lg font-semibold">
Check {keywordCount} keyword
{keywordCount !== 1 ? "s" : ""}
</h3>
<p className="text-sm text-base-content/60 mt-1">
{keywordCount} keywords &times; {dc} device
{dc !== 1 ? "s" : ""} = {totalChecks} SERP checks
</p>
</div>
<button
className="flex w-full items-center gap-4 rounded-xl border-2 border-base-300 p-4 text-left transition-colors hover:border-primary hover:bg-primary/5"
onClick={onRunNow}
disabled={isPending}
>
<div className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<Zap className="size-5 text-primary" />
</div>
<div className="flex-1">
<p className="font-medium">Run Now</p>
<p className="text-xs text-base-content/60">
Results in ~
{liveTime < 60 ? `${liveTime}s` : `${Math.ceil(liveTime / 60)} min`}
</p>
</div>
<div className="text-right">
<p className="font-mono font-semibold">${costUsd.toFixed(2)}</p>
{isPending && <Loader2 className="size-3 animate-spin ml-auto" />}
</div>
</button>
<button className="btn btn-ghost btn-sm self-center" onClick={onCancel}>
Cancel
</button>
</Modal>
);
}

View File

@ -0,0 +1,220 @@
import { useState } from "react";
import { toast } from "sonner";
import { useMutation } from "@tanstack/react-query";
import {
createRankTrackingConfig,
updateRankTrackingConfig,
} from "@/serverFunctions/rank-tracking";
import { 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 {
LOCATION_OPTIONS,
DEFAULT_LOCATION_CODE,
getLanguageCode,
} from "@/client/features/keywords/locations";
type Props = {
projectId: string;
existingConfig?: RankTrackingConfig | null;
onClose: () => void;
onSaved: () => void;
};
export function RankTrackingConfigModal({
projectId,
existingConfig,
onClose,
onSaved,
}: Props) {
const isEdit = !!existingConfig;
const [domain, setDomain] = useState(existingConfig?.domain ?? "");
const [devices, setDevices] = useState<"both" | "desktop" | "mobile">(
existingConfig?.devices ?? "mobile",
);
const [locationCode, setLocationCode] = useState(
existingConfig?.locationCode ?? DEFAULT_LOCATION_CODE,
);
const [schedule, setSchedule] = useState<"daily" | "weekly" | "manual">(
existingConfig?.scheduleInterval ?? "weekly",
);
const createMutation = useMutation({
mutationFn: () =>
createRankTrackingConfig({
data: {
projectId,
domain,
devices,
locationCode,
languageCode: getLanguageCode(locationCode),
scheduleInterval: schedule,
},
}),
onSuccess: () => {
captureClientEvent("rank_tracking:config_create");
toast.success("Domain added for rank tracking");
onSaved();
},
onError: (error) => {
toast.error(getStandardErrorMessage(error, "Failed to save config"));
},
});
const updateMutation = useMutation({
mutationFn: () =>
updateRankTrackingConfig({
data: {
projectId,
configId: existingConfig!.id,
domain,
devices,
locationCode,
languageCode: getLanguageCode(locationCode),
scheduleInterval: schedule,
},
}),
onSuccess: () => {
captureClientEvent("rank_tracking:config_update");
toast.success("Configuration updated");
onSaved();
},
onError: (error) => {
toast.error(getStandardErrorMessage(error, "Failed to update config"));
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (isPending) return;
if (!domain.trim()) {
toast.error("Please enter a domain");
return;
}
if (isEdit) {
updateMutation.mutate();
} else {
createMutation.mutate();
}
};
const isPending = createMutation.isPending || updateMutation.isPending;
return (
<Modal>
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">
{isEdit ? "Edit Domain Config" : "Add Domain"}
</h2>
<button className="btn btn-ghost btn-sm btn-square" onClick={onClose}>
<X className="size-4" />
</button>
</div>
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<div className="form-control">
<label className="label">
<span className="label-text font-medium">Target Domain</span>
</label>
<input
type="text"
placeholder="example.com"
className="input input-bordered w-full"
value={domain}
onChange={(e) => setDomain(e.target.value)}
/>
</div>
<div className="form-control">
<label className="label">
<span className="label-text font-medium">Country</span>
</label>
<select
className="select select-bordered w-full"
value={locationCode}
onChange={(e) => setLocationCode(Number(e.target.value))}
>
{LOCATION_OPTIONS.map((loc) => (
<option key={loc.code} value={loc.code}>
{loc.label}
</option>
))}
</select>
</div>
<div className="form-control">
<label className="label">
<span className="label-text font-medium">Devices</span>
</label>
<select
className="select select-bordered w-full"
value={devices}
onChange={(e) => {
const value = e.target.value;
if (
value === "both" ||
value === "desktop" ||
value === "mobile"
) {
setDevices(value);
}
}}
>
<option value="both">Desktop + Mobile</option>
<option value="desktop">Desktop only</option>
<option value="mobile">Mobile only</option>
</select>
</div>
<div className="form-control">
<label className="label">
<span className="label-text font-medium">Schedule</span>
</label>
<select
className="select select-bordered w-full"
value={schedule}
onChange={(e) => {
const value = e.target.value;
if (
value === "daily" ||
value === "weekly" ||
value === "manual"
) {
setSchedule(value);
}
}}
>
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
<option value="manual">Manual only</option>
</select>
</div>
<p className="text-xs text-base-content/60">
After adding a domain, manage tracked keywords from the domain detail
view.
</p>
<div className="flex justify-end gap-2 pt-2">
<button
type="button"
className="btn btn-ghost btn-sm"
onClick={onClose}
>
Cancel
</button>
<button
type="submit"
className="btn btn-primary btn-sm"
disabled={isPending || !domain.trim()}
>
{isPending && <Loader2 className="size-3.5 animate-spin" />}
{isEdit ? "Save Changes" : "Add Domain"}
</button>
</div>
</form>
</Modal>
);
}

View File

@ -0,0 +1,330 @@
import { useState } from "react";
import { toast } from "sonner";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
getLatestRankResults,
estimateRankCheckCost,
} from "@/serverFunctions/rank-tracking";
import {
AlertTriangle,
ArrowLeft,
Loader2,
Plus,
Settings,
SlidersHorizontal,
} from "lucide-react";
import { captureClientEvent } from "@/client/lib/posthog";
import { RankTrackingTable, exportRankTrackingCsv } from "./RankTrackingTable";
import type { RankTrackingConfig } from "@/types/schemas/rank-tracking";
import type { ComparePeriod } from "@/types/schemas/rank-tracking";
import { LOCATIONS } from "@/client/features/keywords/locations";
import { devicesLabel, scheduleLabel } from "@/shared/rank-tracking";
import { ActionsMenu } from "./ActionsMenu";
import { AddKeywordsPanel } from "./AddKeywordsPanel";
import {
FilterPanel,
applyFilters,
countActiveFilters,
EMPTY_FILTERS,
type Filters,
} from "./RankTrackingFilters";
import { CheckConfirmModal } from "./CheckConfirmModal";
import { useRankCheckTrigger } from "./useRankCheckTrigger";
import { useRankRunPolling } from "./useRankRunPolling";
import { useRankTableSort } from "./useRankTableSort";
const COMPARE_PERIODS: ReadonlySet<string> = new Set([
"previous",
"7d",
"30d",
"90d",
]);
function isComparePeriod(v: string): v is ComparePeriod {
return COMPARE_PERIODS.has(v);
}
export function RankTrackingDomainDetail({
config,
projectId,
onBack,
onEdit,
}: {
config: RankTrackingConfig;
projectId: string;
onBack: () => void;
onEdit: () => void;
}) {
const queryClient = useQueryClient();
const [showAddKeywords, setShowAddKeywords] = useState(false);
const [showFilters, setShowFilters] = useState(false);
const [filters, setFilters] = useState<Filters>(EMPTY_FILTERS);
const [comparePeriod, setComparePeriod] = useState<ComparePeriod>("previous");
const { data: resultsData, isLoading: resultsLoading } = useQuery({
queryKey: ["rankTrackingResults", projectId, config.id, comparePeriod],
queryFn: () =>
getLatestRankResults({
data: { projectId, configId: config.id, comparePeriod },
}),
});
const latestRun = useRankRunPolling(projectId, config.id);
const { data: costEstimate } = useQuery({
queryKey: ["rankTrackingCostEstimate", projectId, config.id],
queryFn: () =>
estimateRankCheckCost({ data: { projectId, configId: config.id } }),
});
const [pendingCheck, setPendingCheck] = useState<{
count: number;
keywordIds?: string[];
} | null>(null);
const handleKeywordsAdded = (result: {
added: number;
addedIds?: string[];
}) => {
void queryClient.invalidateQueries({
queryKey: ["rankTrackingCostEstimate", projectId, config.id],
});
void queryClient.invalidateQueries({
queryKey: ["rankTrackingResults", 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);
};
const isRunning =
(latestRun?.status === "pending" || latestRun?.status === "running") &&
!latestRun?.maybeStale;
const { startCheck, isBusy, isPending } = useRankCheckTrigger({
configId: config.id,
isRunning,
projectId,
onSuccess: () => setPendingCheck(null),
});
const requestCheck = (count: number, keywordIds?: string[]) => {
if (count < 50) {
startCheck({ keywordIds });
return;
}
if (isBusy) return;
setPendingCheck({ count, keywordIds });
};
const rows = resultsData?.rows ?? [];
const run = resultsData?.run;
const showDesktop = config.devices !== "mobile";
const showMobile = config.devices !== "desktop";
const filtered = applyFilters(rows, filters);
const activeFilterCount = countActiveFilters(filters);
const defaultSort =
config.devices === "desktop" ? "desktopPosition" : "mobilePosition";
const { sorted, sortField, sortDir, handleSort } = useRankTableSort(
filtered,
defaultSort,
);
return (
<div className="space-y-3">
<button
className="btn btn-ghost btn-xs gap-1 -ml-2 text-base-content/60"
onClick={onBack}
>
<ArrowLeft className="size-3" />
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" />
<span>
Last scheduled check was skipped due to insufficient credits. Top up
your balance to resume automatic tracking.
</span>
</div>
)}
{latestRun?.maybeStale && (
<div className="alert alert-warning text-sm py-2">
<AlertTriangle className="size-4" />
<span>
This run may be unresponsive and will be cleaned up automatically.
</span>
</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">
{/* Table toolbar */}
<div className="shrink-0 flex items-center gap-2 px-4 py-2 border-b border-base-300">
<button
className={`btn btn-ghost btn-sm gap-1.5 ${showFilters ? "btn-active" : ""}`}
onClick={() => setShowFilters((c) => !c)}
title="Toggle table filters"
>
<SlidersHorizontal className="size-3.5" />
Filters
{activeFilterCount > 0 && (
<span className="badge badge-xs badge-primary border-0 text-primary-content">
{activeFilterCount}
</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">
<Loader2 className="size-3.5 animate-spin text-primary" />
<span>
{latestRun.status === "pending"
? "Preparing..."
: "Checking keywords..."}{" "}
{latestRun.keywordsChecked}/{latestRun.keywordsTotal || "?"}
</span>
{latestRun.keywordsTotal > 0 && (
<progress
className="progress progress-primary w-24"
value={latestRun.keywordsChecked}
max={latestRun.keywordsTotal}
/>
)}
</div>
) : (
<span className="text-sm text-base-content/60">
{filtered.length} keywords
</span>
)}
<div className="flex-1" />
<ActionsMenu
onCheckNow={() => {
const count = costEstimate?.keywordCount ?? rows.length;
if (count > 0) requestCheck(count);
}}
onExport={() =>
exportRankTrackingCsv(
sorted,
showDesktop,
showMobile,
config.domain,
)
}
onCopyKeywords={() => {
const text = sorted.map((r) => r.keyword).join("\n");
void navigator.clipboard.writeText(text);
toast.success("Keywords copied to clipboard");
}}
isRunning={isBusy}
hasData={sorted.length > 0}
/>
</div>
{/* Filters panel */}
{showFilters && (
<FilterPanel
filters={filters}
setFilters={setFilters}
activeFilterCount={activeFilterCount}
onReset={() => setFilters(EMPTY_FILTERS)}
/>
)}
{/* Table */}
<div className="p-4">
<RankTrackingTable
totalCount={rows.length}
sorted={sorted}
resultsLoading={resultsLoading}
showDesktop={showDesktop}
showMobile={showMobile}
sortField={sortField}
sortDir={sortDir}
onSort={handleSort}
domain={config.domain}
configId={config.id}
projectId={projectId}
/>
</div>
</div>
{pendingCheck && (
<CheckConfirmModal
keywordCount={pendingCheck.count}
devices={config.devices}
isPending={isPending}
onRunNow={() =>
startCheck({
keywordIds: pendingCheck.keywordIds,
})
}
onCancel={() => setPendingCheck(null)}
/>
)}
</div>
);
}

View File

@ -0,0 +1,106 @@
import { useQuery } from "@tanstack/react-query";
import { LOCATIONS } from "@/client/features/keywords/locations";
import { AlertTriangle, Globe, Plus, ChevronRight } from "lucide-react";
import { getRankTrackingConfigSummaries } from "@/serverFunctions/rank-tracking";
import {
devicesLabel as getDevicesLabel,
scheduleLabel as getScheduleLabel,
} from "@/shared/rank-tracking";
type ConfigSummary = Awaited<
ReturnType<typeof getRankTrackingConfigSummaries>
>[number];
export function RankTrackingDomainList({
projectId,
onSelectConfig,
onAddDomain,
}: {
projectId: string;
onSelectConfig: (configId: string) => void;
onAddDomain: () => void;
}) {
const { data: summaries } = useQuery({
queryKey: ["rankTrackingConfigSummaries", projectId],
queryFn: () => getRankTrackingConfigSummaries({ data: { projectId } }),
});
return (
<div className="card bg-base-100 border border-base-300">
<div className="card-body gap-0 p-0">
<div className="flex items-center justify-between px-5 pt-4 pb-3">
<h2 className="text-sm font-semibold">Tracked Domains</h2>
<button
className="btn btn-primary btn-sm btn-outline gap-1"
onClick={onAddDomain}
>
<Plus className="size-3.5" />
Add Domain
</button>
</div>
<div className="divide-y divide-base-300">
{(summaries ?? []).map((summary) => (
<DomainRow
key={summary.id}
summary={summary}
onClick={() => onSelectConfig(summary.id)}
/>
))}
</div>
</div>
</div>
);
}
function DomainRow({
summary,
onClick,
}: {
summary: ConfigSummary;
onClick: () => 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"
onClick={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">
{LOCATIONS[summary.locationCode] ?? "US"} &middot; {dl} &middot; {sl}
{summary.lastRunCompletedAt && (
<>
{" "}
&middot; Last:{" "}
{new Date(summary.lastRunCompletedAt).toLocaleDateString()}
</>
)}
</p>
{summary.lastSkipReason === "insufficient_credits" && (
<p className="flex items-center gap-1 text-xs text-warning">
<AlertTriangle className="size-3" />
Scheduled check skipped insufficient credits
</p>
)}
</div>
<div className="hidden sm:flex items-center gap-6 text-sm">
{summary.keywordCount > 0 && (
<div className="text-center">
<p className="text-xs uppercase tracking-wide text-base-content/60">
Keywords
</p>
<p className="font-mono font-medium">{summary.keywordCount}</p>
</div>
)}
</div>
<ChevronRight className="size-4 shrink-0 text-base-content/40" />
</button>
);
}

View File

@ -0,0 +1,193 @@
import { RotateCcw } from "lucide-react";
import type { RankTrackingRow } from "@/types/schemas/rank-tracking";
export type Filters = {
include: string;
exclude: string;
minDesktopPos: string;
maxDesktopPos: string;
minMobilePos: string;
maxMobilePos: string;
};
export const EMPTY_FILTERS: Filters = {
include: "",
exclude: "",
minDesktopPos: "",
maxDesktopPos: "",
minMobilePos: "",
maxMobilePos: "",
};
export function FilterPanel({
filters,
setFilters,
activeFilterCount,
onReset,
}: {
filters: Filters;
setFilters: (f: Filters) => void;
activeFilterCount: number;
onReset: () => void;
}) {
const update = (key: keyof Filters, value: string) =>
setFilters({ ...filters, [key]: value });
return (
<div className="shrink-0 border-b border-base-300 bg-gradient-to-b from-base-100 to-base-200/30 px-4 py-3 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<p className="text-sm font-semibold">Refine results</p>
{activeFilterCount > 0 && (
<span className="badge badge-xs badge-primary border-0 text-primary-content">
{activeFilterCount} active
</span>
)}
</div>
<button
className="btn btn-xs btn-ghost gap-1"
onClick={onReset}
disabled={activeFilterCount === 0}
>
<RotateCcw className="size-3" />
Clear all
</button>
</div>
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
<div className="space-y-1.5">
<p className="text-[11px] font-semibold uppercase tracking-wide text-base-content/60">
Include
</p>
<input
className="input input-bordered input-sm w-full bg-base-100"
placeholder="e.g. seo, tool"
value={filters.include}
onChange={(e) => update("include", e.target.value)}
/>
</div>
<div className="space-y-1.5">
<p className="text-[11px] font-semibold uppercase tracking-wide text-base-content/60">
Exclude
</p>
<input
className="input input-bordered input-sm w-full bg-base-100"
placeholder="e.g. free, cheap"
value={filters.exclude}
onChange={(e) => update("exclude", e.target.value)}
/>
</div>
</div>
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
<RangeFilter
title="Desktop position"
minValue={filters.minDesktopPos}
maxValue={filters.maxDesktopPos}
onMinChange={(v) => update("minDesktopPos", v)}
onMaxChange={(v) => update("maxDesktopPos", v)}
/>
<RangeFilter
title="Mobile position"
minValue={filters.minMobilePos}
maxValue={filters.maxMobilePos}
onMinChange={(v) => update("minMobilePos", v)}
onMaxChange={(v) => update("maxMobilePos", v)}
/>
</div>
</div>
);
}
function RangeFilter({
title,
minValue,
maxValue,
onMinChange,
onMaxChange,
}: {
title: string;
minValue: string;
maxValue: string;
onMinChange: (v: string) => void;
onMaxChange: (v: string) => void;
}) {
return (
<div className="rounded-lg border border-base-300 bg-base-100 p-2.5 space-y-2">
<p className="text-[11px] font-semibold uppercase tracking-wide text-base-content/60">
{title}
</p>
<div className="grid grid-cols-2 gap-2">
<input
className="input input-bordered input-xs bg-base-100"
placeholder="Min"
type="number"
value={minValue}
onChange={(e) => onMinChange(e.target.value)}
/>
<input
className="input input-bordered input-xs bg-base-100"
placeholder="Max"
type="number"
value={maxValue}
onChange={(e) => onMaxChange(e.target.value)}
/>
</div>
</div>
);
}
export function applyFilters(
rows: RankTrackingRow[],
filters: Filters,
): RankTrackingRow[] {
return rows.filter((row) => {
const kw = row.keyword.toLowerCase();
if (filters.include) {
const terms = filters.include
.toLowerCase()
.split(",")
.map((t) => t.trim())
.filter(Boolean);
if (terms.length > 0 && !terms.some((t) => kw.includes(t))) return false;
}
if (filters.exclude) {
const terms = filters.exclude
.toLowerCase()
.split(",")
.map((t) => t.trim())
.filter(Boolean);
if (terms.some((t) => kw.includes(t))) return false;
}
if (filters.minDesktopPos || filters.maxDesktopPos) {
const min = filters.minDesktopPos ? Number(filters.minDesktopPos) : 0;
const max = filters.maxDesktopPos
? Number(filters.maxDesktopPos)
: Infinity;
if (row.desktop.position === null) return false;
if (row.desktop.position < min || row.desktop.position > max)
return false;
}
if (filters.minMobilePos || filters.maxMobilePos) {
const min = filters.minMobilePos ? Number(filters.minMobilePos) : 0;
const max = filters.maxMobilePos
? Number(filters.maxMobilePos)
: Infinity;
if (row.mobile.position === null) return false;
if (row.mobile.position < min || row.mobile.position > max) return false;
}
return true;
});
}
export function countActiveFilters(filters: Filters): number {
let count = 0;
if (filters.include) count++;
if (filters.exclude) count++;
if (filters.minDesktopPos || filters.maxDesktopPos) count++;
if (filters.minMobilePos || filters.maxMobilePos) count++;
return count;
}

View File

@ -0,0 +1,107 @@
import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Loader2, TrendingUp } from "lucide-react";
import { getRankTrackingConfigs } from "@/serverFunctions/rank-tracking";
import type { RankTrackingConfig } from "@/types/schemas/rank-tracking";
import { RankTrackingDomainList } from "./RankTrackingDomainList";
import { RankTrackingDomainDetail } from "./RankTrackingDomainDetail";
import { RankTrackingConfigModal } from "./RankTrackingConfigModal";
export function RankTrackingPage({ projectId }: { projectId: string }) {
const queryClient = useQueryClient();
const [selectedConfigId, setSelectedConfigId] = useState<string | null>(null);
const [showConfigModal, setShowConfigModal] = useState(false);
const [editingConfig, setEditingConfig] = useState<RankTrackingConfig | null>(
null,
);
const { data: configs, isLoading } = useQuery({
queryKey: ["rankTrackingConfigs", projectId],
queryFn: () => getRankTrackingConfigs({ data: { projectId } }),
});
const invalidateConfigs = () => {
void queryClient.invalidateQueries({
queryKey: ["rankTrackingConfigs", projectId],
});
void queryClient.invalidateQueries({
queryKey: ["rankTrackingConfigSummaries", projectId],
});
};
const selectedConfig =
configs?.find((c) => c.id === selectedConfigId) ?? null;
const openAddModal = () => {
setEditingConfig(null);
setShowConfigModal(true);
};
const openEditModal = (config: RankTrackingConfig) => {
setEditingConfig(config);
setShowConfigModal(true);
};
return (
<div className="px-4 py-4 pb-24 overflow-auto md:px-6 md:py-6 md:pb-8">
<div className="mx-auto max-w-7xl space-y-4">
<div>
<h1 className="text-2xl font-semibold">Rank Tracking</h1>
<p className="text-sm text-base-content/70">
Track keyword positions across domains
</p>
</div>
{isLoading ? (
<div className="flex items-center justify-center p-12">
<Loader2 className="size-6 animate-spin text-base-content/50" />
</div>
) : !configs || configs.length === 0 ? (
<section className="rounded-2xl border border-dashed border-base-300 bg-base-100/70 p-8 text-center space-y-3">
<div className="mx-auto flex size-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<TrendingUp className="size-6" />
</div>
<h2 className="text-lg font-medium text-base-content/80">
Track your keyword positions over time
</h2>
<p className="text-sm text-base-content/55 max-w-md mx-auto">
Monitor rankings for your saved keywords, see position changes,
and spot trends across desktop and mobile.
</p>
<button className="btn btn-primary btn-sm" onClick={openAddModal}>
Add Domain
</button>
<p className="text-xs text-base-content/50">
Requires saved keywords in this project.
</p>
</section>
) : selectedConfig ? (
<RankTrackingDomainDetail
config={selectedConfig}
projectId={projectId}
onBack={() => setSelectedConfigId(null)}
onEdit={() => openEditModal(selectedConfig)}
/>
) : (
<RankTrackingDomainList
projectId={projectId}
onSelectConfig={setSelectedConfigId}
onAddDomain={openAddModal}
/>
)}
</div>
{showConfigModal && (
<RankTrackingConfigModal
projectId={projectId}
existingConfig={editingConfig}
onClose={() => setShowConfigModal(false)}
onSaved={() => {
setShowConfigModal(false);
invalidateConfigs();
}}
/>
)}
</div>
);
}

View File

@ -0,0 +1,245 @@
import { useState } from "react";
import { toast } from "sonner";
import { Loader2, Trash2 } from "lucide-react";
import { Modal } from "@/client/components/Modal";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { removeTrackingKeywords } from "@/serverFunctions/rank-tracking";
import { getStandardErrorMessage } from "@/client/lib/error-messages";
import type { RankTrackingRow } from "@/types/schemas/rank-tracking";
import {
SortableHeader,
DeviceRankCell,
type SortField,
type SortDir,
} from "./RankTrackingTableParts";
export {
comparePositions,
exportRankTrackingCsv,
} from "./RankTrackingTableParts";
export type { SortField, SortDir } from "./RankTrackingTableParts";
export function RankTrackingTable({
totalCount,
sorted,
resultsLoading,
showDesktop,
showMobile,
sortField,
sortDir,
onSort,
domain,
configId,
projectId,
}: {
totalCount: number;
sorted: RankTrackingRow[];
resultsLoading: boolean;
showDesktop: boolean;
showMobile: boolean;
sortField: SortField;
sortDir: SortDir;
onSort: (field: SortField) => void;
domain: string;
configId: string;
projectId: string;
}) {
const queryClient = useQueryClient();
const [selected, setSelected] = useState<Set<string>>(new Set());
const [showConfirm, setShowConfirm] = useState(false);
// Only count/act on selections that are currently visible
const visibleIds = new Set(sorted.map((r) => r.trackingKeywordId));
const visibleSelected = new Set(
[...selected].filter((id) => visibleIds.has(id)),
);
const visibleSelectedCount = visibleSelected.size;
const removeMutation = useMutation({
mutationFn: (keywordIds: string[]) =>
removeTrackingKeywords({ data: { projectId, configId, keywordIds } }),
onSuccess: (result) => {
setSelected(new Set());
setShowConfirm(false);
void queryClient.invalidateQueries({
queryKey: ["rankTrackingResults", projectId, configId],
});
void queryClient.invalidateQueries({
queryKey: ["rankTrackingCostEstimate", projectId, configId],
});
toast.success(
`${result.removed} keyword${result.removed !== 1 ? "s" : ""} removed`,
);
},
onError: (error) => {
toast.error(getStandardErrorMessage(error, "Failed to remove keywords"));
},
});
const toggleSelect = (id: string) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const toggleAll = () => {
if (visibleSelectedCount === sorted.length && sorted.length > 0) {
setSelected(new Set());
} else {
setSelected(new Set(sorted.map((r) => r.trackingKeywordId)));
}
};
if (resultsLoading) {
return (
<div className="flex items-center justify-center p-8">
<Loader2 className="size-5 animate-spin text-base-content/50" />
</div>
);
}
if (sorted.length === 0) {
return (
<div className="rounded-xl border border-dashed border-base-300 p-10 text-center text-sm text-base-content/55">
{totalCount === 0
? 'No rank data yet. Click "Check Now" to run your first check.'
: "No keywords match your search."}
</div>
);
}
const allSelected =
visibleSelectedCount === sorted.length && sorted.length > 0;
return (
<>
{/* Bulk action bar */}
{visibleSelectedCount > 0 && (
<div className="flex items-center gap-3 rounded-lg bg-base-200 px-3 py-2 text-sm">
<span className="text-base-content/70">
{visibleSelectedCount} keyword
{visibleSelectedCount !== 1 ? "s" : ""} selected
</span>
<button
className="btn btn-error btn-xs gap-1"
onClick={() => setShowConfirm(true)}
>
<Trash2 className="size-3" />
Remove
</button>
<button
className="btn btn-ghost btn-xs"
onClick={() => setSelected(new Set())}
>
Clear
</button>
</div>
)}
{/* Confirm modal */}
{showConfirm && (
<Modal>
<h3 className="text-lg font-semibold">Remove keywords?</h3>
<p className="text-sm text-base-content/70">
This will stop tracking {visibleSelectedCount} keyword
{visibleSelectedCount !== 1 ? "s" : ""}. Historical ranking data is
preserved but won't appear in the table.
</p>
<div className="flex justify-end gap-2">
<button
className="btn btn-ghost btn-sm"
onClick={() => setShowConfirm(false)}
>
Cancel
</button>
<button
className="btn btn-error btn-sm gap-1"
onClick={() => removeMutation.mutate([...visibleSelected])}
disabled={removeMutation.isPending}
>
{removeMutation.isPending && (
<Loader2 className="size-3 animate-spin" />
)}
Remove {visibleSelectedCount} keyword
{visibleSelectedCount !== 1 ? "s" : ""}
</button>
</div>
</Modal>
)}
<div className="overflow-x-auto">
<table className="table table-sm">
<thead>
<tr>
<th className="w-8">
<input
type="checkbox"
className="checkbox checkbox-xs"
checked={allSelected}
onChange={toggleAll}
/>
</th>
<SortableHeader
label="Keyword"
field="keyword"
currentField={sortField}
currentDir={sortDir}
onClick={onSort}
/>
{showDesktop && (
<SortableHeader
label="Desktop"
field="desktopPosition"
currentField={sortField}
currentDir={sortDir}
onClick={onSort}
className="min-w-44"
/>
)}
{showMobile && (
<SortableHeader
label="Mobile"
field="mobilePosition"
currentField={sortField}
currentDir={sortDir}
onClick={onSort}
className="min-w-44"
/>
)}
</tr>
</thead>
<tbody>
{sorted.map((row) => (
<tr key={row.trackingKeywordId}>
<td className="w-8">
<input
type="checkbox"
className="checkbox checkbox-xs"
checked={selected.has(row.trackingKeywordId)}
onChange={() => toggleSelect(row.trackingKeywordId)}
/>
</td>
<td className="font-medium">{row.keyword}</td>
{showDesktop && (
<td className="align-top">
<DeviceRankCell result={row.desktop} domain={domain} />
</td>
)}
{showMobile && (
<td className="align-top">
<DeviceRankCell result={row.mobile} domain={domain} />
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
<p className="text-xs text-base-content/60 pt-2">
{sorted.length} of {totalCount} keywords
</p>
</>
);
}

View File

@ -0,0 +1,265 @@
import { ArrowUp, ArrowDown, Minus, Sparkles } from "lucide-react";
import { toast } from "sonner";
import { buildCsv, downloadCsv } from "@/client/lib/csv";
import { captureClientEvent } from "@/client/lib/posthog";
import type {
RankTrackingDeviceResult,
RankTrackingRow,
} from "@/types/schemas/rank-tracking";
export type SortField = "keyword" | "desktopPosition" | "mobilePosition";
export type SortDir = "asc" | "desc";
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",
};
export function SortableHeader({
label,
field,
currentField,
currentDir,
onClick,
className = "",
}: {
label: string;
field: SortField;
currentField: SortField;
currentDir: SortDir;
onClick: (field: SortField) => void;
className?: string;
}) {
const isActive = currentField === field;
return (
<th
className={`cursor-pointer select-none text-xs uppercase tracking-wide text-base-content/60 hover:text-base-content ${className}`}
onClick={() => onClick(field)}
title={HEADER_TOOLTIPS[field]}
>
{label}
{isActive && (
<span className="ml-1">{currentDir === "asc" ? "↑" : "↓"}</span>
)}
</th>
);
}
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",
ai_overview: "AI",
local_pack: "Local",
knowledge_panel: "KP",
video: "Video",
images: "Img",
shopping: "Shop",
top_stories: "News",
};
const FEATURE_TOOLTIPS: Record<string, string> = {
featured_snippet:
"Featured Snippet — highlighted answer box at top of results",
people_also_ask: "People Also Ask — expandable related questions",
ai_overview: "AI Overview — AI-generated summary at top of search",
local_pack: "Local Pack — map with local business listings",
knowledge_panel: "Knowledge Panel — info box about an entity",
video: "Video — video results shown in the SERP",
images: "Images — image results shown in the SERP",
shopping: "Shopping — product listings with prices",
top_stories: "Top Stories — news articles carousel",
};
function SerpFeatureTags({ features }: { features: string[] }) {
const notable = features.filter((f) => f in FEATURE_SHORT_LABELS);
if (notable.length === 0) return null;
return (
<div className="flex gap-1 flex-wrap">
{notable.map((f) => (
<span
key={f}
className="badge badge-outline badge-xs gap-0.5 cursor-help"
title={FEATURE_TOOLTIPS[f] ?? f}
>
{f === "ai_overview" && <Sparkles className="size-2.5" />}
{FEATURE_SHORT_LABELS[f]}
</span>
))}
</div>
);
}
function PositionWithChange({
position,
previous,
}: {
position: number | null;
previous: number | null;
}) {
return (
<span className="inline-flex w-full items-center justify-between px-3">
<PositionBadge position={position} />
<ChangeIndicator current={position} previous={previous} />
</span>
);
}
export function DeviceRankCell({
result,
domain,
}: {
result: RankTrackingDeviceResult;
domain: string;
}) {
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>
);
}
export function comparePositions(a: number | null, b: number | null): number {
if (a === null && b === null) return 0;
if (a === null) return 1; // nulls sort last
if (b === null) return -1;
return a - b;
}
/** Numeric change for CSV export — numbers bypass the CSV formula-injection sanitizer */
function csvChange(
current: number | null,
previous: number | null,
): number | string {
if (previous === null) return current !== null ? "new" : "";
if (current === null) return "lost";
return previous - current;
}
export function exportRankTrackingCsv(
sorted: RankTrackingRow[],
showDesktop: boolean,
showMobile: boolean,
domain: string,
) {
if (sorted.length === 0) {
toast.error("No data to export");
return;
}
const headers = [
"Keyword",
...(showDesktop
? [
"Desktop Position",
"Desktop Change",
"Desktop URL",
"Desktop SERP Features",
]
: []),
...(showMobile
? [
"Mobile Position",
"Mobile Change",
"Mobile URL",
"Mobile SERP Features",
]
: []),
];
const csvRows = sorted.map((row) => [
row.keyword,
...(showDesktop
? [
row.desktop.position ?? "Not ranking",
csvChange(row.desktop.position, row.desktop.previousPosition),
row.desktop.rankingUrl ?? "",
row.desktop.serpFeatures.join(", "),
]
: []),
...(showMobile
? [
row.mobile.position ?? "Not ranking",
csvChange(row.mobile.position, row.mobile.previousPosition),
row.mobile.rankingUrl ?? "",
row.mobile.serpFeatures.join(", "),
]
: []),
]);
downloadCsv(`rank-tracking-${domain}.csv`, buildCsv(headers, csvRows));
captureClientEvent("rank_tracking:export_csv");
}
function toPath(url: string): string {
try {
return new URL(url).pathname;
} catch {
return url;
}
}
function toFullUrl(url: string, domain: string): string {
if (url.startsWith("http")) return url;
return `https://${domain}${url}`;
}

View File

@ -0,0 +1,59 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { getStandardErrorMessage } from "@/client/lib/error-messages";
import { captureClientEvent } from "@/client/lib/posthog";
import { triggerRankCheck } from "@/serverFunctions/rank-tracking";
export function useRankCheckTrigger({
configId,
isRunning,
projectId,
onSuccess,
}: {
configId: string;
isRunning: boolean;
projectId: string;
onSuccess: () => void;
}) {
const queryClient = useQueryClient();
const triggerMutation = useMutation({
mutationFn: (opts: { keywordIds?: string[] }) =>
triggerRankCheck({
data: {
projectId,
configId,
keywordIds: opts.keywordIds,
},
}),
onSuccess: (result) => {
onSuccess();
void queryClient.invalidateQueries({
queryKey: ["rankTrackingLatestRun", projectId, configId],
});
if (!result.ok) {
toast.info("A rank check is already running");
return;
}
captureClientEvent("rank_tracking:check_trigger");
toast.success("Rank check started");
},
onError: (error) => {
toast.error(getStandardErrorMessage(error, "Failed to start rank check"));
},
});
const startCheck = (opts: { keywordIds?: string[] }) => {
if (triggerMutation.isPending || isRunning) return;
triggerMutation.mutate(opts);
};
return {
startCheck,
/** True while the trigger request is in-flight */
isPending: triggerMutation.isPending,
/** True when any check activity is happening (running, starting, or pending) */
isBusy: isRunning || triggerMutation.isPending,
};
}

View File

@ -0,0 +1,39 @@
import { useRef } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { getLatestRankRun } from "@/serverFunctions/rank-tracking";
/**
* Polls the latest rank check run for a config, auto-refreshing results
* when a run transitions from "running" to "completed".
*/
export function useRankRunPolling(projectId: string, configId: string) {
const queryClient = useQueryClient();
const prevStatusRef = useRef<string | undefined>(undefined);
const { data: latestRun } = useQuery({
queryKey: ["rankTrackingLatestRun", projectId, configId],
queryFn: () => getLatestRankRun({ data: { projectId, configId } }),
refetchInterval: (query) => {
const run = query.state.data;
const prev = prevStatusRef.current;
prevStatusRef.current = run?.status;
// When a run transitions to a terminal state, invalidate results
const isTerminal =
run?.status === "completed" || run?.status === "failed";
const wasActive = prev === "running" || prev === "pending";
if (wasActive && isTerminal) {
void queryClient.invalidateQueries({
queryKey: ["rankTrackingResults", projectId, configId],
});
}
// Keep polling active runs, including stale ones (they'll be cleaned up
// by the cron handler and we want to show the transition).
if (run?.status === "pending" || run?.status === "running") return 3000;
return false;
},
});
return latestRun;
}

View File

@ -0,0 +1,37 @@
import { useEffect, useState } from "react";
import {
comparePositions,
type SortField,
type SortDir,
} from "./RankTrackingTable";
import type { RankTrackingRow } from "@/types/schemas/rank-tracking";
export function useRankTableSort(
rows: RankTrackingRow[],
defaultField: SortField,
) {
const [sortField, setSortField] = useState<SortField>(defaultField);
const [sortDir, setSortDir] = useState<SortDir>("asc");
useEffect(() => {
setSortField(defaultField);
}, [defaultField]);
const sorted = rows.toSorted((a, b) => {
const dir = sortDir === "asc" ? 1 : -1;
if (sortField === "keyword")
return dir * a.keyword.localeCompare(b.keyword);
const device = sortField === "desktopPosition" ? "desktop" : "mobile";
return dir * comparePositions(a[device].position, b[device].position);
});
const handleSort = (field: SortField) => {
if (sortField === field) setSortDir((d) => (d === "asc" ? "desc" : "asc"));
else {
setSortField(field);
setSortDir("asc");
}
};
return { sorted, sortField, sortDir, handleSort };
}

View File

@ -5,6 +5,7 @@ import {
Globe,
Link2,
Search,
TrendingUp,
} from "lucide-react";
import { linkOptions } from "@tanstack/react-router";
@ -21,6 +22,12 @@ const projectNavItems = [
icon: Bookmark,
matchSegment: "/saved",
},
{
to: "/p/$projectId/rank-tracking" as const,
label: "Rank Tracking",
icon: TrendingUp,
matchSegment: "/rank-tracking",
},
{
to: "/p/$projectId/domain" as const,
label: "Domain Overview",

View File

@ -100,6 +100,153 @@ export const keywordMetrics = sqliteTable(
],
);
// ============================================================================
// Rank Tracking tables
// ============================================================================
// One configuration per project+domain — defines what domain to track and how
export const rankTrackingConfigs = sqliteTable(
"rank_tracking_configs",
{
id: text("id").primaryKey(),
projectId: text("project_id")
.notNull()
.references(() => projects.id, { onDelete: "cascade" }),
domain: text("domain").notNull(),
locationCode: integer("location_code").notNull().default(2840),
languageCode: text("language_code").notNull().default("en"),
devices: text("devices", {
enum: ["both", "desktop", "mobile"],
})
.notNull()
.default("both"),
scheduleInterval: text("schedule_interval", {
enum: ["daily", "weekly", "manual"],
})
.notNull()
.default("weekly"),
isActive: integer("is_active", { mode: "boolean" }).notNull().default(true),
lastCheckedAt: text("last_checked_at"),
nextCheckAt: text("next_check_at"),
lastSkipReason: text("last_skip_reason"),
createdAt: text("created_at")
.notNull()
.default(sql`(current_timestamp)`),
},
(table) => [
uniqueIndex("rank_tracking_configs_project_domain_location_idx").on(
table.projectId,
table.domain,
table.locationCode,
),
],
);
// Keywords tracked per domain config
export const rankTrackingKeywords = sqliteTable(
"rank_tracking_keywords",
{
id: text("id").primaryKey(),
configId: text("config_id")
.notNull()
.references(() => rankTrackingConfigs.id, { onDelete: "cascade" }),
keyword: text("keyword").notNull(),
createdAt: text("created_at")
.notNull()
.default(sql`(current_timestamp)`),
},
(table) => [
uniqueIndex("rank_tracking_keywords_config_keyword_idx").on(
table.configId,
table.keyword,
),
],
);
// One row per check execution (manual or scheduled)
export const rankCheckRuns = sqliteTable(
"rank_check_runs",
{
id: text("id").primaryKey(),
configId: text("config_id")
.notNull()
.references(() => rankTrackingConfigs.id, { onDelete: "cascade" }),
projectId: text("project_id")
.notNull()
.references(() => projects.id, { onDelete: "cascade" }),
status: text("status", {
enum: ["pending", "running", "completed", "failed"],
})
.notNull()
.default("pending"),
keywordsTotal: integer("keywords_total").notNull().default(0),
keywordsChecked: integer("keywords_checked").notNull().default(0),
isSubsetRun: integer("is_subset_run", { mode: "boolean" })
.notNull()
.default(false),
errorMessage: text("error_message"),
startedAt: text("started_at")
.notNull()
.default(sql`(current_timestamp)`),
completedAt: text("completed_at"),
},
(table) => [
index("rank_check_runs_config_idx").on(table.configId, table.startedAt),
index("rank_check_runs_project_idx").on(table.projectId, table.startedAt),
],
);
// One active lock per rank tracking config to prevent overlapping runs
export const rankCheckLocks = sqliteTable(
"rank_check_locks",
{
configId: text("config_id")
.primaryKey()
.references(() => rankTrackingConfigs.id, { onDelete: "cascade" }),
runId: text("run_id").notNull(),
acquiredAt: text("acquired_at")
.notNull()
.default(sql`(current_timestamp)`),
},
(table) => [index("rank_check_locks_run_idx").on(table.runId)],
);
// One row per keyword per device per check run
export const rankSnapshots = sqliteTable(
"rank_snapshots",
{
id: integer("id").primaryKey({ autoIncrement: true }),
runId: text("run_id")
.notNull()
.references(() => rankCheckRuns.id, { onDelete: "cascade" }),
// No FK to rankTrackingKeywords — intentional. Historical snapshots are
// preserved after a keyword is removed from tracking so users can still
// see past position data for deleted keywords.
trackingKeywordId: text("tracking_keyword_id").notNull(),
keyword: text("keyword").notNull(),
device: text("device", { enum: ["desktop", "mobile"] }).notNull(),
position: integer("position"), // null = not found in top 20
url: text("url"),
serpFeatures: text("serp_features"), // JSON array of feature type strings
checkedAt: text("checked_at")
.notNull()
.default(sql`(current_timestamp)`),
},
(table) => [
index("rank_snapshots_run_idx").on(table.runId),
index("rank_snapshots_keyword_device_idx").on(
table.trackingKeywordId,
table.device,
table.checkedAt,
),
uniqueIndex("rank_snapshots_run_keyword_device_idx").on(
table.runId,
table.trackingKeywordId,
table.device,
),
],
);
// ============================================================================
// Site Audit tables
// ============================================================================

View File

@ -28,6 +28,7 @@ import { Route as AppHelpDataforseoApiKeyRouteImport } from './routes/_app/help/
import { Route as ProjectPProjectIdRouteRouteImport } from './routes/_project/p/$projectId/route'
import { Route as ProjectPProjectIdIndexRouteImport } from './routes/_project/p/$projectId/index'
import { Route as ProjectPProjectIdSavedRouteImport } from './routes/_project/p/$projectId/saved'
import { Route as ProjectPProjectIdRankTrackingRouteImport } from './routes/_project/p/$projectId/rank-tracking'
import { Route as ProjectPProjectIdKeywordsRouteImport } from './routes/_project/p/$projectId/keywords'
import { Route as ProjectPProjectIdDomainRouteImport } from './routes/_project/p/$projectId/domain'
import { Route as ProjectPProjectIdBacklinksRouteImport } from './routes/_project/p/$projectId/backlinks'
@ -127,6 +128,12 @@ const ProjectPProjectIdSavedRoute = ProjectPProjectIdSavedRouteImport.update({
path: '/saved',
getParentRoute: () => ProjectPProjectIdRouteRoute,
} as any)
const ProjectPProjectIdRankTrackingRoute =
ProjectPProjectIdRankTrackingRouteImport.update({
id: '/rank-tracking',
path: '/rank-tracking',
getParentRoute: () => ProjectPProjectIdRouteRoute,
} as any)
const ProjectPProjectIdKeywordsRoute =
ProjectPProjectIdKeywordsRouteImport.update({
id: '/keywords',
@ -186,6 +193,7 @@ export interface FileRoutesByFullPath {
'/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute
'/p/$projectId/domain': typeof ProjectPProjectIdDomainRoute
'/p/$projectId/keywords': typeof ProjectPProjectIdKeywordsRoute
'/p/$projectId/rank-tracking': typeof ProjectPProjectIdRankTrackingRoute
'/p/$projectId/saved': typeof ProjectPProjectIdSavedRoute
'/p/$projectId/': typeof ProjectPProjectIdIndexRoute
'/p/$projectId/audit/': typeof ProjectPProjectIdAuditIndexRoute
@ -208,6 +216,7 @@ export interface FileRoutesByTo {
'/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute
'/p/$projectId/domain': typeof ProjectPProjectIdDomainRoute
'/p/$projectId/keywords': typeof ProjectPProjectIdKeywordsRoute
'/p/$projectId/rank-tracking': typeof ProjectPProjectIdRankTrackingRoute
'/p/$projectId/saved': typeof ProjectPProjectIdSavedRoute
'/p/$projectId': typeof ProjectPProjectIdIndexRoute
'/p/$projectId/audit': typeof ProjectPProjectIdAuditIndexRoute
@ -237,6 +246,7 @@ export interface FileRoutesById {
'/_project/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute
'/_project/p/$projectId/domain': typeof ProjectPProjectIdDomainRoute
'/_project/p/$projectId/keywords': typeof ProjectPProjectIdKeywordsRoute
'/_project/p/$projectId/rank-tracking': typeof ProjectPProjectIdRankTrackingRoute
'/_project/p/$projectId/saved': typeof ProjectPProjectIdSavedRoute
'/_project/p/$projectId/': typeof ProjectPProjectIdIndexRoute
'/_project/p/$projectId/audit/': typeof ProjectPProjectIdAuditIndexRoute
@ -263,6 +273,7 @@ export interface FileRouteTypes {
| '/p/$projectId/backlinks'
| '/p/$projectId/domain'
| '/p/$projectId/keywords'
| '/p/$projectId/rank-tracking'
| '/p/$projectId/saved'
| '/p/$projectId/'
| '/p/$projectId/audit/'
@ -285,6 +296,7 @@ export interface FileRouteTypes {
| '/p/$projectId/backlinks'
| '/p/$projectId/domain'
| '/p/$projectId/keywords'
| '/p/$projectId/rank-tracking'
| '/p/$projectId/saved'
| '/p/$projectId'
| '/p/$projectId/audit'
@ -313,6 +325,7 @@ export interface FileRouteTypes {
| '/_project/p/$projectId/backlinks'
| '/_project/p/$projectId/domain'
| '/_project/p/$projectId/keywords'
| '/_project/p/$projectId/rank-tracking'
| '/_project/p/$projectId/saved'
| '/_project/p/$projectId/'
| '/_project/p/$projectId/audit/'
@ -466,6 +479,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ProjectPProjectIdSavedRouteImport
parentRoute: typeof ProjectPProjectIdRouteRoute
}
'/_project/p/$projectId/rank-tracking': {
id: '/_project/p/$projectId/rank-tracking'
path: '/rank-tracking'
fullPath: '/p/$projectId/rank-tracking'
preLoaderRoute: typeof ProjectPProjectIdRankTrackingRouteImport
parentRoute: typeof ProjectPProjectIdRouteRoute
}
'/_project/p/$projectId/keywords': {
id: '/_project/p/$projectId/keywords'
path: '/keywords'
@ -559,6 +579,7 @@ interface ProjectPProjectIdRouteRouteChildren {
ProjectPProjectIdBacklinksRoute: typeof ProjectPProjectIdBacklinksRoute
ProjectPProjectIdDomainRoute: typeof ProjectPProjectIdDomainRoute
ProjectPProjectIdKeywordsRoute: typeof ProjectPProjectIdKeywordsRoute
ProjectPProjectIdRankTrackingRoute: typeof ProjectPProjectIdRankTrackingRoute
ProjectPProjectIdSavedRoute: typeof ProjectPProjectIdSavedRoute
ProjectPProjectIdIndexRoute: typeof ProjectPProjectIdIndexRoute
}
@ -570,6 +591,7 @@ const ProjectPProjectIdRouteRouteChildren: ProjectPProjectIdRouteRouteChildren =
ProjectPProjectIdBacklinksRoute: ProjectPProjectIdBacklinksRoute,
ProjectPProjectIdDomainRoute: ProjectPProjectIdDomainRoute,
ProjectPProjectIdKeywordsRoute: ProjectPProjectIdKeywordsRoute,
ProjectPProjectIdRankTrackingRoute: ProjectPProjectIdRankTrackingRoute,
ProjectPProjectIdSavedRoute: ProjectPProjectIdSavedRoute,
ProjectPProjectIdIndexRoute: ProjectPProjectIdIndexRoute,
}

View File

@ -0,0 +1,11 @@
import { createFileRoute } from "@tanstack/react-router";
import { RankTrackingPage } from "@/client/features/rank-tracking/RankTrackingPage";
export const Route = createFileRoute("/_project/p/$projectId/rank-tracking")({
component: RankTrackingRoute,
});
function RankTrackingRoute() {
const { projectId } = Route.useParams();
return <RankTrackingPage projectId={projectId} />;
}

View File

@ -6,7 +6,14 @@ import {
getSavedKeywords,
removeSavedKeyword,
} from "@/serverFunctions/keywords";
import { Trash2, Download, Search, Loader2, AlertCircle } from "lucide-react";
import {
Download,
Search,
Loader2,
AlertCircle,
Trash2,
Copy,
} from "lucide-react";
import { buildCsv, downloadCsv } from "@/client/lib/csv";
import { getStandardErrorMessage } from "@/client/lib/error-messages";
import { captureClientEvent } from "@/client/lib/posthog";
@ -15,17 +22,30 @@ export const Route = createFileRoute("/_project/p/$projectId/saved")({
component: SavedKeywordsPage,
});
type SavedKeyword = {
id: string;
keyword: string;
searchVolume: number | null;
cpc: number | null;
competition: number | null;
keywordDifficulty: number | null;
intent: string | null;
fetchedAt: string | null;
};
function SavedKeywordsPage() {
const { projectId } = Route.useParams();
const queryClient = useQueryClient();
const [removeError, setRemoveError] = useState<string | null>(null);
const [removingId, setRemovingId] = useState<string | null>(null);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [showConfirm, setShowConfirm] = useState(false);
const [deleting, setDeleting] = useState(false);
const { data: savedKeywordsData, isLoading } = useQuery({
queryKey: ["savedKeywords", projectId],
queryFn: () => getSavedKeywords({ data: { projectId } }),
});
const savedKeywords = savedKeywordsData?.rows ?? [];
const savedKeywords: SavedKeyword[] = savedKeywordsData?.rows ?? [];
const removeMutation = useMutation({
mutationFn: (savedKeywordId: string) =>
@ -42,16 +62,54 @@ function SavedKeywordsPage() {
},
});
const handleRemoveKeyword = (savedKeywordId: string) => {
const handleDeleteSelected = async () => {
setDeleting(true);
setRemoveError(null);
setRemovingId(savedKeywordId);
removeMutation.mutate(savedKeywordId, {
onSettled: () => {
setRemovingId((current) =>
current === savedKeywordId ? null : current,
);
},
const ids = [...selected];
for (const id of ids) {
try {
await removeMutation.mutateAsync(id);
} catch {
break;
}
}
setDeleting(false);
setSelected(new Set());
setShowConfirm(false);
void queryClient.invalidateQueries({
queryKey: ["savedKeywords", projectId],
});
captureClientEvent("saved_keywords:bulk_remove");
toast.success(
`${ids.length} keyword${ids.length !== 1 ? "s" : ""} removed`,
);
};
const handleCopySelected = () => {
const keywords = savedKeywords
.filter((kw) => selected.has(kw.id))
.map((kw) => kw.keyword);
void navigator.clipboard.writeText(keywords.join("\n"));
toast.success(
`${keywords.length} keyword${keywords.length !== 1 ? "s" : ""} copied`,
);
};
const toggleSelect = (id: string) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const toggleAll = () => {
if (selected.size === savedKeywords.length) {
setSelected(new Set());
} else {
setSelected(new Set(savedKeywords.map((kw) => kw.id)));
}
};
const exportCsv = () => {
@ -59,7 +117,6 @@ function SavedKeywordsPage() {
toast.error("No keywords to export");
return;
}
const headers = [
"Keyword",
"Volume",
@ -86,42 +143,6 @@ function SavedKeywordsPage() {
});
};
return (
<SavedKeywordsContent
isLoading={isLoading}
removeError={removeError}
removingId={removingId}
savedKeywords={savedKeywords}
onExportCsv={exportCsv}
onRemoveKeyword={handleRemoveKeyword}
/>
);
}
function SavedKeywordsContent({
isLoading,
removeError,
removingId,
savedKeywords,
onExportCsv,
onRemoveKeyword,
}: {
isLoading: boolean;
removeError: string | null;
removingId: string | null;
savedKeywords: Array<{
id: string;
keyword: string;
searchVolume: number | null;
cpc: number | null;
competition: number | null;
keywordDifficulty: number | null;
intent: string | null;
fetchedAt: string | null;
}>;
onExportCsv: () => void;
onRemoveKeyword: (savedKeywordId: string) => void;
}) {
return (
<div className="px-4 py-4 md:px-6 md:py-6 pb-24 md:pb-8 overflow-auto">
<div className="mx-auto max-w-5xl space-y-4">
@ -133,7 +154,7 @@ function SavedKeywordsContent({
</p>
</div>
{savedKeywords.length > 0 && (
<button className="btn btn-sm" onClick={onExportCsv}>
<button className="btn btn-sm" onClick={exportCsv}>
<Download className="size-4" /> Export CSV
</button>
)}
@ -178,46 +199,57 @@ function SavedKeywordsContent({
<span>{removeError}</span>
</div>
) : null}
{/* Bulk action bar or keyword count */}
{selected.size > 0 ? (
<div className="flex items-center gap-3 rounded-lg bg-base-200 px-3 py-2 text-sm">
<span className="text-base-content/70">
{selected.size} keyword
{selected.size !== 1 ? "s" : ""} selected
</span>
<button
className="btn btn-ghost btn-xs gap-1"
onClick={handleCopySelected}
>
<Copy className="size-3" />
Copy
</button>
<button
className="btn btn-error btn-xs gap-1"
onClick={() => setShowConfirm(true)}
>
<Trash2 className="size-3" />
Delete
</button>
<button
className="btn btn-ghost btn-xs"
onClick={() => setSelected(new Set())}
>
Clear
</button>
</div>
) : (
<p className="text-sm text-base-content/70">
{savedKeywords.length} saved keyword
{savedKeywords.length !== 1 ? "s" : ""}
</p>
<SavedKeywordsTable
rows={savedKeywords}
removingId={removingId}
onRemoveKeyword={onRemoveKeyword}
/>
</div>
</div>
)}
</div>
</div>
);
}
function SavedKeywordsTable({
rows,
removingId,
onRemoveKeyword,
}: {
rows: Array<{
id: string;
keyword: string;
searchVolume: number | null;
cpc: number | null;
competition: number | null;
keywordDifficulty: number | null;
intent: string | null;
fetchedAt: string | null;
}>;
removingId: string | null;
onRemoveKeyword: (savedKeywordId: string) => void;
}) {
return (
<div className="overflow-x-auto">
<table className="table table-zebra table-sm">
<thead>
<tr>
<th className="w-8">
<input
type="checkbox"
className="checkbox checkbox-xs"
checked={
selected.size === savedKeywords.length &&
savedKeywords.length > 0
}
onChange={toggleAll}
/>
</th>
<th>Keyword</th>
<th>Volume</th>
<th>CPC</th>
@ -225,17 +257,28 @@ function SavedKeywordsTable({
<th>Difficulty</th>
<th>Intent</th>
<th>Last Fetched</th>
<th></th>
</tr>
</thead>
<tbody>
{rows.map((kw) => (
{savedKeywords.map((kw) => (
<tr key={kw.id}>
<td className="w-8">
<input
type="checkbox"
className="checkbox checkbox-xs"
checked={selected.has(kw.id)}
onChange={() => toggleSelect(kw.id)}
/>
</td>
<td className="font-medium">{kw.keyword}</td>
<td>{formatNumber(kw.searchVolume)}</td>
<td>{kw.cpc == null ? "-" : `$${kw.cpc.toFixed(2)}`}</td>
<td>
{kw.competition == null ? "-" : kw.competition.toFixed(2)}
{kw.cpc == null ? "-" : `$${kw.cpc.toFixed(2)}`}
</td>
<td>
{kw.competition == null
? "-"
: kw.competition.toFixed(2)}
</td>
<td>
<DifficultyBadge value={kw.keywordDifficulty} />
@ -250,25 +293,48 @@ function SavedKeywordsTable({
? new Date(kw.fetchedAt).toLocaleDateString()
: "-"}
</td>
<td>
<button
className="btn btn-ghost btn-xs text-error"
onClick={() => onRemoveKeyword(kw.id)}
disabled={removingId === kw.id}
title="Remove"
>
{removingId === kw.id ? (
<Loader2 className="size-3 animate-spin" />
) : (
<Trash2 className="size-3" />
)}
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
)}
{/* Confirm delete modal */}
{showConfirm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="card bg-base-100 border border-base-300 w-full max-w-sm shadow-xl">
<div className="card-body gap-4">
<h3 className="text-lg font-semibold">Delete keywords?</h3>
<p className="text-sm text-base-content/70">
This will permanently delete {selected.size} saved keyword
{selected.size !== 1 ? "s" : ""}.
</p>
<div className="flex justify-end gap-2">
<button
className="btn btn-ghost btn-sm"
onClick={() => setShowConfirm(false)}
>
Cancel
</button>
<button
className="btn btn-error btn-sm gap-1"
onClick={() => void handleDeleteSelected()}
disabled={deleting}
>
{deleting && <Loader2 className="size-3 animate-spin" />}
Delete {selected.size} keyword
{selected.size !== 1 ? "s" : ""}
</button>
</div>
</div>
</div>
</div>
)}
</div>
</div>
);
}

View File

@ -2,12 +2,119 @@ import {
createStartHandler,
defaultStreamHandler,
} from "@tanstack/react-start/server";
import { RankTrackingRepository } from "@/server/features/rank-tracking/repositories/RankTrackingRepository";
import { beginRankCheckRun } from "@/server/features/rank-tracking/services/rankCheckRunGuards";
import { customerHasManagedServiceAccess } from "@/server/billing/subscription";
import { isHostedServerAuthMode } from "@/server/lib/runtime-env";
import { computeNextCheckAt } from "@/shared/rank-tracking";
const fetch = createStartHandler(defaultStreamHandler);
// Export Workflow classes as named exports
export { SiteAuditWorkflow } from "./server/workflows/SiteAuditWorkflow";
export { RankCheckWorkflow } from "./server/workflows/RankCheckWorkflow";
export default {
fetch,
async scheduled(
_controller: ScheduledController,
env: Env,
_ctx: ExecutionContext,
) {
const nowIso = new Date().toISOString();
const dueConfigs =
await RankTrackingRepository.getDueConfigsWithOrganization(nowIso);
const isHosted = await isHostedServerAuthMode();
for (const config of dueConfigs) {
try {
// Skip configs whose org no longer has paid access
if (
isHosted &&
!(await customerHasManagedServiceAccess(config.organizationId))
) {
console.log(
`[cron] Skipping config ${config.id} (${config.domain}) — org ${config.organizationId} no longer has access`,
);
continue;
}
// Skip configs with no keywords before advancing the schedule
const kwCount = await RankTrackingRepository.getKeywordCountForConfig(
config.id,
);
if (kwCount === 0) {
console.log(
`[cron] Skipping config ${config.id} (${config.domain}) — no keywords`,
);
// Still advance schedule so this config doesn't stay due forever
const skipInterval =
config.scheduleInterval === "daily" ||
config.scheduleInterval === "weekly"
? config.scheduleInterval
: null;
if (skipInterval) {
await RankTrackingRepository.updateConfig(
config.id,
config.projectId,
{
nextCheckAt: computeNextCheckAt(
skipInterval,
config.nextCheckAt,
),
},
);
}
continue;
}
// Advance nextCheckAt immediately to prevent retry storms if the run fails
const interval =
config.scheduleInterval === "daily" ||
config.scheduleInterval === "weekly"
? config.scheduleInterval
: null;
if (interval) {
await RankTrackingRepository.updateConfig(
config.id,
config.projectId,
{
nextCheckAt: computeNextCheckAt(interval, config.nextCheckAt),
},
);
}
const result = await beginRankCheckRun({
workflow: env.RANK_CHECK_WORKFLOW,
config,
projectId: config.projectId,
billingCustomer: {
userId: "system",
userEmail: "system@openseo.so",
organizationId: config.organizationId,
projectId: config.projectId,
},
keywordsTotal: kwCount,
trigger: "scheduled",
workflowStartErrorMessage: "Failed to start scheduled workflow",
});
if (!result.ok) {
console.log(
`[cron] Skipping config ${config.id} (${config.domain}) — run already active`,
);
} else {
console.log(
`[cron] Started scheduled rank check ${result.runId} for config ${config.id} (${config.domain})`,
);
}
} catch (err) {
console.error(
`[cron] Error processing config ${config.id} (${config.domain}):`,
err,
);
}
}
},
};

View File

@ -0,0 +1,411 @@
import { and, asc, count, desc, eq, gte, inArray, lte, max } from "drizzle-orm";
import type { InferInsertModel } from "drizzle-orm";
import { db } from "@/db";
import {
rankTrackingConfigs,
rankCheckRuns,
rankCheckLocks,
rankSnapshots,
rankTrackingKeywords,
projects,
} from "@/db/schema";
const DB_BATCH_SIZE = 100;
type BatchStatement = Parameters<typeof db.batch>[0][number];
async function executeInBatches<T>(
items: T[],
buildStatement: (item: T) => BatchStatement,
) {
for (let i = 0; i < items.length; i += DB_BATCH_SIZE) {
const chunk = items.slice(i, i + DB_BATCH_SIZE).map(buildStatement);
const [first, ...rest] = chunk;
if (!first) continue;
await db.batch([first, ...rest]);
}
}
// ---------------------------------------------------------------------------
// Config CRUD
// ---------------------------------------------------------------------------
async function getConfigsForProject(projectId: string) {
return db
.select()
.from(rankTrackingConfigs)
.where(eq(rankTrackingConfigs.projectId, projectId))
.orderBy(rankTrackingConfigs.createdAt);
}
async function getConfigById({
configId,
projectId,
}: {
configId: string;
projectId: string;
}) {
const rows = await db
.select()
.from(rankTrackingConfigs)
.where(
and(
eq(rankTrackingConfigs.id, configId),
eq(rankTrackingConfigs.projectId, projectId),
),
)
.limit(1);
return rows[0] ?? null;
}
async function getConfigByProjectDomainLocation(
projectId: string,
domain: string,
locationCode: number,
) {
const rows = await db
.select()
.from(rankTrackingConfigs)
.where(
and(
eq(rankTrackingConfigs.projectId, projectId),
eq(rankTrackingConfigs.domain, domain),
eq(rankTrackingConfigs.locationCode, locationCode),
),
)
.limit(1);
return rows[0] ?? null;
}
async function createConfig(
data: InferInsertModel<typeof rankTrackingConfigs>,
) {
await db.insert(rankTrackingConfigs).values(data);
}
async function updateConfig(
configId: string,
projectId: string,
data: Partial<InferInsertModel<typeof rankTrackingConfigs>>,
) {
await db
.update(rankTrackingConfigs)
.set(data)
.where(
and(
eq(rankTrackingConfigs.id, configId),
eq(rankTrackingConfigs.projectId, projectId),
),
);
}
async function getDueConfigsWithOrganization(nowIso: string) {
return db
.select({
id: rankTrackingConfigs.id,
projectId: rankTrackingConfigs.projectId,
domain: rankTrackingConfigs.domain,
locationCode: rankTrackingConfigs.locationCode,
languageCode: rankTrackingConfigs.languageCode,
devices: rankTrackingConfigs.devices,
scheduleInterval: rankTrackingConfigs.scheduleInterval,
nextCheckAt: rankTrackingConfigs.nextCheckAt,
organizationId: projects.organizationId,
})
.from(rankTrackingConfigs)
.innerJoin(projects, eq(rankTrackingConfigs.projectId, projects.id))
.where(
and(
eq(rankTrackingConfigs.isActive, true),
lte(rankTrackingConfigs.nextCheckAt, nowIso),
),
)
.limit(50);
}
// ---------------------------------------------------------------------------
// Run CRUD
// ---------------------------------------------------------------------------
async function createRun(data: {
id: string;
configId: string;
projectId: string;
keywordsTotal: number;
isSubsetRun?: boolean;
}) {
await db.insert(rankCheckRuns).values({
...data,
status: "pending",
});
}
async function updateRun(
runId: string,
data: Partial<InferInsertModel<typeof rankCheckRuns>>,
) {
await db.update(rankCheckRuns).set(data).where(eq(rankCheckRuns.id, runId));
}
async function getRunById(runId: string) {
const rows = await db
.select()
.from(rankCheckRuns)
.where(eq(rankCheckRuns.id, runId))
.limit(1);
return rows[0] ?? null;
}
async function getLatestRunForConfig(configId: string) {
const rows = await db
.select()
.from(rankCheckRuns)
.where(eq(rankCheckRuns.configId, configId))
.orderBy(desc(rankCheckRuns.startedAt))
.limit(1);
return rows[0] ?? null;
}
async function tryCreateRunLock(configId: string, runId: string) {
const inserted = await db
.insert(rankCheckLocks)
.values({ configId, runId })
.onConflictDoNothing({ target: rankCheckLocks.configId })
.returning({ runId: rankCheckLocks.runId });
return inserted.length > 0;
}
async function getRunLock(configId: string) {
const rows = await db
.select()
.from(rankCheckLocks)
.where(eq(rankCheckLocks.configId, configId))
.limit(1);
return rows[0] ?? null;
}
async function deleteRunLock(configId: string, runId?: string) {
await db
.delete(rankCheckLocks)
.where(
runId
? and(
eq(rankCheckLocks.configId, configId),
eq(rankCheckLocks.runId, runId),
)
: eq(rankCheckLocks.configId, configId),
);
}
// ---------------------------------------------------------------------------
// Snapshots
// ---------------------------------------------------------------------------
async function insertSnapshots(
snapshots: Array<
Omit<InferInsertModel<typeof rankSnapshots>, "id" | "checkedAt">
>,
) {
await executeInBatches(snapshots, (snapshot) =>
db.insert(rankSnapshots).values(snapshot).onConflictDoNothing(),
);
}
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
// ---------------------------------------------------------------------------
async function getKeywordsForConfig(configId: string) {
return db
.select()
.from(rankTrackingKeywords)
.where(eq(rankTrackingKeywords.configId, configId))
.orderBy(rankTrackingKeywords.createdAt);
}
async function addKeywordsToConfig(
keywords: Array<{ id: string; configId: string; keyword: string }>,
) {
await executeInBatches(keywords, (kw) =>
db.insert(rankTrackingKeywords).values(kw).onConflictDoNothing(),
);
}
async function removeKeywordsFromConfig(
keywordIds: string[],
configId: string,
) {
await db
.delete(rankTrackingKeywords)
.where(
and(
inArray(rankTrackingKeywords.id, keywordIds),
eq(rankTrackingKeywords.configId, configId),
),
);
}
async function getConfigSummaries(projectId: string) {
const configs = await getConfigsForProject(projectId);
if (configs.length === 0) return [];
// Batch: keyword counts grouped by config
const kwCounts = await db
.select({
configId: rankTrackingKeywords.configId,
value: count(),
})
.from(rankTrackingKeywords)
.where(
inArray(
rankTrackingKeywords.configId,
configs.map((c) => c.id),
),
)
.groupBy(rankTrackingKeywords.configId);
const kwCountMap = new Map(kwCounts.map((r) => [r.configId, r.value]));
// Subquery: latest startedAt per config
const latestStarted = db
.select({
configId: rankCheckRuns.configId,
maxStartedAt: max(rankCheckRuns.startedAt).as("maxStartedAt"),
})
.from(rankCheckRuns)
.where(
inArray(
rankCheckRuns.configId,
configs.map((c) => c.id),
),
)
.groupBy(rankCheckRuns.configId)
.as("latestStarted");
// Join back to get status + completedAt for each config's latest run
const latestRuns = await db
.select({
configId: rankCheckRuns.configId,
status: rankCheckRuns.status,
completedAt: rankCheckRuns.completedAt,
})
.from(rankCheckRuns)
.innerJoin(
latestStarted,
and(
eq(rankCheckRuns.configId, latestStarted.configId),
eq(rankCheckRuns.startedAt, latestStarted.maxStartedAt),
),
);
const latestRunMap = new Map<
string,
{ status: string; completedAt: string | null }
>();
for (const run of latestRuns) {
latestRunMap.set(run.configId, {
status: run.status,
completedAt: run.completedAt,
});
}
return configs.map((config) => ({
...config,
keywordCount: kwCountMap.get(config.id) ?? 0,
lastRunStatus: latestRunMap.get(config.id)?.status ?? null,
lastRunCompletedAt: latestRunMap.get(config.id)?.completedAt ?? null,
}));
}
async function getKeywordCountForConfig(configId: string) {
const rows = await db
.select({ value: count() })
.from(rankTrackingKeywords)
.where(eq(rankTrackingKeywords.configId, configId));
return rows[0]?.value ?? 0;
}
export const RankTrackingRepository = {
getConfigsForProject,
getConfigById,
getConfigByProjectDomainLocation,
createConfig,
updateConfig,
getDueConfigsWithOrganization,
createRun,
updateRun,
getRunById,
getLatestRunForConfig,
tryCreateRunLock,
getRunLock,
deleteRunLock,
insertSnapshots,
getSnapshotsForRun,
getRecentCompletedRuns,
getClosestCompletedRun,
getKeywordsForConfig,
addKeywordsToConfig,
removeKeywordsFromConfig,
getKeywordCountForConfig,
getConfigSummaries,
};

View File

@ -0,0 +1,311 @@
import { env } from "cloudflare:workers";
import type { BillingCustomerContext } from "@/server/billing/subscription";
import { RankTrackingRepository } from "@/server/features/rank-tracking/repositories/RankTrackingRepository";
import { AppError } from "@/server/lib/errors";
import type {
RankTrackingConfig,
RankCheckTriggerResult,
} from "@/types/schemas/rank-tracking";
import {
beginRankCheckRun,
reconcileActiveRankCheckRun,
} from "./rankCheckRunGuards";
import {
estimateRankCheckCredits,
computeNextCheckAt,
devicesCount,
MAX_KEYWORDS_PER_CONFIG,
MAX_CONFIGS_PER_PROJECT,
} from "@/shared/rank-tracking";
// ---------------------------------------------------------------------------
// Config
// ---------------------------------------------------------------------------
async function createConfig(input: {
projectId: string;
domain: string;
locationCode?: number;
languageCode?: string;
devices?: RankTrackingConfig["devices"];
scheduleInterval?: RankTrackingConfig["scheduleInterval"];
}) {
const normalizedDomain = normalizeDomain(input.domain);
const locationCode = input.locationCode ?? 2840;
const existing =
await RankTrackingRepository.getConfigByProjectDomainLocation(
input.projectId,
normalizedDomain,
locationCode,
);
if (existing) {
throw new AppError(
"INTERNAL_ERROR",
"This domain + country combination is already being tracked",
);
}
const allConfigs = await RankTrackingRepository.getConfigsForProject(
input.projectId,
);
if (allConfigs.length >= MAX_CONFIGS_PER_PROJECT) {
throw new AppError(
"INTERNAL_ERROR",
`Maximum ${MAX_CONFIGS_PER_PROJECT} tracked domains per project`,
);
}
const configId = crypto.randomUUID();
const scheduleInterval = input.scheduleInterval ?? "weekly";
const nextCheckAt =
scheduleInterval === "daily" || scheduleInterval === "weekly"
? computeNextCheckAt(scheduleInterval)
: null;
await RankTrackingRepository.createConfig({
id: configId,
projectId: input.projectId,
domain: normalizedDomain,
locationCode: input.locationCode ?? 2840,
languageCode: input.languageCode ?? "en",
devices: input.devices ?? "mobile",
scheduleInterval,
nextCheckAt,
});
return { configId };
}
async function updateConfig(
configId: string,
projectId: string,
input: {
domain?: string;
locationCode?: number;
languageCode?: string;
devices?: RankTrackingConfig["devices"];
scheduleInterval?: RankTrackingConfig["scheduleInterval"];
isActive?: boolean;
},
) {
const updates: typeof input & { nextCheckAt?: string | null } = {};
if (input.domain !== undefined)
updates.domain = normalizeDomain(input.domain);
if (input.locationCode !== undefined)
updates.locationCode = input.locationCode;
if (input.languageCode !== undefined)
updates.languageCode = input.languageCode;
if (input.devices !== undefined) updates.devices = input.devices;
if (input.isActive !== undefined) updates.isActive = input.isActive;
if (input.scheduleInterval !== undefined) {
updates.scheduleInterval = input.scheduleInterval;
if (input.scheduleInterval === "manual") {
updates.nextCheckAt = null;
} else {
updates.nextCheckAt = computeNextCheckAt(input.scheduleInterval);
}
}
await RankTrackingRepository.updateConfig(configId, projectId, updates);
}
// ---------------------------------------------------------------------------
// Keywords
// ---------------------------------------------------------------------------
async function addKeywords(
configId: string,
projectId: string,
keywords: string[],
) {
await getValidatedConfig(configId, projectId);
// Filter out keywords that already exist for this config.
// We must do this before inserting because onConflictDoNothing silently
// skips duplicates but we pre-generate UUIDs — returning those phantom IDs
// would cause the auto-check workflow to find no keywords and fail.
const existing = await RankTrackingRepository.getKeywordsForConfig(configId);
if (existing.length >= MAX_KEYWORDS_PER_CONFIG) {
throw new AppError(
"INTERNAL_ERROR",
`Maximum ${MAX_KEYWORDS_PER_CONFIG} keywords per domain. Currently tracking ${existing.length}.`,
);
}
const existingKeywords = new Set(existing.map((kw) => kw.keyword));
const available = MAX_KEYWORDS_PER_CONFIG - existing.length;
const seen = new Set<string>();
const rows: Array<{ id: string; configId: string; keyword: string }> = [];
for (const raw of keywords) {
if (rows.length >= available) break;
const normalized = raw.trim().toLowerCase();
if (
normalized &&
!seen.has(normalized) &&
!existingKeywords.has(normalized)
) {
seen.add(normalized);
rows.push({ id: crypto.randomUUID(), configId, keyword: normalized });
}
}
if (rows.length > 0) {
await RankTrackingRepository.addKeywordsToConfig(rows);
}
return { added: rows.length, addedIds: rows.map((r) => r.id) };
}
async function removeKeywords(
configId: string,
projectId: string,
keywordIds: string[],
) {
await getValidatedConfig(configId, projectId);
await RankTrackingRepository.removeKeywordsFromConfig(keywordIds, configId);
}
// ---------------------------------------------------------------------------
// Trigger a manual check
// ---------------------------------------------------------------------------
async function triggerCheck(input: {
configId: string;
projectId: string;
billingCustomer: BillingCustomerContext;
keywordIds?: string[];
}): Promise<RankCheckTriggerResult> {
const config = await getValidatedConfig(input.configId, input.projectId);
const keywords = await RankTrackingRepository.getKeywordsForConfig(config.id);
if (keywords.length === 0) {
throw new AppError(
"INTERNAL_ERROR",
"No keywords to track. Add keywords to this domain first.",
);
}
return beginRankCheckRun({
workflow: env.RANK_CHECK_WORKFLOW,
config,
projectId: input.projectId,
billingCustomer: {
userId: input.billingCustomer.userId,
userEmail: input.billingCustomer.userEmail,
organizationId: input.billingCustomer.organizationId,
projectId: input.billingCustomer.projectId,
},
keywordsTotal: keywords.length,
keywordIds: input.keywordIds,
trigger: "manual",
workflowStartErrorMessage: "Failed to start rank check workflow",
});
}
async function getLatestRun(configId: string, projectId: string) {
await getValidatedConfig(configId, projectId);
const run = await RankTrackingRepository.getLatestRunForConfig(configId);
if (!run) return null;
// If the DB says the run is still active, check the workflow instance.
// We only report staleness here — the cron handler cleans up stale locks
// when it next tries to acquire one (via cleanupStaleLock). Mutating from
// this read path caused a race where the original workflow kept running
// while a replacement was started.
const reconciliation = await reconcileActiveRankCheckRun(run);
if (reconciliation) {
return formatRun(run, {
maybeStale: true,
staleReason: reconciliation.errorMessage,
});
}
return formatRun(run);
}
// ---------------------------------------------------------------------------
// Cost estimation
// ---------------------------------------------------------------------------
async function estimateCost(configId: string, projectId: string) {
const config = await getValidatedConfig(configId, projectId);
const keywordCount =
await RankTrackingRepository.getKeywordCountForConfig(configId);
const { costUsd, costCredits } = estimateRankCheckCredits(
keywordCount,
config.devices,
);
return {
costUsd,
costCredits,
keywordCount,
devicesCount: devicesCount(config.devices),
};
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
async function getValidatedConfig(configId: string, projectId: string) {
const config = await RankTrackingRepository.getConfigById({
configId,
projectId,
});
if (!config) {
throw new AppError("INTERNAL_ERROR", "Rank tracking config not found");
}
return config;
}
function normalizeDomain(domain: string): string {
let d = domain.trim().toLowerCase();
// Strip protocol
d = d.replace(/^https?:\/\//, "");
// Strip path, query string, and fragment
d = d.replace(/[/?#].*$/, "");
// Strip trailing slash
d = d.replace(/\/+$/, "");
// Strip www. prefix
d = d.replace(/^www\./, "");
if (!d) {
throw new AppError("INTERNAL_ERROR", "Invalid domain");
}
return d;
}
type RunRow = NonNullable<
Awaited<ReturnType<typeof RankTrackingRepository.getLatestRunForConfig>>
>;
function formatRun(
run: RunRow,
stale?: { maybeStale: boolean; staleReason: string },
) {
return {
id: run.id,
status: run.status,
keywordsTotal: run.keywordsTotal,
keywordsChecked: run.keywordsChecked,
errorMessage: run.errorMessage,
startedAt: run.startedAt,
completedAt: run.completedAt,
maybeStale: stale?.maybeStale ?? false,
staleReason: stale?.staleReason ?? null,
};
}
export const RankTrackingService = {
createConfig,
updateConfig,
addKeywords,
removeKeywords,
triggerCheck,
getLatestRun,
estimateCost,
};

View File

@ -0,0 +1,272 @@
import { env } from "cloudflare:workers";
import type { BillingCustomerContext } from "@/server/billing/subscription";
import { RankTrackingRepository } from "@/server/features/rank-tracking/repositories/RankTrackingRepository";
import type {
RankCheckTriggerResult,
RankTrackingConfig,
} from "@/types/schemas/rank-tracking";
type RunRow = Awaited<ReturnType<typeof RankTrackingRepository.getRunById>>;
type RunLockRow = Awaited<ReturnType<typeof RankTrackingRepository.getRunLock>>;
// Coordination invariants for rank checks:
// - `workflow id === run id`, so the workflow instance is the authoritative
// runtime identity for a stored run.
// - `rank_check_locks` enforces at most one active runner per config.
// - Only the lock owner is allowed to spend credits, write snapshots, or
// finalize the run.
// - Missing/unknown workflow state is tolerated briefly during startup before
// we treat the run as stale and repair it.
type RankCheckWorkflowStatus = {
status:
| "queued"
| "running"
| "paused"
| "errored"
| "terminated"
| "complete"
| "waiting"
| "waitingForPause"
| "unknown";
error?: {
message: string;
};
};
type RankCheckConfigForStart = Pick<
RankTrackingConfig,
"id" | "domain" | "locationCode" | "languageCode" | "devices"
>;
const ACTIVE_WORKFLOW_STATUSES = new Set<RankCheckWorkflowStatus["status"]>([
"queued",
"running",
"waiting",
"waitingForPause",
"paused",
]);
const RANK_CHECK_STARTUP_GRACE_MS = 60 * 1000;
async function getRankCheckWorkflowStatus(
runId: string,
): Promise<RankCheckWorkflowStatus | null> {
try {
const instance = await env.RANK_CHECK_WORKFLOW.get(runId);
return (await instance.status()) as RankCheckWorkflowStatus;
} catch {
return null;
}
}
function getStaleReason(
workflowStatus: RankCheckWorkflowStatus | null,
run: RunRow,
): string {
if (run?.status === "completed" || run?.status === "failed") {
return `Run already ${run.status}`;
}
if (!workflowStatus) {
return "Workflow instance was not found";
}
if (
workflowStatus.status === "errored" ||
workflowStatus.status === "terminated"
) {
return workflowStatus.error?.message ?? `Workflow ${workflowStatus.status}`;
}
if (workflowStatus.status === "complete") {
return "Workflow completed without releasing the run lock";
}
return `Workflow is no longer active (${workflowStatus.status})`;
}
export async function beginRankCheckRun(input: {
workflow: Env["RANK_CHECK_WORKFLOW"];
config: RankCheckConfigForStart;
projectId: string;
billingCustomer: BillingCustomerContext;
keywordsTotal: number;
keywordIds?: string[];
trigger: "manual" | "scheduled";
workflowStartErrorMessage: string;
}): Promise<RankCheckTriggerResult> {
const runId = crypto.randomUUID();
const lockResult = await acquireRankCheckRunLock(input.config.id, runId);
if (!lockResult.acquired) {
return {
ok: false,
reason: "already_running",
blockingRunId: lockResult.blockingRunId,
};
}
try {
await RankTrackingRepository.createRun({
id: runId,
configId: input.config.id,
projectId: input.projectId,
keywordsTotal: input.keywordsTotal,
isSubsetRun: (input.keywordIds?.length ?? 0) > 0,
});
await input.workflow.create({
id: runId,
params: {
runId,
configId: input.config.id,
billingCustomer: input.billingCustomer,
projectId: input.projectId,
domain: input.config.domain,
locationCode: input.config.locationCode,
languageCode: input.config.languageCode,
devices: input.config.devices,
trigger: input.trigger,
keywordIds: input.keywordIds,
},
});
} catch (error) {
try {
await failRunAndReleaseRankCheckLock(
input.config.id,
runId,
input.workflowStartErrorMessage,
);
} catch {
await releaseRankCheckRunLock(input.config.id, runId);
}
try {
const instance = await input.workflow.get(runId);
await instance.terminate();
} catch {
// Workflow may not have been created
}
throw error;
}
return { ok: true, runId };
}
async function getStaleRankCheckRunReason(input: {
run: RunRow;
runId: string;
ageMs: number;
}) {
const workflowStatus = await getRankCheckWorkflowStatus(input.runId);
if (workflowStatus && ACTIVE_WORKFLOW_STATUSES.has(workflowStatus.status)) {
return null;
}
const startupWindow =
input.ageMs < RANK_CHECK_STARTUP_GRACE_MS &&
(!input.run ||
input.run.status === "pending" ||
input.run.status === "running") &&
(!workflowStatus || workflowStatus.status === "unknown");
if (startupWindow) {
return null;
}
return getStaleReason(workflowStatus, input.run);
}
async function failRunIfNeeded(runId: string, reason: string, run: RunRow) {
if (!run || run.status === "completed" || run.status === "failed") return;
await RankTrackingRepository.updateRun(runId, {
status: "failed",
errorMessage: reason,
completedAt: new Date().toISOString(),
});
}
async function cleanupStaleLock(lock: NonNullable<RunLockRow>) {
const run = await RankTrackingRepository.getRunById(lock.runId);
if (run?.status === "completed" || run?.status === "failed") {
await RankTrackingRepository.deleteRunLock(lock.configId, lock.runId);
return true;
}
const staleReason = await getStaleRankCheckRunReason({
runId: lock.runId,
run,
ageMs: Date.now() - new Date(lock.acquiredAt).getTime(),
});
if (!staleReason) return false;
await failRunIfNeeded(lock.runId, staleReason, run);
await RankTrackingRepository.deleteRunLock(lock.configId, lock.runId);
return true;
}
export async function reconcileActiveRankCheckRun(run: NonNullable<RunRow>) {
if (run.status !== "running" && run.status !== "pending") {
return null;
}
const staleReason = await getStaleRankCheckRunReason({
runId: run.id,
run,
ageMs: Date.now() - new Date(run.startedAt).getTime(),
});
if (!staleReason) return null;
return {
errorMessage: staleReason,
completedAt: new Date().toISOString(),
};
}
async function acquireRankCheckRunLock(configId: string, runId: string) {
// Optimistic: try to grab the lock immediately
if (await RankTrackingRepository.tryCreateRunLock(configId, runId)) {
return { acquired: true as const };
}
// Lock exists — check if it's stale and can be cleaned up
const existingLock = await RankTrackingRepository.getRunLock(configId);
if (!existingLock) {
// Lock was released between our insert and select — retry once
if (await RankTrackingRepository.tryCreateRunLock(configId, runId)) {
return { acquired: true as const };
}
const blocker = await RankTrackingRepository.getRunLock(configId);
return { acquired: false as const, blockingRunId: blocker?.runId ?? null };
}
const cleaned = await cleanupStaleLock(existingLock);
if (!cleaned) {
return { acquired: false as const, blockingRunId: existingLock.runId };
}
// Stale lock cleaned — retry once
if (await RankTrackingRepository.tryCreateRunLock(configId, runId)) {
return { acquired: true as const };
}
const blocker = await RankTrackingRepository.getRunLock(configId);
return { acquired: false as const, blockingRunId: blocker?.runId ?? null };
}
export async function runOwnsRankCheckLock(configId: string, runId: string) {
const lock = await RankTrackingRepository.getRunLock(configId);
return lock?.runId === runId;
}
export async function releaseRankCheckRunLock(configId: string, runId: string) {
await RankTrackingRepository.deleteRunLock(configId, runId);
}
export async function failRunAndReleaseRankCheckLock(
configId: string,
runId: string,
errorMessage: string,
) {
const run = await RankTrackingRepository.getRunById(runId);
await failRunIfNeeded(runId, errorMessage, run);
await RankTrackingRepository.deleteRunLock(configId, runId);
}

View File

@ -0,0 +1,155 @@
import { RankTrackingRepository } from "@/server/features/rank-tracking/repositories/RankTrackingRepository";
import { AppError } from "@/server/lib/errors";
import type { ComparePeriod } from "@/types/schemas/rank-tracking";
import type {
RankTrackingDeviceResult,
RankTrackingRow,
} from "@/types/schemas/rank-tracking";
type SnapshotRow = Awaited<
ReturnType<typeof RankTrackingRepository.getSnapshotsForRun>
>[0];
const PERIOD_DAYS: Record<Exclude<ComparePeriod, "previous">, number> = {
"7d": 7,
"30d": 30,
"90d": 90,
};
export async function getLatestResults(
configId: string,
projectId: string,
comparePeriod: ComparePeriod = "previous",
): Promise<{
rows: RankTrackingRow[];
run: { id: string; startedAt: string } | null;
}> {
const config = await RankTrackingRepository.getConfigById({
configId,
projectId,
});
if (!config) {
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);
const rows = new Map<string, RankTrackingRow>(
activeKeywords.map((keyword) => [
keyword.id,
{
trackingKeywordId: keyword.id,
keyword: keyword.keyword,
desktop: createEmptyDeviceResult(
previousPositions.get(`${keyword.id}:desktop`) ?? null,
),
mobile: createEmptyDeviceResult(
previousPositions.get(`${keyword.id}:mobile`) ?? null,
),
},
]),
);
for (const snapshot of currentSnapshots) {
const row = rows.get(snapshot.trackingKeywordId);
if (!row) continue;
row[snapshot.device] = toDeviceResult(
snapshot,
previousPositions.get(
`${snapshot.trackingKeywordId}:${snapshot.device}`,
) ?? null,
);
}
return {
rows: activeKeywords
.map((keyword) => rows.get(keyword.id))
.filter((row): row is RankTrackingRow => row != null),
run: {
id: currentRun.id,
startedAt: currentRun.startedAt,
},
};
}
function parseSerpFeatures(raw: string | null): string[] {
if (!raw) return [];
try {
const parsed: unknown = JSON.parse(raw);
if (Array.isArray(parsed)) {
return parsed.filter((item): item is string => typeof item === "string");
}
} catch {
// ignore
}
return [];
}
function createEmptyDeviceResult(
previousPosition: number | null,
): RankTrackingDeviceResult {
return {
position: null,
previousPosition,
rankingUrl: null,
serpFeatures: [],
};
}
function toDeviceResult(
snapshot: SnapshotRow,
previousPosition: number | null,
): RankTrackingDeviceResult {
return {
position: snapshot.position,
previousPosition,
rankingUrl: snapshot.url,
serpFeatures: parseSerpFeatures(snapshot.serpFeatures),
};
}

View File

@ -1,3 +1,4 @@
/* eslint-disable max-lines */
import {
DataforseoLabsApi,
DataforseoLabsGoogleRelatedKeywordsLiveRequestInfo,
@ -7,6 +8,7 @@ import {
DataforseoLabsGoogleRankedKeywordsLiveRequestInfo,
} from "dataforseo-client";
import { env } from "cloudflare:workers";
import { z } from "zod";
import type { DataforseoApiResponse } from "@/server/lib/dataforseoCost";
import { AppError } from "@/server/lib/errors";
import {
@ -380,3 +382,99 @@ export async function fetchLiveSerpItemsRaw(
billing: buildTaskBilling(task),
};
}
// ---------------------------------------------------------------------------
// SERP Rank Check API wrapper (Google Organic Live with target matching)
// ---------------------------------------------------------------------------
export interface RankCheckResult {
keywordId: string;
keyword: string;
position: number | null;
url: string | null;
serpFeatures: string[];
}
export async function fetchRankCheckSerpRaw(input: {
keyword: string;
keywordId: string;
locationCode: number;
languageCode: string;
device: "desktop" | "mobile";
targetDomain: string;
}): Promise<DataforseoApiResponse<RankCheckResult>> {
const responseRaw = await postDataforseo(
"/v3/serp/google/organic/live/advanced",
[
{
keyword: input.keyword,
location_code: input.locationCode,
language_code: input.languageCode,
device: input.device,
os: input.device === "desktop" ? "windows" : "android",
depth: 20,
target: input.targetDomain,
},
],
);
const response = dataforseoResponseSchema.parse(responseRaw);
if (response.status_code !== 20000) {
throw new AppError(
"INTERNAL_ERROR",
response.status_message || "DataForSEO request failed",
);
}
const task = response.tasks?.[0];
if (!task) {
throw new AppError("INTERNAL_ERROR", "DataForSEO response missing task");
}
// "No Search Results" (40501) is valid for obscure/new keywords —
// treat as empty result set rather than failing the entire run.
const isNoResults =
task.status_code === 40501 ||
task.status_message?.toLowerCase().includes("no search results");
if (task.status_code !== 20000 && !isNoResults) {
throw new AppError(
"INTERNAL_ERROR",
task.status_message || "DataForSEO task failed",
);
}
const parsedTask = successfulDataforseoTaskSchema.safeParse(task);
if (!parsedTask.success) {
throw new AppError(
"INTERNAL_ERROR",
`DataForSEO rank check task missing billing metadata`,
);
}
const items = z
.array(serpSnapshotItemSchema)
.parse(parsedTask.data.result?.[0]?.items ?? []);
const target = input.targetDomain.toLowerCase();
const organicMatch = items.find((item) => {
if (item.type !== "organic" || item.domain == null) return false;
const d = item.domain.toLowerCase();
return d === target || d.endsWith(`.${target}`);
});
return {
data: {
keywordId: input.keywordId,
keyword: input.keyword,
position: organicMatch
? (organicMatch.rank_absolute ?? organicMatch.rank_group ?? null)
: null,
url: organicMatch?.url ?? null,
serpFeatures: [
...new Set(items.map((item) => item.type).filter(Boolean)),
],
},
billing: buildTaskBilling(parsedTask.data),
};
}

View File

@ -15,6 +15,7 @@ import {
fetchDomainRankOverviewRaw,
fetchRankedKeywordsRaw,
fetchLiveSerpItemsRaw,
fetchRankCheckSerpRaw,
type LabsKeywordDataItem,
type SerpLiveItem,
} from "@/server/lib/dataforseo";
@ -43,7 +44,8 @@ type CreditFeature =
| "keyword_research"
| "domain_overview"
| "backlinks"
| "site_audit";
| "site_audit"
| "rank_tracking";
/**
* Maps a DataForSEO API response path (e.g. ["v3", "dataforseo_labs", "google", "related_keywords", "live"])
@ -198,6 +200,20 @@ export function createDataforseoClient(customer: BillingCustomerContext) {
),
);
},
rankCheck(input: {
keyword: string;
keywordId: string;
locationCode: number;
languageCode: string;
device: "desktop" | "mobile";
targetDomain: string;
}) {
return meterDataforseoCall(
customer,
() => fetchRankCheckSerpRaw(input),
"rank_tracking",
);
},
},
lighthouse: {
live(input: { url: string; strategy: LighthouseStrategy }) {
@ -212,6 +228,7 @@ export function createDataforseoClient(customer: BillingCustomerContext) {
async function meterDataforseoCall<T>(
customer: BillingCustomerContext,
execute: () => Promise<DataforseoApiResponse<T>>,
creditFeature?: CreditFeature,
): Promise<T> {
const isHostedMode = await isHostedServerAuthMode();
@ -233,6 +250,7 @@ async function meterDataforseoCall<T>(
customerId: billingCustomer.id,
billing: result.billing,
monthlyRemaining,
creditFeature,
});
return result.data;
@ -265,6 +283,7 @@ async function trackDataforseoCost(args: {
customerId: string;
billing: DataforseoApiCallCost;
monthlyRemaining: number;
creditFeature?: CreditFeature;
}) {
const totalCostUsd = roundUsdForBilling(
args.billing.costUsd * SEO_DATA_COST_MARKUP,
@ -316,7 +335,9 @@ async function trackDataforseoCost(args: {
organizationId: args.customer.organizationId,
properties: {
project_id: args.customer.projectId,
credit_feature: mapDataforseoPathToCreditFeature(args.billing.path),
credit_feature:
args.creditFeature ??
mapDataforseoPathToCreditFeature(args.billing.path),
monthly_credits: monthlyDeduct,
topup_credits: topupDeduct,
total_credits: totalCostCredits,

View File

@ -0,0 +1,307 @@
import {
WorkflowEntrypoint,
type WorkflowEvent,
type WorkflowStep,
} from "cloudflare:workers";
import { NonRetryableError } from "cloudflare:workflows";
import type { BillingCustomerContext } from "@/server/billing/subscription";
import { RankTrackingRepository } from "@/server/features/rank-tracking/repositories/RankTrackingRepository";
import {
failRunAndReleaseRankCheckLock,
releaseRankCheckRunLock,
runOwnsRankCheckLock,
} from "@/server/features/rank-tracking/services/rankCheckRunGuards";
import { runLiveCheck } from "@/server/workflows/rankCheckPaths";
import { createDataforseoClient } from "@/server/lib/dataforseoClient";
import { captureServerEvent } from "@/server/lib/posthog";
import { AppError } from "@/server/lib/errors";
import { autumn } from "@/server/billing/autumn";
import {
AUTUMN_SEO_DATA_BALANCE_FEATURE_ID,
AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID,
} from "@/shared/billing";
import { estimateRankCheckCredits } from "@/shared/rank-tracking";
import { isHostedServerAuthMode } from "@/server/lib/runtime-env";
const SINGLE_ATTEMPT_STEP_CONFIG = {
retries: { limit: 0, delay: "1 second" as const },
timeout: "2 minutes" as const,
};
interface RankCheckParams {
runId: string;
configId: string;
billingCustomer: BillingCustomerContext;
projectId: string;
domain: string;
locationCode: number;
languageCode: string;
devices: "both" | "desktop" | "mobile";
trigger: "manual" | "scheduled";
keywordIds?: string[];
}
async function prepareRankCheckKeywords(input: {
runId: string;
configId: string;
billingCustomer: BillingCustomerContext;
devices: RankCheckParams["devices"];
keywordIds?: string[];
}) {
const ownsLock = await runOwnsRankCheckLock(input.configId, input.runId);
if (!ownsLock) {
throw new NonRetryableError(
`Rank check lock is not held by run ${input.runId}`,
);
}
await RankTrackingRepository.updateRun(input.runId, {
status: "running",
});
let trackingKeywords = await RankTrackingRepository.getKeywordsForConfig(
input.configId,
);
if (input.keywordIds && input.keywordIds.length > 0) {
const idSet = new Set(input.keywordIds);
trackingKeywords = trackingKeywords.filter((kw) => idSet.has(kw.id));
}
if (trackingKeywords.length === 0) {
throw new AppError("INTERNAL_ERROR", "No keywords to track");
}
// Verify the user has enough credits for the full check before starting
if (await isHostedServerAuthMode()) {
const { costCredits } = estimateRankCheckCredits(
trackingKeywords.length,
input.devices,
);
const [monthlyCheck, topupCheck] = await Promise.all([
autumn.check({
customerId: input.billingCustomer.organizationId,
featureId: AUTUMN_SEO_DATA_BALANCE_FEATURE_ID,
}),
autumn.check({
customerId: input.billingCustomer.organizationId,
featureId: AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID,
}),
]);
const available =
(monthlyCheck.balance?.remaining ?? 0) +
(topupCheck.balance?.remaining ?? 0);
if (available < costCredits) {
throw new AppError(
"INSUFFICIENT_CREDITS",
"Insufficient credits for rank check",
);
}
}
await RankTrackingRepository.updateRun(input.runId, {
keywordsTotal: trackingKeywords.length,
});
return {
keywords: trackingKeywords.map((kw) => ({
id: kw.id,
keyword: kw.keyword,
})),
};
}
async function finalizeRankCheckRun(input: {
runId: string;
configId: string;
projectId: string;
billingCustomer: BillingCustomerContext;
trigger: RankCheckParams["trigger"];
batchError: string | null;
}) {
// Re-check lock ownership before finalizing. If the lock was stolen
// (stale cleanup raced with a slow workflow), bail out to avoid
// overwriting the replacement run's state.
const ownsLock = await runOwnsRankCheckLock(input.configId, input.runId);
if (!ownsLock) {
console.warn(
`[rank-check] ${input.runId} lost lock ownership, skipping finalization`,
);
return;
}
const nowIso = new Date().toISOString();
// Snapshots were written incrementally by each batch step.
// Count from DB to get the authoritative keyword count.
const snapshots = await RankTrackingRepository.getSnapshotsForRun(
input.runId,
);
const keywordsChecked = new Set(snapshots.map((s) => s.trackingKeywordId))
.size;
// Derive incompleteCount from the run's keywordsTotal (set in prepare step)
const run = await RankTrackingRepository.getRunById(input.runId);
const keywordsTotal = run?.keywordsTotal ?? keywordsChecked;
const incompleteCount = keywordsTotal - keywordsChecked;
let errorMessage: string | undefined;
if (input.batchError) {
errorMessage = `Completed ${keywordsChecked} of ${keywordsTotal} keyword(s). Error: ${input.batchError}`;
} else if (incompleteCount > 0) {
errorMessage = `${incompleteCount} keyword(s) could not be checked`;
}
await RankTrackingRepository.updateRun(input.runId, {
status: "completed",
keywordsChecked,
completedAt: nowIso,
...(errorMessage ? { errorMessage } : {}),
});
// Clear any previous skip reason on success.
// Note: nextCheckAt is NOT set here — the cron handler advances it eagerly
// before starting the workflow to prevent retry storms.
await RankTrackingRepository.updateConfig(input.configId, input.projectId, {
lastCheckedAt: nowIso,
lastSkipReason: null,
});
await releaseRankCheckRunLock(input.configId, input.runId);
await captureServerEvent({
distinctId: input.billingCustomer.userId,
event: "rank_tracking:check_complete",
organizationId: input.billingCustomer.organizationId,
properties: {
project_id: input.projectId,
status: "completed",
trigger: input.trigger,
keywords_checked: keywordsChecked,
},
});
}
async function markRankCheckRunFailed(input: {
runId: string;
configId: string;
projectId: string;
billingCustomer: BillingCustomerContext;
error: unknown;
}) {
const errorMessage =
input.error instanceof Error ? input.error.message : "Unknown error";
await failRunAndReleaseRankCheckLock(
input.configId,
input.runId,
errorMessage,
);
// Flag the config so the UI can show why the scheduled check was skipped
const isInsufficientCredits =
input.error instanceof AppError &&
input.error.code === "INSUFFICIENT_CREDITS";
if (isInsufficientCredits) {
await RankTrackingRepository.updateConfig(input.configId, input.projectId, {
lastSkipReason: "insufficient_credits",
});
}
await captureServerEvent({
distinctId: input.billingCustomer.userId,
event: "rank_tracking:check_complete",
organizationId: input.billingCustomer.organizationId,
properties: {
project_id: input.projectId,
status: "failed",
error: errorMessage,
},
});
}
export class RankCheckWorkflow extends WorkflowEntrypoint<
Env,
RankCheckParams
> {
async run(event: WorkflowEvent<RankCheckParams>, step: WorkflowStep) {
const {
runId,
configId,
billingCustomer,
projectId,
domain,
locationCode,
languageCode,
devices,
trigger,
keywordIds,
} = event.payload;
const client = createDataforseoClient(billingCustomer);
try {
console.log(
`[rank-check] ${runId} starting (trigger=${trigger}, devices=${devices})`,
);
const prepareResult = await step.do(
"prepare",
{ retries: { limit: 0, delay: "1 second" } },
async () =>
prepareRankCheckKeywords({
runId,
configId,
billingCustomer,
devices,
keywordIds,
}),
);
const keywords = prepareResult.keywords;
console.log(`[rank-check] ${runId} loaded ${keywords.length} keywords`);
let batchError: string | null = null;
try {
await runLiveCheck(step, {
client,
keywords,
devices,
domain,
locationCode,
languageCode,
runId,
});
} catch (error) {
// Batch failure — snapshots for completed batches are already
// persisted incrementally. Continue to finalization.
batchError = error instanceof Error ? error.message : String(error);
console.warn(`[rank-check] ${runId} partial failure: ${batchError}`);
}
await step.do("finalize", SINGLE_ATTEMPT_STEP_CONFIG, async () =>
finalizeRankCheckRun({
runId,
configId,
projectId,
billingCustomer,
trigger,
batchError,
}),
);
} catch (error) {
console.error(`Rank check ${runId} failed:`, error);
await step.do("mark-failed", SINGLE_ATTEMPT_STEP_CONFIG, async () =>
markRankCheckRunFailed({
runId,
configId,
projectId,
billingCustomer,
error,
}),
);
throw error;
}
}
}

View File

@ -0,0 +1,113 @@
import type { WorkflowStep } from "cloudflare:workers";
import { RankTrackingRepository } from "@/server/features/rank-tracking/repositories/RankTrackingRepository";
import type { createDataforseoClient } from "@/server/lib/dataforseoClient";
import type { RankCheckResult } from "@/server/lib/dataforseo";
import type { RankTrackingConfig } from "@/types/schemas/rank-tracking";
import { KEYWORDS_PER_BATCH } from "@/shared/rank-tracking";
const SINGLE_ATTEMPT_STEP_CONFIG = {
retries: { limit: 0, delay: "1 second" as const },
timeout: "2 minutes" as const,
};
type KeywordEntry = { id: string; keyword: string };
type RankCheckResultWithDevice = RankCheckResult & {
device: "desktop" | "mobile";
};
function mapResultsToSnapshotRows(
runId: string,
results: RankCheckResultWithDevice[],
) {
return results.map((r) => ({
runId,
trackingKeywordId: r.keywordId,
keyword: r.keyword,
device: r.device,
position: r.position,
url: r.url,
serpFeatures:
r.serpFeatures.length > 0 ? JSON.stringify(r.serpFeatures) : null,
}));
}
interface CheckContext {
client: ReturnType<typeof createDataforseoClient>;
keywords: KeywordEntry[];
devices: RankTrackingConfig["devices"];
domain: string;
locationCode: number;
languageCode: string;
runId: string;
}
/**
* Check keywords via Live API, parallel devices per keyword, real-time progress.
* Snapshots are written incrementally after each batch so partial results
* survive batch failures. ~6s per keyword batch.
* Billing is handled per-call by the metered client.
*/
export async function runLiveCheck(
step: WorkflowStep,
ctx: CheckContext,
): Promise<{ totalFailed: number }> {
const deviceList: Array<"desktop" | "mobile"> =
ctx.devices === "both" ? ["desktop", "mobile"] : [ctx.devices];
let checked = 0;
let totalFailed = 0;
for (let i = 0; i < ctx.keywords.length; i += KEYWORDS_PER_BATCH) {
const batch = ctx.keywords.slice(i, i + KEYWORDS_PER_BATCH);
const batchIndex = Math.floor(i / KEYWORDS_PER_BATCH);
const batchResults = await step.do(
`live-batch-${batchIndex}`,
SINGLE_ATTEMPT_STEP_CONFIG,
async () => {
const promises = batch.flatMap((kw) =>
deviceList.map((device) =>
ctx.client.serp
.rankCheck({
keyword: kw.keyword,
keywordId: kw.id,
locationCode: ctx.locationCode,
languageCode: ctx.languageCode,
device,
targetDomain: ctx.domain,
})
.then((r) => ({ ...r, device })),
),
);
const settled = await Promise.allSettled(promises);
const results: RankCheckResultWithDevice[] = [];
let batchFailed = 0;
for (const outcome of settled) {
if (outcome.status === "fulfilled") {
results.push(outcome.value);
} else {
batchFailed++;
console.error("Rank check call failed:", outcome.reason);
}
}
checked += batch.length;
await RankTrackingRepository.updateRun(ctx.runId, {
keywordsChecked: checked,
});
if (results.length > 0) {
await RankTrackingRepository.insertSnapshots(
mapResultsToSnapshotRows(ctx.runId, results),
);
}
return { batchFailed };
},
);
totalFailed += batchResults.batchFailed;
}
if (totalFailed > 0) {
console.warn(`Rank check completed with ${totalFailed} failed API call(s)`);
}
return { totalFailed };
}

View File

@ -18,11 +18,7 @@ export const startAudit = createServerFn({ method: "POST" })
.handler(async ({ data, context }) => {
const result = await AuditService.startAudit({
actorUserId: context.userId,
billingCustomer: {
organizationId: context.organizationId,
userEmail: context.userEmail,
userId: context.userId,
},
billingCustomer: context,
projectId: context.projectId,
startUrl: data.startUrl,
maxPages: data.maxPages,

View File

@ -0,0 +1,154 @@
import { createServerFn } from "@tanstack/react-start";
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 { captureServerEvent } from "@/server/lib/posthog";
import { requireProjectContext } from "@/serverFunctions/middleware";
import {
getConfigsSchema,
createConfigSchema,
updateConfigSchema,
triggerCheckSchema,
getLatestResultsSchema,
getLatestRunSchema,
estimateCostSchema,
addKeywordsSchema,
removeKeywordsSchema,
} from "@/types/schemas/rank-tracking";
export const getRankTrackingConfigs = createServerFn({ method: "POST" })
.middleware(requireProjectContext)
.inputValidator((data: unknown) => getConfigsSchema.parse(data))
.handler(async ({ context }) => {
return RankTrackingRepository.getConfigsForProject(context.projectId);
});
export const getRankTrackingConfigSummaries = createServerFn({ method: "POST" })
.middleware(requireProjectContext)
.inputValidator((data: unknown) => getConfigsSchema.parse(data))
.handler(async ({ context }) => {
return RankTrackingRepository.getConfigSummaries(context.projectId);
});
export const createRankTrackingConfig = createServerFn({ method: "POST" })
.middleware(requireProjectContext)
.inputValidator((data: unknown) => createConfigSchema.parse(data))
.handler(async ({ data, context }) => {
const result = await RankTrackingService.createConfig({
projectId: context.projectId,
domain: data.domain,
locationCode: data.locationCode,
languageCode: data.languageCode,
devices: data.devices,
scheduleInterval: data.scheduleInterval,
});
waitUntil(
captureServerEvent({
distinctId: context.userId,
event: "rank_tracking:config_create",
organizationId: context.organizationId,
properties: {
project_id: context.projectId,
domain: data.domain,
devices: data.devices ?? "both",
schedule: data.scheduleInterval ?? "weekly",
},
}),
);
return result;
});
export const updateRankTrackingConfig = createServerFn({ method: "POST" })
.middleware(requireProjectContext)
.inputValidator((data: unknown) => updateConfigSchema.parse(data))
.handler(async ({ data, context }) => {
await RankTrackingService.updateConfig(data.configId, context.projectId, {
domain: data.domain,
locationCode: data.locationCode,
languageCode: data.languageCode,
devices: data.devices,
scheduleInterval: data.scheduleInterval,
isActive: data.isActive,
});
return { success: true };
});
export const triggerRankCheck = createServerFn({ method: "POST" })
.middleware(requireProjectContext)
.inputValidator((data: unknown) => triggerCheckSchema.parse(data))
.handler(async ({ data, context }) => {
const result = await RankTrackingService.triggerCheck({
configId: data.configId,
projectId: context.projectId,
billingCustomer: context,
keywordIds: data.keywordIds,
});
if (result.ok) {
waitUntil(
captureServerEvent({
distinctId: context.userId,
event: "rank_tracking:check_trigger",
organizationId: context.organizationId,
properties: {
project_id: context.projectId,
config_id: data.configId,
run_id: result.runId,
},
}),
);
}
return result;
});
export const getLatestRankResults = createServerFn({ method: "POST" })
.middleware(requireProjectContext)
.inputValidator((data: unknown) => getLatestResultsSchema.parse(data))
.handler(async ({ data, context }) => {
return getLatestResults(
data.configId,
context.projectId,
data.comparePeriod,
);
});
export const getLatestRankRun = createServerFn({ method: "POST" })
.middleware(requireProjectContext)
.inputValidator((data: unknown) => getLatestRunSchema.parse(data))
.handler(async ({ data, context }) => {
return RankTrackingService.getLatestRun(data.configId, context.projectId);
});
export const estimateRankCheckCost = createServerFn({ method: "POST" })
.middleware(requireProjectContext)
.inputValidator((data: unknown) => estimateCostSchema.parse(data))
.handler(async ({ data, context }) => {
return RankTrackingService.estimateCost(data.configId, context.projectId);
});
export const addTrackingKeywords = createServerFn({ method: "POST" })
.middleware(requireProjectContext)
.inputValidator((data: unknown) => addKeywordsSchema.parse(data))
.handler(async ({ data, context }) => {
return RankTrackingService.addKeywords(
data.configId,
context.projectId,
data.keywords,
);
});
export const removeTrackingKeywords = createServerFn({ method: "POST" })
.middleware(requireProjectContext)
.inputValidator((data: unknown) => removeKeywordsSchema.parse(data))
.handler(async ({ data, context }) => {
await RankTrackingService.removeKeywords(
data.configId,
context.projectId,
data.keywordIds,
);
return { removed: data.keywordIds.length };
});

View File

@ -0,0 +1,97 @@
import {
AUTUMN_SEO_DATA_CREDITS_PER_USD,
SEO_DATA_COST_MARKUP,
roundUsdForBilling,
} from "./billing";
import type { RankTrackingConfig } from "@/types/schemas/rank-tracking";
// ---------------------------------------------------------------------------
// Cost constants
// ---------------------------------------------------------------------------
/** Per-SERP cost from DataForSEO Live API */
const COST_PER_SERP_USD = 0.002;
/** How many keywords are checked per batch */
export const KEYWORDS_PER_BATCH = 10;
/** Approximate seconds per batch */
export const SECONDS_PER_BATCH = 6;
/** Maximum keywords allowed per rank tracking config */
export const MAX_KEYWORDS_PER_CONFIG = 1000;
/** Maximum configs (domain+location combos) per project */
export const MAX_CONFIGS_PER_PROJECT = 20;
// ---------------------------------------------------------------------------
// Cost estimation
// ---------------------------------------------------------------------------
export function estimateRankCheckCredits(
keywordCount: number,
devices: RankTrackingConfig["devices"],
) {
const totalChecks = keywordCount * devicesCount(devices);
const costUsd = roundUsdForBilling(
totalChecks * COST_PER_SERP_USD * SEO_DATA_COST_MARKUP,
);
const costCredits = Math.ceil(costUsd * AUTUMN_SEO_DATA_CREDITS_PER_USD);
return { costUsd, costCredits };
}
// ---------------------------------------------------------------------------
// Schedule
// ---------------------------------------------------------------------------
/**
* Compute the next check time for a scheduled config.
*
* If `previousNextCheckAt` is provided, advances from that anchor by the
* interval until the result is in the future. This prevents schedule drift
* when runs are delayed (e.g., a weekly config due Monday that fires on
* Wednesday will still schedule the next check for the following Monday).
*
* Otherwise a random hour (0409 UTC) and minute are chosen.
*/
export function computeNextCheckAt(
interval: "daily" | "weekly",
previousNextCheckAt?: string | null,
): string {
const daysAhead = interval === "daily" ? 1 : 7;
if (previousNextCheckAt) {
const anchor = new Date(previousNextCheckAt).getTime();
const intervalMs = daysAhead * 86_400_000;
const steps = Math.floor(Math.max(0, Date.now() - anchor) / intervalMs) + 1;
return new Date(anchor + steps * intervalMs).toISOString();
}
const nextDate = new Date();
nextDate.setUTCDate(nextDate.getUTCDate() + daysAhead);
const hour = 4 + Math.floor(Math.random() * 6);
const minute = Math.floor(Math.random() * 60);
nextDate.setUTCHours(hour, minute, 0, 0);
return nextDate.toISOString();
}
// ---------------------------------------------------------------------------
// Display labels
// ---------------------------------------------------------------------------
export function devicesLabel(devices: RankTrackingConfig["devices"]): string {
if (devices === "both") return "Desktop + Mobile";
return devices === "desktop" ? "Desktop" : "Mobile";
}
export function scheduleLabel(
interval: RankTrackingConfig["scheduleInterval"],
): string {
if (interval === "daily") return "Daily";
if (interval === "weekly") return "Weekly";
return "Manual";
}
export function devicesCount(devices: RankTrackingConfig["devices"]): number {
return devices === "both" ? 2 : 1;
}

View File

@ -0,0 +1,120 @@
import type { InferSelectModel } from "drizzle-orm";
import { z } from "zod";
import { rankTrackingConfigs } from "@/db/app.schema";
// ---------------------------------------------------------------------------
// DB-derived types
// ---------------------------------------------------------------------------
export type RankTrackingConfig = InferSelectModel<typeof rankTrackingConfigs>;
// ---------------------------------------------------------------------------
// API / UI types
// ---------------------------------------------------------------------------
export type RankCheckTriggerResult =
| {
ok: true;
runId: string;
}
| {
ok: false;
reason: "already_running";
blockingRunId: string | null;
};
export interface RankTrackingDeviceResult {
position: number | null;
previousPosition: number | null;
rankingUrl: string | null;
serpFeatures: string[];
}
export interface RankTrackingRow {
trackingKeywordId: string;
keyword: string;
desktop: RankTrackingDeviceResult;
mobile: RankTrackingDeviceResult;
}
// ---------------------------------------------------------------------------
// Validation schemas
// ---------------------------------------------------------------------------
const devicesEnum = z.enum(rankTrackingConfigs.devices.enumValues);
const scheduleEnum = z.enum(rankTrackingConfigs.scheduleInterval.enumValues);
export const getConfigsSchema = z.object({
projectId: z.string().uuid(),
});
export const createConfigSchema = z.object({
projectId: z.string().uuid(),
domain: z
.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",
),
locationCode: z.number().int().positive().optional(),
languageCode: z.string().max(10).optional(),
devices: devicesEnum.optional(),
scheduleInterval: scheduleEnum.optional(),
});
export const updateConfigSchema = z.object({
projectId: z.string().uuid(),
configId: z.string().uuid(),
domain: z
.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",
)
.optional(),
locationCode: z.number().int().positive().optional(),
languageCode: z.string().max(10).optional(),
devices: devicesEnum.optional(),
scheduleInterval: scheduleEnum.optional(),
isActive: z.boolean().optional(),
});
export const triggerCheckSchema = z.object({
projectId: z.string().uuid(),
configId: z.string().uuid(),
keywordIds: z.array(z.string().uuid()).max(2000).optional(),
});
export const comparePeriodSchema = z.enum(["previous", "7d", "30d", "90d"]);
export type ComparePeriod = z.infer<typeof comparePeriodSchema>;
export const getLatestResultsSchema = z.object({
projectId: z.string().uuid(),
configId: z.string().uuid(),
comparePeriod: comparePeriodSchema.optional(),
});
export const getLatestRunSchema = z.object({
projectId: z.string().uuid(),
configId: z.string().uuid(),
});
export const estimateCostSchema = z.object({
projectId: z.string().uuid(),
configId: z.string().uuid(),
});
export const addKeywordsSchema = z.object({
projectId: z.string().uuid(),
configId: z.string().uuid(),
keywords: z.array(z.string().min(1).max(200)).min(1).max(2000),
});
export const removeKeywordsSchema = z.object({
projectId: z.string().uuid(),
configId: z.string().uuid(),
keywordIds: z.array(z.string().uuid()).min(1).max(2000),
});

View File

@ -1,5 +1,5 @@
/* eslint-disable */
// Generated by Wrangler by running `wrangler types` (hash: 0e03c138742d121a834f2931f5295961)
// Generated by Wrangler by running `wrangler types` (hash: a66fa3c1bf7b028f26292b2e592c2ff1)
// Runtime types generated with workerd@1.20260219.0 2025-09-02 nodejs_compat
declare namespace Cloudflare {
interface GlobalProps {
@ -9,16 +9,19 @@ declare namespace Cloudflare {
KV: KVNamespace;
R2: R2Bucket;
DB: D1Database;
VITE_APP_ID: string;
VITE_GATEWAY_URL: string;
GATEWAY_URL: string;
GATEWAY_APP_API_TOKEN: string;
DATAFORSEO_API_KEY: string;
PORT: string;
AUTH_MODE: string;
BETTER_AUTH_SECRET: string;
JWT_PRIVATE_KEY: string;
JWT_PUBLIC_KEY: string;
BETTER_AUTH_URL: string;
AUTUMN_SECRET_KEY: string;
LOOPS_API_KEY: string;
LOOPS_TRANSACTIONAL_VERIFY_EMAIL_ID: string;
LOOPS_TRANSACTIONAL_RESET_PASSWORD_ID: string;
POSTHOG_HOST: string;
POSTHOG_PUBLIC_KEY: string;
SITE_AUDIT_WORKFLOW: Workflow<Parameters<import("./src/server").SiteAuditWorkflow['run']>[0]['payload']>;
RANK_CHECK_WORKFLOW: Workflow<Parameters<import("./src/server").RankCheckWorkflow['run']>[0]['payload']>;
}
}
interface Env extends Cloudflare.Env {}
@ -26,7 +29,7 @@ type StringifyValues<EnvType extends Record<string, unknown>> = {
[Binding in keyof EnvType]: EnvType[Binding] extends string ? EnvType[Binding] : string;
};
declare namespace NodeJS {
interface ProcessEnv extends StringifyValues<Pick<Cloudflare.Env, "VITE_APP_ID" | "VITE_GATEWAY_URL" | "GATEWAY_URL" | "GATEWAY_APP_API_TOKEN" | "DATAFORSEO_API_KEY" | "PORT" | "BETTER_AUTH_SECRET" | "JWT_PRIVATE_KEY" | "JWT_PUBLIC_KEY">> {}
interface ProcessEnv extends StringifyValues<Pick<Cloudflare.Env, "DATAFORSEO_API_KEY" | "PORT" | "AUTH_MODE" | "BETTER_AUTH_SECRET" | "BETTER_AUTH_URL" | "AUTUMN_SECRET_KEY" | "LOOPS_API_KEY" | "LOOPS_TRANSACTIONAL_VERIFY_EMAIL_ID" | "LOOPS_TRANSACTIONAL_RESET_PASSWORD_ID" | "POSTHOG_HOST" | "POSTHOG_PUBLIC_KEY">> {}
}
// Begin runtime types

View File

@ -23,7 +23,15 @@
"binding": "SITE_AUDIT_WORKFLOW",
"class_name": "SiteAuditWorkflow",
},
{
"name": "rank-check-workflow",
"binding": "RANK_CHECK_WORKFLOW",
"class_name": "RankCheckWorkflow",
},
],
"triggers": {
"crons": ["*/15 * * * *"],
},
"kv_namespaces": [
{
"binding": "KV",