diff --git a/knip.jsonc b/knip.jsonc index 8104a22..c4f00af 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -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"], diff --git a/package.json b/package.json index 6aa15ab..7416933 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/scripts/backlinks-cost-profile.ts b/scripts/backlinks-cost-profile.ts index 7f926e8..30722bc 100644 --- a/scripts/backlinks-cost-profile.ts +++ b/scripts/backlinks-cost-profile.ts @@ -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 = {}; - - 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( diff --git a/scripts/cli-utils.ts b/scripts/cli-utils.ts new file mode 100644 index 0000000..fef4eed --- /dev/null +++ b/scripts/cli-utils.ts @@ -0,0 +1,41 @@ +import { existsSync, readFileSync } from "node:fs"; +import process from "node:process"; + +export function parseArgs(argv: string[]) { + const parsed: Record = {}; + 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, ""); + } + } +} diff --git a/scripts/seed-rank-tracking.ts b/scripts/seed-rank-tracking.ts new file mode 100644 index 0000000..78d7c7e --- /dev/null +++ b/scripts/seed-rank-tracking.ts @@ -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 => 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 { + 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>; + +async function findFirstProject(db: SeedDb): Promise { + 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); +} diff --git a/src/client/features/backlinks/BacklinksTable.tsx b/src/client/features/backlinks/BacklinksTable.tsx index 23bf37d..443c2e0 100644 --- a/src/client/features/backlinks/BacklinksTable.tsx +++ b/src/client/features/backlinks/BacklinksTable.tsx @@ -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([ - { 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(), diff --git a/src/client/features/rank-tracking/RankTrackingColumns.tsx b/src/client/features/rank-tracking/RankTrackingColumns.tsx new file mode 100644 index 0000000..29c474d --- /dev/null +++ b/src/client/features/rank-tracking/RankTrackingColumns.tsx @@ -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 = { + 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 ( + + ); +} + +const positionSort: SortingFn = (rowA, rowB, columnId) => { + const device = columnId === "desktopPosition" ? "desktop" : "mobile"; + return comparePositions( + rowA.original[device].position, + rowB.original[device].position, + ); +}; + +const selectColumn: ColumnDef = { + id: "select", + size: 32, + enableSorting: false, + header: ({ table }) => ( + + ), + cell: ({ row }) => ( + + ), +}; + +const keywordColumn: ColumnDef = { + id: "keyword", + accessorKey: "keyword", + header: ({ column }) => ( + + ), + cell: ({ getValue }) => ( + {getValue()} + ), + sortingFn: "alphanumeric", +}; + +function makeDeviceColumn( + device: "desktop" | "mobile", + domain: string, +): ColumnDef { + const id = device === "desktop" ? "desktopPosition" : "mobilePosition"; + const label = device === "desktop" ? "Desktop" : "Mobile"; + return { + id, + accessorFn: (row) => row[device].position, + header: ({ column }) => ( + + ), + size: 176, + minSize: 176, + cell: ({ row }) => ( + + ), + sortingFn: positionSort, + }; +} + +export function useRankTrackingColumns( + showDesktop: boolean, + showMobile: boolean, + domain: string, +): ColumnDef[] { + return useMemo(() => { + const cols: ColumnDef[] = [selectColumn, keywordColumn]; + if (showDesktop) cols.push(makeDeviceColumn("desktop", domain)); + if (showMobile) cols.push(makeDeviceColumn("mobile", domain)); + return cols; + }, [showDesktop, showMobile, domain]); +} diff --git a/src/client/features/rank-tracking/RankTrackingDomainDetail.tsx b/src/client/features/rank-tracking/RankTrackingDomainDetail.tsx index 5f3ac8b..a85217c 100644 --- a/src/client/features/rank-tracking/RankTrackingDomainDetail.tsx +++ b/src/client/features/rank-tracking/RankTrackingDomainDetail.tsx @@ -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 = 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 (
@@ -263,24 +264,24 @@ export function RankTrackingDomainDetail({ { - 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} />
@@ -297,14 +298,12 @@ export function RankTrackingDomainDetail({ {/* Table */}
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; diff --git a/src/client/features/rank-tracking/RankTrackingTable.tsx b/src/client/features/rank-tracking/RankTrackingTable.tsx index cf8e379..4cb48ff 100644 --- a/src/client/features/rank-tracking/RankTrackingTable.tsx +++ b/src/client/features/rank-tracking/RankTrackingTable.tsx @@ -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>(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 (
@@ -100,7 +85,7 @@ export function RankTrackingTable({ ); } - if (sorted.length === 0) { + if (rows.length === 0) { return (
{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 && (
- {visibleSelectedCount} keyword - {visibleSelectedCount !== 1 ? "s" : ""} selected + {selectedCount} keyword + {selectedCount !== 1 ? "s" : ""} selected @@ -143,8 +125,8 @@ export function RankTrackingTable({

Remove keywords?

- 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.

@@ -156,14 +138,16 @@ export function RankTrackingTable({
@@ -172,73 +156,36 @@ export function RankTrackingTable({
- - - - {showDesktop && ( - - )} - {showMobile && ( - - )} - + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + ))} + + ))} - {sorted.map((row) => ( - - - - {showDesktop && ( - + {row.getVisibleCells().map((cell) => ( + - )} - {showMobile && ( - - )} + ))} ))}
- -
+ {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext(), + )} +
- toggleSelect(row.trackingKeywordId)} - /> - {row.keyword} - + {table.getRowModel().rows.map((row) => ( +
+ {flexRender(cell.column.columnDef.cell, cell.getContext())} - -

- {sorted.length} of {totalCount} keywords + {rows.length} of {totalCount} keywords

); diff --git a/src/client/features/rank-tracking/RankTrackingTableParts.tsx b/src/client/features/rank-tracking/RankTrackingTableParts.tsx index 9361778..24f506f 100644 --- a/src/client/features/rank-tracking/RankTrackingTableParts.tsx +++ b/src/client/features/rank-tracking/RankTrackingTableParts.tsx @@ -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 = { - 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 ( - onClick(field)} - title={HEADER_TOOLTIPS[field]} - > - {label} - {isActive && ( - {currentDir === "asc" ? "↑" : "↓"} - )} - - ); -} - function PositionBadge({ position }: { position: number | null }) { if (position === null) { return -; diff --git a/src/client/features/rank-tracking/useRankTableSort.ts b/src/client/features/rank-tracking/useRankTableSort.ts deleted file mode 100644 index d0b00df..0000000 --- a/src/client/features/rank-tracking/useRankTableSort.ts +++ /dev/null @@ -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(defaultField); - const [sortDir, setSortDir] = useState("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 }; -}