refactor: convert rank tracking table to Tanstack Table (#111)
This commit is contained in:
parent
0024691da8
commit
1b6bf8fd94
@ -9,8 +9,9 @@
|
||||
"src/routes/**/*.tsx",
|
||||
// Drizzle config (plugin disabled due to cloudflare:workers import issues)
|
||||
"drizzle.config.ts",
|
||||
// DB index re-exports schema for convenience
|
||||
// DB schema — exports consumed via `import * as schema` / drizzle()
|
||||
"src/db/index.ts",
|
||||
"src/db/better-auth-schema.ts",
|
||||
],
|
||||
"project": ["**/*.{js,mjs,ts,tsx}", "!src/routeTree.gen.ts", "!web/**"],
|
||||
"ignore": ["drizzle-prod.config.ts"],
|
||||
|
||||
@ -27,6 +27,7 @@
|
||||
"test:watch": "vitest",
|
||||
"test:ci": "vitest run --reporter=dot",
|
||||
"billing:backlinks": "tsx scripts/backlinks-cost-profile.ts",
|
||||
"seed:rank-tracking": "tsx scripts/seed-rank-tracking.ts",
|
||||
"ci:check": "prettier --check . && knip && tsc --noEmit && oxlint . --type-aware"
|
||||
},
|
||||
"cloudflare": {
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import process from "node:process";
|
||||
import { createBacklinksService } from "@/server/features/backlinks/services/BacklinksService";
|
||||
import type { BillingCustomerContext } from "@/server/billing/subscription";
|
||||
@ -6,6 +5,7 @@ import type {
|
||||
BacklinksLookupInput,
|
||||
BacklinksTargetScope,
|
||||
} from "@/types/schemas/backlinks";
|
||||
import { loadLocalEnv, parseArgs } from "./cli-utils";
|
||||
|
||||
loadLocalEnv();
|
||||
|
||||
@ -111,35 +111,6 @@ function buildBillingCustomer(
|
||||
};
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]) {
|
||||
const parsed: Record<string, string> = {};
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const token = argv[index];
|
||||
if (!token.startsWith("--")) continue;
|
||||
|
||||
const withoutPrefix = token.slice(2);
|
||||
const separatorIndex = withoutPrefix.indexOf("=");
|
||||
if (separatorIndex >= 0) {
|
||||
parsed[withoutPrefix.slice(0, separatorIndex)] = withoutPrefix.slice(
|
||||
separatorIndex + 1,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const next = argv[index + 1];
|
||||
if (!next || next.startsWith("--")) {
|
||||
parsed[withoutPrefix] = "true";
|
||||
continue;
|
||||
}
|
||||
|
||||
parsed[withoutPrefix] = next;
|
||||
index += 1;
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parseBoolean(value: string | undefined, fallback: boolean) {
|
||||
if (value == null) return fallback;
|
||||
return value === "true";
|
||||
@ -159,26 +130,6 @@ function parsePositiveInteger(value: string | undefined, fallback: number) {
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
||||
}
|
||||
|
||||
function loadLocalEnv() {
|
||||
for (const path of [".env.local", ".env"]) {
|
||||
if (!existsSync(path)) continue;
|
||||
const content = readFileSync(path, "utf8");
|
||||
for (const line of content.split(/\r?\n/u)) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||
|
||||
const separatorIndex = trimmed.indexOf("=");
|
||||
if (separatorIndex < 0) continue;
|
||||
|
||||
const key = trimmed.slice(0, separatorIndex).trim();
|
||||
const rawValue = trimmed.slice(separatorIndex + 1).trim();
|
||||
if (!key || process.env[key]) continue;
|
||||
|
||||
process.env[key] = rawValue.replace(/^['"]|['"]$/g, "");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function printUsageAndExit(message: string): never {
|
||||
console.error(message);
|
||||
console.error(
|
||||
|
||||
41
scripts/cli-utils.ts
Normal file
41
scripts/cli-utils.ts
Normal file
@ -0,0 +1,41 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import process from "node:process";
|
||||
|
||||
export function parseArgs(argv: string[]) {
|
||||
const parsed: Record<string, string> = {};
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const token = argv[i];
|
||||
if (!token.startsWith("--")) continue;
|
||||
const withoutPrefix = token.slice(2);
|
||||
const sep = withoutPrefix.indexOf("=");
|
||||
if (sep >= 0) {
|
||||
parsed[withoutPrefix.slice(0, sep)] = withoutPrefix.slice(sep + 1);
|
||||
continue;
|
||||
}
|
||||
const next = argv[i + 1];
|
||||
if (!next || next.startsWith("--")) {
|
||||
parsed[withoutPrefix] = "true";
|
||||
continue;
|
||||
}
|
||||
parsed[withoutPrefix] = next;
|
||||
i += 1;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export function loadLocalEnv() {
|
||||
for (const path of [".env.local", ".env"]) {
|
||||
if (!existsSync(path)) continue;
|
||||
const content = readFileSync(path, "utf8");
|
||||
for (const line of content.split(/\r?\n/u)) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||
const sep = trimmed.indexOf("=");
|
||||
if (sep < 0) continue;
|
||||
const key = trimmed.slice(0, sep).trim();
|
||||
const rawValue = trimmed.slice(sep + 1).trim();
|
||||
if (!key || process.env[key]) continue;
|
||||
process.env[key] = rawValue.replace(/^['"]|['"]$/g, "");
|
||||
}
|
||||
}
|
||||
}
|
||||
273
scripts/seed-rank-tracking.ts
Normal file
273
scripts/seed-rank-tracking.ts
Normal file
@ -0,0 +1,273 @@
|
||||
/**
|
||||
* Seed the local D1 database with rank tracking data from DataForSEO.
|
||||
*
|
||||
* Usage:
|
||||
* pnpm seed:rank-tracking --domain=example.com [--projectId=xxx]
|
||||
*
|
||||
* 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)
|
||||
*/
|
||||
|
||||
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 * as schema from "../src/db/schema";
|
||||
import {
|
||||
domainRankedKeywordItemSchema,
|
||||
type DomainRankedKeywordItem,
|
||||
} from "../src/server/lib/dataforseoSchemas";
|
||||
import { z } from "zod";
|
||||
import { loadLocalEnv, parseArgs } from "./cli-utils";
|
||||
|
||||
loadLocalEnv();
|
||||
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
|
||||
await main();
|
||||
|
||||
async function main() {
|
||||
const domain = normalizeDomain(args.domain);
|
||||
if (!domain) {
|
||||
exitWithUsage("Missing --domain argument.");
|
||||
}
|
||||
|
||||
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...`);
|
||||
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 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<typeof k> => k !== null);
|
||||
console.log(`Mapped ${keywords.length} valid keywords.`);
|
||||
|
||||
// Generate IDs
|
||||
const configId = crypto.randomUUID();
|
||||
const runId = crypto.randomUUID();
|
||||
const now = new Date().toISOString();
|
||||
|
||||
// Insert config
|
||||
await db.insert(schema.rankTrackingConfigs).values({
|
||||
id: configId,
|
||||
projectId,
|
||||
domain,
|
||||
locationCode: 2840,
|
||||
languageCode: "en",
|
||||
devices: "mobile",
|
||||
scheduleInterval: "weekly",
|
||||
isActive: true,
|
||||
lastCheckedAt: now,
|
||||
});
|
||||
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,
|
||||
}));
|
||||
const keywordStmts = keywordRows.map((row) =>
|
||||
db.insert(schema.rankTrackingKeywords).values(row).onConflictDoNothing(),
|
||||
);
|
||||
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,
|
||||
});
|
||||
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(),
|
||||
);
|
||||
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.`,
|
||||
);
|
||||
} finally {
|
||||
await dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DataForSEO
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function fetchRankedKeywords(
|
||||
apiKey: string,
|
||||
domain: string,
|
||||
limit: number,
|
||||
): Promise<DomainRankedKeywordItem[]> {
|
||||
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"}`,
|
||||
);
|
||||
}
|
||||
|
||||
const task = response.tasks?.[0];
|
||||
if (!task || task.status_code !== 20000) {
|
||||
throw new Error(
|
||||
`DataForSEO task error: ${task?.status_message ?? "no task returned"}`,
|
||||
);
|
||||
}
|
||||
|
||||
const rawItems = task.result?.[0]?.items ?? [];
|
||||
const parsed = z.array(domainRankedKeywordItemSchema).safeParse(rawItems);
|
||||
if (!parsed.success) {
|
||||
console.error("Schema validation issues:", parsed.error.issues.slice(0, 3));
|
||||
throw new Error("DataForSEO response failed schema validation");
|
||||
}
|
||||
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mapping (mirrors DomainService.mapKeywordItem)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function mapKeywordItem(item: DomainRankedKeywordItem) {
|
||||
const keywordData = item.keyword_data;
|
||||
const rankedSerpElement = item.ranked_serp_element;
|
||||
const serpItem = rankedSerpElement?.serp_item;
|
||||
|
||||
const keyword = keywordData?.keyword ?? item.keyword;
|
||||
if (!keyword) return null;
|
||||
|
||||
const position =
|
||||
serpItem?.rank_absolute ?? rankedSerpElement?.rank_absolute ?? null;
|
||||
const url = serpItem?.url ?? rankedSerpElement?.url ?? null;
|
||||
|
||||
return {
|
||||
keyword: keyword.toLowerCase().trim(),
|
||||
position: position != null ? Math.round(position) : null,
|
||||
url,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DB helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type SeedDb = ReturnType<typeof drizzle<typeof schema>>;
|
||||
|
||||
async function findFirstProject(db: SeedDb): Promise<string | null> {
|
||||
const row = await db.query.projects.findFirst();
|
||||
return row?.id ?? null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CLI helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function normalizeDomain(raw: string | undefined): string | undefined {
|
||||
if (!raw) return undefined;
|
||||
return raw
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/^https?:\/\//u, "")
|
||||
.replace(/\/.*$/u, "")
|
||||
.replace(/^www\./u, "");
|
||||
}
|
||||
|
||||
function exitWithUsage(message: string): never {
|
||||
console.error(message);
|
||||
console.error(
|
||||
"Usage: pnpm seed:rank-tracking --domain=example.com [--projectId=xxx]",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
@ -3,10 +3,9 @@ import {
|
||||
getCoreRowModel,
|
||||
getExpandedRowModel,
|
||||
getSortedRowModel,
|
||||
type SortingState,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMemo } from "react";
|
||||
import { EmptyTableState } from "./BacklinksPageEmptyTableState";
|
||||
import { backlinksColumns } from "./BacklinksTableColumns";
|
||||
import type { BacklinksOverviewData } from "./backlinksPageTypes";
|
||||
@ -17,17 +16,14 @@ export function BacklinksTable({
|
||||
}: {
|
||||
rows: BacklinksOverviewData["backlinks"];
|
||||
}) {
|
||||
const [sorting, setSorting] = useState<SortingState>([
|
||||
{ id: "firstSeen", desc: true },
|
||||
]);
|
||||
|
||||
const groupedData = useMemo(() => groupBacklinksByDomain(rows), [rows]);
|
||||
|
||||
const table = useReactTable({
|
||||
data: groupedData,
|
||||
columns: backlinksColumns,
|
||||
state: { sorting },
|
||||
onSortingChange: setSorting,
|
||||
initialState: {
|
||||
sorting: [{ id: "firstSeen", desc: true }],
|
||||
},
|
||||
getSubRows: (row) => row.subRows,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
|
||||
119
src/client/features/rank-tracking/RankTrackingColumns.tsx
Normal file
119
src/client/features/rank-tracking/RankTrackingColumns.tsx
Normal file
@ -0,0 +1,119 @@
|
||||
import { useMemo } from "react";
|
||||
import { ArrowUp, ArrowDown } from "lucide-react";
|
||||
import type { ColumnDef, SortingFn } from "@tanstack/react-table";
|
||||
import type { RankTrackingRow } from "@/types/schemas/rank-tracking";
|
||||
import { comparePositions, DeviceRankCell } from "./RankTrackingTableParts";
|
||||
|
||||
const HEADER_TOOLTIPS: Record<string, string> = {
|
||||
keyword: "The search term being tracked",
|
||||
desktopPosition: "Google ranking details on desktop devices",
|
||||
mobilePosition: "Google ranking details on mobile devices",
|
||||
};
|
||||
|
||||
function SortableHeader({
|
||||
column,
|
||||
label,
|
||||
id,
|
||||
}: {
|
||||
column: {
|
||||
getIsSorted: () => false | "asc" | "desc";
|
||||
getToggleSortingHandler: () => ((event: unknown) => void) | undefined;
|
||||
};
|
||||
label: string;
|
||||
id: string;
|
||||
}) {
|
||||
const sorted = column.getIsSorted();
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 text-xs uppercase tracking-wide font-medium text-base-content/60 transition-colors hover:text-base-content"
|
||||
onClick={column.getToggleSortingHandler()}
|
||||
title={HEADER_TOOLTIPS[id]}
|
||||
aria-label={`Sort by ${label}`}
|
||||
aria-pressed={!!sorted}
|
||||
>
|
||||
{label}
|
||||
{sorted === "asc" ? (
|
||||
<ArrowUp className="size-3 shrink-0" />
|
||||
) : sorted === "desc" ? (
|
||||
<ArrowDown className="size-3 shrink-0" />
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
const positionSort: SortingFn<RankTrackingRow> = (rowA, rowB, columnId) => {
|
||||
const device = columnId === "desktopPosition" ? "desktop" : "mobile";
|
||||
return comparePositions(
|
||||
rowA.original[device].position,
|
||||
rowB.original[device].position,
|
||||
);
|
||||
};
|
||||
|
||||
const selectColumn: ColumnDef<RankTrackingRow> = {
|
||||
id: "select",
|
||||
size: 32,
|
||||
enableSorting: false,
|
||||
header: ({ table }) => (
|
||||
<input
|
||||
type="checkbox"
|
||||
className="checkbox checkbox-xs"
|
||||
checked={table.getIsAllRowsSelected()}
|
||||
onChange={table.getToggleAllRowsSelectedHandler()}
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<input
|
||||
type="checkbox"
|
||||
className="checkbox checkbox-xs"
|
||||
checked={row.getIsSelected()}
|
||||
onChange={row.getToggleSelectedHandler()}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
const keywordColumn: ColumnDef<RankTrackingRow> = {
|
||||
id: "keyword",
|
||||
accessorKey: "keyword",
|
||||
header: ({ column }) => (
|
||||
<SortableHeader column={column} label="Keyword" id="keyword" />
|
||||
),
|
||||
cell: ({ getValue }) => (
|
||||
<span className="font-medium">{getValue<string>()}</span>
|
||||
),
|
||||
sortingFn: "alphanumeric",
|
||||
};
|
||||
|
||||
function makeDeviceColumn(
|
||||
device: "desktop" | "mobile",
|
||||
domain: string,
|
||||
): ColumnDef<RankTrackingRow> {
|
||||
const id = device === "desktop" ? "desktopPosition" : "mobilePosition";
|
||||
const label = device === "desktop" ? "Desktop" : "Mobile";
|
||||
return {
|
||||
id,
|
||||
accessorFn: (row) => row[device].position,
|
||||
header: ({ column }) => (
|
||||
<SortableHeader column={column} label={label} id={id} />
|
||||
),
|
||||
size: 176,
|
||||
minSize: 176,
|
||||
cell: ({ row }) => (
|
||||
<DeviceRankCell result={row.original[device]} domain={domain} />
|
||||
),
|
||||
sortingFn: positionSort,
|
||||
};
|
||||
}
|
||||
|
||||
export function useRankTrackingColumns(
|
||||
showDesktop: boolean,
|
||||
showMobile: boolean,
|
||||
domain: string,
|
||||
): ColumnDef<RankTrackingRow>[] {
|
||||
return useMemo(() => {
|
||||
const cols: ColumnDef<RankTrackingRow>[] = [selectColumn, keywordColumn];
|
||||
if (showDesktop) cols.push(makeDeviceColumn("desktop", domain));
|
||||
if (showMobile) cols.push(makeDeviceColumn("mobile", domain));
|
||||
return cols;
|
||||
}, [showDesktop, showMobile, domain]);
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
@ -14,9 +14,12 @@ import {
|
||||
SlidersHorizontal,
|
||||
} from "lucide-react";
|
||||
import { captureClientEvent } from "@/client/lib/posthog";
|
||||
import { RankTrackingTable, exportRankTrackingCsv } from "./RankTrackingTable";
|
||||
import type { RankTrackingConfig } from "@/types/schemas/rank-tracking";
|
||||
import type { ComparePeriod } from "@/types/schemas/rank-tracking";
|
||||
import { RankTrackingTable } from "./RankTrackingTable";
|
||||
import { exportRankTrackingCsv } from "./RankTrackingTableParts";
|
||||
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";
|
||||
@ -31,7 +34,6 @@ import {
|
||||
import { CheckConfirmModal } from "./CheckConfirmModal";
|
||||
import { useRankCheckTrigger } from "./useRankCheckTrigger";
|
||||
import { useRankRunPolling } from "./useRankRunPolling";
|
||||
import { useRankTableSort } from "./useRankTableSort";
|
||||
|
||||
const COMPARE_PERIODS: ReadonlySet<string> = new Set([
|
||||
"previous",
|
||||
@ -120,18 +122,17 @@ export function RankTrackingDomainDetail({
|
||||
setPendingCheck({ count, keywordIds });
|
||||
};
|
||||
|
||||
const rows = resultsData?.rows ?? [];
|
||||
const rows = resultsData?.rows;
|
||||
const run = resultsData?.run;
|
||||
const showDesktop = config.devices !== "mobile";
|
||||
const showMobile = config.devices !== "desktop";
|
||||
const filtered = applyFilters(rows, filters);
|
||||
const activeFilterCount = countActiveFilters(filters);
|
||||
const defaultSort =
|
||||
config.devices === "desktop" ? "desktopPosition" : "mobilePosition";
|
||||
const { sorted, sortField, sortDir, handleSort } = useRankTableSort(
|
||||
filtered,
|
||||
defaultSort,
|
||||
const filtered = useMemo(
|
||||
() => applyFilters(rows ?? [], filters),
|
||||
[rows, filters],
|
||||
);
|
||||
const activeFilterCount = countActiveFilters(filters);
|
||||
const defaultSortId =
|
||||
config.devices === "desktop" ? "desktopPosition" : "mobilePosition";
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
@ -263,24 +264,24 @@ export function RankTrackingDomainDetail({
|
||||
|
||||
<ActionsMenu
|
||||
onCheckNow={() => {
|
||||
const count = costEstimate?.keywordCount ?? rows.length;
|
||||
const count = costEstimate?.keywordCount ?? rows?.length ?? 0;
|
||||
if (count > 0) requestCheck(count);
|
||||
}}
|
||||
onExport={() =>
|
||||
exportRankTrackingCsv(
|
||||
sorted,
|
||||
filtered,
|
||||
showDesktop,
|
||||
showMobile,
|
||||
config.domain,
|
||||
)
|
||||
}
|
||||
onCopyKeywords={() => {
|
||||
const text = sorted.map((r) => r.keyword).join("\n");
|
||||
const text = filtered.map((r) => r.keyword).join("\n");
|
||||
void navigator.clipboard.writeText(text);
|
||||
toast.success("Keywords copied to clipboard");
|
||||
}}
|
||||
isRunning={isBusy}
|
||||
hasData={sorted.length > 0}
|
||||
hasData={filtered.length > 0}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -297,14 +298,12 @@ export function RankTrackingDomainDetail({
|
||||
{/* Table */}
|
||||
<div className="p-4">
|
||||
<RankTrackingTable
|
||||
totalCount={rows.length}
|
||||
sorted={sorted}
|
||||
totalCount={rows?.length ?? 0}
|
||||
rows={filtered}
|
||||
resultsLoading={resultsLoading}
|
||||
showDesktop={showDesktop}
|
||||
showMobile={showMobile}
|
||||
sortField={sortField}
|
||||
sortDir={sortDir}
|
||||
onSort={handleSort}
|
||||
defaultSortId={defaultSortId}
|
||||
domain={config.domain}
|
||||
configId={config.id}
|
||||
projectId={projectId}
|
||||
|
||||
@ -139,26 +139,28 @@ export function applyFilters(
|
||||
rows: RankTrackingRow[],
|
||||
filters: Filters,
|
||||
): RankTrackingRow[] {
|
||||
const includeTerms = filters.include
|
||||
? filters.include
|
||||
.toLowerCase()
|
||||
.split(",")
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
const excludeTerms = filters.exclude
|
||||
? filters.exclude
|
||||
.toLowerCase()
|
||||
.split(",")
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
|
||||
return rows.filter((row) => {
|
||||
const kw = row.keyword.toLowerCase();
|
||||
|
||||
if (filters.include) {
|
||||
const terms = filters.include
|
||||
.toLowerCase()
|
||||
.split(",")
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean);
|
||||
if (terms.length > 0 && !terms.some((t) => kw.includes(t))) return false;
|
||||
}
|
||||
if (includeTerms.length > 0 && !includeTerms.some((t) => kw.includes(t)))
|
||||
return false;
|
||||
|
||||
if (filters.exclude) {
|
||||
const terms = filters.exclude
|
||||
.toLowerCase()
|
||||
.split(",")
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean);
|
||||
if (terms.some((t) => kw.includes(t))) return false;
|
||||
}
|
||||
if (excludeTerms.some((t) => kw.includes(t))) return false;
|
||||
|
||||
if (filters.minDesktopPos || filters.maxDesktopPos) {
|
||||
const min = filters.minDesktopPos ? Number(filters.minDesktopPos) : 0;
|
||||
|
||||
@ -1,64 +1,66 @@
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Loader2, Trash2 } from "lucide-react";
|
||||
import {
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table";
|
||||
import { Modal } from "@/client/components/Modal";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { removeTrackingKeywords } from "@/serverFunctions/rank-tracking";
|
||||
import { getStandardErrorMessage } from "@/client/lib/error-messages";
|
||||
import type { RankTrackingRow } from "@/types/schemas/rank-tracking";
|
||||
import {
|
||||
SortableHeader,
|
||||
DeviceRankCell,
|
||||
type SortField,
|
||||
type SortDir,
|
||||
} from "./RankTrackingTableParts";
|
||||
export {
|
||||
comparePositions,
|
||||
exportRankTrackingCsv,
|
||||
} from "./RankTrackingTableParts";
|
||||
export type { SortField, SortDir } from "./RankTrackingTableParts";
|
||||
import { useRankTrackingColumns } from "./RankTrackingColumns";
|
||||
|
||||
export function RankTrackingTable({
|
||||
totalCount,
|
||||
sorted,
|
||||
rows,
|
||||
resultsLoading,
|
||||
showDesktop,
|
||||
showMobile,
|
||||
sortField,
|
||||
sortDir,
|
||||
onSort,
|
||||
defaultSortId,
|
||||
domain,
|
||||
configId,
|
||||
projectId,
|
||||
}: {
|
||||
totalCount: number;
|
||||
sorted: RankTrackingRow[];
|
||||
rows: RankTrackingRow[];
|
||||
resultsLoading: boolean;
|
||||
showDesktop: boolean;
|
||||
showMobile: boolean;
|
||||
sortField: SortField;
|
||||
sortDir: SortDir;
|
||||
onSort: (field: SortField) => void;
|
||||
defaultSortId: string;
|
||||
domain: string;
|
||||
configId: string;
|
||||
projectId: string;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [showConfirm, setShowConfirm] = useState(false);
|
||||
|
||||
// Only count/act on selections that are currently visible
|
||||
const visibleIds = new Set(sorted.map((r) => r.trackingKeywordId));
|
||||
const visibleSelected = new Set(
|
||||
[...selected].filter((id) => visibleIds.has(id)),
|
||||
);
|
||||
const visibleSelectedCount = visibleSelected.size;
|
||||
const columns = useRankTrackingColumns(showDesktop, showMobile, domain);
|
||||
|
||||
const table = useReactTable({
|
||||
data: rows,
|
||||
columns,
|
||||
initialState: {
|
||||
sorting: [{ id: defaultSortId, desc: false }],
|
||||
},
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getRowId: (row) => row.trackingKeywordId,
|
||||
enableRowSelection: true,
|
||||
});
|
||||
|
||||
// Only includes rows that are in the current data (respects parent filtering)
|
||||
const selectedRows = table.getSelectedRowModel().rows;
|
||||
const selectedCount = selectedRows.length;
|
||||
|
||||
const removeMutation = useMutation({
|
||||
mutationFn: (keywordIds: string[]) =>
|
||||
removeTrackingKeywords({ data: { projectId, configId, keywordIds } }),
|
||||
onSuccess: (result) => {
|
||||
setSelected(new Set());
|
||||
table.resetRowSelection();
|
||||
setShowConfirm(false);
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ["rankTrackingResults", projectId, configId],
|
||||
@ -75,23 +77,6 @@ export function RankTrackingTable({
|
||||
},
|
||||
});
|
||||
|
||||
const toggleSelect = (id: string) => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleAll = () => {
|
||||
if (visibleSelectedCount === sorted.length && sorted.length > 0) {
|
||||
setSelected(new Set());
|
||||
} else {
|
||||
setSelected(new Set(sorted.map((r) => r.trackingKeywordId)));
|
||||
}
|
||||
};
|
||||
|
||||
if (resultsLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
@ -100,7 +85,7 @@ export function RankTrackingTable({
|
||||
);
|
||||
}
|
||||
|
||||
if (sorted.length === 0) {
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<div className="rounded-xl border border-dashed border-base-300 p-10 text-center text-sm text-base-content/55">
|
||||
{totalCount === 0
|
||||
@ -110,17 +95,14 @@ export function RankTrackingTable({
|
||||
);
|
||||
}
|
||||
|
||||
const allSelected =
|
||||
visibleSelectedCount === sorted.length && sorted.length > 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Bulk action bar */}
|
||||
{visibleSelectedCount > 0 && (
|
||||
{selectedCount > 0 && (
|
||||
<div className="flex items-center gap-3 rounded-lg bg-base-200 px-3 py-2 text-sm">
|
||||
<span className="text-base-content/70">
|
||||
{visibleSelectedCount} keyword
|
||||
{visibleSelectedCount !== 1 ? "s" : ""} selected
|
||||
{selectedCount} keyword
|
||||
{selectedCount !== 1 ? "s" : ""} selected
|
||||
</span>
|
||||
<button
|
||||
className="btn btn-error btn-xs gap-1"
|
||||
@ -131,7 +113,7 @@ export function RankTrackingTable({
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-ghost btn-xs"
|
||||
onClick={() => setSelected(new Set())}
|
||||
onClick={() => table.resetRowSelection()}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
@ -143,8 +125,8 @@ export function RankTrackingTable({
|
||||
<Modal>
|
||||
<h3 className="text-lg font-semibold">Remove keywords?</h3>
|
||||
<p className="text-sm text-base-content/70">
|
||||
This will stop tracking {visibleSelectedCount} keyword
|
||||
{visibleSelectedCount !== 1 ? "s" : ""}. Historical ranking data is
|
||||
This will stop tracking {selectedCount} keyword
|
||||
{selectedCount !== 1 ? "s" : ""}. Historical ranking data is
|
||||
preserved but won't appear in the table.
|
||||
</p>
|
||||
<div className="flex justify-end gap-2">
|
||||
@ -156,14 +138,16 @@ export function RankTrackingTable({
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-error btn-sm gap-1"
|
||||
onClick={() => removeMutation.mutate([...visibleSelected])}
|
||||
onClick={() =>
|
||||
removeMutation.mutate(selectedRows.map((r) => r.id))
|
||||
}
|
||||
disabled={removeMutation.isPending}
|
||||
>
|
||||
{removeMutation.isPending && (
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
)}
|
||||
Remove {visibleSelectedCount} keyword
|
||||
{visibleSelectedCount !== 1 ? "s" : ""}
|
||||
Remove {selectedCount} keyword
|
||||
{selectedCount !== 1 ? "s" : ""}
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
@ -172,73 +156,36 @@ export function RankTrackingTable({
|
||||
<div className="overflow-x-auto">
|
||||
<table className="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="w-8">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="checkbox checkbox-xs"
|
||||
checked={allSelected}
|
||||
onChange={toggleAll}
|
||||
/>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<tr key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<th key={header.id}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext(),
|
||||
)}
|
||||
</th>
|
||||
<SortableHeader
|
||||
label="Keyword"
|
||||
field="keyword"
|
||||
currentField={sortField}
|
||||
currentDir={sortDir}
|
||||
onClick={onSort}
|
||||
/>
|
||||
{showDesktop && (
|
||||
<SortableHeader
|
||||
label="Desktop"
|
||||
field="desktopPosition"
|
||||
currentField={sortField}
|
||||
currentDir={sortDir}
|
||||
onClick={onSort}
|
||||
className="min-w-44"
|
||||
/>
|
||||
)}
|
||||
{showMobile && (
|
||||
<SortableHeader
|
||||
label="Mobile"
|
||||
field="mobilePosition"
|
||||
currentField={sortField}
|
||||
currentDir={sortDir}
|
||||
onClick={onSort}
|
||||
className="min-w-44"
|
||||
/>
|
||||
)}
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</thead>
|
||||
<tbody>
|
||||
{sorted.map((row) => (
|
||||
<tr key={row.trackingKeywordId}>
|
||||
<td className="w-8">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="checkbox checkbox-xs"
|
||||
checked={selected.has(row.trackingKeywordId)}
|
||||
onChange={() => toggleSelect(row.trackingKeywordId)}
|
||||
/>
|
||||
{table.getRowModel().rows.map((row) => (
|
||||
<tr key={row.id}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<td key={cell.id} className="align-top">
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</td>
|
||||
<td className="font-medium">{row.keyword}</td>
|
||||
{showDesktop && (
|
||||
<td className="align-top">
|
||||
<DeviceRankCell result={row.desktop} domain={domain} />
|
||||
</td>
|
||||
)}
|
||||
{showMobile && (
|
||||
<td className="align-top">
|
||||
<DeviceRankCell result={row.mobile} domain={domain} />
|
||||
</td>
|
||||
)}
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p className="text-xs text-base-content/60 pt-2">
|
||||
{sorted.length} of {totalCount} keywords
|
||||
{rows.length} of {totalCount} keywords
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
|
||||
@ -7,45 +7,6 @@ import type {
|
||||
RankTrackingRow,
|
||||
} from "@/types/schemas/rank-tracking";
|
||||
|
||||
export type SortField = "keyword" | "desktopPosition" | "mobilePosition";
|
||||
export type SortDir = "asc" | "desc";
|
||||
|
||||
const HEADER_TOOLTIPS: Record<string, string> = {
|
||||
keyword: "The search term being tracked",
|
||||
desktopPosition: "Google ranking details on desktop devices",
|
||||
mobilePosition: "Google ranking details on mobile devices",
|
||||
};
|
||||
|
||||
export function SortableHeader({
|
||||
label,
|
||||
field,
|
||||
currentField,
|
||||
currentDir,
|
||||
onClick,
|
||||
className = "",
|
||||
}: {
|
||||
label: string;
|
||||
field: SortField;
|
||||
currentField: SortField;
|
||||
currentDir: SortDir;
|
||||
onClick: (field: SortField) => void;
|
||||
className?: string;
|
||||
}) {
|
||||
const isActive = currentField === field;
|
||||
return (
|
||||
<th
|
||||
className={`cursor-pointer select-none text-xs uppercase tracking-wide text-base-content/60 hover:text-base-content ${className}`}
|
||||
onClick={() => onClick(field)}
|
||||
title={HEADER_TOOLTIPS[field]}
|
||||
>
|
||||
{label}
|
||||
{isActive && (
|
||||
<span className="ml-1">{currentDir === "asc" ? "↑" : "↓"}</span>
|
||||
)}
|
||||
</th>
|
||||
);
|
||||
}
|
||||
|
||||
function PositionBadge({ position }: { position: number | null }) {
|
||||
if (position === null) {
|
||||
return <span className="text-base-content/40">-</span>;
|
||||
|
||||
@ -1,37 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
comparePositions,
|
||||
type SortField,
|
||||
type SortDir,
|
||||
} from "./RankTrackingTable";
|
||||
import type { RankTrackingRow } from "@/types/schemas/rank-tracking";
|
||||
|
||||
export function useRankTableSort(
|
||||
rows: RankTrackingRow[],
|
||||
defaultField: SortField,
|
||||
) {
|
||||
const [sortField, setSortField] = useState<SortField>(defaultField);
|
||||
const [sortDir, setSortDir] = useState<SortDir>("asc");
|
||||
|
||||
useEffect(() => {
|
||||
setSortField(defaultField);
|
||||
}, [defaultField]);
|
||||
|
||||
const sorted = rows.toSorted((a, b) => {
|
||||
const dir = sortDir === "asc" ? 1 : -1;
|
||||
if (sortField === "keyword")
|
||||
return dir * a.keyword.localeCompare(b.keyword);
|
||||
const device = sortField === "desktopPosition" ? "desktop" : "mobile";
|
||||
return dir * comparePositions(a[device].position, b[device].position);
|
||||
});
|
||||
|
||||
const handleSort = (field: SortField) => {
|
||||
if (sortField === field) setSortDir((d) => (d === "asc" ? "desc" : "asc"));
|
||||
else {
|
||||
setSortField(field);
|
||||
setSortDir("asc");
|
||||
}
|
||||
};
|
||||
|
||||
return { sorted, sortField, sortDir, handleSort };
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user