diff --git a/scripts/seed-rank-tracking.ts b/scripts/seed-rank-tracking.ts index f6ba69b..11cd1ca 100644 --- a/scripts/seed-rank-tracking.ts +++ b/scripts/seed-rank-tracking.ts @@ -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: - * 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= * - * Requires: - * - DATAFORSEO_API_KEY in .env.local or .env - * - Local D1 database (run `pnpm db:migrate:local` first) - * - At least one project in the database (start the dev server once) + * Then view it: + * env AUTH_MODE=local_noauth pnpm dev # then open Rank Tracking + * + * 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 { getPlatformProxy } from "wrangler"; import { drizzle } from "drizzle-orm/d1"; -import { eq } from "drizzle-orm"; -import { - DataforseoLabsApi, - DataforseoLabsGoogleRankedKeywordsLiveRequestInfo, -} from "dataforseo-client"; +import { and, eq } from "drizzle-orm"; import * as schema from "../src/db/schema"; -import type { DomainRankedKeywordItem } from "../src/server/lib/dataforseo"; -import { loadLocalEnv, parseArgs } from "./cli-utils"; +import { 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)); - -await main(); +type SeedDb = ReturnType>; +type BatchStatement = Parameters[0][number]; async function main() { - const domain = normalizeDomain(args.domain); - if (!domain) { - exitWithUsage("Missing --domain argument."); - } + const args = parseArgs(process.argv.slice(2)); + const domain = normalizeDomain(args.domain) ?? "acme-demo.com"; + const runs = clampInt(args.runs, 16, 2, 52); + const keywordCount = clampInt(args.keywords, 20, 1, KEYWORDS.length); - const apiKey = process.env.DATAFORSEO_API_KEY; - if (!apiKey) { - exitWithUsage("Missing DATAFORSEO_API_KEY. Set it in .env.local or .env."); - } - - console.log(`Setting up local D1 connection...`); + console.log("Setting up local D1 connection..."); const { env, dispose } = await getPlatformProxy<{ DB: D1Database }>(); const db = drizzle(env.DB, { schema }); try { - // Resolve project - const projectId = args.projectId ?? (await findFirstProject(db)); - if (!projectId) { - exitWithUsage( - "No projects found in local DB. Start the dev server and create a project first.", - ); + const projectId = await resolveProject(db, args.projectId); + console.log(`Using project ${projectId}`); + + // Reset any previous demo config for this domain (cascades runs/snapshots/ + // 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({ - where: eq(schema.projects.id, projectId), - }); - 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 => k !== null); - console.log(`Mapped ${keywords.length} valid keywords.`); - - // Generate IDs + const keywords = KEYWORDS.slice(0, keywordCount); + const runDates = buildRunDates(runs); const configId = crypto.randomUUID(); - const runId = crypto.randomUUID(); - const now = new Date().toISOString(); + const newest = runDates[runDates.length - 1]; - // Insert config await db.insert(schema.rankTrackingConfigs).values({ id: configId, projectId, domain, - locationCode: 2840, + locationCode: LOCATION_CODE, languageCode: "en", - devices: "mobile", - serpDepth: 20, + devices: "both", + serpDepth: SERP_DEPTH, scheduleInterval: "weekly", 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) => ({ id: crypto.randomUUID(), configId, keyword: k.keyword, + searchVolume: k.volume, + keywordDifficulty: k.kd, + cpc: k.cpc, + metricsFetchedAt: dbTimestamp(newest), })); - const keywordStmts = keywordRows.map((row) => - db.insert(schema.rankTrackingKeywords).values(row).onConflictDoNothing(), + await batched(db, keywordRows, (row) => + 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.`); - // Insert completed run - await db.insert(schema.rankCheckRuns).values({ - id: runId, - configId, - projectId, - status: "completed", - keywordsTotal: keywordRows.length, - keywordsChecked: keywordRows.length, - completedAt: now, + // One completed run per date; each run snapshots every keyword on both + // devices. + const runRows = runDates.map((date) => ({ + id: crypto.randomUUID(), + date, + })); + await batched(db, runRows, (run) => + db.insert(schema.rankCheckRuns).values({ + 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.`); - - // 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(), + await batched(db, snapshotValues, (row) => + db.insert(schema.rankSnapshots).values(row), ); - if (snapshotStmts.length > 0) { - const [first, ...rest] = snapshotStmts; - await db.batch([first, ...rest]); - } - console.log(`Inserted ${keywordRows.length} snapshots.`); - 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 { await dispose(); } } // --------------------------------------------------------------------------- -// DataForSEO +// Project / local_noauth bootstrap // --------------------------------------------------------------------------- -async function fetchRankedKeywords( - apiKey: string, - domain: string, - limit: number, -): Promise { - const api = new DataforseoLabsApi("https://api.dataforseo.com", { - fetch: (url: RequestInfo, init?: RequestInit) => { - const headers = new Headers(init?.headers); - headers.set("Authorization", `Basic ${apiKey}`); - return fetch(url, { ...init, headers }); - }, - }); - - 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"}`, - ); +async function resolveProject( + db: SeedDb, + projectIdArg: string | undefined, +): Promise { + if (projectIdArg) { + const existing = await db.query.projects.findFirst({ + where: eq(schema.projects.id, projectIdArg), + }); + if (!existing) { + exit(`Project ${projectIdArg} not found in local DB.`); + } + return projectIdArg; } - const task = response.tasks?.[0]; - if (!task || task.status_code !== 20000) { - throw new Error( - `DataForSEO task error: ${task?.status_message ?? "no task returned"}`, - ); - } + // Bootstrap the same user/org/Default project that AUTH_MODE=local_noauth + // resolves, so the seeded data is viewable without signing up. + await db + .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) { - const keywordData = item.keyword_data; - const rankedSerpElement = item.ranked_serp_element; - const serpItem = rankedSerpElement?.serp_item; +type Profile = + | "climber" + | "faller" + | "leader" + | "volatile" + | "steady_mid" + | "newcomer" + | "lost"; - const keyword = keywordData?.keyword ?? item.keyword; - if (!keyword) return null; +/** Continuous "true" desktop rank for a keyword at run `i` (0 = oldest). 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 = - serpItem?.rank_absolute ?? rankedSerpElement?.rank_absolute ?? null; - const url = serpItem?.url ?? rankedSerpElement?.url ?? null; +/** Round a continuous rank and drop it to null when it falls past the depth. */ +function toStored(rank: number | null): number | 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 { - keyword: keyword.toLowerCase().trim(), - position: position != null ? Math.round(position) : null, - url, + runId, + trackingKeywordId: kw.id, + keyword: kw.keyword, + device, + position, + url: position === null ? null : `https://${domain}${path}`, + serpFeatures: null, + checkedAt, }; } // --------------------------------------------------------------------------- -// DB helpers +// Helpers // --------------------------------------------------------------------------- -type SeedDb = ReturnType>; - -async function findFirstProject(db: SeedDb): Promise { - const row = await db.query.projects.findFirst(); - return row?.id ?? null; +/** Weekly dates, oldest first, all at noon UTC (so local-time rendering can't + * shift a point across a day boundary). */ +function buildRunDates(runs: number): Date[] { + const dates: Date[] = []; + 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; } -// --------------------------------------------------------------------------- -// CLI helpers -// --------------------------------------------------------------------------- +/** SQLite current_timestamp format (UTC): "YYYY-MM-DD HH:MM:SS". */ +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( + db: SeedDb, + items: T[], + buildStatement: (item: T) => BatchStatement, +): Promise { + 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 { if (!raw) return undefined; @@ -254,10 +359,167 @@ function normalizeDomain(raw: string | undefined): string | undefined { .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( - "Usage: pnpm seed:rank-tracking --domain=example.com [--projectId=xxx]", - ); 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(); diff --git a/src/client/components/SegmentedToggle.tsx b/src/client/components/SegmentedToggle.tsx index dc7dbe3..303a961 100644 --- a/src/client/components/SegmentedToggle.tsx +++ b/src/client/components/SegmentedToggle.tsx @@ -10,21 +10,24 @@ export function SegmentedToggle({ items, value, onChange, + showLabels = false, }: { items: SegmentedToggleItem[]; value: T; onChange: (value: T) => void; + showLabels?: boolean; }) { return (
{items.map((item) => ( ))}
diff --git a/src/client/features/rank-tracking/ActionsMenu.tsx b/src/client/features/rank-tracking/ActionsMenu.tsx deleted file mode 100644 index 6b6c837..0000000 --- a/src/client/features/rank-tracking/ActionsMenu.tsx +++ /dev/null @@ -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 ( -
- - {open && ( - <> -
setOpen(false)} /> -
- {!checkDisabled && ( - - )} - - - - -
- - )} -
- ); -} diff --git a/src/client/features/rank-tracking/KeywordTrendModal.tsx b/src/client/features/rank-tracking/KeywordTrendModal.tsx new file mode 100644 index 0000000..5a77459 --- /dev/null +++ b/src/client/features/rank-tracking/KeywordTrendModal.tsx @@ -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 (":") 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(); + 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 ( + +
+
+

+ {target.keyword} +

+

+ {domain} · {LOCATIONS[locationCode] ?? "US"} · + Position over time +

+
+ +
+ + {isLoading ? ( +
+ +
+ ) : maxPerDevice <= 1 ? ( + + ) : ( + <> + ( + + )} + /> + +
+ + +
+ +
+ + + + + {devices.length > 1 && } + + + + + + {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 ( + + + {devices.length > 1 && ( + + )} + + + + ); + })} + +
DateDevicePositionΔ vs previous check
+ {new Date(r.checkedAt).toLocaleDateString()} + + {DEVICE_STYLE[r.device].label} + + {r.position === null ? ( + + Not in top {serpDepth} + + ) : ( + + {r.position} + + )} + + {noPrevious ? ( + // Invisible placeholders matching the "before → after" + // layout so the lone pill lines up under the position + // badge column instead of floating. + + + + → + + + {r.position} + + + ) : ( + + )} +
+
+ + )} + +
+ +
+
+ ); +} + +function EmptyState({ count }: { count: number }) { + return ( +
+ {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."} +
+ ); +} + +function ChartTooltip({ + label, + entries, + serpDepth, + bottomBandKeys, +}: { + label: number; + entries: Array<{ dataKey?: string | number; value: number | null }>; + serpDepth: number; + bottomBandKeys: Set; +}) { + return ( +
+

+ {new Date(label).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + })} +

+ {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 ( +

+ {device}:{" "} + {inBottomBand ? ( + + Not in top {serpDepth} + + ) : ( + e.value + )} +

+ ); + })} +
+ ); +} + +// --------------------------------------------------------------------------- +// 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 { + 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(); + 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, ""); +} diff --git a/src/client/features/rank-tracking/RankTrackingColumns.tsx b/src/client/features/rank-tracking/RankTrackingColumns.tsx index b996753..818904b 100644 --- a/src/client/features/rank-tracking/RankTrackingColumns.tsx +++ b/src/client/features/rank-tracking/RankTrackingColumns.tsx @@ -100,17 +100,28 @@ const cpcColumn: ColumnDef = { sortingFn: nullsLastNumeric, }; -const keywordColumn: ColumnDef = { - id: "keyword", - accessorKey: "keyword", - header: ({ column }) => ( - - ), - cell: ({ getValue }) => ( - {getValue()} - ), - sortingFn: "alphanumeric", -}; +function makeKeywordColumn( + onKeywordClick: (row: RankTrackingRow) => void, +): ColumnDef { + return { + id: "keyword", + accessorKey: "keyword", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + ), + sortingFn: "alphanumeric", + }; +} function makeDeviceColumn( device: "desktop" | "mobile", @@ -178,11 +189,12 @@ export function useRankTrackingColumns( showMobile: boolean, domain: string, selectAnchorRef: MutableRefObject, + onKeywordClick: (row: RankTrackingRow) => void, ): ColumnDef[] { return useMemo(() => { const cols: ColumnDef[] = [ makeSelectionColumn(selectAnchorRef), - keywordColumn, + makeKeywordColumn(onKeywordClick), ]; if (showDesktop) { cols.push(makeDeviceColumn("desktop")); @@ -200,5 +212,5 @@ export function useRankTrackingColumns( cols.push(makeSerpColumn("mobile")); } return cols; - }, [showDesktop, showMobile, domain, selectAnchorRef]); + }, [showDesktop, showMobile, domain, selectAnchorRef, onKeywordClick]); } diff --git a/src/client/features/rank-tracking/RankTrackingDetailHeader.tsx b/src/client/features/rank-tracking/RankTrackingDetailHeader.tsx new file mode 100644 index 0000000..4fea69f --- /dev/null +++ b/src/client/features/rank-tracking/RankTrackingDetailHeader.tsx @@ -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 = 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 ( +
+
+

{config.domain}

+

+ {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 + )} +

+
+
+ {hasBothDevices && ( + , + label: "Desktop", + }, + { + value: "mobile" as const, + icon: , + label: "Mobile", + }, + ]} + value={activeDevice} + onChange={onActiveDeviceChange} + /> + )} + +
+ + +
+
+ ); +} diff --git a/src/client/features/rank-tracking/RankTrackingDomainDetail.tsx b/src/client/features/rank-tracking/RankTrackingDomainDetail.tsx index 21ba854..870f021 100644 --- a/src/client/features/rank-tracking/RankTrackingDomainDetail.tsx +++ b/src/client/features/rank-tracking/RankTrackingDomainDetail.tsx @@ -4,23 +4,22 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { AutumnProvider, useCustomer } from "autumn-js/react"; import { getLatestRankResults, + getRankPositionMatrix, estimateRankCheckCost, } from "@/serverFunctions/rank-tracking"; -import { - AlertTriangle, - ArrowLeft, - Loader2, - Monitor, - Plus, - Settings, - SlidersHorizontal, - Smartphone, -} from "lucide-react"; +import { AlertTriangle, ArrowLeft } from "lucide-react"; import { useSession } from "@/lib/auth-client"; import { getCustomerPlanStatus } from "@/client/features/billing/plan-detection"; import { captureClientEvent } from "@/client/lib/posthog"; import { FreePlanAlert } from "./FreePlanAlert"; +import { RankTrackingDetailHeader } from "./RankTrackingDetailHeader"; +import { RankTrackingOverview } from "./RankTrackingOverview"; import { RankTrackingTable } from "./RankTrackingTable"; +import { + countMatrixRuns, + RankTrackingHistoryMatrix, +} from "./RankTrackingHistoryMatrix"; +import { RankTrackingTableToolbar } from "./RankTrackingTableToolbar"; import { exportRankTrackingCsv, exportRankTrackingToSheets, @@ -29,9 +28,6 @@ import type { RankTrackingConfig, ComparePeriod, } from "@/types/schemas/rank-tracking"; -import { LOCATIONS } from "@/client/features/keywords/locations"; -import { devicesLabel, scheduleLabel } from "@/shared/rank-tracking"; -import { ActionsMenu } from "./ActionsMenu"; import { AddKeywordsPanel } from "./AddKeywordsPanel"; import { FilterPanel, @@ -41,19 +37,24 @@ import { type Filters, } from "./RankTrackingFilters"; import { CheckConfirmModal } from "./CheckConfirmModal"; -import { SegmentedToggle } from "@/client/components/SegmentedToggle"; import { useMetricsRefresh } from "./useMetricsRefresh"; import { useRankCheckTrigger } from "./useRankCheckTrigger"; import { useRankRunPolling } from "./useRankRunPolling"; -const COMPARE_PERIODS: ReadonlySet = new Set([ - "1d", - "7d", - "30d", - "90d", -]); -function isComparePeriod(v: string): v is ComparePeriod { - return COMPARE_PERIODS.has(v); +function deviceVisibility( + devices: RankTrackingConfig["devices"], + activeDevice: "desktop" | "mobile", +): { showDesktop: boolean; showMobile: boolean } { + if (devices === "both") { + return { + showDesktop: activeDevice === "desktop", + showMobile: activeDevice === "mobile", + }; + } + return { + showDesktop: devices !== "mobile", + showMobile: devices !== "desktop", + }; } export function RankTrackingDomainDetail(props: { @@ -98,6 +99,7 @@ function RankTrackingDomainDetailInner({ const [activeDevice, setActiveDevice] = useState<"desktop" | "mobile">( config.devices === "mobile" ? "mobile" : "desktop", ); + const [viewMode, setViewMode] = useState<"table" | "history">("table"); const { data: resultsData, isLoading: resultsLoading } = useQuery({ queryKey: ["rankTrackingResults", projectId, config.id, comparePeriod], @@ -109,6 +111,17 @@ function RankTrackingDomainDetailInner({ 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({ queryKey: ["rankTrackingCostEstimate", projectId, config.id], queryFn: () => @@ -169,18 +182,18 @@ function RankTrackingDomainDetailInner({ const rows = resultsData?.rows; const run = resultsData?.run; const hasBothDevices = config.devices === "both"; - const showDesktop = hasBothDevices - ? activeDevice === "desktop" - : config.devices !== "mobile"; - const showMobile = hasBothDevices - ? activeDevice === "mobile" - : config.devices !== "desktop"; + const { showDesktop, showMobile } = deviceVisibility( + config.devices, + activeDevice, + ); const filtered = useMemo( () => applyFilters(rows ?? [], filters), [rows, filters], ); const activeFilterCount = countActiveFilters(filters); const defaultSortId = showDesktop ? "desktopPosition" : "mobilePosition"; + // Fall back to the table if history disappears (e.g. device switch). + const effectiveViewMode = historyAvailable ? viewMode : "table"; return (
@@ -216,39 +229,18 @@ function RankTrackingDomainDetailInner({ {/* Results card */}
{/* Domain header */} -
-
-

{config.domain}

-

- {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 - )} -

-
-
- - -
-
+ setShowAddKeywords((c) => !c)} + /> {showAddKeywords && (
@@ -261,109 +253,54 @@ function RankTrackingDomainDetailInner({
)} - {/* Table toolbar */} -
- - - {isRunning && latestRun ? ( -
- - - {latestRun.status === "pending" - ? "Preparing..." - : `Getting rankings for ${latestRun.keywordsTotal || "?"} keyword${latestRun.keywordsTotal !== 1 ? "s" : ""}...`}{" "} - {latestRun.keywordsChecked}/{latestRun.keywordsTotal || "?"} - - {latestRun.keywordsTotal > 0 && ( - - )} -
- ) : ( - - {filtered.length} keywords - - )} - -
- - - - {hasBothDevices && ( - , - label: "Desktop", - }, - { - value: "mobile" as const, - icon: , - label: "Mobile", - }, - ]} - value={activeDevice} - onChange={setActiveDevice} - /> - )} - - { - 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} + {/* Portfolio overview */} + {(rows?.length ?? 0) > 0 && ( + -
+ )} + + {/* Table toolbar */} + 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 */} {showFilters && ( @@ -377,18 +314,31 @@ function RankTrackingDomainDetailInner({ {/* Table */}
- + {effectiveViewMode === "history" ? ( + ({ + trackingKeywordId: r.trackingKeywordId, + keyword: r.keyword, + }))} + /> + ) : ( + + )}
diff --git a/src/client/features/rank-tracking/RankTrackingHistoryMatrix.tsx b/src/client/features/rank-tracking/RankTrackingHistoryMatrix.tsx new file mode 100644 index 0000000..4898402 --- /dev/null +++ b/src/client/features/rank-tracking/RankTrackingHistoryMatrix.tsx @@ -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 ( +
+ +
+ ); + } + + if (runs.length === 0 || keywords.length === 0) { + return ( +
+ No history yet. Run a check to start building the timeline. +
+ ); + } + + return ( +
+ + + + {/* Unconstrained keyword column absorbs the slack when only a few + check columns exist, so sparse history doesn't stretch oddly. */} + + {runs.map((r) => ( + + ))} + + + + {keywords.map((kw) => { + const byRun = cellByKeyword.get(kw.trackingKeywordId); + return ( + + + {runs.map((r, i) => { + const position = byRun?.get(r.runId) ?? null; + const previous = + i > 0 ? (byRun?.get(runs[i - 1].runId) ?? null) : undefined; + return ( + + ); + })} + + ); + })} + +
Keyword + {formatDate(r.checkedAt)} +
+ {kw.keyword} + + +
+
+ ); +} + +function MatrixCell({ + position, + previous, +}: { + position: number | null; + previous: number | null | undefined; +}) { + if (position === null) { + return ; + } + // 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 ( + + {position} + {change != null && change > 0 && ( + ▲{change} + )} + {change != null && change < 0 && ( + ▼{-change} + )} + + ); +} + +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>; +} { + const runMap = new Map(); // runId -> checkedAt + const cellByKeyword = new Map>(); + 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", + }); +} diff --git a/src/client/features/rank-tracking/RankTrackingOverview.tsx b/src/client/features/rank-tracking/RankTrackingOverview.tsx new file mode 100644 index 0000000..199087c --- /dev/null +++ b/src/client/features/rank-tracking/RankTrackingOverview.tsx @@ -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 ( +
+
+ {/* All metrics in one card */} +
+
+ + + + + +
+
+ + {/* Position distribution */} +
+
+ Position distribution + +
+ +
+ {BUCKETS.map((b) => ( + + + {b.label} + + ))} +
+ + {trendLoading ? ( +
+ +
+ ) : chartData.length <= 1 ? ( +
+ {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."} +
+ ) : ( +
+ {width > 0 ? ( + + + + + ) => { + 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 ( + + ); + }} + cursor={{ stroke: "rgba(150,150,150,0.3)" }} + /> + {BUCKETS.map((b) => ( + + ))} + + ) : null} +
+ )} +
+
+
+ ); +} + +function DistributionTooltip({ + label, + byKey, +}: { + label: number; + byKey: Map; +}) { + return ( +
+

+ {new Date(label).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + })} +

+ {BUCKETS.map((b) => ( +

+ + {b.label}: + + {byKey.get(b.key) ?? 0} + +

+ ))} +
+ ); +} + +function Scorecard({ + label, + value, + delta, + hint, +}: { + label: string; + value: string; + delta?: number | null; + hint?: string; +}) { + return ( +
+

{label}

+
+ {value} + {delta != null && delta !== 0 && ( + 0 ? "text-success" : "text-warning" + }`} + > + {delta > 0 ? "▲" : "▼"}{" "} + {Number.isInteger(delta) + ? Math.abs(delta) + : Math.abs(delta).toFixed(1)} + + )} +
+ {hint &&

{hint}

} +
+ ); +} diff --git a/src/client/features/rank-tracking/RankTrackingTable.tsx b/src/client/features/rank-tracking/RankTrackingTable.tsx index a2181f4..02531d3 100644 --- a/src/client/features/rank-tracking/RankTrackingTable.tsx +++ b/src/client/features/rank-tracking/RankTrackingTable.tsx @@ -1,4 +1,4 @@ -import { useRef, useState } from "react"; +import { useCallback, useRef, useState } from "react"; import { toast } from "sonner"; import { FileDown, Loader2, Sheet, Trash2 } from "lucide-react"; 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 { useRankTrackingColumns } from "./RankTrackingColumns"; import { buildRankTrackingExport } from "./RankTrackingTableParts"; +import { + KeywordTrendModal, + type KeywordTrendTarget, +} from "./KeywordTrendModal"; import type { SelectionAnchor } from "@/client/components/table/tableSelection"; export function RankTrackingTable({ @@ -33,6 +37,8 @@ export function RankTrackingTable({ domain, configId, projectId, + locationCode, + serpDepth, }: { totalCount: number; rows: RankTrackingRow[]; @@ -43,16 +49,31 @@ export function RankTrackingTable({ domain: string; configId: string; projectId: string; + locationCode: number; + serpDepth: number; }) { const queryClient = useQueryClient(); const [showConfirm, setShowConfirm] = useState(false); + const [trendTarget, setTrendTarget] = useState( + null, + ); const selectAnchorRef = useRef(null); + const handleKeywordClick = useCallback( + (row: RankTrackingRow) => + setTrendTarget({ + trackingKeywordId: row.trackingKeywordId, + keyword: row.keyword, + }), + [], + ); + const columns = useRankTrackingColumns( showDesktop, showMobile, domain, selectAnchorRef, + handleKeywordClick, ); const table = useAppTable({ @@ -211,6 +232,18 @@ export function RankTrackingTable({ )} + {trendTarget && ( + setTrendTarget(null)} + /> + )} + "align-top"} />

{rows.length} of {totalCount} keywords diff --git a/src/client/features/rank-tracking/RankTrackingTableParts.tsx b/src/client/features/rank-tracking/RankTrackingTableParts.tsx index cc80431..cb7ee71 100644 --- a/src/client/features/rank-tracking/RankTrackingTableParts.tsx +++ b/src/client/features/rank-tracking/RankTrackingTableParts.tsx @@ -160,7 +160,7 @@ export function CpcCell({ value }: { value: number | null }) { } /** Numeric change for CSV export — numbers bypass the CSV formula-injection sanitizer */ -function csvChange( +export function csvChange( current: number | null, previous: number | null, ): number | string { diff --git a/src/client/features/rank-tracking/RankTrackingTableToolbar.tsx b/src/client/features/rank-tracking/RankTrackingTableToolbar.tsx new file mode 100644 index 0000000..efbcecb --- /dev/null +++ b/src/client/features/rank-tracking/RankTrackingTableToolbar.tsx @@ -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 ( +

+ {/* History needs at least two checks to compare; until then the toggle + would only offer a worse copy of the Latest table. */} + {historyAvailable && ( + , + label: "Latest", + }, + { + value: "history" as const, + icon: , + label: "History", + }, + ]} + value={viewMode} + onChange={onViewModeChange} + /> + )} + + + + {isRunning && latestRun ? ( +
+ + + {latestRun.status === "pending" + ? "Preparing..." + : `Getting rankings for ${latestRun.keywordsTotal || "?"} keyword${latestRun.keywordsTotal !== 1 ? "s" : ""}...`}{" "} + {latestRun.keywordsChecked}/{latestRun.keywordsTotal || "?"} + + {latestRun.keywordsTotal > 0 && ( + + )} +
+ ) : ( + + {keywordCount} keywords + + )} + +
+ + + + +
+ ); +} diff --git a/src/client/features/rank-tracking/RankTrackingTrendChart.tsx b/src/client/features/rank-tracking/RankTrackingTrendChart.tsx new file mode 100644 index 0000000..7987310 --- /dev/null +++ b/src/client/features/rank-tracking/RankTrackingTrendChart.tsx @@ -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>; + 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 ( +
+
+ Google position (1 = best) + + Better + +
+
+ {chartWidth > 0 ? ( + + + {/* Muted bottom band: not in top {serpDepth} */} + {showBottomBand && ( + + )} + + + ) => { + 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) => ( + + ))} + + ) : null} +
+
+ ); +} + +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(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 ( +
+ {TREND_RANGES.map((range) => ( + + ))} +
+ ); +} diff --git a/src/client/features/rank-tracking/ToolbarMenus.tsx b/src/client/features/rank-tracking/ToolbarMenus.tsx new file mode 100644 index 0000000..41ab74f --- /dev/null +++ b/src/client/features/rank-tracking/ToolbarMenus.tsx @@ -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 ( +
+ + {open && ( + <> +
setOpen(false)} /> +
setOpen(false)} + > + {children} +
+ + )} +
+ ); +} + +function MenuItem({ + icon, + label, + description, + onClick, + disabled, +}: { + icon: ReactNode; + label: string; + description?: string; + onClick: () => void; + disabled?: boolean; +}) { + return ( + + ); +} + +export function MoreMenu({ + onCheckNow, + checkBusy, + checkDisabled, + onRefreshMetrics, + metricsRefreshing, + hasData, +}: { + onCheckNow: () => void; + checkBusy: boolean; + checkDisabled: boolean; + onRefreshMetrics: () => void; + metricsRefreshing: boolean; + hasData: boolean; +}) { + return ( + } + title="More actions" + > + {!checkDisabled && ( + } + label={checkBusy ? "Running..." : "Check rankings"} + description="Fetch current Google positions" + onClick={onCheckNow} + disabled={checkBusy} + /> + )} + + } + label={metricsRefreshing ? "Refreshing..." : "Update keyword stats"} + description="Volume, difficulty & CPC — not rankings" + onClick={onRefreshMetrics} + disabled={metricsRefreshing || !hasData} + /> + + ); +} + +export function ExportMenu({ + onExport, + onExportToSheets, + onCopyKeywords, + hasData, +}: { + onExport: () => void; + onExportToSheets: () => void; + onCopyKeywords: () => void; + hasData: boolean; +}) { + return ( + }> + } + label="Export to Sheets" + onClick={onExportToSheets} + disabled={!hasData} + /> + } + label="Export CSV" + onClick={onExport} + disabled={!hasData} + /> + } + label="Copy keywords" + onClick={onCopyKeywords} + disabled={!hasData} + /> + + ); +} diff --git a/src/client/features/rank-tracking/rankTrackingScorecards.test.ts b/src/client/features/rank-tracking/rankTrackingScorecards.test.ts new file mode 100644 index 0000000..80271f6 --- /dev/null +++ b/src/client/features/rank-tracking/rankTrackingScorecards.test.ts @@ -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); + }); +}); diff --git a/src/client/features/rank-tracking/rankTrackingScorecards.ts b/src/client/features/rank-tracking/rankTrackingScorecards.ts new file mode 100644 index 0000000..8d128f3 --- /dev/null +++ b/src/client/features/rank-tracking/rankTrackingScorecards.ts @@ -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, + }; +} diff --git a/src/server/features/rank-tracking/rankTrackingTimestamps.ts b/src/server/features/rank-tracking/rankTrackingTimestamps.ts new file mode 100644 index 0000000..a278c0d --- /dev/null +++ b/src/server/features/rank-tracking/rankTrackingTimestamps.ts @@ -0,0 +1,3 @@ +export function toSqliteTimestamp(date: Date): string { + return date.toISOString().slice(0, 19).replace("T", " "); +} diff --git a/src/server/features/rank-tracking/repositories/RankTrackingRepository.ts b/src/server/features/rank-tracking/repositories/RankTrackingRepository.ts index 5367790..a906516 100644 --- a/src/server/features/rank-tracking/repositories/RankTrackingRepository.ts +++ b/src/server/features/rank-tracking/repositories/RankTrackingRepository.ts @@ -12,6 +12,9 @@ import { getLatestSnapshotsForKeywords, getSnapshotsBeforeDate, getEarliestSnapshotsForKeywords, + getKeywordHistory, + getConfigTrend, + getPositionMatrix, } from "./snapshotQueries"; const DB_BATCH_SIZE = 100; @@ -380,4 +383,7 @@ export const RankTrackingRepository = { getLatestSnapshotsForKeywords, getSnapshotsBeforeDate, getEarliestSnapshotsForKeywords, + getKeywordHistory, + getConfigTrend, + getPositionMatrix, }; diff --git a/src/server/features/rank-tracking/repositories/snapshotQueries.test.ts b/src/server/features/rank-tracking/repositories/snapshotQueries.test.ts new file mode 100644 index 0000000..2451a65 --- /dev/null +++ b/src/server/features/rank-tracking/repositories/snapshotQueries.test.ts @@ -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", + ); + }); +}); diff --git a/src/server/features/rank-tracking/repositories/snapshotQueries.ts b/src/server/features/rank-tracking/repositories/snapshotQueries.ts index 33b9b7b..402d724 100644 --- a/src/server/features/rank-tracking/repositories/snapshotQueries.ts +++ b/src/server/features/rank-tracking/repositories/snapshotQueries.ts @@ -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 { 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`sum(case when ${rankSnapshots.position} between 1 and 3 then 1 else 0 end)`, + top4to10: sql`sum(case when ${rankSnapshots.position} between 4 and 10 then 1 else 0 end)`, + top11to20: sql`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 diff --git a/src/server/features/rank-tracking/services/rankTrackingResults.ts b/src/server/features/rank-tracking/services/rankTrackingResults.ts index 0e94427..0fbaa8d 100644 --- a/src/server/features/rank-tracking/services/rankTrackingResults.ts +++ b/src/server/features/rank-tracking/services/rankTrackingResults.ts @@ -1,4 +1,5 @@ import { RankTrackingRepository } from "@/server/features/rank-tracking/repositories/RankTrackingRepository"; +import { toSqliteTimestamp } from "@/server/features/rank-tracking/rankTrackingTimestamps"; import { AppError } from "@/server/lib/errors"; import type { ComparePeriod } from "@/types/schemas/rank-tracking"; import type { @@ -42,9 +43,9 @@ export async function getLatestResults( // Get comparison snapshots from before the target date const days = PERIOD_DAYS[comparePeriod]; - const targetDate = new Date( - Date.now() - days * 24 * 60 * 60 * 1000, - ).toISOString(); + const targetDate = toSqliteTimestamp( + new Date(Date.now() - days * 24 * 60 * 60 * 1000), + ); const comparisonSnapshots = await RankTrackingRepository.getSnapshotsBeforeDate(configId, targetDate); diff --git a/src/serverFunctions/rank-tracking.ts b/src/serverFunctions/rank-tracking.ts index aae5254..c77b4f3 100644 --- a/src/serverFunctions/rank-tracking.ts +++ b/src/serverFunctions/rank-tracking.ts @@ -19,8 +19,44 @@ import { addKeywordsSchema, removeKeywordsSchema, refreshMetricsSchema, + getKeywordHistorySchema, + getConfigTrendSchema, + getPositionMatrixSchema, } 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" }) .middleware(requireProjectContext) .inputValidator((data: unknown) => getConfigsSchema.parse(data)) @@ -251,3 +287,55 @@ export const refreshTrackingKeywordMetrics = createServerFn({ method: "POST" }) return result; }); + +export const getRankKeywordHistory = createServerFn({ method: "POST" }) + .middleware(requireProjectContext) + .inputValidator((data: unknown) => getKeywordHistorySchema.parse(data)) + .handler(async ({ data, context }): Promise => { + 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 => { + 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 => { + await requireConfig(data.configId, context.projectId); + return RankTrackingRepository.getPositionMatrix( + data.configId, + data.device, + data.runLimit, + ); + }); diff --git a/src/types/schemas/rank-tracking.ts b/src/types/schemas/rank-tracking.ts index 2fb176d..8451b7d 100644 --- a/src/types/schemas/rank-tracking.ts +++ b/src/types/schemas/rank-tracking.ts @@ -114,3 +114,27 @@ export const refreshMetricsSchema = z.object({ projectId: 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), +});