Rank tracking: trends & data exploration (#247)
This commit is contained in:
parent
076a7fb6d7
commit
383531c5cd
@ -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) => ({
|
||||||
configId,
|
id: crypto.randomUUID(),
|
||||||
projectId,
|
date,
|
||||||
status: "completed",
|
}));
|
||||||
keywordsTotal: keywordRows.length,
|
await batched(db, runRows, (run) =>
|
||||||
keywordsChecked: keywordRows.length,
|
db.insert(schema.rankCheckRuns).values({
|
||||||
completedAt: now,
|
id: run.id,
|
||||||
|
configId,
|
||||||
|
projectId,
|
||||||
|
status: "completed" as const,
|
||||||
|
keywordsTotal: keywordRows.length,
|
||||||
|
keywordsChecked: keywordRows.length,
|
||||||
|
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) =>
|
||||||
|
db.insert(schema.rankSnapshots).values(row),
|
||||||
// Insert snapshots (mobile device)
|
|
||||||
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}`);
|
if (!existing) {
|
||||||
return fetch(url, { ...init, headers });
|
exit(`Project ${projectIdArg} not found in local DB.`);
|
||||||
},
|
}
|
||||||
});
|
return projectIdArg;
|
||||||
|
|
||||||
const req = new DataforseoLabsGoogleRankedKeywordsLiveRequestInfo({
|
|
||||||
target: domain,
|
|
||||||
location_code: 2840,
|
|
||||||
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 1–3
|
||||||
|
case "volatile":
|
||||||
|
return 9 + Math.sin(i * 1.25) * 5 + noise * 3;
|
||||||
|
case "steady_mid":
|
||||||
|
return 12 + noise * 1.2; // ~11–13
|
||||||
|
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();
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
398
src/client/features/rank-tracking/KeywordTrendModal.tsx
Normal file
398
src/client/features/rank-tracking/KeywordTrendModal.tsx
Normal 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} · {LOCATIONS[locationCode] ?? "US"} ·
|
||||||
|
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, "");
|
||||||
|
}
|
||||||
@ -100,17 +100,28 @@ const cpcColumn: ColumnDef<RankTrackingRow> = {
|
|||||||
sortingFn: nullsLastNumeric,
|
sortingFn: nullsLastNumeric,
|
||||||
};
|
};
|
||||||
|
|
||||||
const keywordColumn: ColumnDef<RankTrackingRow> = {
|
function makeKeywordColumn(
|
||||||
id: "keyword",
|
onKeywordClick: (row: RankTrackingRow) => void,
|
||||||
accessorKey: "keyword",
|
): ColumnDef<RankTrackingRow> {
|
||||||
header: ({ column }) => (
|
return {
|
||||||
<SortableHeader column={column} label="Keyword" id="keyword" />
|
id: "keyword",
|
||||||
),
|
accessorKey: "keyword",
|
||||||
cell: ({ getValue }) => (
|
header: ({ column }) => (
|
||||||
<span className="font-medium">{getValue<string>()}</span>
|
<SortableHeader column={column} label="Keyword" id="keyword" />
|
||||||
),
|
),
|
||||||
sortingFn: "alphanumeric",
|
cell: ({ row }) => (
|
||||||
};
|
<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",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
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]);
|
||||||
}
|
}
|
||||||
|
|||||||
110
src/client/features/rank-tracking/RankTrackingDetailHeader.tsx
Normal file
110
src/client/features/rank-tracking/RankTrackingDetailHeader.tsx
Normal 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"} ·{" "}
|
||||||
|
{devicesLabel(config.devices)} ·{" "}
|
||||||
|
{scheduleLabel(config.scheduleInterval)}
|
||||||
|
{run && (
|
||||||
|
<>
|
||||||
|
{" "}
|
||||||
|
· Last: {new Date(run.lastCheckedAt).toLocaleDateString()}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{costEstimate && costEstimate.keywordCount > 0 && (
|
||||||
|
<> · ~${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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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"} ·{" "}
|
hasBothDevices={hasBothDevices}
|
||||||
{devicesLabel(config.devices)} ·{" "}
|
activeDevice={activeDevice}
|
||||||
{scheduleLabel(config.scheduleInterval)}
|
onActiveDeviceChange={setActiveDevice}
|
||||||
{run && (
|
comparePeriod={comparePeriod}
|
||||||
<>
|
onComparePeriodChange={setComparePeriod}
|
||||||
{" "}
|
onEdit={onEdit}
|
||||||
· Last:{" "}
|
onToggleAddKeywords={() => setShowAddKeywords((c) => !c)}
|
||||||
{new Date(run.lastCheckedAt).toLocaleDateString()}
|
/>
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{costEstimate && costEstimate.keywordCount > 0 && (
|
|
||||||
<> · ~${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,109 +253,54 @@ function RankTrackingDomainDetailInner({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Table toolbar */}
|
{/* Portfolio overview */}
|
||||||
<div className="shrink-0 flex items-center gap-2 px-4 py-2 border-y border-base-300">
|
{(rows?.length ?? 0) > 0 && (
|
||||||
<button
|
<RankTrackingOverview
|
||||||
className={`btn btn-ghost btn-sm gap-1.5 ${showFilters ? "btn-active" : ""}`}
|
rows={rows ?? []}
|
||||||
onClick={() => setShowFilters((c) => !c)}
|
device={activeDevice}
|
||||||
title="Toggle table filters"
|
projectId={projectId}
|
||||||
>
|
configId={config.id}
|
||||||
<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">
|
|
||||||
{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={() =>
|
|
||||||
exportRankTrackingCsv(
|
|
||||||
filtered,
|
|
||||||
showDesktop,
|
|
||||||
showMobile,
|
|
||||||
config.domain,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
onExportToSheets={() =>
|
|
||||||
exportRankTrackingToSheets(filtered, showDesktop, showMobile)
|
|
||||||
}
|
|
||||||
onCopyKeywords={() => {
|
|
||||||
void navigator.clipboard.writeText(
|
|
||||||
filtered.map((r) => r.keyword).join("\n"),
|
|
||||||
);
|
|
||||||
toast.success("Keywords copied to clipboard");
|
|
||||||
}}
|
|
||||||
isRunning={isBusy}
|
|
||||||
hasData={filtered.length > 0}
|
|
||||||
checkDisabled={isFreePlan}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
|
{/* Table toolbar */}
|
||||||
|
<RankTrackingTableToolbar
|
||||||
|
showFilters={showFilters}
|
||||||
|
onToggleFilters={() => setShowFilters((c) => !c)}
|
||||||
|
activeFilterCount={activeFilterCount}
|
||||||
|
isRunning={isRunning}
|
||||||
|
latestRun={latestRun}
|
||||||
|
keywordCount={filtered.length}
|
||||||
|
viewMode={effectiveViewMode}
|
||||||
|
onViewModeChange={setViewMode}
|
||||||
|
historyAvailable={historyAvailable}
|
||||||
|
onExport={() =>
|
||||||
|
exportRankTrackingCsv(
|
||||||
|
filtered,
|
||||||
|
showDesktop,
|
||||||
|
showMobile,
|
||||||
|
config.domain,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onExportToSheets={() =>
|
||||||
|
exportRankTrackingToSheets(filtered, showDesktop, showMobile)
|
||||||
|
}
|
||||||
|
onCopyKeywords={() => {
|
||||||
|
void navigator.clipboard.writeText(
|
||||||
|
filtered.map((r) => r.keyword).join("\n"),
|
||||||
|
);
|
||||||
|
toast.success("Keywords copied to clipboard");
|
||||||
|
}}
|
||||||
|
onCheckNow={() => {
|
||||||
|
const count = costEstimate?.keywordCount ?? rows?.length ?? 0;
|
||||||
|
if (count > 0) requestCheck(count);
|
||||||
|
}}
|
||||||
|
onRefreshMetrics={refreshMetrics}
|
||||||
|
metricsRefreshing={metricsRefreshing}
|
||||||
|
checkBusy={isBusy}
|
||||||
|
checkDisabled={isFreePlan}
|
||||||
|
hasData={filtered.length > 0}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Filters panel */}
|
{/* Filters panel */}
|
||||||
{showFilters && (
|
{showFilters && (
|
||||||
@ -377,18 +314,31 @@ function RankTrackingDomainDetailInner({
|
|||||||
|
|
||||||
{/* Table */}
|
{/* Table */}
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<RankTrackingTable
|
{effectiveViewMode === "history" ? (
|
||||||
key={defaultSortId}
|
<RankTrackingHistoryMatrix
|
||||||
totalCount={rows?.length ?? 0}
|
cells={matrixCells ?? []}
|
||||||
rows={filtered}
|
isLoading={matrixLoading}
|
||||||
resultsLoading={resultsLoading}
|
keywords={filtered.map((r) => ({
|
||||||
showDesktop={showDesktop}
|
trackingKeywordId: r.trackingKeywordId,
|
||||||
showMobile={showMobile}
|
keyword: r.keyword,
|
||||||
defaultSortId={defaultSortId}
|
}))}
|
||||||
domain={config.domain}
|
/>
|
||||||
configId={config.id}
|
) : (
|
||||||
projectId={projectId}
|
<RankTrackingTable
|
||||||
/>
|
key={defaultSortId}
|
||||||
|
totalCount={rows?.length ?? 0}
|
||||||
|
rows={filtered}
|
||||||
|
resultsLoading={resultsLoading}
|
||||||
|
showDesktop={showDesktop}
|
||||||
|
showMobile={showMobile}
|
||||||
|
defaultSortId={defaultSortId}
|
||||||
|
domain={config.domain}
|
||||||
|
configId={config.id}
|
||||||
|
projectId={projectId}
|
||||||
|
locationCode={config.locationCode}
|
||||||
|
serpDepth={config.serpDepth}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
145
src/client/features/rank-tracking/RankTrackingHistoryMatrix.tsx
Normal file
145
src/client/features/rank-tracking/RankTrackingHistoryMatrix.tsx
Normal 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",
|
||||||
|
});
|
||||||
|
}
|
||||||
293
src/client/features/rank-tracking/RankTrackingOverview.tsx
Normal file
293
src/client/features/rank-tracking/RankTrackingOverview.tsx
Normal 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: "4–10", color: "#2563eb" },
|
||||||
|
{ key: "top11to20", label: "11–20", 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
127
src/client/features/rank-tracking/RankTrackingTableToolbar.tsx
Normal file
127
src/client/features/rank-tracking/RankTrackingTableToolbar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
213
src/client/features/rank-tracking/RankTrackingTrendChart.tsx
Normal file
213
src/client/features/rank-tracking/RankTrackingTrendChart.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
165
src/client/features/rank-tracking/ToolbarMenus.tsx
Normal file
165
src/client/features/rank-tracking/ToolbarMenus.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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 (0–100%) 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
107
src/client/features/rank-tracking/rankTrackingScorecards.ts
Normal file
107
src/client/features/rank-tracking/rankTrackingScorecards.ts
Normal 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 (0–100):
|
||||||
|
* Σ(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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -0,0 +1,3 @@
|
|||||||
|
export function toSqliteTimestamp(date: Date): string {
|
||||||
|
return date.toISOString().slice(0, 19).replace("T", " ");
|
||||||
|
}
|
||||||
@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@ -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),
|
||||||
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user