refactor: convert rank tracking table to Tanstack Table (#111)

This commit is contained in:
Ben Senescu 2026-04-15 10:42:28 -04:00 committed by GitHub
parent 0024691da8
commit 1b6bf8fd94
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 542 additions and 288 deletions

View File

@ -9,8 +9,9 @@
"src/routes/**/*.tsx", "src/routes/**/*.tsx",
// Drizzle config (plugin disabled due to cloudflare:workers import issues) // Drizzle config (plugin disabled due to cloudflare:workers import issues)
"drizzle.config.ts", "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/index.ts",
"src/db/better-auth-schema.ts",
], ],
"project": ["**/*.{js,mjs,ts,tsx}", "!src/routeTree.gen.ts", "!web/**"], "project": ["**/*.{js,mjs,ts,tsx}", "!src/routeTree.gen.ts", "!web/**"],
"ignore": ["drizzle-prod.config.ts"], "ignore": ["drizzle-prod.config.ts"],

View File

@ -27,6 +27,7 @@
"test:watch": "vitest", "test:watch": "vitest",
"test:ci": "vitest run --reporter=dot", "test:ci": "vitest run --reporter=dot",
"billing:backlinks": "tsx scripts/backlinks-cost-profile.ts", "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" "ci:check": "prettier --check . && knip && tsc --noEmit && oxlint . --type-aware"
}, },
"cloudflare": { "cloudflare": {

View File

@ -1,4 +1,3 @@
import { existsSync, readFileSync } from "node:fs";
import process from "node:process"; import process from "node:process";
import { createBacklinksService } from "@/server/features/backlinks/services/BacklinksService"; import { createBacklinksService } from "@/server/features/backlinks/services/BacklinksService";
import type { BillingCustomerContext } from "@/server/billing/subscription"; import type { BillingCustomerContext } from "@/server/billing/subscription";
@ -6,6 +5,7 @@ import type {
BacklinksLookupInput, BacklinksLookupInput,
BacklinksTargetScope, BacklinksTargetScope,
} from "@/types/schemas/backlinks"; } from "@/types/schemas/backlinks";
import { loadLocalEnv, parseArgs } from "./cli-utils";
loadLocalEnv(); 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) { function parseBoolean(value: string | undefined, fallback: boolean) {
if (value == null) return fallback; if (value == null) return fallback;
return value === "true"; return value === "true";
@ -159,26 +130,6 @@ function parsePositiveInteger(value: string | undefined, fallback: number) {
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; 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 { function printUsageAndExit(message: string): never {
console.error(message); console.error(message);
console.error( console.error(

41
scripts/cli-utils.ts Normal file
View 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, "");
}
}
}

View 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);
}

View File

@ -3,10 +3,9 @@ import {
getCoreRowModel, getCoreRowModel,
getExpandedRowModel, getExpandedRowModel,
getSortedRowModel, getSortedRowModel,
type SortingState,
useReactTable, useReactTable,
} from "@tanstack/react-table"; } from "@tanstack/react-table";
import { useMemo, useState } from "react"; import { useMemo } from "react";
import { EmptyTableState } from "./BacklinksPageEmptyTableState"; import { EmptyTableState } from "./BacklinksPageEmptyTableState";
import { backlinksColumns } from "./BacklinksTableColumns"; import { backlinksColumns } from "./BacklinksTableColumns";
import type { BacklinksOverviewData } from "./backlinksPageTypes"; import type { BacklinksOverviewData } from "./backlinksPageTypes";
@ -17,17 +16,14 @@ export function BacklinksTable({
}: { }: {
rows: BacklinksOverviewData["backlinks"]; rows: BacklinksOverviewData["backlinks"];
}) { }) {
const [sorting, setSorting] = useState<SortingState>([
{ id: "firstSeen", desc: true },
]);
const groupedData = useMemo(() => groupBacklinksByDomain(rows), [rows]); const groupedData = useMemo(() => groupBacklinksByDomain(rows), [rows]);
const table = useReactTable({ const table = useReactTable({
data: groupedData, data: groupedData,
columns: backlinksColumns, columns: backlinksColumns,
state: { sorting }, initialState: {
onSortingChange: setSorting, sorting: [{ id: "firstSeen", desc: true }],
},
getSubRows: (row) => row.subRows, getSubRows: (row) => row.subRows,
getCoreRowModel: getCoreRowModel(), getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(), getSortedRowModel: getSortedRowModel(),

View 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]);
}

View File

@ -1,4 +1,4 @@
import { useState } from "react"; import { useMemo, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { import {
@ -14,9 +14,12 @@ import {
SlidersHorizontal, SlidersHorizontal,
} from "lucide-react"; } from "lucide-react";
import { captureClientEvent } from "@/client/lib/posthog"; import { captureClientEvent } from "@/client/lib/posthog";
import { RankTrackingTable, exportRankTrackingCsv } from "./RankTrackingTable"; import { RankTrackingTable } from "./RankTrackingTable";
import type { RankTrackingConfig } from "@/types/schemas/rank-tracking"; import { exportRankTrackingCsv } from "./RankTrackingTableParts";
import type { ComparePeriod } from "@/types/schemas/rank-tracking"; import type {
RankTrackingConfig,
ComparePeriod,
} from "@/types/schemas/rank-tracking";
import { LOCATIONS } from "@/client/features/keywords/locations"; import { LOCATIONS } from "@/client/features/keywords/locations";
import { devicesLabel, scheduleLabel } from "@/shared/rank-tracking"; import { devicesLabel, scheduleLabel } from "@/shared/rank-tracking";
import { ActionsMenu } from "./ActionsMenu"; import { ActionsMenu } from "./ActionsMenu";
@ -31,7 +34,6 @@ import {
import { CheckConfirmModal } from "./CheckConfirmModal"; import { CheckConfirmModal } from "./CheckConfirmModal";
import { useRankCheckTrigger } from "./useRankCheckTrigger"; import { useRankCheckTrigger } from "./useRankCheckTrigger";
import { useRankRunPolling } from "./useRankRunPolling"; import { useRankRunPolling } from "./useRankRunPolling";
import { useRankTableSort } from "./useRankTableSort";
const COMPARE_PERIODS: ReadonlySet<string> = new Set([ const COMPARE_PERIODS: ReadonlySet<string> = new Set([
"previous", "previous",
@ -120,18 +122,17 @@ export function RankTrackingDomainDetail({
setPendingCheck({ count, keywordIds }); setPendingCheck({ count, keywordIds });
}; };
const rows = resultsData?.rows ?? []; const rows = resultsData?.rows;
const run = resultsData?.run; const run = resultsData?.run;
const showDesktop = config.devices !== "mobile"; const showDesktop = config.devices !== "mobile";
const showMobile = config.devices !== "desktop"; const showMobile = config.devices !== "desktop";
const filtered = applyFilters(rows, filters); const filtered = useMemo(
const activeFilterCount = countActiveFilters(filters); () => applyFilters(rows ?? [], filters),
const defaultSort = [rows, filters],
config.devices === "desktop" ? "desktopPosition" : "mobilePosition";
const { sorted, sortField, sortDir, handleSort } = useRankTableSort(
filtered,
defaultSort,
); );
const activeFilterCount = countActiveFilters(filters);
const defaultSortId =
config.devices === "desktop" ? "desktopPosition" : "mobilePosition";
return ( return (
<div className="space-y-3"> <div className="space-y-3">
@ -263,24 +264,24 @@ export function RankTrackingDomainDetail({
<ActionsMenu <ActionsMenu
onCheckNow={() => { onCheckNow={() => {
const count = costEstimate?.keywordCount ?? rows.length; const count = costEstimate?.keywordCount ?? rows?.length ?? 0;
if (count > 0) requestCheck(count); if (count > 0) requestCheck(count);
}} }}
onExport={() => onExport={() =>
exportRankTrackingCsv( exportRankTrackingCsv(
sorted, filtered,
showDesktop, showDesktop,
showMobile, showMobile,
config.domain, config.domain,
) )
} }
onCopyKeywords={() => { onCopyKeywords={() => {
const text = sorted.map((r) => r.keyword).join("\n"); const text = filtered.map((r) => r.keyword).join("\n");
void navigator.clipboard.writeText(text); void navigator.clipboard.writeText(text);
toast.success("Keywords copied to clipboard"); toast.success("Keywords copied to clipboard");
}} }}
isRunning={isBusy} isRunning={isBusy}
hasData={sorted.length > 0} hasData={filtered.length > 0}
/> />
</div> </div>
@ -297,14 +298,12 @@ export function RankTrackingDomainDetail({
{/* Table */} {/* Table */}
<div className="p-4"> <div className="p-4">
<RankTrackingTable <RankTrackingTable
totalCount={rows.length} totalCount={rows?.length ?? 0}
sorted={sorted} rows={filtered}
resultsLoading={resultsLoading} resultsLoading={resultsLoading}
showDesktop={showDesktop} showDesktop={showDesktop}
showMobile={showMobile} showMobile={showMobile}
sortField={sortField} defaultSortId={defaultSortId}
sortDir={sortDir}
onSort={handleSort}
domain={config.domain} domain={config.domain}
configId={config.id} configId={config.id}
projectId={projectId} projectId={projectId}

View File

@ -139,26 +139,28 @@ export function applyFilters(
rows: RankTrackingRow[], rows: RankTrackingRow[],
filters: Filters, filters: Filters,
): RankTrackingRow[] { ): 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) => { return rows.filter((row) => {
const kw = row.keyword.toLowerCase(); const kw = row.keyword.toLowerCase();
if (filters.include) { if (includeTerms.length > 0 && !includeTerms.some((t) => kw.includes(t)))
const terms = filters.include return false;
.toLowerCase()
.split(",")
.map((t) => t.trim())
.filter(Boolean);
if (terms.length > 0 && !terms.some((t) => kw.includes(t))) return false;
}
if (filters.exclude) { if (excludeTerms.some((t) => kw.includes(t))) return false;
const terms = filters.exclude
.toLowerCase()
.split(",")
.map((t) => t.trim())
.filter(Boolean);
if (terms.some((t) => kw.includes(t))) return false;
}
if (filters.minDesktopPos || filters.maxDesktopPos) { if (filters.minDesktopPos || filters.maxDesktopPos) {
const min = filters.minDesktopPos ? Number(filters.minDesktopPos) : 0; const min = filters.minDesktopPos ? Number(filters.minDesktopPos) : 0;

View File

@ -1,64 +1,66 @@
import { useState } from "react"; import { useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Loader2, Trash2 } from "lucide-react"; import { Loader2, Trash2 } from "lucide-react";
import {
flexRender,
getCoreRowModel,
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table";
import { Modal } from "@/client/components/Modal"; import { Modal } from "@/client/components/Modal";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { removeTrackingKeywords } from "@/serverFunctions/rank-tracking"; import { removeTrackingKeywords } from "@/serverFunctions/rank-tracking";
import { getStandardErrorMessage } from "@/client/lib/error-messages"; import { getStandardErrorMessage } from "@/client/lib/error-messages";
import type { RankTrackingRow } from "@/types/schemas/rank-tracking"; import type { RankTrackingRow } from "@/types/schemas/rank-tracking";
import { import { useRankTrackingColumns } from "./RankTrackingColumns";
SortableHeader,
DeviceRankCell,
type SortField,
type SortDir,
} from "./RankTrackingTableParts";
export {
comparePositions,
exportRankTrackingCsv,
} from "./RankTrackingTableParts";
export type { SortField, SortDir } from "./RankTrackingTableParts";
export function RankTrackingTable({ export function RankTrackingTable({
totalCount, totalCount,
sorted, rows,
resultsLoading, resultsLoading,
showDesktop, showDesktop,
showMobile, showMobile,
sortField, defaultSortId,
sortDir,
onSort,
domain, domain,
configId, configId,
projectId, projectId,
}: { }: {
totalCount: number; totalCount: number;
sorted: RankTrackingRow[]; rows: RankTrackingRow[];
resultsLoading: boolean; resultsLoading: boolean;
showDesktop: boolean; showDesktop: boolean;
showMobile: boolean; showMobile: boolean;
sortField: SortField; defaultSortId: string;
sortDir: SortDir;
onSort: (field: SortField) => void;
domain: string; domain: string;
configId: string; configId: string;
projectId: string; projectId: string;
}) { }) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [selected, setSelected] = useState<Set<string>>(new Set());
const [showConfirm, setShowConfirm] = useState(false); const [showConfirm, setShowConfirm] = useState(false);
// Only count/act on selections that are currently visible const columns = useRankTrackingColumns(showDesktop, showMobile, domain);
const visibleIds = new Set(sorted.map((r) => r.trackingKeywordId));
const visibleSelected = new Set( const table = useReactTable({
[...selected].filter((id) => visibleIds.has(id)), data: rows,
); columns,
const visibleSelectedCount = visibleSelected.size; 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({ const removeMutation = useMutation({
mutationFn: (keywordIds: string[]) => mutationFn: (keywordIds: string[]) =>
removeTrackingKeywords({ data: { projectId, configId, keywordIds } }), removeTrackingKeywords({ data: { projectId, configId, keywordIds } }),
onSuccess: (result) => { onSuccess: (result) => {
setSelected(new Set()); table.resetRowSelection();
setShowConfirm(false); setShowConfirm(false);
void queryClient.invalidateQueries({ void queryClient.invalidateQueries({
queryKey: ["rankTrackingResults", projectId, configId], 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) { if (resultsLoading) {
return ( return (
<div className="flex items-center justify-center p-8"> <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 ( return (
<div className="rounded-xl border border-dashed border-base-300 p-10 text-center text-sm text-base-content/55"> <div className="rounded-xl border border-dashed border-base-300 p-10 text-center text-sm text-base-content/55">
{totalCount === 0 {totalCount === 0
@ -110,17 +95,14 @@ export function RankTrackingTable({
); );
} }
const allSelected =
visibleSelectedCount === sorted.length && sorted.length > 0;
return ( return (
<> <>
{/* Bulk action bar */} {/* 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"> <div className="flex items-center gap-3 rounded-lg bg-base-200 px-3 py-2 text-sm">
<span className="text-base-content/70"> <span className="text-base-content/70">
{visibleSelectedCount} keyword {selectedCount} keyword
{visibleSelectedCount !== 1 ? "s" : ""} selected {selectedCount !== 1 ? "s" : ""} selected
</span> </span>
<button <button
className="btn btn-error btn-xs gap-1" className="btn btn-error btn-xs gap-1"
@ -131,7 +113,7 @@ export function RankTrackingTable({
</button> </button>
<button <button
className="btn btn-ghost btn-xs" className="btn btn-ghost btn-xs"
onClick={() => setSelected(new Set())} onClick={() => table.resetRowSelection()}
> >
Clear Clear
</button> </button>
@ -143,8 +125,8 @@ export function RankTrackingTable({
<Modal> <Modal>
<h3 className="text-lg font-semibold">Remove keywords?</h3> <h3 className="text-lg font-semibold">Remove keywords?</h3>
<p className="text-sm text-base-content/70"> <p className="text-sm text-base-content/70">
This will stop tracking {visibleSelectedCount} keyword This will stop tracking {selectedCount} keyword
{visibleSelectedCount !== 1 ? "s" : ""}. Historical ranking data is {selectedCount !== 1 ? "s" : ""}. Historical ranking data is
preserved but won't appear in the table. preserved but won't appear in the table.
</p> </p>
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
@ -156,14 +138,16 @@ export function RankTrackingTable({
</button> </button>
<button <button
className="btn btn-error btn-sm gap-1" className="btn btn-error btn-sm gap-1"
onClick={() => removeMutation.mutate([...visibleSelected])} onClick={() =>
removeMutation.mutate(selectedRows.map((r) => r.id))
}
disabled={removeMutation.isPending} disabled={removeMutation.isPending}
> >
{removeMutation.isPending && ( {removeMutation.isPending && (
<Loader2 className="size-3 animate-spin" /> <Loader2 className="size-3 animate-spin" />
)} )}
Remove {visibleSelectedCount} keyword Remove {selectedCount} keyword
{visibleSelectedCount !== 1 ? "s" : ""} {selectedCount !== 1 ? "s" : ""}
</button> </button>
</div> </div>
</Modal> </Modal>
@ -172,73 +156,36 @@ export function RankTrackingTable({
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="table table-sm"> <table className="table table-sm">
<thead> <thead>
<tr> {table.getHeaderGroups().map((headerGroup) => (
<th className="w-8"> <tr key={headerGroup.id}>
<input {headerGroup.headers.map((header) => (
type="checkbox" <th key={header.id}>
className="checkbox checkbox-xs" {header.isPlaceholder
checked={allSelected} ? null
onChange={toggleAll} : flexRender(
/> header.column.columnDef.header,
</th> header.getContext(),
<SortableHeader )}
label="Keyword" </th>
field="keyword" ))}
currentField={sortField} </tr>
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> </thead>
<tbody> <tbody>
{sorted.map((row) => ( {table.getRowModel().rows.map((row) => (
<tr key={row.trackingKeywordId}> <tr key={row.id}>
<td className="w-8"> {row.getVisibleCells().map((cell) => (
<input <td key={cell.id} className="align-top">
type="checkbox" {flexRender(cell.column.columnDef.cell, cell.getContext())}
className="checkbox checkbox-xs"
checked={selected.has(row.trackingKeywordId)}
onChange={() => toggleSelect(row.trackingKeywordId)}
/>
</td>
<td className="font-medium">{row.keyword}</td>
{showDesktop && (
<td className="align-top">
<DeviceRankCell result={row.desktop} domain={domain} />
</td> </td>
)} ))}
{showMobile && (
<td className="align-top">
<DeviceRankCell result={row.mobile} domain={domain} />
</td>
)}
</tr> </tr>
))} ))}
</tbody> </tbody>
</table> </table>
</div> </div>
<p className="text-xs text-base-content/60 pt-2"> <p className="text-xs text-base-content/60 pt-2">
{sorted.length} of {totalCount} keywords {rows.length} of {totalCount} keywords
</p> </p>
</> </>
); );

View File

@ -7,45 +7,6 @@ import type {
RankTrackingRow, RankTrackingRow,
} from "@/types/schemas/rank-tracking"; } 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 }) { function PositionBadge({ position }: { position: number | null }) {
if (position === null) { if (position === null) {
return <span className="text-base-content/40">-</span>; return <span className="text-base-content/40">-</span>;

View File

@ -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 };
}