From 20bb4680af4674ef643c46bc2787db7ba60f882f Mon Sep 17 00:00:00 2001 From: PrashantUnity Date: Sat, 13 Jun 2026 17:10:43 +0530 Subject: [PATCH] know what is what --- docs/GLOSSARY.md | 2 + web/src/components/ChartCard.tsx | 40 ++ web/src/components/HelpHint.tsx | 238 +++++++ web/src/components/SectionHeader.tsx | 46 ++ web/src/components/StatCard.tsx | 13 +- web/src/components/Table.tsx | 30 +- .../backlinks/GscLinksSummaryCards.tsx | 25 +- web/src/components/compare/CompareCharts.tsx | 21 +- .../components/compare/CompareTabPanels.tsx | 15 +- web/src/components/google/GoogleChartCard.tsx | 20 +- .../google/SortablePaginatedTable.tsx | 30 +- web/src/components/google/SummaryCard.tsx | 5 +- web/src/components/index.ts | 4 + web/src/components/issues/IssueTaskBoard.tsx | 10 +- .../KeywordExplorerChrome.tsx | 11 +- .../keywordsExplorer/KeywordTableColumns.tsx | 22 +- .../components/lighthouse/ThresholdBar.tsx | 35 +- .../components/links/InlinksMetricCell.tsx | 5 - .../components/links/LinkAttributesCharts.tsx | 14 +- web/src/components/links/SortTh.tsx | 16 +- .../explorer/LinksExplorerSummaryCharts.tsx | 97 +++ .../links/explorer/LinksExplorerTableTab.tsx | 54 +- web/src/components/links/explorer/index.ts | 1 + web/src/components/links/tabs/ContentTab.tsx | 46 +- web/src/components/links/tabs/OverviewTab.tsx | 24 +- .../components/overview/OverviewChartsTab.tsx | 57 +- .../components/overview/OverviewPagesTab.tsx | 9 +- .../overview/OverviewSummaryTab.tsx | 41 +- .../portfolio/PortfolioPropertyCard.tsx | 41 +- .../searchPerformance/GscCharts.tsx | 28 +- .../siteStructure/PathTreeTable.tsx | 35 +- web/src/lib/metricHelp.test.ts | 41 ++ web/src/lib/metricHelp.ts | 33 + web/src/lib/statusDistribution.ts | 22 + web/src/strings.json | 658 ++++++++++++++++++ web/src/types/components.ts | 2 + web/src/views/Backlinks.tsx | 12 +- web/src/views/CompareReports.tsx | 26 +- web/src/views/Contacts.tsx | 3 +- web/src/views/Content.tsx | 13 +- web/src/views/ContentAnalytics.tsx | 171 ++--- web/src/views/Gallery.tsx | 14 +- web/src/views/Home.tsx | 14 +- web/src/views/Indexation.tsx | 9 +- web/src/views/Issues.tsx | 8 +- web/src/views/JavaScriptErrors.tsx | 11 +- web/src/views/Lighthouse.tsx | 18 +- web/src/views/LogAnalyzer.tsx | 54 +- web/src/views/Network.tsx | 8 +- web/src/views/Redirects.tsx | 3 +- web/src/views/SearchPerformance.tsx | 37 +- web/src/views/Security.tsx | 44 +- web/src/views/SiteStructure.tsx | 7 + web/src/views/Subdomains.tsx | 21 +- web/src/views/TechStack.tsx | 21 +- web/src/views/TextContentAnalysis.tsx | 54 +- web/src/views/Traffic.tsx | 21 +- 57 files changed, 1883 insertions(+), 477 deletions(-) create mode 100644 web/src/components/ChartCard.tsx create mode 100644 web/src/components/HelpHint.tsx create mode 100644 web/src/components/SectionHeader.tsx create mode 100644 web/src/components/links/explorer/LinksExplorerSummaryCharts.tsx create mode 100644 web/src/lib/metricHelp.test.ts create mode 100644 web/src/lib/metricHelp.ts diff --git a/docs/GLOSSARY.md b/docs/GLOSSARY.md index 0bb21d7c..a99ba409 100644 --- a/docs/GLOSSARY.md +++ b/docs/GLOSSARY.md @@ -82,6 +82,8 @@ impact_score = priority_weight + (gsc_clicks × 10) + (ga4_sessions × 5) Priority weights: Critical = 1000, High = 100, Medium = 10, Low = 1. +**UI hints:** Metric explanations in the app (circled **?** tooltips on KPIs, table headers, and chart titles) are sourced from `web/src/strings.json` under `metricHelp`. Shared keys live in `metricHelp.shared.*` (e.g. `metricHelp.shared.impactScore`); view-specific keys under `metricHelp.views.{viewId}.*`. Keep glossary definitions and `metricHelp` copy aligned when changing formulas or data sources. + --- ## Provenance badges diff --git a/web/src/components/ChartCard.tsx b/web/src/components/ChartCard.tsx new file mode 100644 index 00000000..9b25c131 --- /dev/null +++ b/web/src/components/ChartCard.tsx @@ -0,0 +1,40 @@ +'use client'; + +import type { ReactNode } from 'react'; +import HelpHint, { normalizeHintContent, type HelpHintContent } from './HelpHint'; + +export interface ChartCardProps { + title: string; + hint?: HelpHintContent; + ariaLabel?: string; + heightClass?: string; + children?: ReactNode; + className?: string; +} + +export default function ChartCard({ + title, + hint, + ariaLabel, + heightClass = 'h-56', + children, + className = '', +}: ChartCardProps) { + const hintContent = normalizeHintContent(hint); + + return ( +
+
+

{title}

+ {hintContent ? ( + + {hintContent.body} + + ) : null} +
+
+ {children} +
+
+ ); +} diff --git a/web/src/components/HelpHint.tsx b/web/src/components/HelpHint.tsx new file mode 100644 index 00000000..bb20c2a8 --- /dev/null +++ b/web/src/components/HelpHint.tsx @@ -0,0 +1,238 @@ +'use client'; + +import { + useCallback, + useEffect, + useId, + useLayoutEffect, + useRef, + useState, + type CSSProperties, + type ReactNode, +} from 'react'; +import { createPortal } from 'react-dom'; +import { CircleHelp } from 'lucide-react'; +import { metricHelpHint } from '@/lib/metricHelp'; + +const VIEWPORT_PAD = 8; +const GAP = 8; +const TOOLTIP_Z = 9999; + +export interface HelpHintProps { + title?: string; + children: ReactNode; + side?: 'top' | 'bottom'; + className?: string; + /** Accessible label for the trigger button. Defaults to "More information". */ + ariaLabel?: string; +} + +type TooltipCoords = { top: number; left: number }; + +function clamp(value: number, min: number, max: number) { + return Math.min(Math.max(value, min), max); +} + +export default function HelpHint({ + title, + children, + side = 'top', + className = '', + ariaLabel = 'More information', +}: HelpHintProps) { + const [open, setOpen] = useState(false); + const [coords, setCoords] = useState(null); + const [mounted, setMounted] = useState(false); + const id = useId(); + const rootRef = useRef(null); + const buttonRef = useRef(null); + const tooltipRef = useRef(null); + + useEffect(() => { + setMounted(true); + }, []); + + const close = useCallback(() => { + setOpen(false); + setCoords(null); + }, []); + + const updatePosition = useCallback(() => { + const trigger = buttonRef.current; + const tooltip = tooltipRef.current; + if (!trigger || !tooltip) return; + + const triggerRect = trigger.getBoundingClientRect(); + const tooltipRect = tooltip.getBoundingClientRect(); + const vw = window.innerWidth; + const vh = window.innerHeight; + const maxLeft = vw - tooltipRect.width - VIEWPORT_PAD; + const maxTop = vh - tooltipRect.height - VIEWPORT_PAD; + + let top = + side === 'top' + ? triggerRect.top - tooltipRect.height - GAP + : triggerRect.bottom + GAP; + + // Flip vertically when clipped + if (top < VIEWPORT_PAD) { + top = triggerRect.bottom + GAP; + } + if (top > maxTop) { + top = triggerRect.top - tooltipRect.height - GAP; + } + top = clamp(top, VIEWPORT_PAD, Math.max(VIEWPORT_PAD, maxTop)); + + // Prefer centered on trigger, then clamp to viewport + let left = triggerRect.left + triggerRect.width / 2 - tooltipRect.width / 2; + left = clamp(left, VIEWPORT_PAD, Math.max(VIEWPORT_PAD, maxLeft)); + + // Near right edge: align tooltip's right edge to trigger (keeps content readable) + if (triggerRect.right > vw - VIEWPORT_PAD - 48) { + left = triggerRect.right - tooltipRect.width; + left = clamp(left, VIEWPORT_PAD, Math.max(VIEWPORT_PAD, maxLeft)); + } + + // Near left edge: align tooltip's left edge to trigger + if (triggerRect.left < VIEWPORT_PAD + 48) { + left = triggerRect.left; + left = clamp(left, VIEWPORT_PAD, Math.max(VIEWPORT_PAD, maxLeft)); + } + + setCoords({ top, left }); + }, [side]); + + useLayoutEffect(() => { + if (!open) return; + updatePosition(); + const raf = requestAnimationFrame(updatePosition); + return () => cancelAnimationFrame(raf); + }, [open, updatePosition, title, children]); + + useEffect(() => { + if (!open) return; + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') close(); + }; + document.addEventListener('keydown', onKeyDown); + return () => document.removeEventListener('keydown', onKeyDown); + }, [open, close]); + + useEffect(() => { + if (!open) return; + const onPointerDown = (e: PointerEvent) => { + if (!rootRef.current?.contains(e.target as Node)) close(); + }; + const onReposition = () => updatePosition(); + document.addEventListener('pointerdown', onPointerDown); + window.addEventListener('resize', onReposition); + window.addEventListener('scroll', onReposition, true); + return () => { + document.removeEventListener('pointerdown', onPointerDown); + window.removeEventListener('resize', onReposition); + window.removeEventListener('scroll', onReposition, true); + }; + }, [open, close, updatePosition]); + + const tooltipStyle: CSSProperties = coords + ? { position: 'fixed', top: coords.top, left: coords.left, zIndex: TOOLTIP_Z } + : { position: 'fixed', top: -9999, left: -9999, zIndex: TOOLTIP_Z, visibility: 'hidden' as const }; + + const tooltip = open ? ( + + ) : null; + + return ( + + + {mounted && tooltip ? createPortal(tooltip, document.body) : null} + + ); +} + +export type HelpHintContent = string | { title?: string; body: string }; + +/** Normalize hint prop from string or structured object. */ +export function normalizeHintContent(hint: HelpHintContent | undefined): { + title?: string; + body: string; +} | undefined { + if (hint == null) return undefined; + if (typeof hint === 'string') return { body: hint }; + return { title: hint.title, body: hint.body }; +} + +/** Chart/card title with ? hint (replaces title + paragraph hint pattern). */ +export function ChartTitleWithHint({ + title, + helpKey, + hint, + as = 'h3', + className = '', +}: { + title: string; + helpKey?: string; + hint?: HelpHintContent; + as?: 'h2' | 'h3'; + className?: string; +}) { + const hintContent = normalizeHintContent(hint ?? (helpKey ? metricHelpHint(helpKey) : undefined)); + const Tag = as; + return ( +
+ {title} + {hintContent ? ( + + {hintContent.body} + + ) : null} +
+ ); +} + +/** Label text plus ? from metricHelp dot path (e.g. `shared.clicks`). */ +export function LabelWithHint({ + label, + helpKey, + className = '', +}: { + label: ReactNode; + helpKey: string; + className?: string; +}) { + const hintContent = normalizeHintContent(metricHelpHint(helpKey)); + return ( + + {label} + {hintContent ? ( + + {hintContent.body} + + ) : null} + + ); +} diff --git a/web/src/components/SectionHeader.tsx b/web/src/components/SectionHeader.tsx new file mode 100644 index 00000000..0776640d --- /dev/null +++ b/web/src/components/SectionHeader.tsx @@ -0,0 +1,46 @@ +import type { LucideIcon } from 'lucide-react'; +import HelpHint, { normalizeHintContent, type HelpHintContent } from './HelpHint'; +import { metricHelpHint } from '@/lib/metricHelp'; + +export interface SectionHeaderProps { + icon: LucideIcon; + title: string; + description?: string; + hint?: HelpHintContent; + helpKey?: string; + size?: 'sm' | 'md'; + className?: string; +} + +export default function SectionHeader({ + icon: Icon, + title, + description, + hint, + helpKey, + size = 'md', + className = '', +}: SectionHeaderProps) { + const hintContent = normalizeHintContent(hint ?? (helpKey ? metricHelpHint(helpKey) : undefined)); + const iconClass = size === 'sm' ? 'h-4 w-4' : 'h-5 w-5'; + const titleClass = size === 'sm' ? 'text-base' : 'text-lg'; + const descClass = size === 'sm' ? 'text-xs' : 'text-sm'; + const wrapClass = size === 'sm' ? 'pb-3 mb-1' : 'pb-4'; + + return ( +
+
+ +

{title}

+ {hintContent ? ( + + {hintContent.body} + + ) : null} +
+ {description ? ( +

{description}

+ ) : null} +
+ ); +} diff --git a/web/src/components/StatCard.tsx b/web/src/components/StatCard.tsx index d2abaab6..dfade8ff 100644 --- a/web/src/components/StatCard.tsx +++ b/web/src/components/StatCard.tsx @@ -1,4 +1,5 @@ import type { ReactNode } from 'react'; +import HelpHint, { normalizeHintContent, type HelpHintContent } from './HelpHint'; import Card from './Card'; export interface StatCardProps { @@ -6,6 +7,7 @@ export interface StatCardProps { value: ReactNode; sub?: ReactNode; icon?: ReactNode; + hint?: HelpHintContent; size?: 'md' | 'lg'; className?: string; shadow?: boolean; @@ -16,17 +18,24 @@ export default function StatCard({ value, sub, icon, + hint, size = 'md', className = '', shadow = false, }: StatCardProps) { const valueClass = size === 'lg' ? 'text-3xl font-bold text-bright' : 'text-2xl font-bold text-bright tabular-nums'; + const hintContent = normalizeHintContent(hint); return ( -

+

{icon} - {label} + {label} + {hintContent ? ( + + {hintContent.body} + + ) : null}

{value ?? '—'}

{sub ?

{sub}

: null} diff --git a/web/src/components/Table.tsx b/web/src/components/Table.tsx index 8c3f9ef3..ac2170c0 100644 --- a/web/src/components/Table.tsx +++ b/web/src/components/Table.tsx @@ -1,4 +1,5 @@ import type { ReactNode } from 'react'; +import HelpHint, { normalizeHintContent, type HelpHintContent } from './HelpHint'; interface TableProps { children?: ReactNode; @@ -31,9 +32,32 @@ export const TableHead = ({ children, sticky = false }: TableHeadProps) => ( ); -export const TableHeadCell = ({ children, className = '', title }: { children?: ReactNode; className?: string; title?: string }) => ( - {children} -); +export const TableHeadCell = ({ + children, + className = '', + title, + hint, +}: { + children?: ReactNode; + className?: string; + /** @deprecated Use hint for metric explanations; reserve title for native browser tooltip on truncation. */ + title?: string; + hint?: HelpHintContent; +}) => { + const hintContent = normalizeHintContent(hint); + return ( + +
+ {children} + {hintContent ? ( + + {hintContent.body} + + ) : null} +
+ + ); +}; interface TableBodyProps { children?: ReactNode; diff --git a/web/src/components/backlinks/GscLinksSummaryCards.tsx b/web/src/components/backlinks/GscLinksSummaryCards.tsx index 6476b6ef..3775aa50 100644 --- a/web/src/components/backlinks/GscLinksSummaryCards.tsx +++ b/web/src/components/backlinks/GscLinksSummaryCards.tsx @@ -1,4 +1,5 @@ import SummaryCard from '@/components/google/SummaryCard'; +import { metricHelpHint } from '@/lib/metricHelp'; import type { GscLinksReportData } from '@/types/report'; import { summaryCounts } from './backlinksTableUtils'; @@ -16,10 +17,26 @@ export default function GscLinksSummaryCards({ data, labels }: GscLinksSummaryCa const counts = summaryCounts(data); return (
- - - - + + + +
); } diff --git a/web/src/components/compare/CompareCharts.tsx b/web/src/components/compare/CompareCharts.tsx index e968d34e..152e9335 100644 --- a/web/src/components/compare/CompareCharts.tsx +++ b/web/src/components/compare/CompareCharts.tsx @@ -31,6 +31,7 @@ import { getChartTitleColor, getChartLegendLabelColor, } from '@/utils/chartJsDefaults'; +import ChartCard from '@/components/ChartCard'; ChartJS.register(CategoryScale, LinearScale, BarElement, PointElement, LineElement, Title, Tooltip, Legend); @@ -39,26 +40,6 @@ const COLOR_CURRENT = '#3b82f6'; type CompareChartStrings = (typeof import('@/lib/strings').strings)['views']['compare']; -function ChartCard({ - title, - hint, - children, -}: { - title: string; - hint?: string; - children: ReactNode; -}) { - return ( -
-

{title}

- {hint ?

{hint}

:
} -
- {children} -
-
- ); -} - function useChartOptions() { return useMemo(() => { const grid = getGridColor(); diff --git a/web/src/components/compare/CompareTabPanels.tsx b/web/src/components/compare/CompareTabPanels.tsx index f1b1ce09..1a1eff12 100644 --- a/web/src/components/compare/CompareTabPanels.tsx +++ b/web/src/components/compare/CompareTabPanels.tsx @@ -14,6 +14,7 @@ const ComparePerformanceCharts = dynamic( import type { IssueDeltaRow } from '@/lib/reportCompareExtras'; import { ScoreDelta } from '@/components/charts/ScoreDelta'; import type { CompareMetricRow, ReportCompareSummary } from '@/lib/reportCompare'; +import { metricHelpHint } from '@/lib/metricHelp'; import { Card, Table, TableHead, TableHeadCell, TableBody, TableRow, TableCell, Badge } from '@/components'; import { CompareMetricCard } from './CompareDeltaBadge'; import AiSuggestionButton from '@/components/ai/AiSuggestionButton'; @@ -296,8 +297,8 @@ export function ComparePerformancePanel({ URL Perf Δ SEO Δ - {vc.colCurrent} - {vc.colBaseline} + {vc.colCurrent} + {vc.colBaseline} @@ -383,8 +384,8 @@ export function CompareContentPanel({ compare, searchQuery, vc, emptyLabel }: Pa {vc.colKind} Representative URL - {vc.colCurrent} - {vc.colBaseline} + {vc.colCurrent} + {vc.colBaseline} @@ -453,9 +454,9 @@ export function CompareLinksPanel({ compare, searchQuery, vc, emptyLabel }: Pane URL {vc.colMetric} - {vc.colBaseline} - {vc.colCurrent} - Δ + {vc.colBaseline} + {vc.colCurrent} + Δ diff --git a/web/src/components/google/GoogleChartCard.tsx b/web/src/components/google/GoogleChartCard.tsx index 22578ff8..d331472f 100644 --- a/web/src/components/google/GoogleChartCard.tsx +++ b/web/src/components/google/GoogleChartCard.tsx @@ -1,21 +1,9 @@ 'use client'; +import ChartCard from '@/components/ChartCard'; import type { GoogleChartCardProps } from '@/types/components'; -export default function GoogleChartCard({ - title, - hint, - ariaLabel, - heightClass = 'h-56', - children, -}: GoogleChartCardProps) { - return ( -
-

{title}

- {hint &&

{hint}

} -
- {children} -
-
- ); +/** Google chart wrapper — uses shared ChartCard with ? hint popover. */ +export default function GoogleChartCard(props: GoogleChartCardProps) { + return ; } diff --git a/web/src/components/google/SortablePaginatedTable.tsx b/web/src/components/google/SortablePaginatedTable.tsx index 68e271a8..5d269421 100644 --- a/web/src/components/google/SortablePaginatedTable.tsx +++ b/web/src/components/google/SortablePaginatedTable.tsx @@ -2,6 +2,8 @@ import { useState, useMemo, useEffect } from 'react'; import { format } from '../../lib/strings'; +import { metricHelpHint } from '@/lib/metricHelp'; +import HelpHint, { normalizeHintContent } from '../HelpHint'; import { Button } from '../index'; import { PAGE_SIZE, paginateSlice } from './tableUtils'; import type { PaginationLabels, TableColumn } from '@/types/components'; @@ -71,18 +73,40 @@ export default function SortablePaginatedTable({ - {columns.map((col) => ( + {columns.map((col) => { + const hintContent = normalizeHintContent( + col.hint == null + ? undefined + : typeof col.hint === 'string' + ? metricHelpHint(col.hint) + : col.hint, + ); + return ( - ))} + ); + })} diff --git a/web/src/components/google/SummaryCard.tsx b/web/src/components/google/SummaryCard.tsx index c1e87d0d..8fc6b9f6 100644 --- a/web/src/components/google/SummaryCard.tsx +++ b/web/src/components/google/SummaryCard.tsx @@ -1,15 +1,18 @@ import type { ReactNode } from 'react'; import StatCard from '../StatCard'; +import type { HelpHintContent } from '../HelpHint'; /** @deprecated Use StatCard from @/components */ export default function SummaryCard({ label, value, sub, + hint, }: { label: ReactNode; value: ReactNode; sub?: ReactNode; + hint?: HelpHintContent; }) { - return ; + return ; } diff --git a/web/src/components/index.ts b/web/src/components/index.ts index d2f27cdb..ce1db9f0 100644 --- a/web/src/components/index.ts +++ b/web/src/components/index.ts @@ -5,6 +5,10 @@ export { default as Button } from './Button'; export { default as Badge } from './Badge'; export { default as AlertBanner } from './AlertBanner'; export { default as StatCard } from './StatCard'; +export { default as HelpHint, normalizeHintContent, LabelWithHint, ChartTitleWithHint } from './HelpHint'; +export type { HelpHintProps, HelpHintContent } from './HelpHint'; +export { default as ChartCard } from './ChartCard'; +export { default as SectionHeader } from './SectionHeader'; export { default as Select, SELECT_CLASS } from './Select'; export { default as ViewTabs } from './ViewTabs'; export type { ViewTabItem } from './ViewTabs'; diff --git a/web/src/components/issues/IssueTaskBoard.tsx b/web/src/components/issues/IssueTaskBoard.tsx index 009ab323..871655d2 100644 --- a/web/src/components/issues/IssueTaskBoard.tsx +++ b/web/src/components/issues/IssueTaskBoard.tsx @@ -5,6 +5,7 @@ import { apiUrl } from '@/lib/publicBase'; import { strings } from '@/lib/strings'; import type { ReportIssue } from '@/types'; import UrlInspectorButton from '@/components/UrlInspectorButton'; +import { LabelWithHint } from '@/components'; import IssueAiFixButton from '@/components/issues/IssueAiFixButton'; import { useReadOnlySession } from '@/hooks/useReadOnlySession'; @@ -107,7 +108,8 @@ export default function IssueTaskBoard({ propertyId, reportId, issues }: IssueTa return (

- {vi.taskBoardHint || 'Sorted by Search Console clicks to affected URLs when available.'} + {vi.taskBoardHint || 'Sorted by Search Console clicks to affected URLs when available.'}{' '} +

{sorted.map((item, i) => { const msg = item.issue.message || ''; @@ -133,6 +135,12 @@ export default function IssueTaskBoard({ propertyId, reportId, issues }: IssueTa GSC clicks: {item.clicks!.toLocaleString()}

)} + {item.issue.impact_score != null && Number(item.issue.impact_score) > 0 ? ( +

+ :{' '} + {Number(item.issue.impact_score).toLocaleString()} +

+ ) : null} {(item.issue.llm_recommendation || item.issue.recommendation) ? (

{item.issue.llm_recommendation || item.issue.recommendation} diff --git a/web/src/components/keywordsExplorer/KeywordExplorerChrome.tsx b/web/src/components/keywordsExplorer/KeywordExplorerChrome.tsx index 6a4a101c..e5b485a5 100644 --- a/web/src/components/keywordsExplorer/KeywordExplorerChrome.tsx +++ b/web/src/components/keywordsExplorer/KeywordExplorerChrome.tsx @@ -18,7 +18,7 @@ import { } from 'lucide-react'; import type { LucideIcon } from 'lucide-react'; import { strings, format } from '../../lib/strings'; -import { Card } from '../index'; +import { Card, LabelWithHint } from '../index'; import type { KeywordTabId } from './keywordTabMeta'; interface KeywordExplorerChromeProps { @@ -55,6 +55,7 @@ interface KpiDef { value: string; sub: string; accent: string; + helpKey: string; } function KeywordKpiTile({ @@ -93,7 +94,7 @@ function KeywordKpiTile({ />

- {def.label} +

{def.value}

{def.sub}

@@ -129,6 +130,7 @@ export default function KeywordExplorerChrome({ value: kpis.totalDisplay, sub: format(k.totalSub, { n: kpis.sourceCount }), accent: 'bg-blue-500/15 text-blue-400', + helpKey: 'views.keywordsExplorer.totalKeywords', }, { key: 'gsc', @@ -138,6 +140,7 @@ export default function KeywordExplorerChrome({ value: kpis.gscCount.toLocaleString(), sub: k.gscSub, accent: 'bg-emerald-500/15 text-emerald-400', + helpKey: 'views.keywordsExplorer.gscKeywords', }, { key: 'quickwins', @@ -147,6 +150,7 @@ export default function KeywordExplorerChrome({ value: kpis.quickWins.toLocaleString(), sub: k.quickWinsSub, accent: 'bg-amber-500/15 text-amber-400', + helpKey: 'views.keywordsExplorer.quickWins', }, { key: 'cannib', @@ -156,6 +160,7 @@ export default function KeywordExplorerChrome({ value: kpis.cannib.toLocaleString(), sub: k.cannibSub, accent: 'bg-red-500/15 text-red-400', + helpKey: 'views.keywordsExplorer.cannibalisation', }, ]; @@ -168,6 +173,7 @@ export default function KeywordExplorerChrome({ value: kpis.lostClicks.toLocaleString(), sub: k.lostClicksSub, accent: 'bg-orange-500/15 text-orange-400', + helpKey: 'views.keywordsExplorer.lostClicks', }, { key: 'questions', @@ -177,6 +183,7 @@ export default function KeywordExplorerChrome({ value: kpis.questions.toLocaleString(), sub: k.questionsSub, accent: 'bg-violet-500/15 text-violet-400', + helpKey: 'views.keywordsExplorer.questions', }, ]; diff --git a/web/src/components/keywordsExplorer/KeywordTableColumns.tsx b/web/src/components/keywordsExplorer/KeywordTableColumns.tsx index 1b9def52..8ce1d533 100644 --- a/web/src/components/keywordsExplorer/KeywordTableColumns.tsx +++ b/web/src/components/keywordsExplorer/KeywordTableColumns.tsx @@ -138,9 +138,10 @@ export function buildKeywordColumns( cols.push({ key: 'serp_estimated_competition', label: ke.table.serpCompetition, + hint: 'views.keywords.serpCompetition', render: (v) => v != null && typeof v === 'number' ? ( - + {v} ) : ( @@ -149,6 +150,21 @@ export function buildKeywordColumns( }); } + const showOnSiteFreq = allRows.some((r) => r.on_site_frequency != null); + if (showOnSiteFreq) { + cols.push({ + key: 'on_site_frequency', + label: ke.table.onSiteFrequency, + hint: 'shared.onSiteFrequency', + render: (v) => + v != null && typeof v === 'number' ? ( + {Number(v).toLocaleString()} + ) : ( + '—' + ), + }); + } + cols.push( { key: 'difficulty', @@ -163,6 +179,7 @@ export function buildKeywordColumns( { key: 'gsc_position', label: ke.table.position, + hint: 'views.keywords.gscPosition', render: (v, row) => { const r = row as KeywordRow | undefined; const kw = String(r?.keyword ?? ''); @@ -178,18 +195,21 @@ export function buildKeywordColumns( { key: 'gsc_impressions', label: ke.table.impressions, + hint: 'views.keywords.gscImpressions', render: (v) => v != null ? {Number(v).toLocaleString()} : '—', }, { key: 'gsc_clicks', label: ke.table.clicks, + hint: 'views.keywords.gscClicks', render: (v) => v != null ? {Number(v).toLocaleString()} : '—', }, { key: 'gsc_ctr', label: ke.table.ctr, + hint: 'views.keywords.gscCtr', render: (v) => {formatGscCtr(v as number | null | undefined)}, }, { diff --git a/web/src/components/lighthouse/ThresholdBar.tsx b/web/src/components/lighthouse/ThresholdBar.tsx index d865a3b0..8e3c92a8 100644 --- a/web/src/components/lighthouse/ThresholdBar.tsx +++ b/web/src/components/lighthouse/ThresholdBar.tsx @@ -1,4 +1,5 @@ -import { useState, useEffect, useRef } from 'react'; +import { useEffect, useState } from 'react'; +import HelpHint from '../HelpHint'; import { METRIC_THRESHOLDS, metricStatus, formatMetric } from '../../utils/lighthouseUtils'; export interface ThresholdBarProps { @@ -8,9 +9,7 @@ export interface ThresholdBarProps { export default function ThresholdBar({ metricKey, value }: ThresholdBarProps) { const t = METRIC_THRESHOLDS[metricKey]; - const [hovered, setHovered] = useState(false); const [mounted, setMounted] = useState(false); - const barRef = useRef(null); useEffect(() => { const id = setTimeout(() => setMounted(true), 50); @@ -37,15 +36,16 @@ export default function ThresholdBar({ metricKey, value }: ThresholdBarProps) { : 'text-red-600 dark:text-red-400'; const refVal = t.good * 1.5; const pct = Math.min(100, (v / refVal) * 100); + const hintBody = `${t.desc} Good: ≤${formatMetric(metricKey, t.good)}. Needs improvement: ≤${formatMetric(metricKey, t.warn)}.`; return ( -
setHovered(true)} - onMouseLeave={() => setHovered(false)} - ref={barRef} - > - {t.label} +
+ + {t.label} + + {hintBody} + +
- - {hovered && ( -
-
{t.label}
-

{t.desc}

-
- Value: {formatMetric(metricKey, v)} - Good: ≤{formatMetric(metricKey, t.good)} - Warn: ≤{formatMetric(metricKey, t.warn)} -
-
- {status === 'good' ? '✓ Good' : status === 'warn' ? '⚠ Needs improvement' : '✕ Poor'} -
-
- )}
); } diff --git a/web/src/components/links/InlinksMetricCell.tsx b/web/src/components/links/InlinksMetricCell.tsx index 6244de6a..9fea4e4c 100644 --- a/web/src/components/links/InlinksMetricCell.tsx +++ b/web/src/components/links/InlinksMetricCell.tsx @@ -30,11 +30,6 @@ export default function InlinksMetricCell({ ) : null} 0 && inl > 0 - ? `${Math.round(pct)}% of the strongest inlinks count in this view (${maxInSection}).` - : undefined - } > {showIcon ? : null} {inl.toLocaleString()} diff --git a/web/src/components/links/LinkAttributesCharts.tsx b/web/src/components/links/LinkAttributesCharts.tsx index b6754d5d..67099bb9 100644 --- a/web/src/components/links/LinkAttributesCharts.tsx +++ b/web/src/components/links/LinkAttributesCharts.tsx @@ -3,7 +3,7 @@ import { useMemo } from 'react'; import { Bar, Doughnut } from 'react-chartjs-2'; import type { TooltipItem } from 'chart.js'; -import { Card } from '@/components'; +import { Card, ChartTitleWithHint } from '@/components'; import { ChartAccessibleFallback } from '@/components/charts'; import { strings } from '@/lib/strings'; import type { InlinkAnchorRow, LinkRelSummary } from '@/types/report'; @@ -155,8 +155,7 @@ export default function LinkAttributesCharts({ summary, anchors, labels }: LinkA
{scopeChart ? ( -

{vl.chartLinkScopeTitle}

-

{vl.chartLinkScopeHint}

+
-

{vl.chartInternalAttrsTitle}

-

{vl.chartInternalAttrsHint}

+
0 ? ( -

{vl.chartTopAnchorsTitle}

-

{vl.chartTopAnchorsHint}

+
0 ? ( -

{vl.chartTopTargetsTitle}

-

{vl.chartTopTargetsHint}

+
void; className?: string; + hint?: HelpHintContent; } -export default function SortTh({ label, field, sortBy, sortDesc, onSort, className = '' }: SortThProps) { +export default function SortTh({ label, field, sortBy, sortDesc, onSort, className = '', hint }: SortThProps) { const active = sortBy === field; + const hintContent = normalizeHintContent(hint); return (
) : null} {customFieldKeys.map((key) => ( @@ -146,7 +183,14 @@ export function LinksExplorerTableTab({ ))} diff --git a/web/src/components/overview/OverviewSummaryTab.tsx b/web/src/components/overview/OverviewSummaryTab.tsx index 80864535..96d7809b 100644 --- a/web/src/components/overview/OverviewSummaryTab.tsx +++ b/web/src/components/overview/OverviewSummaryTab.tsx @@ -21,9 +21,10 @@ import { import type { ReportPayload } from '@/types'; import type { DataSourceId } from '@/lib/dataProvenance'; import { strings, format } from '@/lib/strings'; +import { metricHelpHint } from '@/lib/metricHelp'; import { crawledUrlCount } from '@/lib/crawlCounts'; import { googleSnapshotStatus } from '@/lib/googleSnapshot'; -import { Card, AlertBanner, StatCard } from '@/components'; +import { Card, AlertBanner, StatCard, LabelWithHint } from '@/components'; import { DataSourceBadgeRow } from '@/components/DataSourceBadge'; import LlmDisclosure from '@/components/LlmDisclosure'; import { OverviewTabPanel } from './OverviewTabPanel'; @@ -218,14 +219,30 @@ export function OverviewSummaryTab({ data, exportHref, compareHref, reportCount
{googleData.gsc ? ( <> - - + + ) : null} {googleData.ga4 ? ( <> - - + + ) : null}
@@ -252,20 +269,20 @@ export function OverviewSummaryTab({ data, exportHref, compareHref, reportCount
- {vo.totalUrls} +
{crawledCount.toLocaleString()}
{s.avg_outlinks ?? 0} {vo.avgOutlinks}
- {vo.successRate} +
{s.success_rate ?? 0}%
- {vo.broken} +
{brokenCount}
@@ -274,7 +291,7 @@ export function OverviewSummaryTab({ data, exportHref, compareHref, reportCount
- {vo.missingH1s} +
{h1Zero}
@@ -283,7 +300,7 @@ export function OverviewSummaryTab({ data, exportHref, compareHref, reportCount
- {vo.medianWordCount} +
{data.content_analytics?.word_count_stats?.median != null @@ -294,7 +311,7 @@ export function OverviewSummaryTab({ data, exportHref, compareHref, reportCount
- {vo.ogCoverage} +
{data.social_coverage?.og_coverage_pct != null ? `${data.social_coverage.og_coverage_pct}%` : sj.emDash} @@ -312,7 +329,7 @@ export function OverviewSummaryTab({ data, exportHref, compareHref, reportCount
- {vo.responseP50} +
{data.response_time_stats?.p50 != null ? `${Math.round(data.response_time_stats.p50)}ms` : sj.emDash} diff --git a/web/src/components/portfolio/PortfolioPropertyCard.tsx b/web/src/components/portfolio/PortfolioPropertyCard.tsx index a7ee4f3e..95fa2571 100644 --- a/web/src/components/portfolio/PortfolioPropertyCard.tsx +++ b/web/src/components/portfolio/PortfolioPropertyCard.tsx @@ -8,7 +8,7 @@ import { Timer, Trash2, } from 'lucide-react'; -import { Card } from '@/components'; +import { Card, LabelWithHint } from '@/components'; import Sparkline, { type SparklineMode } from '@/components/Sparkline'; import { DataSourceBadgeRow } from '@/components/DataSourceBadge'; import { PRIORITY_CONFIG } from '@/lib/issuePriority'; @@ -42,18 +42,22 @@ export interface PortfolioPropertyCardProps { function PortfolioTrendCell({ label, + helpKey, values, displayValue, mode, }: { label: string; + helpKey?: string; values: number[]; displayValue: string; mode: SparklineMode; }) { return (
-

{label}

+

+ {helpKey ? : label} +

@@ -145,7 +149,11 @@ export default function PortfolioPropertyCard({

- {group.crawlOnly ? vh.titleCoverageLabel : vh.healthScoreLabel} + {group.crawlOnly ? ( + + ) : ( + + )}

{group.crawlOnly && trends.titleTrend.length >= 1 ? ( @@ -267,13 +275,17 @@ export default function PortfolioPropertyCard({
-

{vh.urlCountLabel}

+

+ +

{group.urlCount.toLocaleString()}

-

{vh.titleCoverageLabel}

+

+ +

{group.titleCoverage != null ? `${group.titleCoverage}%` : sj.emDash}

@@ -319,7 +331,9 @@ export default function PortfolioPropertyCard({
-

{vh.urlCountLabel}

+

+ +

{group.urlCount.toLocaleString()}

{group.medianWordCount != null ? (

@@ -335,7 +349,7 @@ export default function PortfolioPropertyCard({

- {vh.totalIssuesLabel} +

{group.totalIssues.toLocaleString()}

@@ -360,11 +374,15 @@ export default function PortfolioPropertyCard({

- {vh.perfScoreLabel} + + + {group.perfScore ?? sj.emDash}

- {vh.seoScoreLabel} + + + {group.seoScore ?? sj.emDash}

@@ -430,6 +448,7 @@ export default function PortfolioPropertyCard({ /> -

{title}

- {hint &&

{hint}

} -
- {children} -
-
- ); -} - function useTopBarChart( rows: Array> | null | undefined, labelKey: string, @@ -199,14 +179,14 @@ export function GscDailyTrendChart({ daily }: GscDailyTrendChartProps) { ]; if (!daily?.length) return null; return ( - - + ); } diff --git a/web/src/components/siteStructure/PathTreeTable.tsx b/web/src/components/siteStructure/PathTreeTable.tsx index 8d67f290..07325de0 100644 --- a/web/src/components/siteStructure/PathTreeTable.tsx +++ b/web/src/components/siteStructure/PathTreeTable.tsx @@ -3,6 +3,7 @@ import { ChevronRight, ChevronDown, Folder, Home, FileText } from 'lucide-react' import Table, { TableHead, TableHeadCell, TableBody, TableRow, TableCell } from '../Table'; import InlinksMetricCell from '../links/InlinksMetricCell'; import { rtColor } from '../../utils/linkUtils'; +import { metricHelpHint } from '@/lib/metricHelp'; import type { PathTreeTableRow } from '@/types/report'; interface ComparePairBarProps { @@ -256,26 +257,40 @@ export default function PathTreeTable({
toggle(col.key)} className="px-3 py-2 text-left text-xs font-bold text-muted-foreground uppercase tracking-wider cursor-pointer select-none hover:text-foreground whitespace-nowrap" > - {col.label} + + {col.label} + {hintContent ? ( + e.stopPropagation()} + onKeyDown={(e) => e.stopPropagation()} + > + + {hintContent.body} + + + ) : null} + {sortKey === col.key && ( {sortDir === 'asc' ? '↑' : '↓'} )}
{label} + {hintContent ? ( + e.stopPropagation()} + onKeyDown={(e) => e.stopPropagation()} + > + + {hintContent.body} + + + ) : null} {active ? (sortDesc ? : ) : } diff --git a/web/src/components/links/explorer/LinksExplorerSummaryCharts.tsx b/web/src/components/links/explorer/LinksExplorerSummaryCharts.tsx new file mode 100644 index 00000000..7d9fd731 --- /dev/null +++ b/web/src/components/links/explorer/LinksExplorerSummaryCharts.tsx @@ -0,0 +1,97 @@ +'use client'; + +import { useMemo } from 'react'; +import { Bar } from 'react-chartjs-2'; +import type { ReportLink } from '@/types'; +import { Card, ChartTitleWithHint } from '@/components'; +import { ChartPanel } from '@/components/charts'; +import { barOptsVertical } from '@/components/overview/chartUtils'; +import { strings } from '@/lib/strings'; +import { statusDistributionFromLinks } from '@/lib/statusDistribution'; +import { PALETTE_CATEGORICAL } from '@/utils/chartPalette'; +import { registerChartJsBase } from '@/utils/chartJsDefaults'; + +registerChartJsBase(); + +const WC_BUCKETS = [ + { label: '< 300 words', min: 0, max: 299 }, + { label: '300–999 words', min: 300, max: 999 }, + { label: '1000+ words', min: 1000, max: Infinity }, +] as const; + +function wordCountBandsFromLinks(links: ReportLink[]): { labels: string[]; values: number[] } | null { + const counts = WC_BUCKETS.map(() => 0); + for (const link of links) { + const wc = link.word_count ?? 0; + const idx = WC_BUCKETS.findIndex((b) => wc >= b.min && wc <= b.max); + if (idx >= 0) counts[idx] += 1; + } + if (counts.every((c) => c === 0)) return null; + return { + labels: WC_BUCKETS.map((b) => b.label), + values: counts, + }; +} + +export interface LinksExplorerSummaryChartsProps { + links: ReportLink[]; +} + +export function LinksExplorerSummaryCharts({ links }: LinksExplorerSummaryChartsProps) { + const vl = strings.views.links; + const vo = strings.views.overview; + + const statusChart = useMemo(() => statusDistributionFromLinks(links), [links]); + const wcChart = useMemo(() => wordCountBandsFromLinks(links), [links]); + + if (!statusChart && !wcChart) return null; + + return ( +
+ {statusChart ? ( + + + +
+ +
+
+
+ ) : null} + {wcChart ? ( + + + + + + + ) : null} +
+ ); +} diff --git a/web/src/components/links/explorer/LinksExplorerTableTab.tsx b/web/src/components/links/explorer/LinksExplorerTableTab.tsx index b8e2f78d..aa15535f 100644 --- a/web/src/components/links/explorer/LinksExplorerTableTab.tsx +++ b/web/src/components/links/explorer/LinksExplorerTableTab.tsx @@ -4,6 +4,8 @@ import { type MouseEvent, type RefObject } from 'react'; import { Search, ExternalLink } from 'lucide-react'; import type { ReportLink } from '@/types'; import { strings, format } from '@/lib/strings'; +import { metricHelpHint } from '@/lib/metricHelp'; +import HelpHint, { normalizeHintContent } from '@/components/HelpHint'; import { Card, Badge, Button } from '@/components'; import { formatMs, rtColor, formatPageHrefLines } from '@/utils/linkUtils'; import { linkHasBrowserErrors } from '@/lib/browserErrors'; @@ -13,6 +15,7 @@ import SavedCrawlFiltersBar from '@/components/links/SavedCrawlFiltersBar'; import type { LinksFilterValues } from '@/components/links/LinksFilterBar'; import type { LinkSortKey } from './types'; import { LinksExplorerTabPanel } from './LinksExplorerTabPanel'; +import { LinksExplorerSummaryCharts } from './LinksExplorerSummaryCharts'; export interface LinksExplorerTableTabProps { filterValues: LinksFilterValues; @@ -69,6 +72,8 @@ export function LinksExplorerTableTab({ const sj = strings.common; const hasCustomExtract = links.some((l) => l.custom_extract); const customFieldKeys = collectCustomFieldKeys(links); + const customExtractHint = normalizeHintContent(metricHelpHint('views.links.explorerExtract')); + const jsErrorsHint = normalizeHintContent(metricHelpHint('views.links.explorerJsErrors')); return ( @@ -86,6 +91,8 @@ export function LinksExplorerTableTab({ /> ) : null} + +
- - + + + - {hasCustomExtract ? (
- {vl.thCustomExtract} + + {vl.thCustomExtract} + {customExtractHint ? ( + + {customExtractHint.body} + + ) : null} + - {vl.thJsErrors} + + {vl.thJsErrors} + {jsErrorsHint ? ( + + {jsErrorsHint.body} + + ) : null} + {vl.thActions} diff --git a/web/src/components/links/explorer/index.ts b/web/src/components/links/explorer/index.ts index c3ce7477..295e972f 100644 --- a/web/src/components/links/explorer/index.ts +++ b/web/src/components/links/explorer/index.ts @@ -1,4 +1,5 @@ export type { LinkSortKey } from './types'; +export { LinksExplorerSummaryCharts } from './LinksExplorerSummaryCharts'; export type { LinksExplorerTabId } from './LinksExplorerTabPanel'; export { LinksExplorerAnchorsTab } from './LinksExplorerAnchorsTab'; export { LinksExplorerTabPanel } from './LinksExplorerTabPanel'; diff --git a/web/src/components/links/tabs/ContentTab.tsx b/web/src/components/links/tabs/ContentTab.tsx index ed110c80..335a2e42 100644 --- a/web/src/components/links/tabs/ContentTab.tsx +++ b/web/src/components/links/tabs/ContentTab.tsx @@ -1,10 +1,10 @@ -import { useMemo, useState, createElement, type ComponentType } from 'react'; +import { useMemo, useState } from 'react'; import Link from 'next/link'; import { Chart as ChartJS, CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend, type TooltipItem } from 'chart.js'; import { BookOpen, BarChart2, Check, FileText, Layers, Share2, Tag, X } from 'lucide-react'; import type { LinkDetail } from '@/types/report'; import { useReport } from '../../../context/useReport'; -import { Card } from '../../../components'; +import { Card, SectionHeader, LabelWithHint, ChartTitleWithHint } from '../../../components'; import { RatioBar, RankedBarChart } from '../../../components/charts'; import { wcLabel, readingLabel, parseKeywords, normaliseKw } from '../../../utils/linkUtils'; import { PALETTE_CATEGORICAL } from '../../../utils/chartPalette'; @@ -101,26 +101,6 @@ function h1QualityIndex(count: number | string | undefined): number { return 2; } -interface SectionHeaderProps { - icon: ComponentType<{ className?: string }>; - title: string; - description?: string; -} - -function SectionHeader({ icon, title, description }: SectionHeaderProps) { - return ( -
-
- {createElement(icon, { className: 'h-4 w-4 text-link' })} -
-
-

{title}

- {description &&

{description}

} -
-
- ); -} - function QualityStatusRow({ label, detail, @@ -249,7 +229,8 @@ export default function ContentTab({ link }: ContentTabProps) {
- {lc.kpiWords} + +
{wc.toLocaleString()}
{wcInfo.label}
@@ -262,7 +243,8 @@ export default function ContentTab({ link }: ContentTabProps) {
- {lc.kpiReading} + +
{rl > 0 ? format(lo.readingGrade, { n: rl }) : sj.emDash} @@ -276,7 +258,8 @@ export default function ContentTab({ link }: ContentTabProps) {
- {lc.kpiDepth} + +
{link.depth != null ? link.depth : sj.emDash}
{lc.crawlDepth}
@@ -291,11 +274,10 @@ export default function ContentTab({ link }: ContentTabProps) {
- +
-

{lc.wordCountComparison}

-

{lc.vsSiteAggregates}

+
{compareBarData.values.length > 0 ? ( 0 && (
- +
- +
- +

{String(link.content_excerpt).trim()} @@ -402,7 +384,7 @@ export default function ContentTab({ link }: ContentTabProps) { )}

- + diff --git a/web/src/components/links/tabs/OverviewTab.tsx b/web/src/components/links/tabs/OverviewTab.tsx index 861f42b2..4385e210 100644 --- a/web/src/components/links/tabs/OverviewTab.tsx +++ b/web/src/components/links/tabs/OverviewTab.tsx @@ -1,6 +1,6 @@ import { useMemo } from 'react'; import { Check, ChevronRight, Gauge, X } from 'lucide-react'; -import { Badge } from '../../index'; +import { Badge, LabelWithHint } from '../../index'; import type { LinkDetail, LinkLighthouseData, PageAnalysis } from '@/types/report'; import { useReport } from '../../../context/useReport'; import { strings, format } from '../../../lib/strings'; @@ -86,17 +86,19 @@ export default function OverviewTab({ link, lhData, onOpenTab }: OverviewTabProp const sslExp = (data?.site_ssl_expires_at || null) as string | null; const crawlStats = [ - { label: o.statStatus, value: , raw: true }, + { key: 'status', label: o.statStatus, value: , raw: true }, { - label: o.statResponseTime, + key: 'responseTime', + label: , value: {formatMs(link.response_time_ms)}, raw: true, }, - { label: o.statDepth, value: link.depth != null ? link.depth : sj.emDash }, - { label: o.statInlinks, value: link.inlinks ?? 0 }, - { label: o.statOutlinks, value: link.outlinks ?? 0 }, + { key: 'depth', label: , value: link.depth != null ? link.depth : sj.emDash }, + { key: 'inlinks', label: , value: link.inlinks ?? 0 }, + { key: 'outlinks', label: , value: link.outlinks ?? 0 }, { - label: o.statWords, + key: 'words', + label: , value: wc > 0 ? ( @@ -108,7 +110,8 @@ export default function OverviewTab({ link, lhData, onOpenTab }: OverviewTabProp raw: true, }, { - label: o.statReadingLevel, + key: 'readingLevel', + label: , value: rl > 0 ? ( @@ -120,6 +123,7 @@ export default function OverviewTab({ link, lhData, onOpenTab }: OverviewTabProp raw: true, }, { + key: 'redirects', label: o.statRedirects, value: (link.redirect_chain_length ?? 0) > 0 ? ( @@ -158,8 +162,8 @@ export default function OverviewTab({ link, lhData, onOpenTab }: OverviewTabProp

{o.crawlHeading}

- {crawlStats.map(({ label, value, raw }) => ( -
+ {crawlStats.map(({ key, label, value, raw }) => ( +
{label}
{raw ? value : {value}} diff --git a/web/src/components/overview/OverviewChartsTab.tsx b/web/src/components/overview/OverviewChartsTab.tsx index 176f84b4..13ec940c 100644 --- a/web/src/components/overview/OverviewChartsTab.tsx +++ b/web/src/components/overview/OverviewChartsTab.tsx @@ -3,7 +3,8 @@ import { BarChart3 } from 'lucide-react'; import { Bar } from 'react-chartjs-2'; import { strings, format } from '@/lib/strings'; -import { Card, StatCard } from '@/components'; +import { Card, StatCard, ChartTitleWithHint } from '@/components'; +import { metricHelpHint } from '@/lib/metricHelp'; import { StatusDistributionChart, LighthouseScoreGrid } from '@/components/charts'; import { ChartPanel } from '@/components/charts'; import { barOptionsHorizontal } from '@/utils/chartJsDefaults'; @@ -72,29 +73,25 @@ export function OverviewChartsTab({ charts, depth }: OverviewChartsTabProps) {
{statusDistribution && ( -

{vo.statusDist}

-

{vo.statusDistHint}

+
)} {wordCountChart && ( -

{vo.contentDepth}

-

{vo.contentDepthHint}

+
)} {responseTimeChart && ( -

{vo.serverLatency}

-

{vo.serverLatencyHint}

+
)} {depthChart && ( -

{vo.crawlDepth}

-

{vo.crawlDepthHint}

+
{format(vo.depthSummaryLine, { maxDepth: depth.max_depth ?? sj.emDash, @@ -110,8 +107,7 @@ export function OverviewChartsTab({ charts, depth }: OverviewChartsTabProps) { )} {titleMetaChart && ( -

{vo.titleMetaHealth}

-

{vo.titleMetaHint}

+
@@ -121,53 +117,62 @@ export function OverviewChartsTab({ charts, depth }: OverviewChartsTabProps) { )} {socialStats && ( -

{vo.socialPreview}

-

{vo.socialPreviewHint}

+
{socialStats.og != null && ( - + )} {socialStats.twitter != null && ( - + )} {socialStats.ogImage != null && ( - + )}
)} {readingLevelChart && ( -

{vo.readingLevel}

-

{vo.readingLevelHint}

+
)} {mimeChart && ( -

{vo.topMime}

-

{vo.topMimeHint}

+
)} {outlinksChart && ( -

{vo.outlinksTitle}

-

{vo.outlinksHint}

+
)} {domainsChart && ( -

{vo.topDomains}

-

{vo.topDomainsHint}

+
)} {lighthouseScores && ( -

{vo.lhCategoryScores}

-

{vo.lhCategoryHint}

+ {vo.thPage} - {vo.thImportance} - {vo.thConnections} + + {vo.thImportance} + + + {vo.thConnections} +
- + {s.colPath} - {s.colPages} + + {s.colPages} + {hasCompare && showCompareCharts ? ( - + Δ ) : null} - {s.colInlinks} + + {s.colInlinks} + {hasCompare && showCompareCharts ? ( - + Δ ) : null} - {s.colOutlinks} - {s.colAvgWords} - {s.colAvgRt} - {s.colPerf} - {s.colSeo} + + {s.colOutlinks} + + + {s.colAvgWords} + + + {s.colAvgRt} + + + {s.colPerf} + + + {s.colSeo} + diff --git a/web/src/lib/metricHelp.test.ts b/web/src/lib/metricHelp.test.ts new file mode 100644 index 00000000..6f3e2dab --- /dev/null +++ b/web/src/lib/metricHelp.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest'; +import { getMetricHelp, getMetricHelpBody, metricHelpHint } from '@/lib/metricHelp'; + +describe('getMetricHelp', () => { + it('returns shared metric entries', () => { + const clicks = getMetricHelp('shared.clicks'); + expect(clicks?.body).toContain('Search Console'); + }); + + it('returns view-specific entries', () => { + const linkScore = getMetricHelp('views.overview.linkScore'); + expect(linkScore?.body).toMatch(/internal|link/i); + expect(getMetricHelp('views.keywordsExplorer.quickWins')?.body).toMatch(/position/i); + expect(getMetricHelp('views.jsErrors.consoleTotal')?.body).toMatch(/console/i); + expect(getMetricHelp('views.links.chartStatus')?.body).toMatch(/filter/i); + expect(getMetricHelp('views.contentAnalytics.wordCountDist')?.body).toBeTruthy(); + }); + + it('returns undefined for unknown paths', () => { + expect(getMetricHelp('shared.notARealMetric')).toBeUndefined(); + expect(getMetricHelp('')).toBeUndefined(); + }); + + it('getMetricHelpBody returns body string only', () => { + expect(getMetricHelpBody('shared.ctr')).toContain('click'); + }); + + it('metricHelpHint returns string when no title', () => { + const hint = metricHelpHint('shared.sessions'); + expect(typeof hint === 'string' || (hint && 'body' in hint)).toBe(true); + }); +}); + +describe('normalizeHintContent', () => { + it('parses string and object hints', async () => { + const { normalizeHintContent } = await import('@/components/HelpHint'); + expect(normalizeHintContent('plain')).toEqual({ body: 'plain' }); + expect(normalizeHintContent({ title: 'T', body: 'B' })).toEqual({ title: 'T', body: 'B' }); + expect(normalizeHintContent(undefined)).toBeUndefined(); + }); +}); diff --git a/web/src/lib/metricHelp.ts b/web/src/lib/metricHelp.ts new file mode 100644 index 00000000..74138e28 --- /dev/null +++ b/web/src/lib/metricHelp.ts @@ -0,0 +1,33 @@ +import { strings } from '@/lib/strings'; + +export type MetricHelpEntry = { title?: string; body: string }; + +type MetricHelpNode = MetricHelpEntry | { [key: string]: MetricHelpNode }; + +function isEntry(node: MetricHelpNode | undefined): node is MetricHelpEntry { + return node != null && typeof node === 'object' && 'body' in node && typeof node.body === 'string'; +} + +/** Lookup metric help by dot path, e.g. `shared.clicks` or `views.overview.linkScore`. */ +export function getMetricHelp(key: string): MetricHelpEntry | undefined { + const parts = key.split('.').filter(Boolean); + let node: MetricHelpNode | undefined = (strings as { metricHelp?: MetricHelpNode }).metricHelp; + for (const part of parts) { + if (node == null || typeof node !== 'object' || isEntry(node)) return undefined; + node = node[part]; + } + return isEntry(node) ? node : undefined; +} + +/** Shorthand: returns body only, or undefined. */ +export function getMetricHelpBody(key: string): string | undefined { + return getMetricHelp(key)?.body; +} + +/** For StatCard / table headers: string or structured hint. */ +export function metricHelpHint(key: string): string | MetricHelpEntry | undefined { + const entry = getMetricHelp(key); + if (!entry) return undefined; + if (entry.title) return entry; + return entry.body; +} diff --git a/web/src/lib/statusDistribution.ts b/web/src/lib/statusDistribution.ts index b3f8e035..1653e95b 100644 --- a/web/src/lib/statusDistribution.ts +++ b/web/src/lib/statusDistribution.ts @@ -94,3 +94,25 @@ export function statusDistributionFromCounts( return buildFromBuckets(buckets); } + +/** From per-URL link rows (Link Explorer — full crawl list, ignores table filters). */ +export function statusDistributionFromLinks( + links: Array<{ status?: string | number | null }>, +): StatusDistribution | null { + if (!links.length) return null; + + const buckets: Record = { + ok2xx: 0, + redirect3xx: 0, + client4xx: 0, + server5xx: 0, + error: 0, + }; + + for (const link of links) { + const group = classifyStatusCode(String(link.status ?? '').trim()); + if (group) buckets[group] += 1; + } + + return buildFromBuckets(buckets); +} diff --git a/web/src/strings.json b/web/src/strings.json index 0bd0f082..2974f04b 100644 --- a/web/src/strings.json +++ b/web/src/strings.json @@ -1,4 +1,662 @@ { + "metricHelp": { + "shared": { + "clicks": { + "title": "Clicks", + "body": "Total clicks from Google Search Console for the selected date range and property. Organic search traffic only." + }, + "impressions": { + "title": "Impressions", + "body": "How often pages from this site appeared in Google Search results during the selected date range." + }, + "ctr": { + "title": "CTR", + "body": "Click-through rate: clicks divided by impressions, shown as a percentage. Higher means more searchers clicked after seeing your result." + }, + "position": { + "title": "Average position", + "body": "Mean ranking position in Google Search for the selected queries or pages. Lower numbers are better (1 is top)." + }, + "sessions": { + "title": "Sessions", + "body": "GA4 sessions: grouped visits to your site during the selected date range. One user can have multiple sessions." + }, + "activeUsers": { + "title": "Active users", + "body": "GA4 active users: distinct people who engaged with your site during the selected date range." + }, + "inlinks": { + "title": "Inlinks", + "body": "Internal links pointing to this URL discovered in this crawl. Counts all qualifying anchor links in scope." + }, + "outlinks": { + "title": "Outlinks", + "body": "Links from this URL to other URLs (internal or external) found in this crawl." + }, + "linkEdges": { + "title": "Link edges", + "body": "All discovered anchor relationships in this crawl, including rel attributes (nofollow, sponsored, etc.)." + }, + "healthScore": { + "title": "Site health", + "body": "Score from 0–100 based on issue category weights in this audit. Higher is better. Reflects technical SEO health, not Google rankings." + }, + "impactScore": { + "title": "Impact score", + "body": "Prioritization score: priority weight + (GSC clicks × 10) + (GA4 sessions × 5). Weights: Critical 1000, High 100, Medium 10, Low 1. Higher means fix sooner." + }, + "statusCode": { + "title": "HTTP status", + "body": "Response code returned when this URL was crawled (e.g. 200 OK, 301 redirect, 404 not found)." + }, + "successRate": { + "title": "2xx rate", + "body": "Share of crawled URLs that returned a successful HTTP 2xx response." + }, + "referringDomains": { + "title": "Referring domains", + "body": "Domains linking to your site from a Google Search Console Links export. This is a sample, not a complete backlink index." + }, + "sampleLinks": { + "title": "Sample links", + "body": "Example external links to your site from GSC Links CSV import. Google provides a sample, not all backlinks." + }, + "onSiteFrequency": { + "title": "On-site frequency", + "body": "Heuristic count estimated from this crawl (page occurrences, anchors, etc.). Not Google search volume." + }, + "fetchMethod": { + "title": "Fetch method", + "body": "How this URL was retrieved: static HTTP fetch or rendered with a browser (JavaScript). Depends on crawl render mode." + }, + "readingLevel": { + "title": "Reading level", + "body": "Flesch-Kincaid grade level estimated from visible text on the page. Higher grades mean more advanced reading." + }, + "wordCount": { + "title": "Word count", + "body": "Visible words extracted from main content in this crawl. Used for thin-content checks." + }, + "crawlDepth": { + "title": "Crawl depth", + "body": "Minimum number of link hops from the start URL to reach this page in the crawl graph." + }, + "responseTime": { + "title": "Response time", + "body": "Server response time in milliseconds when this URL was fetched during the crawl." + }, + "lighthouseScore": { + "title": "Lighthouse score", + "body": "Lab performance or category score from 0–100 from a Lighthouse run on this URL. Not the same as CrUX field data." + }, + "titleCoverage": { + "title": "Title coverage", + "body": "Percentage of crawled URLs that have a non-empty HTML title element." + }, + "thinPages": { + "title": "Thin pages", + "body": "URLs flagged as thin content based on word count and body-text thresholds in this audit." + }, + "medianWords": { + "title": "Median words", + "body": "Median visible word count across crawled pages in this audit." + }, + "avgWords": { + "title": "Average words", + "body": "Mean visible word count across crawled pages in this audit." + }, + "pageViews": { + "title": "Page views", + "body": "GA4 screen page views: total times pages were viewed during the selected date range." + } + }, + "views": { + "home": { + "totalBrands": { + "body": "Number of properties (client sites) in your portfolio." + }, + "totalUrls": { + "body": "Total URLs crawled across all properties in the portfolio summary." + }, + "avgHealth": { + "body": "Average site health score (0–100) across properties with audit data." + }, + "urlCount": { + "body": "URLs discovered in the latest crawl for this property." + }, + "totalIssues": { + "body": "Total issues flagged in the latest audit for this property." + }, + "perfScore": { + "body": "Lighthouse performance category score averaged or summarized for this property." + }, + "seoScore": { + "body": "Lighthouse SEO category score for this property." + }, + "trendHealth": { + "body": "Site health score over recent audit runs for this property." + }, + "trendIssues": { + "body": "Total issue count trend across recent audit runs." + }, + "trendUrgent": { + "body": "Count of Critical + High priority issues over recent audits." + } + }, + "overview": { + "linkScore": { + "title": "Link score", + "body": "Internal link importance from this crawl's link graph (PageRank-style). The bar shows share of the top page in this table. Not a Google ranking signal." + }, + "connections": { + "title": "Inlinks + outlinks", + "body": "Total internal link connections for this page in the crawl graph (indegree plus outdegree where available)." + }, + "totalUrls": { + "body": "URLs included in this audit report from the crawl." + }, + "brokenLinks": { + "body": "Internal links pointing to URLs that returned 4xx or 5xx in this crawl." + }, + "missingH1": { + "body": "Pages missing an H1 heading or with multiple H1s, per audit rules." + }, + "ogCoverage": { + "body": "Share of pages with Open Graph meta tags for social sharing." + }, + "responseP50": { + "body": "Median (50th percentile) server response time across crawled URLs." + }, + "urlJoinMatched": { + "body": "URLs present in both this crawl and Google Search Console data for the selected property." + }, + "urlJoinCrawlOnly": { + "body": "URLs found in the crawl but not in GSC data for the date range (may be non-indexed or low traffic)." + }, + "urlJoinGscOnly": { + "body": "URLs with GSC data but not reached in this crawl (outside scope, blocked, or crawl limit)." + }, + "urlJoinGa4Only": { + "body": "URLs with GA4 traffic but not matched to crawl or GSC rows in the join." + }, + "twitterCoverage": { + "body": "Share of crawled pages with Twitter Card meta tags (twitter:card, twitter:title, etc.)." + }, + "ogImageCoverage": { + "body": "Share of crawled pages with an og:image tag for social link previews." + }, + "statusDistChart": { + "body": "HTTP status code distribution for all URLs in this crawl (2xx, 3xx, 4xx, 5xx)." + }, + "contentDepthChart": { + "body": "Pages grouped by visible word-count bands — used to spot thin content at a glance." + }, + "serverLatencyChart": { + "body": "Histogram of server response times (TTFB) measured during the crawl." + }, + "crawlDepthChart": { + "body": "How many clicks from the start URL each page sits in the internal link graph." + }, + "titleMetaChart": { + "body": "Title and meta description length buckets vs SEO best-practice ranges." + }, + "readingLevelChart": { + "body": "Flesch-Kincaid grade-level distribution across crawled page text." + }, + "mimeChart": { + "body": "Most common MIME types returned by crawled URLs." + }, + "outlinksChart": { + "body": "External outlink counts bucketed per page — links leaving your site." + }, + "topDomainsChart": { + "body": "Most-linked external domains from your pages in this crawl." + }, + "lhCategoryChart": { + "body": "Average Lighthouse category scores (0–100) across audited sample URLs." + } + }, + "links": { + "explorerStatus": { + "body": "HTTP status code returned when this URL was crawled." + }, + "explorerInlinks": { + "body": "Count of internal links pointing to this URL in this crawl. Bar width is relative to the highest count in the current results." + }, + "explorerDepth": { + "body": "Shortest path length from the crawl start URL to this page via internal links." + }, + "explorerLoadTime": { + "body": "Time to first byte or total fetch time recorded during crawl, in milliseconds." + }, + "explorerWords": { + "body": "Extracted visible word count used for content-quality checks." + }, + "explorerExtract": { + "body": "Share of page content successfully extracted for analysis (text vs boilerplate)." + }, + "explorerJsErrors": { + "body": "JavaScript console errors captured when the page was rendered (if JS crawl mode was used)." + }, + "chartStatus": { + "body": "Full crawled URL list by HTTP status — ignores table filters above." + }, + "chartWc": { + "body": "Word-count bands matching the word-count filter dropdown in Link Explorer." + }, + "chartLinkScope": { + "body": "All link edges discovered in this crawl — internal vs external." + }, + "chartInternalAttrs": { + "body": "Internal link rel attributes (nofollow, sponsored, ugc, etc.) from this crawl." + }, + "chartTopAnchors": { + "body": "Most-used anchor text on internal links pointing into your site." + }, + "chartTopTargets": { + "body": "Pages receiving the most internal inlinks in this crawl." + } + }, + "siteStructure": { + "urlsInView": { + "body": "Total crawled pages under the selected path prefix or site root." + }, + "pathPrefixes": { + "body": "Distinct URL path segments (folders) in the structure tree." + }, + "totalInlinks": { + "body": "Sum of internal inlinks to pages in the current structure view." + }, + "avgPerf": { + "body": "Average Lighthouse performance score for pages in this path segment." + }, + "changePages": { + "body": "Change in page count under this path vs the baseline audit in Compare mode." + }, + "changeInlinks": { + "body": "Change in total inlinks under this path vs the baseline audit." + }, + "colPath": { + "body": "URL path prefix in the crawl tree. Expand folders to drill into nested segments." + }, + "colPages": { + "body": "Number of crawled pages rolled up under this path prefix." + }, + "colInlinks": { + "body": "Sum of internal inlinks to all pages under this path prefix." + }, + "colSeo": { + "body": "Average Lighthouse SEO category score for pages under this path." + }, + "avgResponse": { + "body": "Mean server response time in milliseconds across pages in the current structure view." + } + }, + "content": { + "duplicateCluster": { + "body": "Duplicate-content cluster ID from near-duplicate detection in this crawl." + }, + "duplicateRepresentative": { + "body": "Canonical or chosen representative URL for this duplicate cluster." + }, + "duplicateUrlCount": { + "body": "Number of URLs grouped as near-duplicates of the representative page." + }, + "metaDescLength": { + "body": "Meta description length in characters. Short or long descriptions may hurt CTR in search." + }, + "h1Count": { + "body": "Number of H1 headings on the page. SEO best practice is exactly one H1." + }, + "contentLength": { + "body": "Visible body text length in characters. Used to flag thin-content pages." + } + }, + "contentAnalytics": { + "twitterCoverage": { + "body": "Share of crawled pages with Twitter Card meta tags for social previews." + }, + "crawlHealth": { + "body": "HTTP status mix and response-time distribution from this crawl — not Google rankings." + }, + "onPageQuality": { + "body": "On-page SEO issue counts, optimal-range coverage, and thin-content signals from the crawl." + }, + "contentMetrics": { + "body": "Word counts, reading levels, HTML-to-text ratio, and on-site keyword frequency from extracted page text." + }, + "onPageSignals": { + "body": "Title tag, meta description, and H1 heading quality buckets across crawled pages." + }, + "socialMetaCoverage": { + "body": "Open Graph and Twitter Card tag coverage for social sharing previews." + }, + "siteArchitecture": { + "body": "How pages are distributed by crawl depth — hops from the start URL via internal links." + }, + "thinSmallBody": { + "body": "Pages flagged as thin by visible character count thresholds (separate from the under-300-word list)." + }, + "wordCountDist": { + "body": "Pages grouped by visible word-count bands from extracted crawl text." + }, + "readingLevelDist": { + "body": "Pages grouped by Flesch-Kincaid reading grade estimated from visible text." + }, + "contentHtmlRatio": { + "body": "Pages grouped by ratio of visible text to total HTML size — low ratios may indicate boilerplate-heavy pages." + }, + "topKeywords": { + "body": "Most frequent on-site keywords from extracted page text in this crawl — not Google search volume." + }, + "wordCountLadder": { + "body": "Word-count percentiles across crawled pages (min through max) to show content depth spread." + }, + "wordCountPercentiles": { + "body": "Statistical word-count summary (min, quartiles, median, mean, max) across crawled pages." + }, + "urlsByStatus": { + "body": "HTTP status code mix for all URLs in this crawl." + }, + "responseTimeDist": { + "body": "Pages grouped by server response-time buckets measured during the crawl." + }, + "issuesByType": { + "body": "Count of crawled URLs flagged for each on-page SEO issue category." + }, + "seoOptimalRanges": { + "body": "Share of pages whose title, meta description, and H1 fall in recommended length or count ranges." + }, + "thinSignals": { + "body": "Comparison of thin-content signals: under 300 words vs small visible body character count." + }, + "h1Dist": { + "body": "Pages grouped by H1 heading count — SEO best practice is exactly one H1 per page." + }, + "titleTagQuality": { + "body": "Title tag length buckets (too short, optimal, too long) across crawled pages." + }, + "metaDescQuality": { + "body": "Meta description length buckets across crawled pages." + }, + "titleVsMetaBuckets": { + "body": "Combined title and meta description quality buckets for on-page SEO coverage." + }, + "seoOptimalVsGapCounts": { + "body": "Pages meeting vs missing optimal title, meta, and H1 ranges side by side." + }, + "missingSocialCompare": { + "body": "Pages missing Open Graph or Twitter Card tags compared to pages that have them." + }, + "crawlDepthDist": { + "body": "Pages grouped by crawl depth — link hops from the start URL in the internal link graph." + } + }, + "gallery": { + "uniqueImages": { + "body": "Distinct image URLs discovered in this crawl (on-page, Open Graph, and Twitter images combined)." + }, + "shownFiltered": { + "body": "Images currently visible after source-type and header search filters." + } + }, + "network": { + "legendOk": { + "body": "Crawled pages that returned a successful HTTP 2xx response." + }, + "legendBroken": { + "body": "Crawled pages that returned HTTP 4xx or 5xx errors." + }, + "legendLink": { + "body": "Internal anchor link between two pages in the crawl graph." + } + }, + "indexation": { + "crawled": { + "body": "URLs successfully crawled in this audit." + }, + "sitemap": { + "body": "URLs listed in the site's XML sitemap(s) discovered during the audit." + }, + "gscPages": { + "body": "URLs with impressions in Google Search Console for the selected period." + }, + "sitemapOnly": { + "body": "URLs in the sitemap but not found in the crawl (orphaned sitemap entries)." + } + }, + "subdomains": { + "apex": { + "body": "Primary registered domain (apex) for this property's subdomain inventory." + }, + "inScopeHosts": { + "body": "Hosts included in this audit scope (crawl and/or GSC data)." + }, + "gscNotCrawled": { + "body": "Hosts with GSC data but no URLs reached in this crawl — often separate subdomains or out-of-scope properties." + }, + "outOfScope": { + "body": "Hosts discovered via certificate transparency or DNS but excluded from this crawl." + }, + "colHost": { + "body": "Subdomain or hostname discovered for this property." + }, + "colSources": { + "body": "Where this host was seen: crawl, GSC, certificate transparency (crt.sh), etc." + }, + "colCrawl": { + "body": "Whether any URL on this host was reached in the latest crawl." + }, + "colGsc": { + "body": "Whether Search Console reports data for this host." + }, + "colCrawlUrls": { + "body": "Count of crawled URLs on this host in the latest audit." + }, + "colGscUrls": { + "body": "Count of URLs with GSC impressions on this host in the selected period." + } + }, + "keywords": { + "serpCompetition": { + "body": "Estimated SERP competition from optional SerpAPI overlay. Heuristic, not official keyword difficulty." + }, + "gscClicks": { + "body": "Search Console clicks attributed to this query or query–page pair in the selected range." + }, + "gscImpressions": { + "body": "Search Console impressions for this query in the selected date range." + }, + "gscCtr": { + "body": "Click-through rate from Search Console for this query (clicks ÷ impressions)." + }, + "gscPosition": { + "body": "Average ranking position in Google Search for this query. Lower is better." + } + }, + "keywordsExplorer": { + "totalKeywords": { + "body": "All keywords in this audit from crawl seeds, expansion sources, and Search Console." + }, + "gscKeywords": { + "body": "Keywords with measured ranking data from Google Search Console in the selected range." + }, + "quickWins": { + "body": "Keywords in positions 4–20 with meaningful estimated click upside if improved to top 3." + }, + "cannibalisation": { + "body": "Queries ranking on multiple URLs — consolidate or canonicalise to one primary page." + }, + "lostClicks": { + "body": "Keywords that lost clicks compared to the prior Search Console period." + }, + "questions": { + "body": "Question-style queries (who, what, how, etc.) useful for FAQ and content ideas." + } + }, + "lighthouse": { + "categoryScores": { + "body": "Lighthouse category scores (Performance, Accessibility, Best Practices, SEO) from 0–100 for audited URLs." + }, + "cruxLcp": { + "body": "Largest Contentful Paint from Chrome UX Report field data (real users). Good: ≤2.5s." + }, + "cruxInp": { + "body": "Interaction to Next Paint from CrUX field data. Good: ≤200ms." + }, + "cruxCls": { + "body": "Cumulative Layout Shift from CrUX field data. Good: ≤0.1." + } + }, + "compare": { + "metricDelta": { + "body": "Difference between baseline and current audit values. Positive or negative depends on the metric." + }, + "currentValue": { + "body": "Value from the latest (current) audit in this comparison." + }, + "baselineValue": { + "body": "Value from the baseline audit you selected for comparison." + }, + "newUrls": { + "body": "URLs present in the current crawl but not in the baseline audit." + }, + "removedUrls": { + "body": "URLs in the baseline audit but missing from the current crawl." + } + }, + "jsErrors": { + "consolePages": { + "body": "Pages where at least one browser console message was recorded during crawl." + }, + "consoleTotal": { + "body": "Total browser console error messages captured across all rendered pages in this crawl." + }, + "exceptionPages": { + "body": "Pages where an uncaught JavaScript exception was recorded during browser render." + }, + "exceptionTotal": { + "body": "Total uncaught JavaScript exceptions captured during this crawl." + }, + "renderMode": { + "body": "Crawl render mode for this audit: static HTML only, JavaScript rendering, or auto." + } + }, + "logAnalyzer": { + "parsedLines": { + "body": "Log file lines successfully parsed from your upload." + }, + "uniquePaths": { + "body": "Distinct URL paths found in the uploaded access log sample." + }, + "googlebotHits": { + "body": "Log lines identified as Googlebot requests in the uploaded sample." + }, + "logOnly": { + "body": "URLs seen in access logs but not in the crawl (may be blocked or out of scope)." + }, + "crawlOnly": { + "body": "URLs crawled but not seen in the uploaded log sample." + } + }, + "redirects": { + "httpStatus": { + "body": "HTTP status code returned for the redirect response (e.g. 301 permanent, 302 temporary)." + } + }, + "techStack": { + "pagesDetected": { + "body": "Number of crawled pages where this technology fingerprint was detected." + }, + "categoryCount": { + "body": "Technologies detected in this category across the crawl, counted by distinct fingerprints." + }, + "detectedChart": { + "body": "Pages per detected technology — bar length shows how many crawled URLs matched each fingerprint." + } + }, + "contacts": { + "sources": { + "body": "Where this contact detail was discovered (e.g. schema.org JSON-LD, visible page text, meta tags)." + } + }, + "backlinks": { + "referringDomain": { + "body": "External domain linking to your site in the GSC Links sample — not a complete backlink index." + }, + "linkCount": { + "body": "Number of sample links from this referring domain in the GSC Links export." + }, + "targetPages": { + "body": "Distinct pages on your site linked from this referring domain in the sample." + }, + "targetPage": { + "body": "Your page receiving external links in the GSC Links sample." + }, + "linkingSites": { + "body": "Count of distinct referring domains linking to this page in the sample." + }, + "anchorText": { + "body": "Anchor text used in external links to your site in the GSC Links sample." + }, + "sourcePage": { + "body": "External URL linking to your site in the GSC Links sample." + }, + "linkedPages": { + "body": "Distinct pages on your site that received external links in the GSC Links sample." + }, + "latestLinks": { + "body": "Most recently discovered sample backlinks from GSC Links export." + } + }, + "security": { + "severityCount": { + "body": "Security findings at this severity from HTTP headers, TLS, and optional probes in this audit." + }, + "findingType": { + "body": "Category of security check that produced this finding (headers, TLS, mixed content, etc.)." + }, + "findingsBySeverityChart": { + "body": "Security findings grouped by severity (Critical, High, Medium, Low, Info) from this audit scan." + }, + "findingsByTypeChart": { + "body": "Count of findings per security check type (headers, TLS, cookies, etc.)." + } + }, + "textContentAnalysis": { + "byPageSection": { + "body": "Per-URL extracted text stats — word counts and reading levels from this crawl." + }, + "analyticsSection": { + "body": "Site-wide text analytics: keyword frequency, clusters, and reading-level distribution." + }, + "thWord": { + "body": "Keyword or phrase extracted from visible page text in this crawl." + }, + "thTotalCount": { + "body": "Estimated on-site frequency — heuristic from crawl text, not Google search volume." + }, + "thPageCount": { + "body": "Number of crawled pages where this term appears." + }, + "thCluster": { + "body": "Semantic topic cluster grouping related keywords from crawl + NLP analysis." + }, + "wordCountDist": { + "body": "Pages grouped by visible word-count bands from extracted crawl text." + }, + "readingLevelDist": { + "body": "Pages grouped by Flesch-Kincaid reading grade estimated from visible text." + }, + "contentHtmlRatio": { + "body": "Pages grouped by ratio of visible text to total HTML size." + }, + "wordCountLadder": { + "body": "Word-count percentiles across crawled pages (min through max)." + } + } + } + }, "app": { "loading": "Loading…", "failedTitle": "Failed to load report", diff --git a/web/src/types/components.ts b/web/src/types/components.ts index 090749ee..de5c0fb6 100644 --- a/web/src/types/components.ts +++ b/web/src/types/components.ts @@ -5,6 +5,8 @@ import type { KeywordHistoryRow } from '@/types/api'; export interface TableColumn { key: string; label: string; + /** Metric help tooltip content for column header. */ + hint?: string | { title?: string; body: string }; render?: (v: unknown, row?: Record) => ReactNode; } diff --git a/web/src/views/Backlinks.tsx b/web/src/views/Backlinks.tsx index 404dcfb7..eca96bc1 100644 --- a/web/src/views/Backlinks.tsx +++ b/web/src/views/Backlinks.tsx @@ -115,15 +115,17 @@ export default function Backlinks(_props: ViewProps) { const domainColumns = useMemo( (): TableColumn[] => [ - { key: 'site', label: vb.table.site, render: (v) => {String(v ?? '')} }, + { key: 'site', label: vb.table.site, hint: 'views.backlinks.referringDomain', render: (v) => {String(v ?? '')} }, { key: 'link_count', label: vb.table.links, + hint: 'views.backlinks.linkCount', render: (v) => {Number(v ?? 0).toLocaleString()}, }, { key: 'target_page_count', label: vb.table.targetPages, + hint: 'views.backlinks.targetPages', render: (v) => {Number(v ?? 0).toLocaleString()}, }, ], @@ -135,6 +137,7 @@ export default function Backlinks(_props: ViewProps) { { key: 'target_page', label: vb.table.targetPage, + hint: 'views.backlinks.targetPage', render: (v, row) => { const url = String(v ?? ''); const inCrawl = row?.target_in_crawl === true; @@ -153,11 +156,13 @@ export default function Backlinks(_props: ViewProps) { { key: 'link_count', label: vb.table.links, + hint: 'views.backlinks.linkCount', render: (v) => {Number(v ?? 0).toLocaleString()}, }, { key: 'linking_site_count', label: vb.table.linkingSites, + hint: 'views.backlinks.linkingSites', render: (v) => {Number(v ?? 0).toLocaleString()}, }, ], @@ -169,6 +174,7 @@ export default function Backlinks(_props: ViewProps) { { key: 'anchor_text', label: vb.table.anchorText, + hint: 'views.backlinks.anchorText', render: (v) => ( {String(v ?? '').trim() || '—'} ), @@ -176,6 +182,7 @@ export default function Backlinks(_props: ViewProps) { { key: 'link_count', label: vb.table.links, + hint: 'views.backlinks.linkCount', render: (v) => {Number(v ?? 0).toLocaleString()}, }, ], @@ -187,6 +194,7 @@ export default function Backlinks(_props: ViewProps) { { key: 'source_page', label: vb.table.sourcePage, + hint: 'views.backlinks.sourcePage', render: (v) => ( { const url = String(v ?? ''); const inspectHref = @@ -220,6 +229,7 @@ export default function Backlinks(_props: ViewProps) { { key: 'anchor_text', label: vb.table.anchorText, + hint: 'views.backlinks.anchorText', render: (v) => {String(v ?? '').trim() || '—'}, }, { diff --git a/web/src/views/CompareReports.tsx b/web/src/views/CompareReports.tsx index 5caaa229..dd0c25b5 100644 --- a/web/src/views/CompareReports.tsx +++ b/web/src/views/CompareReports.tsx @@ -13,6 +13,7 @@ import { } from 'lucide-react'; import { useReport } from '../context/useReport'; import { strings } from '../lib/strings'; +import { metricHelpHint } from '@/lib/metricHelp'; import { formatReportGeneratedAt } from '../lib/reportTimestamps'; import { PageLayout, @@ -26,6 +27,7 @@ import { TableRow, TableCell, Badge, + LabelWithHint, } from '../components'; import ReportCompareControls from '../components/ReportCompareControls'; import { CompareMetricCard } from '../components/compare/CompareDeltaBadge'; @@ -446,11 +448,15 @@ export default function CompareReports({ searchQuery = '' }: ViewProps) {
-
{vo.newUrls}
+
+ +
{urlLists.newUrls.length}
-
{vo.removedUrls}
+
+ +
{urlLists.removedUrls.length}
@@ -520,8 +526,8 @@ export default function CompareReports({ searchQuery = '' }: ViewProps) { {vc.statusColUrl} - {vc.statusColBefore} - {vc.statusColAfter} + {vc.statusColBefore} + {vc.statusColAfter} @@ -562,9 +568,9 @@ export default function CompareReports({ searchQuery = '' }: ViewProps) { Category - Current - Baseline - Δ + Current + Baseline + Δ @@ -594,9 +600,9 @@ export default function CompareReports({ searchQuery = '' }: ViewProps) { Signal - Current - Baseline - Δ + Current + Baseline + Δ diff --git a/web/src/views/Contacts.tsx b/web/src/views/Contacts.tsx index 8041de33..11fcbfb5 100644 --- a/web/src/views/Contacts.tsx +++ b/web/src/views/Contacts.tsx @@ -4,6 +4,7 @@ import { useMemo } from 'react'; import { Contact2, ExternalLink } from 'lucide-react'; import { useReport } from '../context/useReport'; import { strings, format } from '../lib/strings'; +import { metricHelpHint } from '@/lib/metricHelp'; import { PageLayout, PageHeader, @@ -60,7 +61,7 @@ function ContactSectionTable({ {vc.colValue} - {vc.colSources} + {vc.colSources} {vc.colPages} diff --git a/web/src/views/Content.tsx b/web/src/views/Content.tsx index bb462e02..2ab55286 100644 --- a/web/src/views/Content.tsx +++ b/web/src/views/Content.tsx @@ -5,6 +5,7 @@ import type { TooltipItem } from 'chart.js'; import { ExternalLink, CheckCircle2, FileText, Copy, BarChart3, List } from 'lucide-react'; import { useReport } from '../context/useReport'; import { strings, format } from '../lib/strings'; +import { metricHelpHint } from '@/lib/metricHelp'; import { PageLayout, PageHeader, Card, Table, TableHead, TableHeadCell, TableBody, TableRow, TableCell, Button, ViewTabs, ViewTabPanel } from '../components'; import type { ViewTabItem } from '../components'; import { palette } from '../utils/chartPalette'; @@ -149,9 +150,9 @@ export default function Content({ searchQuery = '' }: ViewProps) {
- {vc.colCluster} - {vc.colRepresentative} - {vc.colUrls} + {vc.colCluster} + {vc.colRepresentative} + {vc.colUrls} @@ -298,13 +299,13 @@ export default function Content({ searchQuery = '' }: ViewProps) { {vc.tablePage} {(filter === 'meta_desc_short' || filter === 'meta_desc_long') && ( - {vc.tableLength} + {vc.tableLength} )} {filter === 'multiple_h1' && ( - {vc.tableH1Count} + {vc.tableH1Count} )} {filter === 'thin_content' && ( - {vc.tableChars} + {vc.tableChars} )} diff --git a/web/src/views/ContentAnalytics.tsx b/web/src/views/ContentAnalytics.tsx index daa47fc8..baa852e4 100644 --- a/web/src/views/ContentAnalytics.tsx +++ b/web/src/views/ContentAnalytics.tsx @@ -1,5 +1,5 @@ import type { Chart, TooltipItem } from 'chart.js'; -import { useState, useMemo, type ComponentType, type ReactNode } from 'react'; +import { useState, useMemo } from 'react'; import { useUrlTab } from '@/hooks/useUrlTab'; import type { ContentAnalyticsData, @@ -40,7 +40,8 @@ import { } from 'lucide-react'; import { useReport } from '../context/useReport'; import { strings, format } from '../lib/strings'; -import { PageLayout, PageHeader, Card, Table, TableHead, TableHeadCell, TableBody, TableRow, TableCell, ViewTabs, ViewTabPanel, StatCard } from '../components'; +import { metricHelpHint } from '@/lib/metricHelp'; +import { PageLayout, PageHeader, Card, Table, TableHead, TableHeadCell, TableBody, TableRow, TableCell, ViewTabs, ViewTabPanel, StatCard, SectionHeader, ChartTitleWithHint } from '../components'; import { StatusDistributionChart, CoverageBar, ChartAccessibleFallback, ChartPanel } from '../components/charts'; import { crawledUrlCount } from '@/lib/crawlCounts'; import { statusDistributionFromSummary } from '../lib/statusDistribution'; @@ -200,29 +201,6 @@ function qualityBarColors(labels: string[]) { }); } -function SectionHeader({ - icon, - title, - description, -}: { - icon: ComponentType<{ className?: string }>; - title: ReactNode; - description?: ReactNode; -}) { - const Icon = icon; - return ( -
-
- -
-
-

{title}

- {description &&

{description}

} -
-
- ); -} - function ThinPagesSection({ pages }: { pages: ThinPageEntry[] }) { const [open, setOpen] = useState(false); const vca = strings.views.contentAnalytics; @@ -539,58 +517,49 @@ export default function ContentAnalytics({ searchQuery = '' }: ViewProps) { {/* KPI stat cards */}
- -
- {vca.meanWords} -
-
- {wcStats.mean != null ? Math.round(wcStats.mean).toLocaleString() : sj.emDash} -
-
{vca.perPage}
-
- - -
- {vca.medianWords} -
-
- {wcStats.median != null ? Math.round(wcStats.median).toLocaleString() : sj.emDash} -
-
{vca.perPage}
-
- - -
- {vca.ogCoverage} -
-
- {sc.og_coverage_pct != null ? `${sc.og_coverage_pct}%` : sj.emDash} -
-
{vca.ogTags}
-
- - -
- {vca.twitterCoverage} -
-
- {sc.twitter_coverage_pct != null ? `${sc.twitter_coverage_pct}%` : sj.emDash} -
-
{vca.twitterTags}
-
- - } + hint={metricHelpHint('shared.avgWords')} shadow - className={hasThinPages ? 'ring-1 ring-amber-500/20 border-amber-900/30' : ''} - > -
- {vca.thinPages} -
-
- {thinPages.length} -
-
{vca.under300}
-
+ /> + } + hint={metricHelpHint('shared.medianWords')} + shadow + /> + } + hint={metricHelpHint('views.overview.ogCoverage')} + shadow + className="[&_.text-2xl]:text-link" + /> + } + hint={metricHelpHint('views.contentAnalytics.twitterCoverage')} + shadow + className="[&_.text-2xl]:text-sky-700 [&_.text-2xl]:dark:text-sky-400" + /> + } + hint={metricHelpHint('shared.thinPages')} + shadow + className={hasThinPages ? 'ring-1 ring-amber-500/20 border-amber-900/30 [&_.text-2xl]:text-amber-700 [&_.text-2xl]:dark:text-amber-400' : ''} + />
{richResultsRows.length > 0 ? ( @@ -769,11 +738,11 @@ export default function ContentAnalytics({ searchQuery = '' }: ViewProps) { {(hasStatusChart || hasRtDist) && (
- +
{hasStatusChart && ( -

{vca.urlsByStatus}

+

{vca.totalCrawled}{' '} {crawledCount.toLocaleString()} @@ -789,7 +758,7 @@ export default function ContentAnalytics({ searchQuery = '' }: ViewProps) { )} {hasRtDist && ( -

{vca.responseTimeDist}

+

Pages per latency band {rtStats.p50 != null && ( @@ -828,11 +797,11 @@ export default function ContentAnalytics({ searchQuery = '' }: ViewProps) { {(hasIssueBar || hasSeoOptimalBar || hasThinCompare) && (

- +
{hasIssueBar && ( -

URLs flagged by issue type

+ -

Pages in “good” ranges

+ -

{vca.thinSignals}

+
@@ -918,10 +889,10 @@ export default function ContentAnalytics({ searchQuery = '' }: ViewProps) { {/* Content Metrics section */}
- +
-

{vca.wordCountDist}

+ {wcLabels.length > 0 ? ( -

{vca.readingLevelDist}

+ {rlLabels.length > 0 ? ( -

{vca.contentHtmlRatio}

+ {crLabels.length > 0 ? ( -

{vca.topKeywords}

+ {kwLabels.length > 0 ? ( -

{vca.wordCountLadder}

+ - +
{/* H1 Distribution Doughnut */} -

{vca.h1Dist}

+ {hasH1Data ? ( -

{vca.titleTagQuality}

+ {hasTitleData ? ( -

{vca.metaDescQuality}

+ {hasMetaData ? ( {hasTitleMetaCompare && ( -

{vca.titleVsMetaBuckets}

+ -

{vca.seoOptimalVsGapCounts}

+ - + {/* Coverage progress bars */} {(sc.og_coverage_pct != null || sc.twitter_coverage_pct != null || hasOgImgData) && ( @@ -1226,7 +1197,7 @@ export default function ContentAnalytics({ searchQuery = '' }: ViewProps) { {hasSocialMissCompare && ( -

{vca.missingSocialUrlCompare}

+ - +
-

Crawl Depth Distribution

+ {(depthDist.max_depth != null || depthDist.avg_depth != null) && (

{depthDist.max_depth != null && ( @@ -1413,7 +1384,7 @@ export default function ContentAnalytics({ searchQuery = '' }: ViewProps) { {/* Word count percentile summary */} {wcStats.median != null && ( -

{vca.wordCountPercentiles}

+
{[ { label: 'Min', value: wcStats.min, color: 'text-muted-foreground', barW: 0 }, diff --git a/web/src/views/Gallery.tsx b/web/src/views/Gallery.tsx index 7b2f8530..2d387f45 100644 --- a/web/src/views/Gallery.tsx +++ b/web/src/views/Gallery.tsx @@ -13,7 +13,7 @@ import { } from 'lucide-react'; import { useReport } from '../context/useReport'; import { strings } from '../lib/strings'; -import { PageLayout, PageHeader, Card } from '../components'; +import { PageLayout, PageHeader, Card, LabelWithHint } from '../components'; import type { GalleryImageItem, ReportLink, ViewProps } from '@/types'; type GalleryKind = 'content' | 'og' | 'twitter' | string; @@ -415,11 +415,19 @@ export default function Gallery({ searchQuery = '' }: ViewProps) {
-
{vg.statUnique}
+
{items.length}
-
{vg.statShown}
+
{filtered.length}
{vg.helpBlurb}
diff --git a/web/src/views/Home.tsx b/web/src/views/Home.tsx index 870f120b..966d3ce6 100644 --- a/web/src/views/Home.tsx +++ b/web/src/views/Home.tsx @@ -1,7 +1,7 @@ import { Building2, ChevronDown, Search } from 'lucide-react'; import { useMemo, useState, useEffect, useCallback } from 'react'; import AppLogo from '@/components/AppLogo'; -import { PageLayout, Card } from '../components'; +import { PageLayout, Card, LabelWithHint } from '../components'; import PortfolioPropertyCard from '@/components/portfolio/PortfolioPropertyCard'; import { healthScoreClass, portfolioCardKey } from '@/components/portfolio/portfolioCardUtils'; import { Skeleton, SkeletonDomainCard } from '../components/Skeleton'; @@ -237,7 +237,9 @@ export default function Home({ onNavigate }: ViewProps) {
-

{vh.totalBrandsLabel}

+

+ +

{portfolioLoading ? ( ) : ( @@ -245,7 +247,9 @@ export default function Home({ onNavigate }: ViewProps) { )}
-

{vh.totalUrlsLabel}

+

+ +

{portfolioLoading ? ( ) : ( @@ -253,7 +257,9 @@ export default function Home({ onNavigate }: ViewProps) { )}
-

{vh.avgHealthLabel}

+

+ +

{portfolioLoading ? ( ) : ( diff --git a/web/src/views/Indexation.tsx b/web/src/views/Indexation.tsx index d4b38c78..3a463b5a 100644 --- a/web/src/views/Indexation.tsx +++ b/web/src/views/Indexation.tsx @@ -5,6 +5,7 @@ import { FileSearch } from 'lucide-react'; import { useReport } from '../context/useReport'; import { strings } from '../lib/strings'; import { PageLayout, PageHeader, Card, StatCard } from '../components'; +import { metricHelpHint } from '@/lib/metricHelp'; import UrlGapListsPanel from '../components/google/UrlGapListsPanel'; import type { UrlJoinData, ViewProps } from '@/types'; @@ -37,10 +38,10 @@ export default function Indexation(_props: ViewProps) { } />
- - - - + + + +

{vi.gapsTitle}

diff --git a/web/src/views/Issues.tsx b/web/src/views/Issues.tsx index b0bd57de..a5b459ba 100644 --- a/web/src/views/Issues.tsx +++ b/web/src/views/Issues.tsx @@ -5,7 +5,7 @@ import { AlertTriangle, AlertCircle, Info, ExternalLink, Flame, BarChart2, ListC import { useReport } from '../context/useReport'; import { useOptionalPipeline } from '../context/PipelineContext'; import { strings, format } from '../lib/strings'; -import { PageLayout, PageHeader, Card, Badge, ViewTabs, ViewTabPanel, Button } from '../components'; +import { PageLayout, PageHeader, Card, Badge, ViewTabs, ViewTabPanel, Button, LabelWithHint } from '../components'; import { paginateSlice, PAGE_SIZE } from '@/components/google/tableUtils'; import UrlInspectorButton from '@/components/UrlInspectorButton'; import IssueTaskBoard from '@/components/issues/IssueTaskBoard'; @@ -75,6 +75,12 @@ function IssueCard({ item, vi, emDash }: IssueCardProps) {
)} + {iss.impact_score != null && Number(iss.impact_score) > 0 ? ( +

+ :{' '} + {Number(iss.impact_score).toLocaleString()} +

+ ) : null}
{vi.fixRecommendation}
diff --git a/web/src/views/JavaScriptErrors.tsx b/web/src/views/JavaScriptErrors.tsx index dbb855e8..d863210f 100644 --- a/web/src/views/JavaScriptErrors.tsx +++ b/web/src/views/JavaScriptErrors.tsx @@ -7,6 +7,7 @@ import { useUrlTab } from '@/hooks/useUrlTab'; import { Bug, ChevronDown, ChevronRight, ExternalLink, BarChart3, List } from 'lucide-react'; import { useReport } from '../context/useReport'; import { strings, format } from '../lib/strings'; +import { metricHelpHint } from '@/lib/metricHelp'; import { PageLayout, PageHeader, Card, Button, StatCard, Select, Table, TableHead, TableHeadCell, TableBody, TableRow, TableCell, ViewTabs, ViewTabPanel } from '../components'; import { paginateSlice, PAGE_SIZE } from '@/components/google/tableUtils'; import type { ViewTabItem } from '../components'; @@ -159,11 +160,11 @@ export default function JavaScriptErrors({ searchQuery = '' }: ViewProps) { {activeTab === 'summary' && (
- - - - - {scopeInfo.renderMode}} /> + + + + + {scopeInfo.renderMode}} hint={metricHelpHint('views.jsErrors.renderMode')} />
{topMessages.length > 0 ? ( diff --git a/web/src/views/Lighthouse.tsx b/web/src/views/Lighthouse.tsx index 4e715060..b7616ce0 100644 --- a/web/src/views/Lighthouse.tsx +++ b/web/src/views/Lighthouse.tsx @@ -18,7 +18,7 @@ import { } from '../lib/domainSlug'; import { goToPipeline } from '../lib/pipelineReturn'; import { strings, format } from '../lib/strings'; -import { PageLayout, PageHeader, Card, Button, ViewTabs, ViewTabPanel, Select } from '../components'; +import { PageLayout, PageHeader, Card, Button, ViewTabs, ViewTabPanel, Select, LabelWithHint } from '../components'; import { paginateSlice, PAGE_SIZE } from '@/components/google/tableUtils'; import type { ViewTabItem } from '../components'; import { @@ -391,9 +391,17 @@ export default function Lighthouse({ searchQuery = '' }: ViewProps) { const p75 = data.crux_summary?.metrics?.[ metric === 'lcp' ? 'largest_contentful_paint' : metric === 'inp' ? 'interaction_to_next_paint' : 'cumulative_layout_shift' ]?.p75; + const cruxHelpKey = + metric === 'lcp' + ? 'views.lighthouse.cruxLcp' + : metric === 'inp' + ? 'views.lighthouse.cruxInp' + : 'views.lighthouse.cruxCls'; return (
- {metric} + + +

{p75 != null ? String(p75) : '—'} {pass === false ? '(needs improvement)' : pass ? '(good)' : ''}

@@ -415,7 +423,9 @@ export default function Lighthouse({ searchQuery = '' }: ViewProps) { {activeTab === 'overview' && (
-

{vlh.categoriesSection}

+

+ +

{CATEGORIES.map(({ id, label }) => ( @@ -447,7 +457,7 @@ export default function Lighthouse({ searchQuery = '' }: ViewProps) {

- {vlh.categoriesSection} +

{CATEGORIES.map(({ id, label }) => { diff --git a/web/src/views/LogAnalyzer.tsx b/web/src/views/LogAnalyzer.tsx index ce518398..13ee7699 100644 --- a/web/src/views/LogAnalyzer.tsx +++ b/web/src/views/LogAnalyzer.tsx @@ -6,7 +6,8 @@ import { useOptionalPipeline } from '@/context/PipelineContext'; import { useReport } from '@/context/useReport'; import { apiUrl } from '@/lib/publicBase'; import { strings } from '@/lib/strings'; -import { PageLayout, PageHeader, Card } from '@/components'; +import { metricHelpHint } from '@/lib/metricHelp'; +import { PageLayout, PageHeader, Card, StatCard } from '@/components'; import type { ViewProps } from '@/types'; const vl = strings.views.logAnalyzer; @@ -110,31 +111,32 @@ export default function LogAnalyzer(_props: ViewProps) { {error ?

{error}

: null} {analysis ? (
-
-
-

{vl.parsedLines}

-

{Number(analysis.parsed_lines || 0).toLocaleString()}

-
-
-

{vl.uniquePaths}

-

{Number(analysis.unique_paths || 0).toLocaleString()}

-
-
-

{vl.googlebotHits}

-

{Number(analysis.googlebot_hits || 0).toLocaleString()}

-
-
-

{vl.logOnlyUrls}

-

- {Number(compare?.log_only_count ?? logOnlyPaths.length).toLocaleString()} -

-
-
-

{vl.crawlOnlyUrls}

-

- {Number(compare?.crawl_only_count ?? crawlOnlyPaths.length).toLocaleString()} -

-
+
+ + + + +
{(logOnlyPaths.length > 0 || crawlOnlyPaths.length > 0) ? (
diff --git a/web/src/views/Network.tsx b/web/src/views/Network.tsx index a4225cf1..6559b57e 100644 --- a/web/src/views/Network.tsx +++ b/web/src/views/Network.tsx @@ -3,7 +3,7 @@ import ForceGraph3D from '3d-force-graph'; import { Maximize, Minimize, Loader2 } from 'lucide-react'; import { useReport } from '../context/useReport'; import { strings } from '../lib/strings'; -import { PageLayout, PageHeader, Card, Button, DataViewLayout } from '../components'; +import { PageLayout, PageHeader, Card, Button, DataViewLayout, LabelWithHint } from '../components'; import type { GraphEdge, GraphNode, ViewProps } from '@/types'; interface GraphNodeData { @@ -284,15 +284,15 @@ export default function Network({ searchQuery = '' }: ViewProps) {
- {vn.legendOk} +
- {vn.legendBroken} +
- {vn.legendLink} +
{vr.colFrom} - {vr.colStatus} + {vr.colStatus} {vr.colTo} diff --git a/web/src/views/SearchPerformance.tsx b/web/src/views/SearchPerformance.tsx index 37ae9088..dbf2b108 100644 --- a/web/src/views/SearchPerformance.tsx +++ b/web/src/views/SearchPerformance.tsx @@ -7,6 +7,7 @@ import type { TableColumn } from '@/types/components'; import { TrendingUp, Search, AlertCircle, Settings2, Download } from 'lucide-react'; import { useReport } from '../context/useReport'; import { strings, format } from '../lib/strings'; +import { metricHelpHint } from '@/lib/metricHelp'; import { PageLayout, PageHeader, Card, AlertBanner, StatCard, ViewTabs } from '../components'; import SortablePaginatedTable from '../components/google/SortablePaginatedTable'; import GoogleTableToolbar from '../components/google/GoogleTableToolbar'; @@ -97,21 +98,25 @@ export default function SearchPerformance() { { key: 'clicks', label: sp.table.clicks, + hint: 'shared.clicks', render: (v) => {Number(v ?? 0).toLocaleString()}, }, { key: 'impressions', label: sp.table.impressions, + hint: 'shared.impressions', render: (v) => {Number(v ?? 0).toLocaleString()}, }, { key: 'ctr', label: sp.table.ctr, + hint: 'shared.ctr', render: (v) => {v != null ? `${v}%` : '—'}, }, { key: 'position', label: sp.table.position, + hint: 'shared.position', render: (v) => , }, ], @@ -137,21 +142,25 @@ export default function SearchPerformance() { { key: 'clicks', label: sp.table.clicks, + hint: 'shared.clicks', render: (v) => {Number(v ?? 0).toLocaleString()}, }, { key: 'impressions', label: sp.table.impressions, + hint: 'shared.impressions', render: (v) => {Number(v ?? 0).toLocaleString()}, }, { key: 'ctr', label: sp.table.ctr, + hint: 'shared.ctr', render: (v) => {v != null ? `${v}%` : '—'}, }, { key: 'position', label: sp.table.position, + hint: 'shared.position', render: (v) => , }, { @@ -267,10 +276,26 @@ export default function SearchPerformance() { {gsc?.summary && (
- - - - + + + +
)} @@ -429,21 +454,25 @@ export default function SearchPerformance() { label={sp.urlJoin.matched} value={urlJoin.matched} sub={sp.urlJoin.matchedSub} + hint={metricHelpHint('views.overview.urlJoinMatched')} /> diff --git a/web/src/views/Security.tsx b/web/src/views/Security.tsx index 60ab6b95..83fd7eef 100644 --- a/web/src/views/Security.tsx +++ b/web/src/views/Security.tsx @@ -5,7 +5,8 @@ import type { TooltipItem } from 'chart.js'; import { Shield, Flame, AlertTriangle, AlertCircle, Info, ExternalLink, BarChart3, List } from 'lucide-react'; import { useReport } from '../context/useReport'; import { strings, format } from '../lib/strings'; -import { PageLayout, PageHeader, Card, Badge, ViewTabs, ViewTabPanel, Button } from '../components'; +import { PageLayout, PageHeader, Card, Badge, ViewTabs, ViewTabPanel, Button, StatCard, ChartTitleWithHint } from '../components'; +import { metricHelpHint } from '@/lib/metricHelp'; import { paginateSlice, PAGE_SIZE } from '@/components/google/tableUtils'; import type { ViewTabItem } from '../components'; import { palette } from '../utils/chartPalette'; @@ -239,8 +240,7 @@ export default function Security({ searchQuery = '' }: ViewProps) {
-

{vs.findingsBySeverity}

-

{vs.findingsBySeverityHint}

+
@@ -264,8 +264,7 @@ export default function Security({ searchQuery = '' }: ViewProps) { {typeLabels.length > 0 && ( -

{vs.findingsByType}

-

{vs.findingsByTypeHint}

+
setSeverityFilter((prev) => (prev === sev ? 'All' : sev))} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + setSeverityFilter((prev) => (prev === sev ? 'All' : sev)); + } + }} + role="button" + tabIndex={0} > -
- {sev} -
-
0 ? cfg.text : 'text-muted-foreground'}`}>{count}
- + + {sev} + + } + value={ 0 ? cfg.text : 'text-muted-foreground'}>{count}} + hint={metricHelpHint('views.security.severityCount')} + size="lg" + shadow + className={isActive ? `${cfg.border} border-2` : undefined} + /> +
); })}
diff --git a/web/src/views/SiteStructure.tsx b/web/src/views/SiteStructure.tsx index ba92cc54..89404dc3 100644 --- a/web/src/views/SiteStructure.tsx +++ b/web/src/views/SiteStructure.tsx @@ -27,6 +27,7 @@ import { linkMatchesPathKey, } from '../lib/siteStructureTree'; import { PageLayout, PageHeader, Card, Button, StatCard, AlertBanner, ViewTabs, ViewTabPanel } from '../components'; +import { metricHelpHint } from '@/lib/metricHelp'; import UrlInspectorButton from '@/components/UrlInspectorButton'; import type { ViewTabItem } from '../components'; import PathTreeTable from '../components/siteStructure/PathTreeTable'; @@ -379,31 +380,37 @@ export default function SiteStructure({ searchQuery = '' }: ViewProps) { label={s.stats.urls} value={fmtMetric(rootMetrics?.pages ?? filteredLinks.length)} icon={} + hint={metricHelpHint('views.siteStructure.urlsInView')} /> } + hint={metricHelpHint('views.siteStructure.pathPrefixes')} /> } + hint={metricHelpHint('views.siteStructure.totalInlinks')} /> } + hint={metricHelpHint('shared.avgWords')} /> } + hint={metricHelpHint('views.siteStructure.avgResponse')} /> } + hint={metricHelpHint('views.siteStructure.avgPerf')} />
) : null} diff --git a/web/src/views/Subdomains.tsx b/web/src/views/Subdomains.tsx index e61f9037..79956f79 100644 --- a/web/src/views/Subdomains.tsx +++ b/web/src/views/Subdomains.tsx @@ -19,6 +19,7 @@ import { TableRow, TableCell, } from '../components'; +import { metricHelpHint } from '@/lib/metricHelp'; import type { SubdomainHostEntry, ViewProps } from '@/types'; function yesNo(value: boolean | undefined): string { @@ -79,10 +80,10 @@ export default function Subdomains({ searchQuery = '' }: ViewProps) {
) : null}
- - - - + + + +
{gscGapHosts.length > 0 ? ( @@ -111,12 +112,12 @@ export default function Subdomains({ searchQuery = '' }: ViewProps) {
- {vs.colHost} - {vs.colSources} - {vs.colCrawl} - {vs.colGsc} - {vs.colCrawlUrls} - {vs.colGscUrls} + {vs.colHost} + {vs.colSources} + {vs.colCrawl} + {vs.colGsc} + {vs.colCrawlUrls} + {vs.colGscUrls} diff --git a/web/src/views/TechStack.tsx b/web/src/views/TechStack.tsx index dc8d2309..2b81aff6 100644 --- a/web/src/views/TechStack.tsx +++ b/web/src/views/TechStack.tsx @@ -6,7 +6,8 @@ import { Chart as ChartJS, CategoryScale, LinearScale, BarElement, Title, Toolti import { Bar } from 'react-chartjs-2'; import { useReport } from '../context/useReport'; import { strings, format } from '../lib/strings'; -import { PageLayout, PageHeader, Card, Table, TableHead, TableHeadCell, TableBody, TableRow, TableCell, ViewTabs, ViewTabPanel } from '../components'; +import { metricHelpHint } from '@/lib/metricHelp'; +import { PageLayout, PageHeader, Card, Table, TableHead, TableHeadCell, TableBody, TableRow, TableCell, ViewTabs, ViewTabPanel, StatCard, ChartTitleWithHint } from '../components'; import type { ViewTabItem } from '../components'; import { palette } from '../utils/chartPalette'; import { getGridColor, getChartCanvasTextColor } from '../utils/chartJsDefaults'; @@ -123,17 +124,19 @@ export default function TechStack({ searchQuery = '' }: ViewProps) {
{Object.entries(categoryCounts).slice(0, 4).map(([cat, count]) => ( - -
{cat}
-
{String(count)}
-
{vr.techDetectedSuffix}
-
+ ))}
-

{vr.cardDetected}

-

{vr.cardHint}

+
{chartLabels.length > 0 ? ( {vr.colTechnology} {vr.colCategory} - {vr.colPages} + {vr.colPages} {vr.colSampleUrls} diff --git a/web/src/views/TextContentAnalysis.tsx b/web/src/views/TextContentAnalysis.tsx index 7702d83c..888fba93 100644 --- a/web/src/views/TextContentAnalysis.tsx +++ b/web/src/views/TextContentAnalysis.tsx @@ -1,7 +1,7 @@ 'use client'; import type { Chart, TooltipItem } from 'chart.js'; -import { Fragment, useState, useMemo, useEffect, type ComponentType, type ReactNode } from 'react'; +import { Fragment, useState, useMemo, useEffect } from 'react'; import { useUrlTab } from '@/hooks/useUrlTab'; import type { ContentAnalyticsData, @@ -31,6 +31,7 @@ import { } from 'lucide-react'; import { useReport } from '../context/useReport'; import { strings, format } from '../lib/strings'; +import { metricHelpHint } from '@/lib/metricHelp'; import { PageLayout, PageHeader, @@ -44,6 +45,8 @@ import { ViewTabs, ViewTabPanel, Button, + SectionHeader, + ChartTitleWithHint, } from '../components'; import type { ViewTabItem } from '../components'; import SortablePaginatedTable from '../components/google/SortablePaginatedTable'; @@ -145,29 +148,6 @@ function barOptsH(xTitle?: string, yAxisLabels?: readonly string[]) { }); } -function SectionHeader({ - icon, - title, - description, -}: { - icon: ComponentType<{ className?: string }>; - title: ReactNode; - description?: ReactNode; -}) { - const Icon = icon; - return ( -
-
- -
-
-

{title}

- {description ?

{description}

: null} -
-
- ); -} - function KeywordIndexTable({ rows, vtca, @@ -198,9 +178,13 @@ function KeywordIndexTable({
- {vtca.thWord} - {vtca.thTotalCount} - {vtca.thPageCount} + {vtca.thWord} + + {vtca.thTotalCount} + + + {vtca.thPageCount} + @@ -445,7 +429,7 @@ export default function TextContentAnalysis({ searchQuery = '' }: ViewProps) {
- + {vtca.noKeywordData}

)} - +
-

{vtca.wordCountDist}

+ {wcLabels.length > 0 ? ( -

{vtca.readingLevelDist}

+ {rlLabels.length > 0 ? ( -

{vtca.contentHtmlRatio}

+ {crLabels.length > 0 ? ( -

{vtca.wordCountLadder}

+ - {vtca.thRepresentative} + {vtca.thRepresentative} {vtca.thClusterScore} {vtca.thKeywords} @@ -733,7 +717,7 @@ export default function TextContentAnalysis({ searchQuery = '' }: ViewProps) {
- {vtca.thRepresentative} + {vtca.thRepresentative} {vtca.thClusterScore} {vtca.thKeywords} diff --git a/web/src/views/Traffic.tsx b/web/src/views/Traffic.tsx index 9ca501b9..8d63c792 100644 --- a/web/src/views/Traffic.tsx +++ b/web/src/views/Traffic.tsx @@ -7,6 +7,7 @@ import type { TableColumn } from '@/types/components'; import { Users, AlertCircle, Settings2, Download } from 'lucide-react'; import { useReport } from '../context/useReport'; import { strings, format } from '../lib/strings'; +import { metricHelpHint } from '@/lib/metricHelp'; import { PageLayout, PageHeader, Card, AlertBanner, StatCard, ViewTabs } from '../components'; import SortablePaginatedTable from '../components/google/SortablePaginatedTable'; import GoogleTableToolbar from '../components/google/GoogleTableToolbar'; @@ -91,11 +92,13 @@ export default function Traffic() { { key: 'sessions', label: tf.table.sessions, + hint: 'shared.sessions', render: (v) => {Number(v ?? 0).toLocaleString()}, }, { key: 'activeUsers', label: tf.table.users, + hint: 'shared.activeUsers', render: (v) => {Number(v ?? 0).toLocaleString()}, }, { @@ -238,9 +241,17 @@ export default function Traffic() { {ga4?.summary && (
- - - + + +
)} @@ -395,21 +406,25 @@ export default function Traffic() { label={sp.urlJoin.matched} value={urlJoin.matched} sub={sp.urlJoin.matchedSub} + hint={metricHelpHint('views.overview.urlJoinMatched')} />