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",
// 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"],

View File

@ -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": {

View File

@ -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
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,
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(),

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

View File

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

View File

@ -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}
/>
</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>
{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>
))}
</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)}
/>
</td>
<td className="font-medium">{row.keyword}</td>
{showDesktop && (
<td className="align-top">
<DeviceRankCell result={row.desktop} domain={domain} />
{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>
)}
{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>
</>
);

View File

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

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