Rank tracking: trends & data exploration (#247)

This commit is contained in:
Ben Senescu 2026-06-09 21:06:50 -04:00 committed by GitHub
parent 076a7fb6d7
commit 383531c5cd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 2548 additions and 481 deletions

View File

@ -1,248 +1,353 @@
/** /**
* Seed the local D1 database with rank tracking data from DataForSEO. * Seed the local D1 database with synthetic rank-tracking history so the new
* trends / data-exploration UI has something to show. Fully offline no
* DataForSEO key or network needed.
*
* What it creates:
* - A both-devices config with ~20 keywords (volume / KD / CPC populated).
* - ~16 weekly backdated check runs, each with desktop + mobile snapshots.
* - Positions follow per-keyword trends (climbers, fallers, volatile, new,
* lost) so the line charts, scorecards, and "Not in top N" band all have
* realistic data including keywords that drop out of the tracked depth.
* *
* Usage: * Usage:
* pnpm seed:rank-tracking --domain=example.com [--projectId=xxx] * pnpm db:migrate:local # once creates the local D1
* pnpm seed:rank-tracking # seed demo data
* pnpm seed:rank-tracking --domain=acme.com --runs=20 --keywords=30
* pnpm seed:rank-tracking --projectId=<existing-project-uuid>
* *
* Requires: * Then view it:
* - DATAFORSEO_API_KEY in .env.local or .env * env AUTH_MODE=local_noauth pnpm dev # then open Rank Tracking
* - Local D1 database (run `pnpm db:migrate:local` first) *
* - At least one project in the database (start the dev server once) * With no --projectId, it bootstraps the local_noauth user/org/Default project
* (the same identity `AUTH_MODE=local_noauth` uses) so the data is immediately
* viewable. Re-running resets the demo config for the domain.
*/ */
import process from "node:process"; import process from "node:process";
import { getPlatformProxy } from "wrangler"; import { getPlatformProxy } from "wrangler";
import { drizzle } from "drizzle-orm/d1"; import { drizzle } from "drizzle-orm/d1";
import { eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import {
DataforseoLabsApi,
DataforseoLabsGoogleRankedKeywordsLiveRequestInfo,
} from "dataforseo-client";
import * as schema from "../src/db/schema"; import * as schema from "../src/db/schema";
import type { DomainRankedKeywordItem } from "../src/server/lib/dataforseo"; import { parseArgs } from "./cli-utils";
import { loadLocalEnv, parseArgs } from "./cli-utils";
loadLocalEnv(); const LOCAL_ADMIN_USER_ID = "local-admin";
const LOCAL_ADMIN_EMAIL = "admin@localhost";
const LOCAL_ORG_ID = `delegated-${LOCAL_ADMIN_USER_ID}`;
const LOCATION_CODE = 2840; // United States
const SERP_DEPTH = 20; // positions beyond this are stored null ("not in top 20")
const args = parseArgs(process.argv.slice(2)); type SeedDb = ReturnType<typeof drizzle<typeof schema>>;
type BatchStatement = Parameters<SeedDb["batch"]>[0][number];
await main();
async function main() { async function main() {
const domain = normalizeDomain(args.domain); const args = parseArgs(process.argv.slice(2));
if (!domain) { const domain = normalizeDomain(args.domain) ?? "acme-demo.com";
exitWithUsage("Missing --domain argument."); const runs = clampInt(args.runs, 16, 2, 52);
} const keywordCount = clampInt(args.keywords, 20, 1, KEYWORDS.length);
const apiKey = process.env.DATAFORSEO_API_KEY; console.log("Setting up local D1 connection...");
if (!apiKey) {
exitWithUsage("Missing DATAFORSEO_API_KEY. Set it in .env.local or .env.");
}
console.log(`Setting up local D1 connection...`);
const { env, dispose } = await getPlatformProxy<{ DB: D1Database }>(); const { env, dispose } = await getPlatformProxy<{ DB: D1Database }>();
const db = drizzle(env.DB, { schema }); const db = drizzle(env.DB, { schema });
try { try {
// Resolve project const projectId = await resolveProject(db, args.projectId);
const projectId = args.projectId ?? (await findFirstProject(db)); console.log(`Using project ${projectId}`);
if (!projectId) {
exitWithUsage( // Reset any previous demo config for this domain (cascades runs/snapshots/
"No projects found in local DB. Start the dev server and create a project first.", // keywords) so re-running is clean.
); const removed = await db
.delete(schema.rankTrackingConfigs)
.where(
and(
eq(schema.rankTrackingConfigs.projectId, projectId),
eq(schema.rankTrackingConfigs.domain, domain),
),
)
.returning({ id: schema.rankTrackingConfigs.id });
if (removed.length > 0) {
console.log(`Reset existing config for ${domain}.`);
} }
const existingProject = await db.query.projects.findFirst({ const keywords = KEYWORDS.slice(0, keywordCount);
where: eq(schema.projects.id, projectId), const runDates = buildRunDates(runs);
});
if (!existingProject) {
exitWithUsage(`Project ${projectId} not found in local DB.`);
}
console.log(`Using project: ${existingProject.name} (${projectId})`);
// Check for existing config
const existingConfig = await db.query.rankTrackingConfigs.findFirst({
where: eq(schema.rankTrackingConfigs.domain, domain),
});
if (existingConfig) {
console.log(
`Rank tracking config already exists for ${domain} — skipping. Delete it manually to re-seed.`,
);
return;
}
// Fetch top 50 keywords from DataForSEO
console.log(`Fetching top 50 keywords for ${domain} from DataForSEO...`);
const rankedItems = await fetchRankedKeywords(apiKey, domain, 50);
console.log(`Got ${rankedItems.length} ranked keywords.`);
if (rankedItems.length === 0) {
console.log("No keywords found for this domain. Nothing to seed.");
return;
}
// Map to keyword/position/url
const keywords = rankedItems
.map(mapKeywordItem)
.filter((k): k is NonNullable<typeof k> => k !== null);
console.log(`Mapped ${keywords.length} valid keywords.`);
// Generate IDs
const configId = crypto.randomUUID(); const configId = crypto.randomUUID();
const runId = crypto.randomUUID(); const newest = runDates[runDates.length - 1];
const now = new Date().toISOString();
// Insert config
await db.insert(schema.rankTrackingConfigs).values({ await db.insert(schema.rankTrackingConfigs).values({
id: configId, id: configId,
projectId, projectId,
domain, domain,
locationCode: 2840, locationCode: LOCATION_CODE,
languageCode: "en", languageCode: "en",
devices: "mobile", devices: "both",
serpDepth: 20, serpDepth: SERP_DEPTH,
scheduleInterval: "weekly", scheduleInterval: "weekly",
isActive: true, isActive: true,
lastCheckedAt: now, lastCheckedAt: dbTimestamp(newest),
createdAt: dbTimestamp(runDates[0]),
}); });
console.log(`Created config for ${domain} (mobile, weekly).`);
// Insert keywords (batch individual statements to respect D1 bind limits)
const keywordRows = keywords.map((k) => ({ const keywordRows = keywords.map((k) => ({
id: crypto.randomUUID(), id: crypto.randomUUID(),
configId, configId,
keyword: k.keyword, keyword: k.keyword,
searchVolume: k.volume,
keywordDifficulty: k.kd,
cpc: k.cpc,
metricsFetchedAt: dbTimestamp(newest),
})); }));
const keywordStmts = keywordRows.map((row) => await batched(db, keywordRows, (row) =>
db.insert(schema.rankTrackingKeywords).values(row).onConflictDoNothing(), db.insert(schema.rankTrackingKeywords).values(row),
); );
if (keywordStmts.length > 0) {
const [first, ...rest] = keywordStmts;
await db.batch([first, ...rest]);
}
console.log(`Inserted ${keywordRows.length} keywords.`); console.log(`Inserted ${keywordRows.length} keywords.`);
// Insert completed run // One completed run per date; each run snapshots every keyword on both
await db.insert(schema.rankCheckRuns).values({ // devices.
id: runId, const runRows = runDates.map((date) => ({
id: crypto.randomUUID(),
date,
}));
await batched(db, runRows, (run) =>
db.insert(schema.rankCheckRuns).values({
id: run.id,
configId, configId,
projectId, projectId,
status: "completed", status: "completed" as const,
keywordsTotal: keywordRows.length, keywordsTotal: keywordRows.length,
keywordsChecked: keywordRows.length, keywordsChecked: keywordRows.length,
completedAt: now, startedAt: dbTimestamp(run.date),
completedAt: dbTimestamp(run.date),
}),
);
const snapshotValues: (typeof schema.rankSnapshots.$inferInsert)[] = [];
runRows.forEach((run, runIndex) => {
keywordRows.forEach((kw, kwIndex) => {
const profile = KEYWORDS[kwIndex].profile;
const rng = makeRng(kwIndex * 1000 + runIndex);
const desktopRank = rankFor(profile, runIndex, runs, rng);
const mobileRank =
desktopRank === null ? null : desktopRank + 1 + (rng() - 0.5) * 1.2;
const checkedAt = dbTimestamp(run.date);
const path = `/${slugify(kw.keyword)}`;
snapshotValues.push(
snapshot(run.id, kw, "desktop", desktopRank, domain, path, checkedAt),
snapshot(run.id, kw, "mobile", mobileRank, domain, path, checkedAt),
);
}); });
console.log(`Created completed run.`); });
await batched(db, snapshotValues, (row) =>
// Insert snapshots (mobile device) db.insert(schema.rankSnapshots).values(row),
const snapshotStmts = keywordRows.map((kw, i) =>
db
.insert(schema.rankSnapshots)
.values({
runId,
trackingKeywordId: kw.id,
keyword: kw.keyword,
device: "mobile" as const,
position: keywords[i].position,
url: keywords[i].url,
serpFeatures: null,
})
.onConflictDoNothing(),
); );
if (snapshotStmts.length > 0) {
const [first, ...rest] = snapshotStmts;
await db.batch([first, ...rest]);
}
console.log(`Inserted ${keywordRows.length} snapshots.`);
console.log( console.log(
`\nDone! Seeded rank tracking for ${domain} with ${keywords.length} keywords.`, `Inserted ${runRows.length} runs and ${snapshotValues.length} snapshots.`,
); );
const start = runDates[0].toISOString().slice(0, 10);
const end = newest.toISOString().slice(0, 10);
console.log(
`\nDone. Seeded "${domain}" — ${keywordRows.length} keywords, ${runs} weekly checks (${start}${end}), desktop + mobile.`,
);
if (!args.projectId) {
console.log(
"\nView it:\n env AUTH_MODE=local_noauth pnpm dev\n → open Rank Tracking (the demo lives in the Default project).",
);
}
} finally { } finally {
await dispose(); await dispose();
} }
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// DataForSEO // Project / local_noauth bootstrap
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
async function fetchRankedKeywords( async function resolveProject(
apiKey: string, db: SeedDb,
domain: string, projectIdArg: string | undefined,
limit: number, ): Promise<string> {
): Promise<DomainRankedKeywordItem[]> { if (projectIdArg) {
const api = new DataforseoLabsApi("https://api.dataforseo.com", { const existing = await db.query.projects.findFirst({
fetch: (url: RequestInfo, init?: RequestInit) => { where: eq(schema.projects.id, projectIdArg),
const headers = new Headers(init?.headers);
headers.set("Authorization", `Basic ${apiKey}`);
return fetch(url, { ...init, headers });
},
}); });
if (!existing) {
const req = new DataforseoLabsGoogleRankedKeywordsLiveRequestInfo({ exit(`Project ${projectIdArg} not found in local DB.`);
target: domain, }
location_code: 2840, return projectIdArg;
language_code: "en",
limit,
order_by: ["keyword_data.keyword_info.search_volume,desc"],
});
const response = await api.googleRankedKeywordsLive([req]);
if (!response || response.status_code !== 20000) {
throw new Error(
`DataForSEO error: ${response?.status_message ?? "unknown"}`,
);
} }
const task = response.tasks?.[0]; // Bootstrap the same user/org/Default project that AUTH_MODE=local_noauth
if (!task || task.status_code !== 20000) { // resolves, so the seeded data is viewable without signing up.
throw new Error( await db
`DataForSEO task error: ${task?.status_message ?? "no task returned"}`, .insert(schema.user)
); .values({
} id: LOCAL_ADMIN_USER_ID,
name: "admin",
email: LOCAL_ADMIN_EMAIL,
emailVerified: true,
})
.onConflictDoNothing({ target: schema.user.id });
return task.result?.[0]?.items ?? []; await db
.insert(schema.organization)
.values({
id: LOCAL_ORG_ID,
name: "admin workspace",
slug: `delegated-admin-${toHex(LOCAL_ADMIN_USER_ID)}`,
createdAt: new Date(),
})
.onConflictDoNothing({ target: schema.organization.id });
const existingDefault = await db.query.projects.findFirst({
where: and(
eq(schema.projects.organizationId, LOCAL_ORG_ID),
eq(schema.projects.name, "Default"),
),
});
if (existingDefault) return existingDefault.id;
const projectId = crypto.randomUUID();
await db.insert(schema.projects).values({
id: projectId,
organizationId: LOCAL_ORG_ID,
name: "Default",
domain: null,
});
console.log("Bootstrapped local_noauth Default project.");
return projectId;
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Mapping (mirrors DomainService.mapKeywordItem) // Synthetic positions
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function mapKeywordItem(item: DomainRankedKeywordItem) { type Profile =
const keywordData = item.keyword_data; | "climber"
const rankedSerpElement = item.ranked_serp_element; | "faller"
const serpItem = rankedSerpElement?.serp_item; | "leader"
| "volatile"
| "steady_mid"
| "newcomer"
| "lost";
const keyword = keywordData?.keyword ?? item.keyword; /** Continuous "true" desktop rank for a keyword at run `i` (0 = oldest). null =
if (!keyword) return null; * not present (either not in the tracked depth yet, or dropped out). */
function rankFor(
profile: Profile,
i: number,
runs: number,
rng: () => number,
): number | null {
const t = runs <= 1 ? 1 : i / (runs - 1); // 0..1 over the window
const noise = rng() - 0.5;
switch (profile) {
case "climber":
return 18 - 16 * t + noise * 1.5; // 18 → 2
case "faller":
return 3 + 22 * t + noise * 1.5; // 3 → 25 (drops out late)
case "leader":
return 2 + noise * 0.8; // hovers 13
case "volatile":
return 9 + Math.sin(i * 1.25) * 5 + noise * 3;
case "steady_mid":
return 12 + noise * 1.2; // ~1113
case "newcomer":
return t < 0.4 ? null : 16 - 26 * (t - 0.4) + noise * 1.5; // appears, climbs
case "lost":
return t > 0.75 ? null : 7 + noise * 1.5; // ranks, then disappears
}
}
const position = /** Round a continuous rank and drop it to null when it falls past the depth. */
serpItem?.rank_absolute ?? rankedSerpElement?.rank_absolute ?? null; function toStored(rank: number | null): number | null {
const url = serpItem?.url ?? rankedSerpElement?.url ?? null; if (rank === null) return null;
const r = Math.max(1, Math.round(rank));
return r > SERP_DEPTH ? null : r;
}
function snapshot(
runId: string,
kw: { id: string; keyword: string },
device: "desktop" | "mobile",
rank: number | null,
domain: string,
path: string,
checkedAt: string,
): typeof schema.rankSnapshots.$inferInsert {
const position = toStored(rank);
return { return {
keyword: keyword.toLowerCase().trim(), runId,
position: position != null ? Math.round(position) : null, trackingKeywordId: kw.id,
url, keyword: kw.keyword,
device,
position,
url: position === null ? null : `https://${domain}${path}`,
serpFeatures: null,
checkedAt,
}; };
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// DB helpers // Helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
type SeedDb = ReturnType<typeof drizzle<typeof schema>>; /** Weekly dates, oldest first, all at noon UTC (so local-time rendering can't
* shift a point across a day boundary). */
async function findFirstProject(db: SeedDb): Promise<string | null> { function buildRunDates(runs: number): Date[] {
const row = await db.query.projects.findFirst(); const dates: Date[] = [];
return row?.id ?? null; const base = new Date();
base.setUTCHours(12, 0, 0, 0);
for (let weeksAgo = runs - 1; weeksAgo >= 0; weeksAgo -= 1) {
const d = new Date(base);
d.setUTCDate(d.getUTCDate() - weeksAgo * 7);
dates.push(d);
}
return dates;
} }
// --------------------------------------------------------------------------- /** SQLite current_timestamp format (UTC): "YYYY-MM-DD HH:MM:SS". */
// CLI helpers function dbTimestamp(d: Date): string {
// --------------------------------------------------------------------------- return d.toISOString().slice(0, 19).replace("T", " ");
}
/** Small seeded PRNG (mulberry32) so re-runs produce the same data. */
function makeRng(seed: number): () => number {
let s = seed >>> 0;
return () => {
s = (s + 0x6d2b79f5) >>> 0;
let t = Math.imul(s ^ (s >>> 15), 1 | s);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
async function batched<T>(
db: SeedDb,
items: T[],
buildStatement: (item: T) => BatchStatement,
): Promise<void> {
const SIZE = 80; // statements per D1 batch transaction
for (let i = 0; i < items.length; i += SIZE) {
const chunk = items.slice(i, i + SIZE).map(buildStatement);
const [first, ...rest] = chunk;
if (!first) continue;
await db.batch([first, ...rest]);
}
}
function slugify(value: string): string {
return value
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "");
}
function toHex(value: string): string {
return Array.from(new TextEncoder().encode(value), (b) =>
b.toString(16).padStart(2, "0"),
).join("");
}
function normalizeDomain(raw: string | undefined): string | undefined { function normalizeDomain(raw: string | undefined): string | undefined {
if (!raw) return undefined; if (!raw) return undefined;
@ -254,10 +359,167 @@ function normalizeDomain(raw: string | undefined): string | undefined {
.replace(/^www\./u, ""); .replace(/^www\./u, "");
} }
function exitWithUsage(message: string): never { function clampInt(
raw: string | undefined,
fallback: number,
min: number,
max: number,
): number {
const n = raw ? Number.parseInt(raw, 10) : NaN;
if (!Number.isFinite(n)) return fallback;
return Math.min(max, Math.max(min, n));
}
function exit(message: string): never {
console.error(message); console.error(message);
console.error(
"Usage: pnpm seed:rank-tracking --domain=example.com [--projectId=xxx]",
);
process.exit(1); process.exit(1);
} }
// ---------------------------------------------------------------------------
// Demo keyword set (keyword + metrics + trend profile)
// ---------------------------------------------------------------------------
const KEYWORDS: {
keyword: string;
volume: number;
kd: number;
cpc: number;
profile: Profile;
}[] = [
{
keyword: "seo audit tool",
volume: 18100,
kd: 64,
cpc: 9.4,
profile: "climber",
},
{
keyword: "best rank tracker",
volume: 8100,
kd: 58,
cpc: 7.2,
profile: "leader",
},
{
keyword: "keyword research software",
volume: 12100,
kd: 71,
cpc: 11.8,
profile: "faller",
},
{
keyword: "free backlink checker",
volume: 27100,
kd: 49,
cpc: 4.1,
profile: "volatile",
},
{
keyword: "local seo services",
volume: 6600,
kd: 53,
cpc: 14.2,
profile: "newcomer",
},
{
keyword: "google rank checker",
volume: 9900,
kd: 45,
cpc: 5.6,
profile: "steady_mid",
},
{ keyword: "serp api", volume: 3600, kd: 41, cpc: 6.9, profile: "climber" },
{
keyword: "ai content optimization",
volume: 2400,
kd: 38,
cpc: 8.3,
profile: "newcomer",
},
{
keyword: "technical seo checklist",
volume: 4400,
kd: 36,
cpc: 3.2,
profile: "leader",
},
{
keyword: "competitor keyword analysis",
volume: 2900,
kd: 55,
cpc: 10.1,
profile: "lost",
},
{
keyword: "domain authority checker",
volume: 33100,
kd: 62,
cpc: 4.8,
profile: "volatile",
},
{
keyword: "on page seo tool",
volume: 5400,
kd: 47,
cpc: 7.7,
profile: "climber",
},
{
keyword: "seo for startups",
volume: 1900,
kd: 29,
cpc: 6.4,
profile: "steady_mid",
},
{
keyword: "rank tracking api",
volume: 1300,
kd: 34,
cpc: 8.9,
profile: "newcomer",
},
{
keyword: "content gap analysis",
volume: 2100,
kd: 44,
cpc: 9.1,
profile: "faller",
},
{
keyword: "mobile seo audit",
volume: 1600,
kd: 31,
cpc: 5.0,
profile: "leader",
},
{
keyword: "schema markup generator",
volume: 8800,
kd: 39,
cpc: 3.6,
profile: "volatile",
},
{
keyword: "search intent tool",
volume: 1100,
kd: 27,
cpc: 7.0,
profile: "climber",
},
{
keyword: "seo reporting dashboard",
volume: 2700,
kd: 50,
cpc: 12.5,
profile: "lost",
},
{
keyword: "indie hacker seo",
volume: 720,
kd: 22,
cpc: 4.3,
profile: "newcomer",
},
];
await main();

View File

@ -10,21 +10,24 @@ export function SegmentedToggle<T extends string>({
items, items,
value, value,
onChange, onChange,
showLabels = false,
}: { }: {
items: SegmentedToggleItem<T>[]; items: SegmentedToggleItem<T>[];
value: T; value: T;
onChange: (value: T) => void; onChange: (value: T) => void;
showLabels?: boolean;
}) { }) {
return ( return (
<div className="inline-flex rounded-lg bg-base-300 p-0.5"> <div className="inline-flex rounded-lg bg-base-300 p-0.5">
{items.map((item) => ( {items.map((item) => (
<button <button
key={item.value} key={item.value}
className={`btn btn-xs px-2 ${value === item.value ? "bg-primary/20 text-primary shadow-sm" : "btn-ghost text-base-content/40"}`} className={`btn btn-xs gap-1.5 px-2 ${value === item.value ? "bg-primary/20 text-primary shadow-sm" : "btn-ghost text-base-content/40"}`}
onClick={() => onChange(item.value)} onClick={() => onChange(item.value)}
title={item.label} title={item.label}
> >
{item.icon} {item.icon}
{showLabels && item.label}
</button> </button>
))} ))}
</div> </div>

View File

@ -1,109 +0,0 @@
import { useState } from "react";
import {
Copy,
Download,
MoreHorizontal,
Play,
RefreshCw,
Sheet,
} from "lucide-react";
export function ActionsMenu({
onCheckNow,
onExport,
onExportToSheets,
onCopyKeywords,
onRefreshMetrics,
isRunning,
metricsRefreshing,
hasData,
checkDisabled,
}: {
onCheckNow: () => void;
onExport: () => void;
onExportToSheets: () => void;
onCopyKeywords: () => void;
onRefreshMetrics: () => void;
isRunning: boolean;
metricsRefreshing: boolean;
hasData: boolean;
checkDisabled?: 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]">
{!checkDisabled && (
<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={() => {
onRefreshMetrics();
setOpen(false);
}}
disabled={metricsRefreshing || !hasData}
>
<RefreshCw
className={`size-3.5 ${metricsRefreshing ? "animate-spin" : ""}`}
/>
{metricsRefreshing ? "Refreshing..." : "Refresh Metrics"}
</button>
<button
className="flex w-full items-center gap-2 px-3 py-2 text-sm hover:bg-base-200"
onClick={() => {
onExportToSheets();
setOpen(false);
}}
disabled={!hasData}
>
<Sheet className="size-3.5" />
Export to Sheets
</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,398 @@
import { useMemo, useState } from "react";
import { Copy, Download, Loader2 } from "lucide-react";
import { toast } from "sonner";
import { useQuery } from "@tanstack/react-query";
import { Modal } from "@/client/components/Modal";
import { buildCsv, downloadCsv } from "@/client/lib/csv";
import { captureClientEvent } from "@/client/lib/posthog";
import { getRankKeywordHistory } from "@/serverFunctions/rank-tracking";
import type { RankKeywordHistoryPoint } from "@/serverFunctions/rank-tracking";
import { LOCATIONS } from "@/client/features/keywords/locations";
import { csvChange, DeviceRankCell } from "./RankTrackingTableParts";
import {
RankTrendChart,
TrendRangeToggle,
type TrendSeries,
} from "./RankTrackingTrendChart";
const DEVICE_STYLE: Record<
"desktop" | "mobile",
{ label: string; color: string }
> = {
desktop: { label: "Desktop", color: "#2563eb" },
mobile: { label: "Mobile", color: "#14b8a6" },
};
export interface KeywordTrendTarget {
trackingKeywordId: string;
keyword: string;
}
export function KeywordTrendModal({
target,
projectId,
configId,
domain,
locationCode,
serpDepth,
onClose,
}: {
target: KeywordTrendTarget;
projectId: string;
configId: string;
domain: string;
locationCode: number;
serpDepth: number;
onClose: () => void;
}) {
const [sinceDays, setSinceDays] = useState(730);
const { data: history, isLoading } = useQuery({
queryKey: [
"rankKeywordHistory",
projectId,
configId,
target.trackingKeywordId,
sinceDays,
],
queryFn: () =>
getRankKeywordHistory({
data: {
projectId,
configId,
trackingKeywordId: target.trackingKeywordId,
sinceDays,
},
}),
});
const points = useMemo(() => history ?? [], [history]);
const devices = useMemo(() => deriveDevices(points), [points]);
// A single run yields one point per device, so for a both-devices config
// `points.length` is 2 after one check. The trend only fills in once any one
// device has 2+ checks, so gate the empty state on the per-device count.
const maxPerDevice = useMemo(
() =>
devices.length === 0
? 0
: Math.max(
...devices.map((d) => points.filter((p) => p.device === d).length),
),
[points, devices],
);
const series: TrendSeries[] = devices.map((device) => ({
dataKey: device,
name: DEVICE_STYLE[device].label,
color: DEVICE_STYLE[device].color,
strokeDasharray: "4 3",
}));
const chartData = useMemo(
() => buildChartData(points, serpDepth),
[points, serpDepth],
);
// Keys ("<ts>:<device>") whose plotted point sits in the bottom band because
// the real position was null — so the tooltip can say "Not in top N"
// unambiguously even when a genuine position equals serpDepth.
const bottomBandKeys = useMemo(() => {
const keys = new Set<string>();
for (const p of points) {
if (p.position === null) {
keys.add(`${new Date(p.checkedAt).getTime()}:${p.device}`);
}
}
return keys;
}, [points]);
const historyRows = useMemo(() => buildHistoryRows(points), [points]);
const exportRows = () =>
historyRows.map((r) => [
new Date(r.checkedAt).toISOString(),
DEVICE_STYLE[r.device].label,
r.position ?? "",
csvChange(r.position, r.previousPosition),
]);
const handleCopy = () => {
const headers = ["Date", "Device", "Position", "Change vs previous"];
void navigator.clipboard.writeText(buildCsv(headers, exportRows()));
toast.success("Copied to clipboard");
captureClientEvent("rank_tracking:keyword_trend_copy");
};
const handleExport = () => {
const headers = ["Date", "Device", "Position", "Change vs previous"];
downloadCsv(
`rank-history-${slugify(target.keyword)}.csv`,
buildCsv(headers, exportRows()),
);
captureClientEvent("rank_tracking:keyword_trend_export");
};
return (
<Modal
onClose={onClose}
labelledBy="keyword-trend-title"
maxWidth="max-w-3xl"
>
<div className="flex items-start justify-between gap-3">
<div>
<h3 id="keyword-trend-title" className="text-lg font-semibold">
{target.keyword}
</h3>
<p className="text-xs text-base-content/60">
{domain} &middot; {LOCATIONS[locationCode] ?? "US"} &middot;
Position over time
</p>
</div>
<TrendRangeToggle value={sinceDays} onChange={setSinceDays} />
</div>
{isLoading ? (
<div className="flex items-center justify-center py-16">
<Loader2 className="size-5 animate-spin text-base-content/50" />
</div>
) : maxPerDevice <= 1 ? (
<EmptyState count={maxPerDevice} />
) : (
<>
<RankTrendChart
data={chartData}
series={series}
serpDepth={serpDepth}
showBottomBand
renderTooltip={(label, entries) => (
<ChartTooltip
label={label}
entries={entries}
serpDepth={serpDepth}
bottomBandKeys={bottomBandKeys}
/>
)}
/>
<div className="flex items-center justify-end gap-2">
<button className="btn btn-ghost btn-xs gap-1" onClick={handleCopy}>
<Copy className="size-3.5" />
Copy
</button>
<button
className="btn btn-ghost btn-xs gap-1"
onClick={handleExport}
>
<Download className="size-3.5" />
Export CSV
</button>
</div>
<div className="max-h-64 overflow-auto rounded-lg border border-base-300">
<table className="table table-sm">
<thead className="sticky top-0 bg-base-100">
<tr>
<th>Date</th>
{devices.length > 1 && <th>Device</th>}
<th>Position</th>
<th>Δ vs previous check</th>
</tr>
</thead>
<tbody>
{historyRows.map((r, idx) => {
// No prior ranking to compare against (first check, or the
// previous check was unranked): show the lone position as a
// centered neutral pill so it doesn't look like a stray number
// next to the "before → after" rows.
const noPrevious =
r.position !== null && r.previousPosition === null;
return (
<tr key={`${r.device}-${r.checkedAt}-${idx}`}>
<td className="whitespace-nowrap text-xs">
{new Date(r.checkedAt).toLocaleDateString()}
</td>
{devices.length > 1 && (
<td className="text-xs">
{DEVICE_STYLE[r.device].label}
</td>
)}
<td>
{r.position === null ? (
<span className="text-base-content/40 text-xs">
Not in top {serpDepth}
</span>
) : (
<span className="font-mono text-sm">
{r.position}
</span>
)}
</td>
<td>
{noPrevious ? (
// Invisible placeholders matching the "before → after"
// layout so the lone pill lines up under the position
// badge column instead of floating.
<span className="inline-flex items-center gap-1.5">
<span className="w-6" aria-hidden />
<span aria-hidden className="opacity-0">
</span>
<span className="font-mono rounded bg-base-200 px-1.5 py-0.5 text-xs font-semibold text-base-content/70">
{r.position}
</span>
</span>
) : (
<DeviceRankCell
result={{
position: r.position,
previousPosition: r.previousPosition,
rankingUrl: null,
serpFeatures: [],
}}
/>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</>
)}
<div className="flex justify-end">
<button className="btn btn-ghost btn-sm" onClick={onClose}>
Close
</button>
</div>
</Modal>
);
}
function EmptyState({ count }: { count: number }) {
return (
<div className="rounded-lg border border-dashed border-base-300 p-10 text-center text-sm text-base-content/60">
{count === 0
? "No history yet — run a check to start tracking position over time."
: "Only 1 check so far — the trend chart fills in after the next check."}
</div>
);
}
function ChartTooltip({
label,
entries,
serpDepth,
bottomBandKeys,
}: {
label: number;
entries: Array<{ dataKey?: string | number; value: number | null }>;
serpDepth: number;
bottomBandKeys: Set<string>;
}) {
return (
<div className="rounded-md border border-base-300 bg-base-100 px-3 py-2 shadow-sm space-y-0.5">
<p className="text-xs text-base-content/60">
{new Date(label).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
})}
</p>
{entries.map((e) => {
const device =
e.dataKey === "desktop" || e.dataKey === "mobile"
? DEVICE_STYLE[e.dataKey].label
: String(e.dataKey ?? "");
const inBottomBand = bottomBandKeys.has(`${label}:${e.dataKey}`);
return (
<p key={String(e.dataKey)} className="text-sm font-medium">
{device}:{" "}
{inBottomBand ? (
<span className="text-base-content/60">
Not in top {serpDepth}
</span>
) : (
e.value
)}
</p>
);
})}
</div>
);
}
// ---------------------------------------------------------------------------
// Data shaping
// ---------------------------------------------------------------------------
function deriveDevices(
points: RankKeywordHistoryPoint[],
): Array<"desktop" | "mobile"> {
const present = new Set(points.map((p) => p.device));
return (["desktop", "mobile"] as const).filter((d) => present.has(d));
}
interface ChartRow extends Record<string, unknown> {
checkedAt: number;
desktop?: number;
mobile?: number;
}
/**
* Pivot flat rows into chart rows keyed by checkedAt (ms). A null position is
* plotted at `serpDepth` so it renders inside the muted bottom band and the
* line connects down to it (a drop), rather than leaving a silent gap.
*/
function buildChartData(
points: RankKeywordHistoryPoint[],
serpDepth: number,
): ChartRow[] {
const byTime = new Map<number, ChartRow>();
for (const p of points) {
const ts = new Date(p.checkedAt).getTime();
const row = byTime.get(ts) ?? { checkedAt: ts };
row[p.device] = p.position === null ? serpDepth : p.position;
byTime.set(ts, row);
}
return [...byTime.values()].toSorted((a, b) => a.checkedAt - b.checkedAt);
}
interface HistoryRow {
device: "desktop" | "mobile";
checkedAt: string;
position: number | null;
previousPosition: number | null;
}
/**
* One row per snapshot (newest first) with the previous-check position for the
* same device, so the Δ column can reuse DeviceRankCell's 4-case logic.
*/
function buildHistoryRows(points: RankKeywordHistoryPoint[]): HistoryRow[] {
const prevByDevice = new Map<"desktop" | "mobile", number | null>();
const rows: HistoryRow[] = [];
// points are oldest-first; walk forward to capture the prior position.
for (const p of points) {
const hadPrevious = prevByDevice.has(p.device);
rows.push({
device: p.device,
checkedAt: p.checkedAt,
position: p.position,
previousPosition: hadPrevious
? (prevByDevice.get(p.device) ?? null)
: null,
});
prevByDevice.set(p.device, p.position);
}
return rows.toReversed();
}
function slugify(value: string): string {
return value
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "");
}

View File

@ -100,17 +100,28 @@ const cpcColumn: ColumnDef<RankTrackingRow> = {
sortingFn: nullsLastNumeric, sortingFn: nullsLastNumeric,
}; };
const keywordColumn: ColumnDef<RankTrackingRow> = { function makeKeywordColumn(
onKeywordClick: (row: RankTrackingRow) => void,
): ColumnDef<RankTrackingRow> {
return {
id: "keyword", id: "keyword",
accessorKey: "keyword", accessorKey: "keyword",
header: ({ column }) => ( header: ({ column }) => (
<SortableHeader column={column} label="Keyword" id="keyword" /> <SortableHeader column={column} label="Keyword" id="keyword" />
), ),
cell: ({ getValue }) => ( cell: ({ row }) => (
<span className="font-medium">{getValue<string>()}</span> <button
type="button"
className="font-medium text-left link link-hover decoration-dotted underline-offset-2"
onClick={() => onKeywordClick(row.original)}
title="View position history"
>
{row.original.keyword}
</button>
), ),
sortingFn: "alphanumeric", sortingFn: "alphanumeric",
}; };
}
function makeDeviceColumn( function makeDeviceColumn(
device: "desktop" | "mobile", device: "desktop" | "mobile",
@ -178,11 +189,12 @@ export function useRankTrackingColumns(
showMobile: boolean, showMobile: boolean,
domain: string, domain: string,
selectAnchorRef: MutableRefObject<SelectionAnchor | null>, selectAnchorRef: MutableRefObject<SelectionAnchor | null>,
onKeywordClick: (row: RankTrackingRow) => void,
): ColumnDef<RankTrackingRow>[] { ): ColumnDef<RankTrackingRow>[] {
return useMemo(() => { return useMemo(() => {
const cols: ColumnDef<RankTrackingRow>[] = [ const cols: ColumnDef<RankTrackingRow>[] = [
makeSelectionColumn<RankTrackingRow>(selectAnchorRef), makeSelectionColumn<RankTrackingRow>(selectAnchorRef),
keywordColumn, makeKeywordColumn(onKeywordClick),
]; ];
if (showDesktop) { if (showDesktop) {
cols.push(makeDeviceColumn("desktop")); cols.push(makeDeviceColumn("desktop"));
@ -200,5 +212,5 @@ export function useRankTrackingColumns(
cols.push(makeSerpColumn("mobile")); cols.push(makeSerpColumn("mobile"));
} }
return cols; return cols;
}, [showDesktop, showMobile, domain, selectAnchorRef]); }, [showDesktop, showMobile, domain, selectAnchorRef, onKeywordClick]);
} }

View File

@ -0,0 +1,110 @@
import { Monitor, Plus, Settings, Smartphone } from "lucide-react";
import { SegmentedToggle } from "@/client/components/SegmentedToggle";
import { LOCATIONS } from "@/client/features/keywords/locations";
import { devicesLabel, scheduleLabel } from "@/shared/rank-tracking";
import type {
ComparePeriod,
RankTrackingConfig,
} from "@/types/schemas/rank-tracking";
const COMPARE_PERIODS: ReadonlySet<string> = new Set([
"1d",
"7d",
"30d",
"90d",
]);
function isComparePeriod(v: string): v is ComparePeriod {
return COMPARE_PERIODS.has(v);
}
export function RankTrackingDetailHeader({
config,
run,
costEstimate,
hasBothDevices,
activeDevice,
onActiveDeviceChange,
comparePeriod,
onComparePeriodChange,
onEdit,
onToggleAddKeywords,
}: {
config: RankTrackingConfig;
run: { lastCheckedAt: string } | null | undefined;
costEstimate: { keywordCount: number; costUsd: number } | undefined;
hasBothDevices: boolean;
activeDevice: "desktop" | "mobile";
onActiveDeviceChange: (v: "desktop" | "mobile") => void;
comparePeriod: ComparePeriod;
onComparePeriodChange: (v: ComparePeriod) => void;
onEdit: () => void;
onToggleAddKeywords: () => void;
}) {
return (
<div className="flex flex-col sm:flex-row sm:items-start justify-between gap-2 px-4 pt-4 pb-3">
<div>
<h2 className="text-lg font-semibold">{config.domain}</h2>
<p className="text-xs text-base-content/60">
{LOCATIONS[config.locationCode] ?? "US"} &middot;{" "}
{devicesLabel(config.devices)} &middot;{" "}
{scheduleLabel(config.scheduleInterval)}
{run && (
<>
{" "}
&middot; Last: {new Date(run.lastCheckedAt).toLocaleDateString()}
</>
)}
{costEstimate && costEstimate.keywordCount > 0 && (
<> &middot; ~${costEstimate.costUsd.toFixed(2)}/check</>
)}
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
{hasBothDevices && (
<SegmentedToggle
items={[
{
value: "desktop" as const,
icon: <Monitor className="size-3.5" />,
label: "Desktop",
},
{
value: "mobile" as const,
icon: <Smartphone className="size-3.5" />,
label: "Mobile",
},
]}
value={activeDevice}
onChange={onActiveDeviceChange}
/>
)}
<select
className="select select-bordered select-sm text-xs w-auto"
title="Comparison period"
value={comparePeriod}
onChange={(e) => {
if (isComparePeriod(e.target.value))
onComparePeriodChange(e.target.value);
}}
>
<option value="1d">vs yesterday</option>
<option value="7d">vs last week</option>
<option value="30d">vs last month</option>
<option value="90d">vs 90 days ago</option>
</select>
<div className="hidden sm:block h-6 w-px bg-base-300" />
<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={onToggleAddKeywords}
>
<Plus className="size-3.5" />
Add Keywords
</button>
</div>
</div>
);
}

View File

@ -4,23 +4,22 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
import { AutumnProvider, useCustomer } from "autumn-js/react"; import { AutumnProvider, useCustomer } from "autumn-js/react";
import { import {
getLatestRankResults, getLatestRankResults,
getRankPositionMatrix,
estimateRankCheckCost, estimateRankCheckCost,
} from "@/serverFunctions/rank-tracking"; } from "@/serverFunctions/rank-tracking";
import { import { AlertTriangle, ArrowLeft } from "lucide-react";
AlertTriangle,
ArrowLeft,
Loader2,
Monitor,
Plus,
Settings,
SlidersHorizontal,
Smartphone,
} from "lucide-react";
import { useSession } from "@/lib/auth-client"; import { useSession } from "@/lib/auth-client";
import { getCustomerPlanStatus } from "@/client/features/billing/plan-detection"; import { getCustomerPlanStatus } from "@/client/features/billing/plan-detection";
import { captureClientEvent } from "@/client/lib/posthog"; import { captureClientEvent } from "@/client/lib/posthog";
import { FreePlanAlert } from "./FreePlanAlert"; import { FreePlanAlert } from "./FreePlanAlert";
import { RankTrackingDetailHeader } from "./RankTrackingDetailHeader";
import { RankTrackingOverview } from "./RankTrackingOverview";
import { RankTrackingTable } from "./RankTrackingTable"; import { RankTrackingTable } from "./RankTrackingTable";
import {
countMatrixRuns,
RankTrackingHistoryMatrix,
} from "./RankTrackingHistoryMatrix";
import { RankTrackingTableToolbar } from "./RankTrackingTableToolbar";
import { import {
exportRankTrackingCsv, exportRankTrackingCsv,
exportRankTrackingToSheets, exportRankTrackingToSheets,
@ -29,9 +28,6 @@ import type {
RankTrackingConfig, RankTrackingConfig,
ComparePeriod, ComparePeriod,
} from "@/types/schemas/rank-tracking"; } 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 { AddKeywordsPanel } from "./AddKeywordsPanel";
import { import {
FilterPanel, FilterPanel,
@ -41,19 +37,24 @@ import {
type Filters, type Filters,
} from "./RankTrackingFilters"; } from "./RankTrackingFilters";
import { CheckConfirmModal } from "./CheckConfirmModal"; import { CheckConfirmModal } from "./CheckConfirmModal";
import { SegmentedToggle } from "@/client/components/SegmentedToggle";
import { useMetricsRefresh } from "./useMetricsRefresh"; import { useMetricsRefresh } from "./useMetricsRefresh";
import { useRankCheckTrigger } from "./useRankCheckTrigger"; import { useRankCheckTrigger } from "./useRankCheckTrigger";
import { useRankRunPolling } from "./useRankRunPolling"; import { useRankRunPolling } from "./useRankRunPolling";
const COMPARE_PERIODS: ReadonlySet<string> = new Set([ function deviceVisibility(
"1d", devices: RankTrackingConfig["devices"],
"7d", activeDevice: "desktop" | "mobile",
"30d", ): { showDesktop: boolean; showMobile: boolean } {
"90d", if (devices === "both") {
]); return {
function isComparePeriod(v: string): v is ComparePeriod { showDesktop: activeDevice === "desktop",
return COMPARE_PERIODS.has(v); showMobile: activeDevice === "mobile",
};
}
return {
showDesktop: devices !== "mobile",
showMobile: devices !== "desktop",
};
} }
export function RankTrackingDomainDetail(props: { export function RankTrackingDomainDetail(props: {
@ -98,6 +99,7 @@ function RankTrackingDomainDetailInner({
const [activeDevice, setActiveDevice] = useState<"desktop" | "mobile">( const [activeDevice, setActiveDevice] = useState<"desktop" | "mobile">(
config.devices === "mobile" ? "mobile" : "desktop", config.devices === "mobile" ? "mobile" : "desktop",
); );
const [viewMode, setViewMode] = useState<"table" | "history">("table");
const { data: resultsData, isLoading: resultsLoading } = useQuery({ const { data: resultsData, isLoading: resultsLoading } = useQuery({
queryKey: ["rankTrackingResults", projectId, config.id, comparePeriod], queryKey: ["rankTrackingResults", projectId, config.id, comparePeriod],
@ -109,6 +111,17 @@ function RankTrackingDomainDetailInner({
const latestRun = useRankRunPolling(projectId, config.id); const latestRun = useRankRunPolling(projectId, config.id);
// Also feeds the History toggle: the matrix view only earns its tab once
// there are two checks to compare.
const { data: matrixCells, isLoading: matrixLoading } = useQuery({
queryKey: ["rankPositionMatrix", projectId, config.id, activeDevice],
queryFn: () =>
getRankPositionMatrix({
data: { projectId, configId: config.id, device: activeDevice },
}),
});
const historyAvailable = countMatrixRuns(matrixCells ?? []) >= 2;
const { data: costEstimate } = useQuery({ const { data: costEstimate } = useQuery({
queryKey: ["rankTrackingCostEstimate", projectId, config.id], queryKey: ["rankTrackingCostEstimate", projectId, config.id],
queryFn: () => queryFn: () =>
@ -169,18 +182,18 @@ function RankTrackingDomainDetailInner({
const rows = resultsData?.rows; const rows = resultsData?.rows;
const run = resultsData?.run; const run = resultsData?.run;
const hasBothDevices = config.devices === "both"; const hasBothDevices = config.devices === "both";
const showDesktop = hasBothDevices const { showDesktop, showMobile } = deviceVisibility(
? activeDevice === "desktop" config.devices,
: config.devices !== "mobile"; activeDevice,
const showMobile = hasBothDevices );
? activeDevice === "mobile"
: config.devices !== "desktop";
const filtered = useMemo( const filtered = useMemo(
() => applyFilters(rows ?? [], filters), () => applyFilters(rows ?? [], filters),
[rows, filters], [rows, filters],
); );
const activeFilterCount = countActiveFilters(filters); const activeFilterCount = countActiveFilters(filters);
const defaultSortId = showDesktop ? "desktopPosition" : "mobilePosition"; const defaultSortId = showDesktop ? "desktopPosition" : "mobilePosition";
// Fall back to the table if history disappears (e.g. device switch).
const effectiveViewMode = historyAvailable ? viewMode : "table";
return ( return (
<div className="space-y-3"> <div className="space-y-3">
@ -216,39 +229,18 @@ function RankTrackingDomainDetailInner({
{/* Results card */} {/* Results card */}
<div className="flex-1 flex flex-col min-w-0 border border-base-300 rounded-xl bg-base-100 overflow-hidden"> <div className="flex-1 flex flex-col min-w-0 border border-base-300 rounded-xl bg-base-100 overflow-hidden">
{/* Domain header */} {/* Domain header */}
<div className="flex flex-col sm:flex-row sm:items-start justify-between gap-2 px-4 pt-4 pb-3"> <RankTrackingDetailHeader
<div> config={config}
<h2 className="text-lg font-semibold">{config.domain}</h2> run={run}
<p className="text-xs text-base-content/60"> costEstimate={costEstimate}
{LOCATIONS[config.locationCode] ?? "US"} &middot;{" "} hasBothDevices={hasBothDevices}
{devicesLabel(config.devices)} &middot;{" "} activeDevice={activeDevice}
{scheduleLabel(config.scheduleInterval)} onActiveDeviceChange={setActiveDevice}
{run && ( comparePeriod={comparePeriod}
<> onComparePeriodChange={setComparePeriod}
{" "} onEdit={onEdit}
&middot; Last:{" "} onToggleAddKeywords={() => setShowAddKeywords((c) => !c)}
{new Date(run.lastCheckedAt).toLocaleDateString()} />
</>
)}
{costEstimate && costEstimate.keywordCount > 0 && (
<> &middot; ~${costEstimate.costUsd.toFixed(2)}/check</>
)}
</p>
</div>
<div className="flex gap-2">
<button className="btn btn-outline btn-sm gap-1" onClick={onEdit}>
<Settings className="size-3.5" />
Configure
</button>
<button
className="btn btn-primary btn-sm gap-1"
onClick={() => setShowAddKeywords(!showAddKeywords)}
>
<Plus className="size-3.5" />
Add Keywords
</button>
</div>
</div>
{showAddKeywords && ( {showAddKeywords && (
<div className="px-4 pb-3"> <div className="px-4 pb-3">
@ -261,87 +253,27 @@ function RankTrackingDomainDetailInner({
</div> </div>
)} )}
{/* Portfolio overview */}
{(rows?.length ?? 0) > 0 && (
<RankTrackingOverview
rows={rows ?? []}
device={activeDevice}
projectId={projectId}
configId={config.id}
/>
)}
{/* Table toolbar */} {/* Table toolbar */}
<div className="shrink-0 flex items-center gap-2 px-4 py-2 border-y border-base-300"> <RankTrackingTableToolbar
<button showFilters={showFilters}
className={`btn btn-ghost btn-sm gap-1.5 ${showFilters ? "btn-active" : ""}`} onToggleFilters={() => setShowFilters((c) => !c)}
onClick={() => setShowFilters((c) => !c)} activeFilterCount={activeFilterCount}
title="Toggle table filters" isRunning={isRunning}
> latestRun={latestRun}
<SlidersHorizontal className="size-3.5" /> keywordCount={filtered.length}
Filters viewMode={effectiveViewMode}
{activeFilterCount > 0 && ( onViewModeChange={setViewMode}
<span className="badge badge-xs badge-primary border-0 text-primary-content"> historyAvailable={historyAvailable}
{activeFilterCount}
</span>
)}
</button>
{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..."
: `Getting rankings for ${latestRun.keywordsTotal || "?"} keyword${latestRun.keywordsTotal !== 1 ? "s" : ""}...`}{" "}
{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" />
<select
className="select select-bordered select-sm text-xs w-auto"
value={comparePeriod}
onChange={(e) => {
if (isComparePeriod(e.target.value))
setComparePeriod(e.target.value);
}}
>
<option value="1d">Since yesterday</option>
<option value="7d">Since last week</option>
<option value="30d">Since last month</option>
<option value="90d">Since 90 days ago</option>
</select>
{hasBothDevices && (
<SegmentedToggle
items={[
{
value: "desktop" as const,
icon: <Monitor className="size-3.5" />,
label: "Desktop",
},
{
value: "mobile" as const,
icon: <Smartphone className="size-3.5" />,
label: "Mobile",
},
]}
value={activeDevice}
onChange={setActiveDevice}
/>
)}
<ActionsMenu
onCheckNow={() => {
const count = costEstimate?.keywordCount ?? rows?.length ?? 0;
if (count > 0) requestCheck(count);
}}
onRefreshMetrics={refreshMetrics}
metricsRefreshing={metricsRefreshing}
onExport={() => onExport={() =>
exportRankTrackingCsv( exportRankTrackingCsv(
filtered, filtered,
@ -359,11 +291,16 @@ function RankTrackingDomainDetailInner({
); );
toast.success("Keywords copied to clipboard"); toast.success("Keywords copied to clipboard");
}} }}
isRunning={isBusy} onCheckNow={() => {
hasData={filtered.length > 0} const count = costEstimate?.keywordCount ?? rows?.length ?? 0;
if (count > 0) requestCheck(count);
}}
onRefreshMetrics={refreshMetrics}
metricsRefreshing={metricsRefreshing}
checkBusy={isBusy}
checkDisabled={isFreePlan} checkDisabled={isFreePlan}
hasData={filtered.length > 0}
/> />
</div>
{/* Filters panel */} {/* Filters panel */}
{showFilters && ( {showFilters && (
@ -377,6 +314,16 @@ function RankTrackingDomainDetailInner({
{/* Table */} {/* Table */}
<div className="p-4"> <div className="p-4">
{effectiveViewMode === "history" ? (
<RankTrackingHistoryMatrix
cells={matrixCells ?? []}
isLoading={matrixLoading}
keywords={filtered.map((r) => ({
trackingKeywordId: r.trackingKeywordId,
keyword: r.keyword,
}))}
/>
) : (
<RankTrackingTable <RankTrackingTable
key={defaultSortId} key={defaultSortId}
totalCount={rows?.length ?? 0} totalCount={rows?.length ?? 0}
@ -388,7 +335,10 @@ function RankTrackingDomainDetailInner({
domain={config.domain} domain={config.domain}
configId={config.id} configId={config.id}
projectId={projectId} projectId={projectId}
locationCode={config.locationCode}
serpDepth={config.serpDepth}
/> />
)}
</div> </div>
</div> </div>

View File

@ -0,0 +1,145 @@
import { useMemo } from "react";
import { Loader2 } from "lucide-react";
import type { RankPositionMatrixCell } from "@/serverFunctions/rank-tracking";
/**
* "By date" view: keyword rows × recent check columns, each cell the position
* on that date with its change vs the previous check. This is the pivoted
* history table users want for client reporting ("look — we won 5 positions").
*/
export function RankTrackingHistoryMatrix({
cells,
isLoading,
keywords,
}: {
cells: RankPositionMatrixCell[];
isLoading: boolean;
keywords: { trackingKeywordId: string; keyword: string }[];
}) {
const { runs, cellByKeyword } = useMemo(() => buildMatrix(cells), [cells]);
if (isLoading) {
return (
<div className="flex items-center justify-center p-8">
<Loader2 className="size-5 animate-spin text-base-content/50" />
</div>
);
}
if (runs.length === 0 || keywords.length === 0) {
return (
<div className="rounded-xl border border-dashed border-base-300 p-10 text-center text-sm text-base-content/55">
No history yet. Run a check to start building the timeline.
</div>
);
}
return (
<div className="overflow-x-auto rounded-lg border border-base-300">
<table className="table table-sm">
<thead>
<tr>
{/* Unconstrained keyword column absorbs the slack when only a few
check columns exist, so sparse history doesn't stretch oddly. */}
<th className="sticky left-0 z-10 bg-base-100 w-full">Keyword</th>
{runs.map((r) => (
<th
key={r.runId}
className="w-24 whitespace-nowrap text-right text-xs font-medium text-base-content/60"
>
{formatDate(r.checkedAt)}
</th>
))}
</tr>
</thead>
<tbody>
{keywords.map((kw) => {
const byRun = cellByKeyword.get(kw.trackingKeywordId);
return (
<tr key={kw.trackingKeywordId}>
<td className="sticky left-0 z-10 bg-base-100 whitespace-nowrap font-medium">
{kw.keyword}
</td>
{runs.map((r, i) => {
const position = byRun?.get(r.runId) ?? null;
const previous =
i > 0 ? (byRun?.get(runs[i - 1].runId) ?? null) : undefined;
return (
<td key={r.runId} className="text-right">
<MatrixCell position={position} previous={previous} />
</td>
);
})}
</tr>
);
})}
</tbody>
</table>
</div>
);
}
function MatrixCell({
position,
previous,
}: {
position: number | null;
previous: number | null | undefined;
}) {
if (position === null) {
return <span className="text-base-content/30"></span>;
}
// Only show a change arrow when both checks ranked (no subtracting through a
// null, matching the rest of the rank-tracking UI).
const change =
previous != null && previous !== undefined ? previous - position : null;
return (
<span className="inline-flex items-center justify-end gap-1 font-mono text-xs">
<span>{position}</span>
{change != null && change > 0 && (
<span className="text-success">{change}</span>
)}
{change != null && change < 0 && (
<span className="text-warning">{-change}</span>
)}
</span>
);
}
interface MatrixRun {
runId: string;
checkedAt: string;
}
/** Distinct completed runs in a matrix payload (= history columns). */
export function countMatrixRuns(cells: RankPositionMatrixCell[]): number {
return new Set(cells.map((c) => c.runId)).size;
}
function buildMatrix(cells: RankPositionMatrixCell[]): {
runs: MatrixRun[];
cellByKeyword: Map<string, Map<string, number | null>>;
} {
const runMap = new Map<string, string>(); // runId -> checkedAt
const cellByKeyword = new Map<string, Map<string, number | null>>();
for (const c of cells) {
runMap.set(c.runId, c.checkedAt);
let byRun = cellByKeyword.get(c.trackingKeywordId);
if (!byRun) {
byRun = new Map();
cellByKeyword.set(c.trackingKeywordId, byRun);
}
byRun.set(c.runId, c.position);
}
const runs = [...runMap.entries()]
.map(([runId, checkedAt]) => ({ runId, checkedAt }))
.toSorted((a, b) => a.checkedAt.localeCompare(b.checkedAt));
return { runs, cellByKeyword };
}
function formatDate(value: string): string {
return new Date(value).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
});
}

View File

@ -0,0 +1,293 @@
import { useMemo, useState } from "react";
import { Loader2 } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import {
Area,
AreaChart,
CartesianGrid,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import type { TooltipContentProps } from "recharts";
import { getRankConfigTrend } from "@/serverFunctions/rank-tracking";
import type { RankTrackingRow } from "@/types/schemas/rank-tracking";
import { computeScorecards } from "./rankTrackingScorecards";
import {
formatDateTick,
TrendRangeToggle,
useChartWidth,
} from "./RankTrackingTrendChart";
const BUCKETS = [
{ key: "top3", label: "Top 3", color: "#16a34a" },
{ key: "top4to10", label: "410", color: "#2563eb" },
{ key: "top11to20", label: "1120", color: "#f59e0b" },
{ key: "notRanking", label: "Not in top 20", color: "#6b7280" },
] as const;
/** Narrowed recharts tooltip payload entry (typed `any` upstream). */
interface PayloadEntry {
dataKey?: string | number;
value?: number | string | null;
}
export function RankTrackingOverview({
rows,
device,
projectId,
configId,
}: {
rows: RankTrackingRow[];
device: "desktop" | "mobile";
projectId: string;
configId: string;
}) {
const [sinceDays, setSinceDays] = useState(730);
const scorecards = useMemo(
() => computeScorecards(rows, device),
[rows, device],
);
const { data: trend, isLoading: trendLoading } = useQuery({
queryKey: ["rankConfigTrend", projectId, configId, device, sinceDays],
queryFn: () =>
getRankConfigTrend({
data: { projectId, configId, device, sinceDays },
}),
});
const chartData = useMemo(
() =>
(trend ?? []).map((p) => ({
checkedAt: new Date(p.checkedAt).getTime(),
top3: p.top3,
top4to10: p.top4to10,
top11to20: p.top11to20,
notRanking: p.notRanking,
})),
[trend],
);
const { containerRef, width } = useChartWidth();
return (
<div className="px-4 pt-4 pb-4">
<div className="grid items-start gap-3 lg:grid-cols-2">
{/* All metrics in one card */}
<div className="rounded-lg border border-base-300 bg-base-100 p-4">
<div className="grid grid-cols-2 gap-x-4 gap-y-4 sm:grid-cols-3">
<Scorecard
label="Visibility"
value={
scorecards.visibility === null
? "-"
: `${Math.round(scorecards.visibility)}%`
}
delta={scorecards.visibilityDelta}
hint="of click potential"
/>
<Scorecard
label="Ranking"
value={String(scorecards.ranking)}
delta={scorecards.rankingDelta}
hint={`of ${rows.length} tracked`}
/>
<Scorecard
label="In Top 3"
value={String(scorecards.top3)}
hint="of ranked keywords"
/>
<Scorecard
label="In Top 10"
value={String(scorecards.top10)}
hint="includes Top 3"
/>
<Scorecard
label="Improved / Declined"
value={`${scorecards.improved} / ${scorecards.declined}`}
hint="vs comparison period"
/>
</div>
</div>
{/* Position distribution */}
<div className="rounded-lg border border-base-300 p-3 space-y-2">
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-medium">Position distribution</span>
<TrendRangeToggle value={sinceDays} onChange={setSinceDays} />
</div>
<div className="flex flex-wrap gap-x-3 gap-y-1">
{BUCKETS.map((b) => (
<span
key={b.key}
className="inline-flex items-center gap-1 text-[11px] text-base-content/60"
>
<span
className="size-2 rounded-sm"
style={{ backgroundColor: b.color }}
/>
{b.label}
</span>
))}
</div>
{trendLoading ? (
<div className="flex items-center justify-center p-8">
<Loader2 className="size-4 animate-spin text-base-content/50" />
</div>
) : chartData.length <= 1 ? (
<div className="rounded-lg border border-dashed border-base-300 p-8 text-center text-xs text-base-content/60">
{chartData.length === 0
? "No history yet — run a check to start tracking positions over time."
: "Only 1 check so far — the trend fills in after the next check."}
</div>
) : (
<div
ref={containerRef}
className="w-full min-w-0"
style={{ height: 180 }}
>
{width > 0 ? (
<AreaChart
width={width}
height={180}
data={chartData}
margin={{ top: 8, right: 8, bottom: 0, left: 0 }}
>
<CartesianGrid
strokeDasharray="3 3"
stroke="currentColor"
opacity={0.1}
vertical={false}
/>
<XAxis
dataKey="checkedAt"
type="number"
scale="time"
domain={["dataMin", "dataMax"]}
tickFormatter={formatDateTick}
tick={{ fontSize: 10, fill: "#888" }}
tickLine={false}
axisLine={false}
minTickGap={32}
/>
<YAxis
allowDecimals={false}
tick={{ fontSize: 10, fill: "#888" }}
tickLine={false}
axisLine={false}
width={28}
/>
<Tooltip
content={(props: TooltipContentProps<number, string>) => {
const { active, payload, label } = props;
if (
!active ||
!payload?.length ||
typeof label !== "number"
) {
return null;
}
const byKey = new Map(
payload.map((p: PayloadEntry) => [
String(p.dataKey),
typeof p.value === "number" ? p.value : 0,
]),
);
return (
<DistributionTooltip label={label} byKey={byKey} />
);
}}
cursor={{ stroke: "rgba(150,150,150,0.3)" }}
/>
{BUCKETS.map((b) => (
<Area
key={b.key}
type="monotone"
dataKey={b.key}
name={b.label}
stackId="positions"
stroke={b.color}
fill={b.color}
fillOpacity={0.7}
isAnimationActive={false}
/>
))}
</AreaChart>
) : null}
</div>
)}
</div>
</div>
</div>
);
}
function DistributionTooltip({
label,
byKey,
}: {
label: number;
byKey: Map<string, number>;
}) {
return (
<div className="rounded-md border border-base-300 bg-base-100 px-3 py-2 shadow-sm space-y-0.5">
<p className="text-xs text-base-content/60">
{new Date(label).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
})}
</p>
{BUCKETS.map((b) => (
<p key={b.key} className="text-xs flex items-center gap-1.5">
<span
className="size-2 rounded-sm"
style={{ backgroundColor: b.color }}
/>
<span className="text-base-content/60">{b.label}:</span>
<span className="font-medium tabular-nums">
{byKey.get(b.key) ?? 0}
</span>
</p>
))}
</div>
);
}
function Scorecard({
label,
value,
delta,
hint,
}: {
label: string;
value: string;
delta?: number | null;
hint?: string;
}) {
return (
<div className="min-w-0">
<p className="text-xs text-base-content/55">{label}</p>
<div className="flex items-baseline gap-1.5">
<span className="text-xl font-semibold tabular-nums">{value}</span>
{delta != null && delta !== 0 && (
<span
className={`text-xs font-medium ${
delta > 0 ? "text-success" : "text-warning"
}`}
>
{delta > 0 ? "▲" : "▼"}{" "}
{Number.isInteger(delta)
? Math.abs(delta)
: Math.abs(delta).toFixed(1)}
</span>
)}
</div>
{hint && <p className="text-[11px] text-base-content/45">{hint}</p>}
</div>
);
}

View File

@ -1,4 +1,4 @@
import { useRef, useState } from "react"; import { useCallback, useRef, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { FileDown, Loader2, Sheet, Trash2 } from "lucide-react"; import { FileDown, Loader2, Sheet, Trash2 } from "lucide-react";
import { Modal } from "@/client/components/Modal"; import { Modal } from "@/client/components/Modal";
@ -21,6 +21,10 @@ import { getStandardErrorMessage } from "@/client/lib/error-messages";
import type { RankTrackingRow } from "@/types/schemas/rank-tracking"; import type { RankTrackingRow } from "@/types/schemas/rank-tracking";
import { useRankTrackingColumns } from "./RankTrackingColumns"; import { useRankTrackingColumns } from "./RankTrackingColumns";
import { buildRankTrackingExport } from "./RankTrackingTableParts"; import { buildRankTrackingExport } from "./RankTrackingTableParts";
import {
KeywordTrendModal,
type KeywordTrendTarget,
} from "./KeywordTrendModal";
import type { SelectionAnchor } from "@/client/components/table/tableSelection"; import type { SelectionAnchor } from "@/client/components/table/tableSelection";
export function RankTrackingTable({ export function RankTrackingTable({
@ -33,6 +37,8 @@ export function RankTrackingTable({
domain, domain,
configId, configId,
projectId, projectId,
locationCode,
serpDepth,
}: { }: {
totalCount: number; totalCount: number;
rows: RankTrackingRow[]; rows: RankTrackingRow[];
@ -43,16 +49,31 @@ export function RankTrackingTable({
domain: string; domain: string;
configId: string; configId: string;
projectId: string; projectId: string;
locationCode: number;
serpDepth: number;
}) { }) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [showConfirm, setShowConfirm] = useState(false); const [showConfirm, setShowConfirm] = useState(false);
const [trendTarget, setTrendTarget] = useState<KeywordTrendTarget | null>(
null,
);
const selectAnchorRef = useRef<SelectionAnchor | null>(null); const selectAnchorRef = useRef<SelectionAnchor | null>(null);
const handleKeywordClick = useCallback(
(row: RankTrackingRow) =>
setTrendTarget({
trackingKeywordId: row.trackingKeywordId,
keyword: row.keyword,
}),
[],
);
const columns = useRankTrackingColumns( const columns = useRankTrackingColumns(
showDesktop, showDesktop,
showMobile, showMobile,
domain, domain,
selectAnchorRef, selectAnchorRef,
handleKeywordClick,
); );
const table = useAppTable({ const table = useAppTable({
@ -211,6 +232,18 @@ export function RankTrackingTable({
</Modal> </Modal>
)} )}
{trendTarget && (
<KeywordTrendModal
target={trendTarget}
projectId={projectId}
configId={configId}
domain={domain}
locationCode={locationCode}
serpDepth={serpDepth}
onClose={() => setTrendTarget(null)}
/>
)}
<AppDataTable table={table} getCellClassName={() => "align-top"} /> <AppDataTable table={table} getCellClassName={() => "align-top"} />
<p className="text-xs text-base-content/60 pt-2"> <p className="text-xs text-base-content/60 pt-2">
{rows.length} of {totalCount} keywords {rows.length} of {totalCount} keywords

View File

@ -160,7 +160,7 @@ export function CpcCell({ value }: { value: number | null }) {
} }
/** Numeric change for CSV export — numbers bypass the CSV formula-injection sanitizer */ /** Numeric change for CSV export — numbers bypass the CSV formula-injection sanitizer */
function csvChange( export function csvChange(
current: number | null, current: number | null,
previous: number | null, previous: number | null,
): number | string { ): number | string {

View File

@ -0,0 +1,127 @@
import { CalendarDays, Loader2, SlidersHorizontal, Table } from "lucide-react";
import { SegmentedToggle } from "@/client/components/SegmentedToggle";
import { ExportMenu, MoreMenu } from "./ToolbarMenus";
export function RankTrackingTableToolbar({
showFilters,
onToggleFilters,
activeFilterCount,
isRunning,
latestRun,
keywordCount,
viewMode,
onViewModeChange,
historyAvailable,
onExport,
onExportToSheets,
onCopyKeywords,
onCheckNow,
onRefreshMetrics,
metricsRefreshing,
checkBusy,
checkDisabled,
hasData,
}: {
showFilters: boolean;
onToggleFilters: () => void;
activeFilterCount: number;
isRunning: boolean;
latestRun:
| { status: string; keywordsChecked: number; keywordsTotal: number }
| null
| undefined;
keywordCount: number;
viewMode: "table" | "history";
onViewModeChange: (v: "table" | "history") => void;
historyAvailable: boolean;
onExport: () => void;
onExportToSheets: () => void;
onCopyKeywords: () => void;
onCheckNow: () => void;
onRefreshMetrics: () => void;
metricsRefreshing: boolean;
checkBusy: boolean;
checkDisabled: boolean;
hasData: boolean;
}) {
return (
<div className="shrink-0 flex flex-wrap items-center gap-2 px-4 py-2 border-y border-base-300">
{/* History needs at least two checks to compare; until then the toggle
would only offer a worse copy of the Latest table. */}
{historyAvailable && (
<SegmentedToggle
showLabels
items={[
{
value: "table" as const,
icon: <Table className="size-3.5" />,
label: "Latest",
},
{
value: "history" as const,
icon: <CalendarDays className="size-3.5" />,
label: "History",
},
]}
value={viewMode}
onChange={onViewModeChange}
/>
)}
<button
className={`btn btn-ghost btn-sm gap-1.5 ${showFilters ? "btn-active" : ""}`}
onClick={onToggleFilters}
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>
{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..."
: `Getting rankings for ${latestRun.keywordsTotal || "?"} keyword${latestRun.keywordsTotal !== 1 ? "s" : ""}...`}{" "}
{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">
{keywordCount} keywords
</span>
)}
<div className="flex-1" />
<ExportMenu
onExport={onExport}
onExportToSheets={onExportToSheets}
onCopyKeywords={onCopyKeywords}
hasData={hasData}
/>
<MoreMenu
onCheckNow={onCheckNow}
checkBusy={checkBusy}
checkDisabled={checkDisabled}
onRefreshMetrics={onRefreshMetrics}
metricsRefreshing={metricsRefreshing}
hasData={hasData}
/>
</div>
);
}

View File

@ -0,0 +1,213 @@
import { useCallback, useRef, useState, type ReactNode } from "react";
import {
CartesianGrid,
Line,
LineChart,
ReferenceArea,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import type { TooltipContentProps } from "recharts";
export interface TrendSeries {
/** key into each data row holding the position value (1 = best, serpDepth = bottom band) */
dataKey: string;
name: string;
color: string;
/** dashed = device line where nulls are plotted in the bottom "not in top N" band */
strokeDasharray?: string;
}
interface TooltipEntry {
dataKey?: string | number;
name?: string;
value: number | null;
color?: string;
}
/** Narrowed shape of a recharts tooltip payload entry (typed `any` upstream). */
interface RechartsPayloadEntry {
dataKey?: string | number;
name?: string;
value?: number | string | null;
color?: string;
}
/**
* Shared inverted-axis line chart for rank trends. Y-axis is reversed so #1 is
* pinned at the top and an improving line moves up. The very bottom of the
* plot (= serpDepth) is a muted "Not in top {serpDepth}" band; callers plot
* null positions at `serpDepth` so a drop reads as the line dipping into the
* band rather than a silent gap.
*/
export function RankTrendChart({
data,
series,
serpDepth,
height = 224,
renderTooltip,
showBottomBand = false,
}: {
data: Array<Record<string, unknown>>;
series: TrendSeries[];
serpDepth: number;
height?: number;
renderTooltip: (label: number, entries: TooltipEntry[]) => ReactNode;
/** Show the muted "not in top {serpDepth}" band only meaningful for a
* single keyword's position line, not for an averaged value. */
showBottomBand?: boolean;
}) {
const { containerRef, width: chartWidth } = useChartWidth();
return (
<div className="space-y-1">
<div className="flex items-center justify-between text-[11px] text-base-content/50">
<span>Google position (1 = best)</span>
<span className="inline-flex items-center gap-1">
Better <span aria-hidden></span>
</span>
</div>
<div ref={containerRef} className="w-full min-w-0" style={{ height }}>
{chartWidth > 0 ? (
<LineChart
width={chartWidth}
height={height}
data={data}
margin={{ top: 8, right: 8, bottom: 0, left: 0 }}
>
<CartesianGrid
strokeDasharray="3 3"
stroke="currentColor"
opacity={0.1}
vertical={false}
/>
{/* Muted bottom band: not in top {serpDepth} */}
{showBottomBand && (
<ReferenceArea
y1={serpDepth - 0.5}
y2={serpDepth}
fill="currentColor"
fillOpacity={0.06}
ifOverflow="extendDomain"
/>
)}
<XAxis
dataKey="checkedAt"
type="number"
scale="time"
domain={["dataMin", "dataMax"]}
tickFormatter={formatDateTick}
tick={{ fontSize: 10, fill: "#888" }}
tickLine={false}
axisLine={false}
minTickGap={32}
/>
<YAxis
reversed
domain={[1, serpDepth]}
allowDecimals={false}
tick={{ fontSize: 10, fill: "#888" }}
tickLine={false}
axisLine={false}
width={32}
/>
<Tooltip
content={(props: TooltipContentProps<number, string>) => {
const { active, payload, label } = props;
if (!active || !payload?.length || typeof label !== "number") {
return null;
}
const entries: TooltipEntry[] = payload.map(
(p: RechartsPayloadEntry) => ({
dataKey: p.dataKey,
name: p.name,
value: typeof p.value === "number" ? p.value : null,
color: p.color,
}),
);
return renderTooltip(label, entries);
}}
cursor={{ stroke: "rgba(150,150,150,0.3)" }}
/>
{series.map((s) => (
<Line
key={s.dataKey}
type="monotone"
dataKey={s.dataKey}
name={s.name}
stroke={s.color}
strokeWidth={2}
strokeDasharray={s.strokeDasharray}
dot={{ r: 2 }}
activeDot={{ r: 4 }}
connectNulls={false}
isAnimationActive={false}
/>
))}
</LineChart>
) : null}
</div>
</div>
);
}
export function formatDateTick(value: number): string {
return new Date(value).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
});
}
/** Responsive chart width via ResizeObserver recharts needs an explicit px
* width. Uses a callback ref so it measures whenever the chart node mounts,
* including after a loading state (an effect-on-mount would miss that and leave
* the width stuck at 0). Shared by the line chart and the distribution chart. */
export function useChartWidth() {
const [width, setWidth] = useState(0);
const observerRef = useRef<ResizeObserver | null>(null);
const containerRef = useCallback((el: HTMLDivElement | null) => {
observerRef.current?.disconnect();
observerRef.current = null;
if (!el) return;
setWidth(el.clientWidth);
const observer = new ResizeObserver(() => setWidth(el.clientWidth));
observer.observe(el);
observerRef.current = observer;
}, []);
return { containerRef, width };
}
/** 30d / 90d / All range toggle shared by the modal and overview charts. */
const TREND_RANGES = [
{ label: "30d", sinceDays: 30 },
{ label: "90d", sinceDays: 90 },
{ label: "All", sinceDays: 730 },
] as const;
export function TrendRangeToggle({
value,
onChange,
}: {
value: number;
onChange: (sinceDays: number) => void;
}) {
return (
<div className="join">
{TREND_RANGES.map((range) => (
<button
key={range.label}
type="button"
className={`btn btn-xs join-item ${
value === range.sinceDays ? "btn-active" : "btn-ghost"
}`}
onClick={() => onChange(range.sinceDays)}
>
{range.label}
</button>
))}
</div>
);
}

View File

@ -0,0 +1,165 @@
import { useState, type ReactNode } from "react";
import {
ChevronDown,
Copy,
Download,
FileDown,
MoreHorizontal,
Play,
RefreshCw,
Sheet,
} from "lucide-react";
function ToolbarMenu({
label,
icon,
title,
children,
}: {
label?: string;
icon?: ReactNode;
title?: string;
children: ReactNode;
}) {
const [open, setOpen] = useState(false);
return (
<div className="relative">
<button
type="button"
className={`btn btn-ghost btn-sm ${label ? "gap-1" : "btn-square"}`}
onClick={() => setOpen((c) => !c)}
title={title}
aria-label={title ?? label}
aria-haspopup="menu"
aria-expanded={open}
>
{icon}
{label}
{label && <ChevronDown className="size-3.5 opacity-60" />}
</button>
{open && (
<>
<div className="fixed inset-0 z-40" onClick={() => setOpen(false)} />
<div
role="menu"
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-[230px]"
onClick={() => setOpen(false)}
>
{children}
</div>
</>
)}
</div>
);
}
function MenuItem({
icon,
label,
description,
onClick,
disabled,
}: {
icon: ReactNode;
label: string;
description?: string;
onClick: () => void;
disabled?: boolean;
}) {
return (
<button
type="button"
role="menuitem"
className="flex w-full items-start gap-2 px-3 py-2 text-sm hover:bg-base-200 disabled:opacity-50"
onClick={onClick}
disabled={disabled}
>
<span className="mt-0.5 shrink-0">{icon}</span>
<span className="flex flex-col items-start text-left">
<span>{label}</span>
{description && (
<span className="text-xs text-base-content/50">{description}</span>
)}
</span>
</button>
);
}
export function MoreMenu({
onCheckNow,
checkBusy,
checkDisabled,
onRefreshMetrics,
metricsRefreshing,
hasData,
}: {
onCheckNow: () => void;
checkBusy: boolean;
checkDisabled: boolean;
onRefreshMetrics: () => void;
metricsRefreshing: boolean;
hasData: boolean;
}) {
return (
<ToolbarMenu
icon={<MoreHorizontal className="size-4" />}
title="More actions"
>
{!checkDisabled && (
<MenuItem
icon={<Play className="size-3.5" />}
label={checkBusy ? "Running..." : "Check rankings"}
description="Fetch current Google positions"
onClick={onCheckNow}
disabled={checkBusy}
/>
)}
<MenuItem
icon={
<RefreshCw
className={`size-3.5 ${metricsRefreshing ? "animate-spin" : ""}`}
/>
}
label={metricsRefreshing ? "Refreshing..." : "Update keyword stats"}
description="Volume, difficulty & CPC — not rankings"
onClick={onRefreshMetrics}
disabled={metricsRefreshing || !hasData}
/>
</ToolbarMenu>
);
}
export function ExportMenu({
onExport,
onExportToSheets,
onCopyKeywords,
hasData,
}: {
onExport: () => void;
onExportToSheets: () => void;
onCopyKeywords: () => void;
hasData: boolean;
}) {
return (
<ToolbarMenu label="Export" icon={<Download className="size-3.5" />}>
<MenuItem
icon={<Sheet className="size-3.5" />}
label="Export to Sheets"
onClick={onExportToSheets}
disabled={!hasData}
/>
<MenuItem
icon={<FileDown className="size-3.5" />}
label="Export CSV"
onClick={onExport}
disabled={!hasData}
/>
<MenuItem
icon={<Copy className="size-3.5" />}
label="Copy keywords"
onClick={onCopyKeywords}
disabled={!hasData}
/>
</ToolbarMenu>
);
}

View File

@ -0,0 +1,91 @@
import { describe, expect, it } from "vitest";
import type {
RankTrackingDeviceResult,
RankTrackingRow,
} from "@/types/schemas/rank-tracking";
import { computeScorecards } from "./rankTrackingScorecards";
function device(
position: number | null,
previousPosition: number | null,
): RankTrackingDeviceResult {
return { position, previousPosition, rankingUrl: null, serpFeatures: [] };
}
function row(
desktop: RankTrackingDeviceResult,
mobile: RankTrackingDeviceResult = device(null, null),
searchVolume: number | null = null,
): RankTrackingRow {
return {
trackingKeywordId: crypto.randomUUID(),
keyword: "kw",
searchVolume,
keywordDifficulty: null,
cpc: null,
desktop,
mobile,
};
}
describe("computeScorecards", () => {
it("counts ranking keywords and the delta vs the comparison period", () => {
const rows = [row(device(2, 5)), row(device(null, 8))];
const result = computeScorecards(rows, "desktop");
// Only one keyword currently ranks (position 2); two ranked previously.
expect(result.ranking).toBe(1);
expect(result.rankingDelta).toBe(-1);
const empty = computeScorecards([row(device(null, null))], "desktop");
expect(empty.ranking).toBe(0);
expect(empty.rankingDelta).toBe(0);
});
it("counts Top 3 and Top 10 (Top 3 subset of Top 10)", () => {
const rows = [
row(device(1, null)),
row(device(3, null)),
row(device(9, null)),
row(device(15, null)),
];
const result = computeScorecards(rows, "desktop");
expect(result.top3).toBe(2);
expect(result.top10).toBe(3);
});
it("computes volume-weighted visibility (0100%) and its delta", () => {
// A single keyword at #1 captures the full click potential → 100%.
const top = computeScorecards(
[row(device(1, null), undefined, 1000)],
"desktop",
);
expect(top.visibility).toBe(100);
expect(top.visibilityDelta).toBe(100); // was unranked (0%), now 100%
// Ranking but not found now → 0% visibility.
const lost = computeScorecards(
[row(device(null, 1), undefined, 1000)],
"desktop",
);
expect(lost.visibility).toBe(0);
// No volume anywhere → not computable.
const noVolume = computeScorecards([row(device(1, 1))], "desktop");
expect(noVolume.visibility).toBeNull();
expect(noVolume.visibilityDelta).toBeNull();
});
it("classifies improved/declined with the 4-case null rules", () => {
const rows = [
row(device(2, 5)), // moved up -> improved
row(device(8, 4)), // moved down -> declined
row(device(3, null)), // new entry -> improved
row(device(null, 6)), // lost ranking -> declined
row(device(null, null)), // nothing -> neither
row(device(7, 7)), // unchanged -> neither
];
const result = computeScorecards(rows, "desktop");
expect(result.improved).toBe(2);
expect(result.declined).toBe(2);
});
});

View File

@ -0,0 +1,107 @@
import type { RankTrackingRow } from "@/types/schemas/rank-tracking";
// Approximate organic CTR by position (index = position; aggregate industry
// curves). Only used to weight the visibility metric, so relative weights
// matter, not exact values. Positions past the list fall back to a small CTR.
const CTR_BY_POSITION = [
0, 0.28, 0.15, 0.1, 0.07, 0.05, 0.04, 0.033, 0.028, 0.024, 0.021, 0.018,
0.016, 0.014, 0.012, 0.011, 0.01, 0.009, 0.008, 0.007, 0.006,
];
const TOP_CTR = CTR_BY_POSITION[1];
function ctr(position: number | null): number {
if (position === null || position < 1) return 0;
return CTR_BY_POSITION[position] ?? 0.005;
}
interface Scorecards {
/**
* Volume-weighted, CTR-weighted share of click potential captured (0100):
* Σ(volume × CTR@position) ÷ Σ(volume × CTR@1). null if no volume data.
*/
visibility: number | null;
/** change in visibility (percentage points) vs the comparison period */
visibilityDelta: number | null;
/** keywords currently ranking (found within the tracked depth) */
ranking: number;
/** change in ranking-keyword count vs the comparison period */
rankingDelta: number;
top3: number;
top10: number;
improved: number;
declined: number;
}
/**
* Portfolio scorecards from the already-loaded latest results for one device.
* `ranking` counts keywords found within the tracked depth (a non-null
* position) unlike an average, it correctly drops when keywords fall out.
* Improved/declined use the same 4-case null rules as DeviceRankCell: a "new"
* entry counts as improved, a "lost" ranking counts as declined, and we never
* subtract through a null.
*/
export function computeScorecards(
rows: RankTrackingRow[],
device: "desktop" | "mobile",
): Scorecards {
let countCurrent = 0;
let countPrevious = 0;
let top3 = 0;
let top10 = 0;
let improved = 0;
let declined = 0;
let visNumCurrent = 0;
let visNumPrevious = 0;
let visVolume = 0; // Σ volume over keywords with known volume
for (const row of rows) {
const { position, previousPosition } = row[device];
if (position !== null) {
countCurrent += 1;
if (position <= 3) top3 += 1;
if (position <= 10) top10 += 1;
}
if (previousPosition !== null) {
countPrevious += 1;
}
if (row.searchVolume != null && row.searchVolume > 0) {
visVolume += row.searchVolume;
visNumCurrent += row.searchVolume * ctr(position);
visNumPrevious += row.searchVolume * ctr(previousPosition);
}
// 4-case change classification (mirrors DeviceRankCell)
if (position === null && previousPosition === null) {
// nothing tracked — neither improved nor declined
} else if (position === null) {
declined += 1; // was ranking, now lost
} else if (previousPosition === null) {
improved += 1; // new entry
} else if (previousPosition - position > 0) {
improved += 1; // moved up
} else if (previousPosition - position < 0) {
declined += 1; // moved down
}
}
const visibility =
visVolume > 0 ? (visNumCurrent / (visVolume * TOP_CTR)) * 100 : null;
const visibilityPrevious =
visVolume > 0 ? (visNumPrevious / (visVolume * TOP_CTR)) * 100 : null;
return {
visibility,
visibilityDelta:
visibility !== null && visibilityPrevious !== null
? visibility - visibilityPrevious
: null,
ranking: countCurrent,
rankingDelta: countCurrent - countPrevious,
top3,
top10,
improved,
declined,
};
}

View File

@ -0,0 +1,3 @@
export function toSqliteTimestamp(date: Date): string {
return date.toISOString().slice(0, 19).replace("T", " ");
}

View File

@ -12,6 +12,9 @@ import {
getLatestSnapshotsForKeywords, getLatestSnapshotsForKeywords,
getSnapshotsBeforeDate, getSnapshotsBeforeDate,
getEarliestSnapshotsForKeywords, getEarliestSnapshotsForKeywords,
getKeywordHistory,
getConfigTrend,
getPositionMatrix,
} from "./snapshotQueries"; } from "./snapshotQueries";
const DB_BATCH_SIZE = 100; const DB_BATCH_SIZE = 100;
@ -380,4 +383,7 @@ export const RankTrackingRepository = {
getLatestSnapshotsForKeywords, getLatestSnapshotsForKeywords,
getSnapshotsBeforeDate, getSnapshotsBeforeDate,
getEarliestSnapshotsForKeywords, getEarliestSnapshotsForKeywords,
getKeywordHistory,
getConfigTrend,
getPositionMatrix,
}; };

View File

@ -0,0 +1,10 @@
import { describe, expect, it } from "vitest";
import { toSqliteTimestamp } from "@/server/features/rank-tracking/rankTrackingTimestamps";
describe("rank tracking snapshot queries", () => {
it("formats comparison cutoffs like SQLite current_timestamp", () => {
expect(toSqliteTimestamp(new Date("2026-06-09T12:34:56.789Z"))).toBe(
"2026-06-09 12:34:56",
);
});
});

View File

@ -1,6 +1,141 @@
import { and, eq, inArray, lte, max, min } from "drizzle-orm"; import {
and,
asc,
count,
desc,
eq,
gte,
inArray,
lte,
max,
min,
sql,
} from "drizzle-orm";
import { db } from "@/db"; import { db } from "@/db";
import { rankCheckRuns, rankSnapshots } from "@/db/schema"; import { rankCheckRuns, rankSnapshots } from "@/db/schema";
import { toSqliteTimestamp } from "@/server/features/rank-tracking/rankTrackingTimestamps";
function completedRunIdsForConfig(configId: string) {
return db
.select({ id: rankCheckRuns.id })
.from(rankCheckRuns)
.where(
and(
eq(rankCheckRuns.configId, configId),
eq(rankCheckRuns.status, "completed"),
),
);
}
function cutoffTimestamp(sinceDays: number): string {
return toSqliteTimestamp(
new Date(Date.now() - sinceDays * 24 * 60 * 60 * 1000),
);
}
/**
* Flat per-keyword position series across completed runs, ordered oldest first.
* `null` position = checked but not found within serpDepth (a real event, not a
* missing check). The client pivots these rows per device.
*/
export async function getKeywordHistory(
configId: string,
trackingKeywordId: string,
sinceDays: number,
) {
return db
.select({
device: rankSnapshots.device,
checkedAt: rankSnapshots.checkedAt,
position: rankSnapshots.position,
})
.from(rankSnapshots)
.where(
and(
inArray(rankSnapshots.runId, completedRunIdsForConfig(configId)),
eq(rankSnapshots.trackingKeywordId, trackingKeywordId),
gte(rankSnapshots.checkedAt, cutoffTimestamp(sinceDays)),
),
)
.orderBy(asc(rankSnapshots.checkedAt));
}
/**
* Per-run keyword-position distribution for one device, oldest first. Grouped
* by runId (not checkedAt snapshots in a run don't share an exact insert
* time); the run's startedAt is the x-axis timestamp. The buckets are disjoint
* and cover every tracked keyword: a position past 20, or null (not found in
* the tracked depth), falls into "not ranking" (derived from `total`).
*/
export async function getConfigTrend(
configId: string,
device: "desktop" | "mobile",
sinceDays: number,
) {
return db
.select({
runId: rankSnapshots.runId,
checkedAt: rankCheckRuns.startedAt,
total: count(),
top3: sql<number>`sum(case when ${rankSnapshots.position} between 1 and 3 then 1 else 0 end)`,
top4to10: sql<number>`sum(case when ${rankSnapshots.position} between 4 and 10 then 1 else 0 end)`,
top11to20: sql<number>`sum(case when ${rankSnapshots.position} between 11 and 20 then 1 else 0 end)`,
})
.from(rankSnapshots)
.innerJoin(rankCheckRuns, eq(rankSnapshots.runId, rankCheckRuns.id))
.where(
and(
eq(rankCheckRuns.configId, configId),
eq(rankCheckRuns.status, "completed"),
eq(rankCheckRuns.isSubsetRun, false),
eq(rankSnapshots.device, device),
gte(rankSnapshots.checkedAt, cutoffTimestamp(sinceDays)),
),
)
.groupBy(rankSnapshots.runId, rankCheckRuns.startedAt)
.orderBy(asc(rankCheckRuns.startedAt));
}
/**
* Recent per-keyword positions for one device as a flat list, for the "by date"
* history matrix. Bounded to the last `runLimit` completed runs; the client
* pivots these into keyword rows × run (date) columns.
*/
export async function getPositionMatrix(
configId: string,
device: "desktop" | "mobile",
runLimit: number,
) {
const recentRunIds = db
.select({ id: rankCheckRuns.id })
.from(rankCheckRuns)
.where(
and(
eq(rankCheckRuns.configId, configId),
eq(rankCheckRuns.status, "completed"),
eq(rankCheckRuns.isSubsetRun, false),
),
)
.orderBy(desc(rankCheckRuns.startedAt))
.limit(runLimit);
return db
.select({
runId: rankSnapshots.runId,
checkedAt: rankCheckRuns.startedAt,
trackingKeywordId: rankSnapshots.trackingKeywordId,
position: rankSnapshots.position,
})
.from(rankSnapshots)
.innerJoin(rankCheckRuns, eq(rankSnapshots.runId, rankCheckRuns.id))
.where(
and(
inArray(rankSnapshots.runId, recentRunIds),
eq(rankSnapshots.device, device),
),
)
.orderBy(asc(rankCheckRuns.startedAt));
}
/** /**
* Pick one snapshot per keyword+device from completed runs, using SQL GROUP BY * Pick one snapshot per keyword+device from completed runs, using SQL GROUP BY

View File

@ -1,4 +1,5 @@
import { RankTrackingRepository } from "@/server/features/rank-tracking/repositories/RankTrackingRepository"; import { RankTrackingRepository } from "@/server/features/rank-tracking/repositories/RankTrackingRepository";
import { toSqliteTimestamp } from "@/server/features/rank-tracking/rankTrackingTimestamps";
import { AppError } from "@/server/lib/errors"; import { AppError } from "@/server/lib/errors";
import type { ComparePeriod } from "@/types/schemas/rank-tracking"; import type { ComparePeriod } from "@/types/schemas/rank-tracking";
import type { import type {
@ -42,9 +43,9 @@ export async function getLatestResults(
// Get comparison snapshots from before the target date // Get comparison snapshots from before the target date
const days = PERIOD_DAYS[comparePeriod]; const days = PERIOD_DAYS[comparePeriod];
const targetDate = new Date( const targetDate = toSqliteTimestamp(
Date.now() - days * 24 * 60 * 60 * 1000, new Date(Date.now() - days * 24 * 60 * 60 * 1000),
).toISOString(); );
const comparisonSnapshots = const comparisonSnapshots =
await RankTrackingRepository.getSnapshotsBeforeDate(configId, targetDate); await RankTrackingRepository.getSnapshotsBeforeDate(configId, targetDate);

View File

@ -19,8 +19,44 @@ import {
addKeywordsSchema, addKeywordsSchema,
removeKeywordsSchema, removeKeywordsSchema,
refreshMetricsSchema, refreshMetricsSchema,
getKeywordHistorySchema,
getConfigTrendSchema,
getPositionMatrixSchema,
} from "@/types/schemas/rank-tracking"; } from "@/types/schemas/rank-tracking";
export interface RankKeywordHistoryPoint {
device: "desktop" | "mobile";
checkedAt: string;
position: number | null;
}
interface RankConfigTrendPoint {
runId: string;
checkedAt: string;
top3: number;
top4to10: number;
top11to20: number;
notRanking: number;
}
export interface RankPositionMatrixCell {
runId: string;
checkedAt: string;
trackingKeywordId: string;
position: number | null;
}
async function requireConfig(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;
}
export const getRankTrackingConfigs = createServerFn({ method: "POST" }) export const getRankTrackingConfigs = createServerFn({ method: "POST" })
.middleware(requireProjectContext) .middleware(requireProjectContext)
.inputValidator((data: unknown) => getConfigsSchema.parse(data)) .inputValidator((data: unknown) => getConfigsSchema.parse(data))
@ -251,3 +287,55 @@ export const refreshTrackingKeywordMetrics = createServerFn({ method: "POST" })
return result; return result;
}); });
export const getRankKeywordHistory = createServerFn({ method: "POST" })
.middleware(requireProjectContext)
.inputValidator((data: unknown) => getKeywordHistorySchema.parse(data))
.handler(async ({ data, context }): Promise<RankKeywordHistoryPoint[]> => {
await requireConfig(data.configId, context.projectId);
return RankTrackingRepository.getKeywordHistory(
data.configId,
data.trackingKeywordId,
data.sinceDays,
);
});
export const getRankConfigTrend = createServerFn({ method: "POST" })
.middleware(requireProjectContext)
.inputValidator((data: unknown) => getConfigTrendSchema.parse(data))
.handler(async ({ data, context }): Promise<RankConfigTrendPoint[]> => {
await requireConfig(data.configId, context.projectId);
const rows = await RankTrackingRepository.getConfigTrend(
data.configId,
data.device,
data.sinceDays,
);
// SQLite sum()/count() can return strings; coerce and derive "not ranking"
// (position > 20 or null) as the remainder so the buckets cover every kw.
return rows.map((row) => {
const top3 = Number(row.top3) || 0;
const top4to10 = Number(row.top4to10) || 0;
const top11to20 = Number(row.top11to20) || 0;
const total = Number(row.total) || 0;
return {
runId: row.runId,
checkedAt: row.checkedAt,
top3,
top4to10,
top11to20,
notRanking: Math.max(0, total - top3 - top4to10 - top11to20),
};
});
});
export const getRankPositionMatrix = createServerFn({ method: "POST" })
.middleware(requireProjectContext)
.inputValidator((data: unknown) => getPositionMatrixSchema.parse(data))
.handler(async ({ data, context }): Promise<RankPositionMatrixCell[]> => {
await requireConfig(data.configId, context.projectId);
return RankTrackingRepository.getPositionMatrix(
data.configId,
data.device,
data.runLimit,
);
});

View File

@ -114,3 +114,27 @@ export const refreshMetricsSchema = z.object({
projectId: z.string().uuid(), projectId: z.string().uuid(),
configId: z.string().uuid(), configId: z.string().uuid(),
}); });
const deviceEnum = z.enum(["desktop", "mobile"]);
const sinceDaysField = z.number().int().positive().max(730).default(365);
export const getKeywordHistorySchema = z.object({
projectId: z.string().uuid(),
configId: z.string().uuid(),
trackingKeywordId: z.string().uuid(),
sinceDays: sinceDaysField,
});
export const getConfigTrendSchema = z.object({
projectId: z.string().uuid(),
configId: z.string().uuid(),
device: deviceEnum,
sinceDays: sinceDaysField,
});
export const getPositionMatrixSchema = z.object({
projectId: z.string().uuid(),
configId: z.string().uuid(),
device: deviceEnum,
runLimit: z.number().int().positive().max(26).default(12),
});