From 673459877ed1fb2154066fbd873eeb0673a1b6bd Mon Sep 17 00:00:00 2001 From: Ben Senescu <44480372+bensenescu@users.noreply.github.com> Date: Tue, 7 Apr 2026 11:37:42 -0400 Subject: [PATCH] feat: group backlinks table by domain with expandable rows (#88) * feat: group backlinks table by domain with expandable rows Group individual backlinks by source domain in the Backlinks tab, showing a compact domain summary row that expands to reveal individual backlink entries. Uses TanStack Table for expand/collapse state management with table-fixed layout for stable column widths. * fix: ci:check fixes for grouped backlinks table Extract BacklinkChildRow to its own file to stay under max-lines lint rule. Remove unused export of sortBacklinkRows. Format files with prettier. * refactor: simplify grouped backlinks table * refactor: split backlinks page tables into focused files Break the backlinks, referring domains, and top pages tables into dedicated components so the feature stays easier to navigate and no longer needs a max-lines ignore. * fix: correct grouped backlink totals and remove target sorting * fix: stabilize backlinks table column layout Keep the DA header aligned with numeric cells and lock the backlinks table to fixed column widths so expanded rows with long anchor text do not shift the layout. * refactor: inline backlinks table imports --- .prettierignore | 1 + package.json | 1 + pnpm-lock.yaml | 22 ++ .../backlinks/BacklinksPageSections.tsx | 8 +- .../backlinks/BacklinksPageTables.tsx | 232 ------------ .../features/backlinks/BacklinksTable.tsx | 90 +++++ .../backlinks/BacklinksTableColumns.tsx | 332 ++++++++++++++++++ .../backlinks/BacklinksTableHeaders.tsx | 72 ---- .../backlinks/ReferringDomainsTable.tsx | 57 +++ .../features/backlinks/TopPagesTable.tsx | 52 +++ .../features/backlinks/backlinksPageTypes.ts | 18 + .../backlinks/backlinksPageUtils.test.ts | 53 +++ .../features/backlinks/backlinksPageUtils.ts | 70 +++- .../backlinks/backlinksTableSorting.ts | 50 --- 14 files changed, 698 insertions(+), 360 deletions(-) delete mode 100644 src/client/features/backlinks/BacklinksPageTables.tsx create mode 100644 src/client/features/backlinks/BacklinksTable.tsx create mode 100644 src/client/features/backlinks/BacklinksTableColumns.tsx create mode 100644 src/client/features/backlinks/ReferringDomainsTable.tsx create mode 100644 src/client/features/backlinks/TopPagesTable.tsx create mode 100644 src/client/features/backlinks/backlinksPageUtils.test.ts diff --git a/.prettierignore b/.prettierignore index 30d0c09..23020aa 100644 --- a/.prettierignore +++ b/.prettierignore @@ -2,6 +2,7 @@ **/public pnpm-lock.yaml routeTree.gen.ts +.opencode/package.json dist/ drizzle/ diff --git a/package.json b/package.json index a363a35..ce671f1 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "@tanstack/react-router": "^1.136.3", "@tanstack/react-router-devtools": "^1.136.3", "@tanstack/react-start": "^1.136.3", + "@tanstack/react-table": "^8.21.3", "autumn-js": "^1.1.7", "better-auth": "^1.5.5", "cheerio": "^1.2.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d5b8c54..f9fd60b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,6 +29,9 @@ importers: '@tanstack/react-start': specifier: ^1.136.3 version: 1.162.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)) + '@tanstack/react-table': + specifier: ^8.21.3 + version: 8.21.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) autumn-js: specifier: ^1.1.7 version: 1.1.7(better-auth@1.5.5(@cloudflare/workers-types@4.20260302.0)(@tanstack/react-start@1.162.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)))(drizzle-kit@0.31.9)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260302.0)(@libsql/client@0.15.15)(@opentelemetry/api@1.9.1)(kysely@0.28.12))(mongodb@7.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.11)(vitest@3.2.4(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)))(better-call@1.3.2(zod@4.3.6))(react@19.2.4) @@ -1930,6 +1933,13 @@ packages: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + '@tanstack/react-table@8.21.3': + resolution: {integrity: sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==} + engines: {node: '>=12'} + peerDependencies: + react: '>=16.8' + react-dom: '>=16.8' + '@tanstack/router-core@1.162.2': resolution: {integrity: sha512-su/X6YP7LIhiy3FWkvCb5JL2LVmfupMXavvfyuPQayWb+E1NJQhYU8dbEcSGB0sNIMZeAmz0iIBSV2MVvuzQ8g==} engines: {node: '>=20.19'} @@ -2001,6 +2011,10 @@ packages: '@tanstack/store@0.9.1': resolution: {integrity: sha512-+qcNkOy0N1qSGsP7omVCW0SDrXtaDcycPqBDE726yryiA5eTDFpjBReaYjghVJwNf1pcPMyzIwTGlYjCSQR0Fg==} + '@tanstack/table-core@8.21.3': + resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==} + engines: {node: '>=12'} + '@tanstack/virtual-file-routes@1.161.4': resolution: {integrity: sha512-42WoRePf8v690qG8yGRe/YOh+oHni9vUaUUfoqlS91U2scd3a5rkLtVsc6b7z60w3RogH0I00vdrC5AaeiZ18w==} engines: {node: '>=20.19'} @@ -5040,6 +5054,12 @@ snapshots: react-dom: 19.2.4(react@19.2.4) use-sync-external-store: 1.6.0(react@19.2.4) + '@tanstack/react-table@8.21.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@tanstack/table-core': 8.21.3 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + '@tanstack/router-core@1.162.2': dependencies: '@tanstack/history': 1.161.4 @@ -5170,6 +5190,8 @@ snapshots: '@tanstack/store@0.9.1': {} + '@tanstack/table-core@8.21.3': {} + '@tanstack/virtual-file-routes@1.161.4': {} '@tybys/wasm-util@0.10.1': diff --git a/src/client/features/backlinks/BacklinksPageSections.tsx b/src/client/features/backlinks/BacklinksPageSections.tsx index 528a430..2f66094 100644 --- a/src/client/features/backlinks/BacklinksPageSections.tsx +++ b/src/client/features/backlinks/BacklinksPageSections.tsx @@ -4,11 +4,9 @@ import { BacklinksNewLostChart, BacklinksTrendChart, } from "./BacklinksPageCharts"; -import { - BacklinksTable, - ReferringDomainsTable, - TopPagesTable, -} from "./BacklinksPageTables"; +import { BacklinksTable } from "./BacklinksTable"; +import { ReferringDomainsTable } from "./ReferringDomainsTable"; +import { TopPagesTable } from "./TopPagesTable"; import type { BacklinksOverviewData, BacklinksSearchState, diff --git a/src/client/features/backlinks/BacklinksPageTables.tsx b/src/client/features/backlinks/BacklinksPageTables.tsx deleted file mode 100644 index 26b11bd..0000000 --- a/src/client/features/backlinks/BacklinksPageTables.tsx +++ /dev/null @@ -1,232 +0,0 @@ -import { useMemo, useState } from "react"; -import { EmptyTableState } from "./BacklinksPageEmptyTableState"; -import { - BacklinksTableHeader, - ReferringDomainsTableHeader, - TopPagesTableHeader, -} from "./BacklinksTableHeaders"; -import { - BacklinksExternalLink, - BacklinksSourceLink, -} from "./BacklinksPageLinks"; -import type { BacklinksOverviewData } from "./backlinksPageTypes"; -import { - DEFAULT_BACKLINKS_SORT, - DEFAULT_REFERRING_DOMAINS_SORT, - DEFAULT_TOP_PAGES_SORT, - sortBacklinkRows, - sortReferringDomainRows, - sortTopPageRows, -} from "./backlinksTableSorting"; -import { - formatCompactDate, - formatDecimal, - formatNumber, -} from "./backlinksPageUtils"; - -export function BacklinksTable({ - rows, -}: { - rows: BacklinksOverviewData["backlinks"]; -}) { - const [sort, setSort] = useState(DEFAULT_BACKLINKS_SORT); - const sortedRows = useMemo(() => sortBacklinkRows(rows, sort), [rows, sort]); - - if (rows.length === 0) { - return ; - } - - return ( -
- - - - {sortedRows.map((row, index) => ( - - ))} - -
-
- ); -} - -export function ReferringDomainsTable({ - rows, -}: { - rows: BacklinksOverviewData["referringDomains"]; -}) { - const [sort, setSort] = useState(DEFAULT_REFERRING_DOMAINS_SORT); - const sortedRows = useMemo( - () => sortReferringDomainRows(rows, sort), - [rows, sort], - ); - - if (rows.length === 0) { - return ; - } - - return ( -
- - - - {sortedRows.map((row, index) => ( - - - - - - - - - - ))} - -
{row.domain ?? "-"}{formatNumber(row.backlinks)}{formatNumber(row.referringPages)}{formatNumber(row.rank)}{formatDecimal(row.spamScore)}{formatCompactDate(row.firstSeen)} -
-
Broken links: {formatNumber(row.brokenBacklinks)}
-
- Broken pages: {formatNumber(row.brokenPages)} -
-
-
-
- ); -} - -export function TopPagesTable({ - rows, -}: { - rows: BacklinksOverviewData["topPages"]; -}) { - const [sort, setSort] = useState(DEFAULT_TOP_PAGES_SORT); - const sortedRows = useMemo(() => sortTopPageRows(rows, sort), [rows, sort]); - - if (rows.length === 0) { - return ; - } - - return ( -
- - - - {sortedRows.map((row, index) => ( - - - - - - - - ))} - -
- {row.page ? ( - - ) : ( - "-" - )} - {formatNumber(row.backlinks)}{formatNumber(row.referringDomains)}{formatNumber(row.rank)}{formatNumber(row.brokenBacklinks)}
-
- ); -} - -function BacklinksTableRow({ - row, -}: { - row: BacklinksOverviewData["backlinks"][number]; -}) { - const hasNotableFlags = - row.isLost || - row.isBroken || - row.isDofollow === false || - (row.linksCount != null && row.linksCount > 1); - - return ( - - -
-
- {row.domainFrom?.replace(/^www\./, "") ?? "-"} -
- {row.urlFrom ? ( - - ) : null} -
- - - {row.urlTo ? ( - - ) : ( - "-" - )} - - -
- {row.anchor || "No anchor text"} - {row.itemType ? ( -
{row.itemType}
- ) : null} -
- - {hasNotableFlags ? : null} - - - {formatNumber(row.rank)} - - - - {formatNumber(row.domainFromRank)} - - -
{formatCompactDate(row.firstSeen)}
- {row.lastSeen ? ( -
- Last {formatCompactDate(row.lastSeen)} -
- ) : null} - - - ); -} - -function BacklinkFlags({ - row, -}: { - row: BacklinksOverviewData["backlinks"][number]; -}) { - return ( -
- {row.isLost ? ( - Lost - ) : null} - {row.isBroken ? ( - - Broken - - ) : null} - {row.isDofollow === false ? ( - Nofollow - ) : null} - {row.linksCount != null && row.linksCount > 1 ? ( - - {row.linksCount} links - - ) : null} -
- ); -} diff --git a/src/client/features/backlinks/BacklinksTable.tsx b/src/client/features/backlinks/BacklinksTable.tsx new file mode 100644 index 0000000..23bf37d --- /dev/null +++ b/src/client/features/backlinks/BacklinksTable.tsx @@ -0,0 +1,90 @@ +import { + flexRender, + getCoreRowModel, + getExpandedRowModel, + getSortedRowModel, + type SortingState, + useReactTable, +} from "@tanstack/react-table"; +import { useMemo, useState } from "react"; +import { EmptyTableState } from "./BacklinksPageEmptyTableState"; +import { backlinksColumns } from "./BacklinksTableColumns"; +import type { BacklinksOverviewData } from "./backlinksPageTypes"; +import { groupBacklinksByDomain } from "./backlinksPageUtils"; + +export function BacklinksTable({ + rows, +}: { + 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, + getSubRows: (row) => row.subRows, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getExpandedRowModel: getExpandedRowModel(), + getRowCanExpand: (row) => row.depth === 0, + }); + + if (rows.length === 0) { + return ; + } + + return ( +
+ + + {table.getVisibleLeafColumns().map((column) => ( + + ))} + + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + ))} + + ))} + + + {table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + ))} + + ))} + +
+ {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext(), + )} +
+ {flexRender(cell.column.columnDef.cell, cell.getContext())} +
+
+ ); +} diff --git a/src/client/features/backlinks/BacklinksTableColumns.tsx b/src/client/features/backlinks/BacklinksTableColumns.tsx new file mode 100644 index 0000000..8981259 --- /dev/null +++ b/src/client/features/backlinks/BacklinksTableColumns.tsx @@ -0,0 +1,332 @@ +import type { ColumnDef } from "@tanstack/react-table"; +import { ArrowDown, ArrowUp, ChevronRight } from "lucide-react"; +import { HeaderHelpLabel } from "@/client/features/keywords/components"; +import { BacklinksSourceLink } from "./BacklinksPageLinks"; +import type { BacklinksRow, GroupedBacklinkDomain } from "./backlinksPageTypes"; +import { + formatCompactDate, + formatDecimal, + formatNumber, +} from "./backlinksPageUtils"; + +function BacklinkFlags({ row }: { row: BacklinksRow }) { + return ( +
+ {row.isLost ? ( + Lost + ) : null} + {row.isBroken ? ( + + Broken + + ) : null} + {row.isDofollow === false ? ( + Nofollow + ) : null} + {row.linksCount != null && row.linksCount > 1 ? ( + + {row.linksCount} links + + ) : null} +
+ ); +} + +function DomainFlagBadges({ group }: { group: GroupedBacklinkDomain }) { + const badges: Array<{ label: string; className: string }> = []; + if (group.lostCount > 0) { + badges.push({ + label: `${group.lostCount} Lost`, + className: "badge badge-sm badge-error badge-outline", + }); + } + if (group.brokenCount > 0) { + badges.push({ + label: `${group.brokenCount} Broken`, + className: "badge badge-sm badge-warning badge-outline", + }); + } + if (group.nofollowCount > 0) { + badges.push({ + label: `${group.nofollowCount} Nofollow`, + className: "badge badge-sm badge-outline", + }); + } + if (badges.length === 0) return null; + + return ( +
+ {badges.map((badge) => ( + + {badge.label} + + ))} +
+ ); +} + +function SortableHeader({ + column, + label, + helpText, + align, +}: { + column: { + getIsSorted: () => false | "asc" | "desc"; + getToggleSortingHandler: () => ((event: unknown) => void) | undefined; + }; + label: string; + helpText: string; + align?: "left" | "right"; +}) { + const sorted = column.getIsSorted(); + const content = ( + + ); + + if (align === "right") { + return {content}; + } + + return content; +} + +export const backlinksColumns: ColumnDef[] = [ + { + id: "source", + accessorKey: "domain", + header: ({ column }) => ( + + ), + size: 250, + minSize: 180, + cell: ({ row }) => { + if (row.depth > 0) { + const child = row.original._backlink; + return ( +
+ {child?.urlFrom ? ( + + ) : ( + - + )} +
+ ); + } + + const group = row.original; + return ( +
+ +
+
{group.domain}
+
+ {group.backlinkCount}{" "} + {group.backlinkCount === 1 ? "backlink" : "backlinks"} ·{" "} + {group.targetCount} {group.targetCount === 1 ? "page" : "pages"} +
+
+
+ ); + }, + sortingFn: "alphanumeric", + }, + { + id: "target", + header: () => ( + + ), + size: 220, + minSize: 150, + accessorFn: (row) => row.targetCount, + enableSorting: false, + cell: ({ row }) => { + if (row.depth > 0) { + const child = row.original._backlink; + return ( +
+ {child?.urlTo ? ( + + ) : ( + "-" + )} +
+ ); + } + + return null; + }, + sortingFn: "basic", + }, + { + id: "anchor", + header: ({ column }) => ( + + ), + size: 150, + minSize: 100, + accessorFn: (row) => row._backlink?.anchor ?? row.domain, + cell: ({ row }) => { + if (row.depth > 0) { + const child = row.original._backlink; + return ( +
+ {child?.anchor || "No anchor text"} + {child?.itemType ? ( +
+ {child.itemType} +
+ ) : null} +
+ ); + } + + return null; + }, + sortingFn: "alphanumeric", + enableSorting: false, + }, + { + id: "flags", + header: () => ( + + ), + size: 130, + minSize: 80, + enableSorting: false, + cell: ({ row }) => { + if (row.depth > 0) { + const child = row.original._backlink; + const hasFlags = + child?.isLost || + child?.isBroken || + child?.isDofollow === false || + (child?.linksCount != null && child.linksCount > 1); + return hasFlags && child ? : null; + } + + return ; + }, + }, + { + id: "linkAuthority", + header: ({ column }) => ( + + ), + size: 70, + minSize: 50, + accessorFn: (row) => row._backlink?.rank ?? null, + cell: ({ row }) => { + if (row.depth > 0) { + const child = row.original._backlink; + return ( +
+ + {formatNumber(child?.rank)} + +
+ ); + } + + return null; + }, + sortingFn: "basic", + enableSorting: false, + }, + { + id: "domainAuthority", + accessorKey: "domainAuthority", + header: ({ column }) => ( + + ), + size: 70, + minSize: 50, + cell: ({ row }) => { + if (row.depth > 0) return null; + + return ( +
+ {formatNumber(row.original.domainAuthority)} +
+ ); + }, + sortingFn: "basic", + }, + { + id: "firstSeen", + accessorKey: "firstSeen", + header: ({ column }) => ( + + ), + size: 110, + minSize: 80, + cell: ({ row }) => { + if (row.depth > 0) { + const child = row.original._backlink; + return ( +
+
{formatCompactDate(child?.firstSeen)}
+ {child?.lastSeen ? ( +
+ Last {formatCompactDate(child.lastSeen)} +
+ ) : null} +
+ ); + } + + return ( +
+ {formatCompactDate(row.original.firstSeen)} +
+ ); + }, + sortingFn: "datetime", + }, +]; diff --git a/src/client/features/backlinks/BacklinksTableHeaders.tsx b/src/client/features/backlinks/BacklinksTableHeaders.tsx index 795fea2..e323878 100644 --- a/src/client/features/backlinks/BacklinksTableHeaders.tsx +++ b/src/client/features/backlinks/BacklinksTableHeaders.tsx @@ -2,83 +2,11 @@ import { ArrowDown, ArrowUp } from "lucide-react"; import { HeaderHelpLabel } from "@/client/features/keywords/components"; import { getNextSort, - type BacklinksTableSort, type ReferringDomainsTableSort, type SortDirection, type TopPagesTableSort, } from "./backlinksTableSorting"; -export function BacklinksTableHeader({ - sort, - onSortChange, -}: { - sort: BacklinksTableSort; - onSortChange: (sort: BacklinksTableSort) => void; -}) { - return ( - - - - - - - - - - - - - - ); -} - export function ReferringDomainsTableHeader({ sort, onSortChange, diff --git a/src/client/features/backlinks/ReferringDomainsTable.tsx b/src/client/features/backlinks/ReferringDomainsTable.tsx new file mode 100644 index 0000000..6a32e59 --- /dev/null +++ b/src/client/features/backlinks/ReferringDomainsTable.tsx @@ -0,0 +1,57 @@ +import { useMemo, useState } from "react"; +import { EmptyTableState } from "./BacklinksPageEmptyTableState"; +import { ReferringDomainsTableHeader } from "./BacklinksTableHeaders"; +import type { BacklinksOverviewData } from "./backlinksPageTypes"; +import { + DEFAULT_REFERRING_DOMAINS_SORT, + sortReferringDomainRows, +} from "./backlinksTableSorting"; +import { + formatCompactDate, + formatDecimal, + formatNumber, +} from "./backlinksPageUtils"; + +export function ReferringDomainsTable({ + rows, +}: { + rows: BacklinksOverviewData["referringDomains"]; +}) { + const [sort, setSort] = useState(DEFAULT_REFERRING_DOMAINS_SORT); + const sortedRows = useMemo( + () => sortReferringDomainRows(rows, sort), + [rows, sort], + ); + + if (rows.length === 0) { + return ; + } + + return ( +
+ + + + {sortedRows.map((row, index) => ( + + + + + + + + + + ))} + +
{row.domain ?? "-"}{formatNumber(row.backlinks)}{formatNumber(row.referringPages)}{formatNumber(row.rank)}{formatDecimal(row.spamScore)}{formatCompactDate(row.firstSeen)} +
+
Broken links: {formatNumber(row.brokenBacklinks)}
+
+ Broken pages: {formatNumber(row.brokenPages)} +
+
+
+
+ ); +} diff --git a/src/client/features/backlinks/TopPagesTable.tsx b/src/client/features/backlinks/TopPagesTable.tsx new file mode 100644 index 0000000..7e532fe --- /dev/null +++ b/src/client/features/backlinks/TopPagesTable.tsx @@ -0,0 +1,52 @@ +import { useMemo, useState } from "react"; +import { EmptyTableState } from "./BacklinksPageEmptyTableState"; +import { BacklinksExternalLink } from "./BacklinksPageLinks"; +import { TopPagesTableHeader } from "./BacklinksTableHeaders"; +import type { BacklinksOverviewData } from "./backlinksPageTypes"; +import { + DEFAULT_TOP_PAGES_SORT, + sortTopPageRows, +} from "./backlinksTableSorting"; +import { formatNumber } from "./backlinksPageUtils"; + +export function TopPagesTable({ + rows, +}: { + rows: BacklinksOverviewData["topPages"]; +}) { + const [sort, setSort] = useState(DEFAULT_TOP_PAGES_SORT); + const sortedRows = useMemo(() => sortTopPageRows(rows, sort), [rows, sort]); + + if (rows.length === 0) { + return ; + } + + return ( +
+ + + + {sortedRows.map((row, index) => ( + + + + + + + + ))} + +
+ {row.page ? ( + + ) : ( + "-" + )} + {formatNumber(row.backlinks)}{formatNumber(row.referringDomains)}{formatNumber(row.rank)}{formatNumber(row.brokenBacklinks)}
+
+ ); +} diff --git a/src/client/features/backlinks/backlinksPageTypes.ts b/src/client/features/backlinks/backlinksPageTypes.ts index e34faa8..edfc78c 100644 --- a/src/client/features/backlinks/backlinksPageTypes.ts +++ b/src/client/features/backlinks/backlinksPageTypes.ts @@ -38,3 +38,21 @@ export type BacklinksPageProps = { searchState: BacklinksSearchState; navigate: BacklinksNavigate; }; + +export type BacklinksRow = BacklinksOverviewData["backlinks"][number]; + +export type GroupedBacklinkDomain = { + domain: string; + domainAuthority: number | null; + spamScore: number | null; + firstSeen: string | null; + backlinkCount: number; + targetCount: number; + lostCount: number; + brokenCount: number; + nofollowCount: number; + /** Child rows for TanStack Table's getSubRows — each wraps a BacklinksRow */ + subRows: GroupedBacklinkDomain[]; + /** Set on child rows only — the original backlink data */ + _backlink?: BacklinksRow; +}; diff --git a/src/client/features/backlinks/backlinksPageUtils.test.ts b/src/client/features/backlinks/backlinksPageUtils.test.ts new file mode 100644 index 0000000..d9789c2 --- /dev/null +++ b/src/client/features/backlinks/backlinksPageUtils.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; +import type { BacklinksRow } from "./backlinksPageTypes"; +import { groupBacklinksByDomain } from "./backlinksPageUtils"; + +function makeBacklinkRow(overrides: Partial = {}): BacklinksRow { + return { + domainFrom: "source.example", + urlFrom: "https://source.example/post", + urlTo: "https://target.example/", + anchor: null, + itemType: null, + isDofollow: true, + relAttributes: [], + rank: null, + domainFromRank: null, + pageFromRank: null, + spamScore: null, + firstSeen: null, + lastSeen: null, + isLost: false, + isBroken: false, + linksCount: null, + ...overrides, + }; +} + +describe("groupBacklinksByDomain", () => { + it("sums grouped backlink totals from linksCount", () => { + const groups = groupBacklinksByDomain([ + makeBacklinkRow({ linksCount: 5 }), + makeBacklinkRow({ + urlFrom: "https://source.example/second-post", + urlTo: "https://target.example/pricing", + }), + ]); + + expect(groups).toHaveLength(1); + expect(groups[0]?.backlinkCount).toBe(6); + }); + + it("falls back to one backlink when linksCount is missing", () => { + const groups = groupBacklinksByDomain([ + makeBacklinkRow({ linksCount: null }), + makeBacklinkRow({ + domainFrom: "other.example", + urlFrom: "https://other.example/post", + linksCount: 0, + }), + ]); + + expect(groups.map((group) => group.backlinkCount)).toEqual([1, 1]); + }); +}); diff --git a/src/client/features/backlinks/backlinksPageUtils.ts b/src/client/features/backlinks/backlinksPageUtils.ts index 1ad6157..5c81012 100644 --- a/src/client/features/backlinks/backlinksPageUtils.ts +++ b/src/client/features/backlinks/backlinksPageUtils.ts @@ -1,5 +1,9 @@ import type { BacklinksTab } from "@/types/schemas/backlinks"; -import type { BacklinksOverviewData } from "./backlinksPageTypes"; +import type { + BacklinksOverviewData, + BacklinksRow, + GroupedBacklinkDomain, +} from "./backlinksPageTypes"; export const TAB_DESCRIPTIONS: Record = { backlinks: @@ -115,6 +119,70 @@ export function formatRelativeTimestamp(value: string) { }); } +export function groupBacklinksByDomain( + rows: BacklinksRow[], +): GroupedBacklinkDomain[] { + const groups = new Map(); + + for (const row of rows) { + const key = row.domainFrom?.replace(/^www\./, "") ?? "unknown"; + const existing = groups.get(key); + if (existing) { + existing.push(row); + } else { + groups.set(key, [row]); + } + } + + return Array.from(groups.entries()).map(([domain, children]) => ({ + domain, + domainAuthority: maxNullable(children.map((r) => r.domainFromRank)), + spamScore: maxNullable(children.map((r) => r.spamScore)), + firstSeen: minDateString(children.map((r) => r.firstSeen)), + backlinkCount: children.reduce( + (total, child) => total + getBacklinkCount(child), + 0, + ), + targetCount: new Set(children.map((r) => r.urlTo).filter(Boolean)).size, + lostCount: children.filter((r) => r.isLost).length, + brokenCount: children.filter((r) => r.isBroken).length, + nofollowCount: children.filter((r) => r.isDofollow === false).length, + subRows: children.map((child) => ({ + domain: child.domainFrom?.replace(/^www\./, "") ?? "unknown", + domainAuthority: child.domainFromRank, + spamScore: child.spamScore, + firstSeen: child.firstSeen, + backlinkCount: 1, + targetCount: 1, + lostCount: child.isLost ? 1 : 0, + brokenCount: child.isBroken ? 1 : 0, + nofollowCount: child.isDofollow === false ? 1 : 0, + subRows: [], + _backlink: child, + })), + })); +} + +function getBacklinkCount(row: BacklinksRow) { + return row.linksCount != null && row.linksCount > 0 ? row.linksCount : 1; +} + +function maxNullable(values: (number | null)[]): number | null { + let result: number | null = null; + for (const v of values) { + if (v != null && (result == null || v > result)) result = v; + } + return result; +} + +function minDateString(values: (string | null)[]): string | null { + let result: string | null = null; + for (const v of values) { + if (v && (result == null || v < result)) result = v; + } + return result; +} + export function extractUrlPath(url: string) { try { const parsed = new URL(url); diff --git a/src/client/features/backlinks/backlinksTableSorting.ts b/src/client/features/backlinks/backlinksTableSorting.ts index 3d083b9..2597aae 100644 --- a/src/client/features/backlinks/backlinksTableSorting.ts +++ b/src/client/features/backlinks/backlinksTableSorting.ts @@ -2,14 +2,6 @@ import type { BacklinksOverviewData } from "./backlinksPageTypes"; export type SortDirection = "asc" | "desc"; -export type BacklinksTableSortField = - | "source" - | "target" - | "anchor" - | "linkAuthority" - | "domainAuthority" - | "firstSeen"; - export type ReferringDomainsTableSortField = | "domain" | "backlinks" @@ -31,16 +23,10 @@ export type TableSort = { direction: SortDirection; }; -export type BacklinksTableSort = TableSort; export type ReferringDomainsTableSort = TableSort; export type TopPagesTableSort = TableSort; -export const DEFAULT_BACKLINKS_SORT: BacklinksTableSort = { - field: "firstSeen", - direction: "desc", -}; - export const DEFAULT_REFERRING_DOMAINS_SORT: ReferringDomainsTableSort = { field: "backlinks", direction: "desc", @@ -66,42 +52,6 @@ export function getNextSort( }; } -export function sortBacklinkRows( - rows: BacklinksOverviewData["backlinks"], - sort: BacklinksTableSort, -) { - return rows.toSorted((left, right) => { - switch (sort.field) { - case "source": - return compareStrings( - left.domainFrom?.replace(/^www\./, "") ?? left.urlFrom, - right.domainFrom?.replace(/^www\./, "") ?? right.urlFrom, - sort.direction, - ); - case "target": - return compareStrings(left.urlTo, right.urlTo, sort.direction); - case "anchor": - return compareStrings( - left.anchor ?? left.itemType, - right.anchor ?? right.itemType, - sort.direction, - ); - case "linkAuthority": - return compareNumbers(left.rank, right.rank, sort.direction); - case "domainAuthority": - return compareNumbers( - left.domainFromRank, - right.domainFromRank, - sort.direction, - ); - case "firstSeen": - return compareDates(left.firstSeen, right.firstSeen, sort.direction); - default: - return 0; - } - }); -} - export function sortReferringDomainRows( rows: BacklinksOverviewData["referringDomains"], sort: ReferringDomainsTableSort,