= 1 ? (
) : null}
- {!group.crawlOnly && trends.healthTrend.length >= 1 ? (
+ {!group.crawlOnly && historyLoading ? (
+
+ ) : null}
+ {!group.crawlOnly && !historyLoading && trends.healthTrend.length >= 1 ? (
) : null}
diff --git a/web/src/components/portfolio/home/PortfolioGroupList.tsx b/web/src/components/portfolio/home/PortfolioGroupList.tsx
new file mode 100644
index 0000000..cf0178d
--- /dev/null
+++ b/web/src/components/portfolio/home/PortfolioGroupList.tsx
@@ -0,0 +1,180 @@
+'use client';
+
+import { useMemo } from 'react';
+import { Building2, ChevronDown, Cpu, Gauge, MessageSquare, Search, Sparkles } from 'lucide-react';
+import PortfolioPropertyCard from '@/components/portfolio/PortfolioPropertyCard';
+import { portfolioCardKey } from '@/components/portfolio/portfolioCardUtils';
+import { EmptyState } from '@/components';
+import { Skeleton } from '@/components/Skeleton';
+import { usePortfolio } from '@/context/usePortfolio';
+import { usePortfolioGroups } from '@/hooks/usePortfolioWidget';
+import { format, strings } from '@/lib/strings';
+import type { PortfolioGroup } from '@/types';
+import { portfolioRootDomain } from './portfolioGroupUtils';
+
+export interface PortfolioGroupListProps {
+ filterQuery: string;
+ collapsedGroups: Set;
+ pendingDeleteKey: string | null;
+ deletingKey: string | null;
+ openingCrawlId: number | null;
+ onToggleCollapsed: (rootDomain: string) => void;
+ onOpen: (group: PortfolioGroup) => void;
+ onDeleteToggle: (cardKey: string) => void;
+ onDeleteCancel: () => void;
+ onDeleteConfirm: (group: PortfolioGroup) => void;
+}
+
+export default function PortfolioGroupList({
+ filterQuery,
+ collapsedGroups,
+ pendingDeleteKey,
+ deletingKey,
+ openingCrawlId,
+ onToggleCollapsed,
+ onOpen,
+ onDeleteToggle,
+ onDeleteCancel,
+ onDeleteConfirm,
+}: PortfolioGroupListProps) {
+ const groupsStatus = usePortfolioGroups();
+ const { groups } = usePortfolio();
+ const vh = strings.views.home;
+ const loading = groupsStatus === 'loading' || groupsStatus === 'idle';
+
+ const filteredGroups = useMemo(() => {
+ const q = filterQuery.toLowerCase().trim();
+ if (!q) return groups;
+ return groups.filter(
+ (group) =>
+ group.domainName.toLowerCase().includes(q) ||
+ group.crawlUrl.toLowerCase().includes(q),
+ );
+ }, [groups, filterQuery]);
+
+ const groupedPortfolio = useMemo(() => {
+ const map = new Map();
+ for (const group of filteredGroups) {
+ const key = portfolioRootDomain(group);
+ const items = map.get(key) ?? [];
+ items.push(group);
+ map.set(key, items);
+ }
+ return Array.from(map.entries())
+ .map(([rootDomain, items]) => ({
+ rootDomain,
+ items: items.toSorted((a, b) => b.generatedAtMs - a.generatedAtMs),
+ }))
+ .toSorted((a, b) => (b.items[0]?.generatedAtMs ?? 0) - (a.items[0]?.generatedAtMs ?? 0));
+ }, [filteredGroups]);
+
+ if (loading) {
+ return (
+
+
{strings.app.loading}
+ {[0, 1, 2].map((i) => (
+
+ ))}
+
+ );
+ }
+
+ if (filteredGroups.length > 0) {
+ return (
+
+ {groupedPortfolio.map(({ rootDomain, items }) => {
+ const collapsed = collapsedGroups.has(rootDomain);
+ return (
+
+
+ {!collapsed ? (
+
+ {items.map((group) => {
+ const cardKey = portfolioCardKey(group);
+ return (
+
{ onOpen(group); }}
+ onDeleteToggle={() => onDeleteToggle(cardKey)}
+ onDeleteCancel={onDeleteCancel}
+ onDeleteConfirm={() => { onDeleteConfirm(group); }}
+ />
+ );
+ })}
+
+ ) : null}
+
+ );
+ })}
+
+ );
+ }
+
+ if (filterQuery) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ );
+}
diff --git a/web/src/components/portfolio/home/PortfolioResumeSection.tsx b/web/src/components/portfolio/home/PortfolioResumeSection.tsx
new file mode 100644
index 0000000..29b9498
--- /dev/null
+++ b/web/src/components/portfolio/home/PortfolioResumeSection.tsx
@@ -0,0 +1,71 @@
+'use client';
+
+import type { CSSProperties } from 'react';
+import { Building2 } from 'lucide-react';
+import { healthScoreClass, portfolioCardKey } from '@/components/portfolio/portfolioCardUtils';
+import { usePortfolio } from '@/context/usePortfolio';
+import { usePortfolioGroups } from '@/hooks/usePortfolioWidget';
+import { format, strings } from '@/lib/strings';
+import type { PortfolioGroup } from '@/types';
+
+export interface PortfolioResumeSectionProps {
+ filterQuery: string;
+ onOpen: (group: PortfolioGroup) => void;
+ openingCrawlId: number | null;
+}
+
+export default function PortfolioResumeSection({
+ filterQuery,
+ onOpen,
+ openingCrawlId,
+}: PortfolioResumeSectionProps) {
+ const groupsStatus = usePortfolioGroups();
+ const { groups } = usePortfolio();
+ const vh = strings.views.home;
+ const loading = groupsStatus === 'loading' || groupsStatus === 'idle';
+
+ const recentAudits = groups.toSorted((a, b) => b.generatedAtMs - a.generatedAtMs).slice(0, 4);
+ const showResume = !filterQuery && !loading && recentAudits.length > 1;
+
+ if (!showResume) return null;
+
+ return (
+
+ {vh.resumeHeading}
+
+ {recentAudits.map((group, i) => {
+ const opening = openingCrawlId != null && openingCrawlId === group.crawlRunId;
+ return (
+
+ );
+ })}
+
+
+ );
+}
diff --git a/web/src/components/portfolio/home/PortfolioStatsRow.tsx b/web/src/components/portfolio/home/PortfolioStatsRow.tsx
new file mode 100644
index 0000000..16b7015
--- /dev/null
+++ b/web/src/components/portfolio/home/PortfolioStatsRow.tsx
@@ -0,0 +1,56 @@
+'use client';
+
+import { Building2, Gauge, Sparkles } from 'lucide-react';
+import { StatCard, LabelWithHint } from '@/components';
+import { healthScoreClass } from '@/components/portfolio/portfolioCardUtils';
+import { usePortfolio } from '@/context/usePortfolio';
+import { usePortfolioSummary } from '@/hooks/usePortfolioWidget';
+import { strings } from '@/lib/strings';
+
+const statSkeleton = (
+
+);
+
+export default function PortfolioStatsRow() {
+ const summaryStatus = usePortfolioSummary();
+ const { summary } = usePortfolio();
+ const vh = strings.views.home;
+ const sj = strings.common;
+ const loading = summaryStatus === 'loading' || summaryStatus === 'idle';
+
+ const totals = summary ?? { totalBrands: 0, totalUrls: 0, avgHealth: null };
+
+ return (
+
+ }
+ value={loading ? statSkeleton : totals.totalBrands.toLocaleString()}
+ icon={}
+ size="lg"
+ shadow
+ />
+ }
+ value={loading ? statSkeleton : totals.totalUrls.toLocaleString()}
+ icon={}
+ size="lg"
+ shadow
+ />
+ }
+ value={loading ? statSkeleton : (totals.avgHealth ?? sj.emDash)}
+ valueClassName={
+ totals.avgHealth != null ? healthScoreClass(totals.avgHealth) : 'text-bright'
+ }
+ icon={}
+ size="lg"
+ shadow
+ />
+
+ );
+}
+
+export { statSkeleton };
diff --git a/web/src/components/portfolio/home/portfolioGroupUtils.ts b/web/src/components/portfolio/home/portfolioGroupUtils.ts
new file mode 100644
index 0000000..14f175f
--- /dev/null
+++ b/web/src/components/portfolio/home/portfolioGroupUtils.ts
@@ -0,0 +1,10 @@
+import { extractHostname } from '@/lib/domainSlug';
+import type { PortfolioGroup } from '@/types';
+
+export function portfolioRootDomain(group: PortfolioGroup): string {
+ const host = extractHostname(group.crawlUrl) || group.domainName.trim().toLowerCase();
+ if (!host) return group.domainName || 'unknown';
+ const parts = host.split('.').filter(Boolean);
+ if (parts.length <= 2) return host;
+ return parts.slice(-2).join('.');
+}
diff --git a/web/src/context/PortfolioContext.tsx b/web/src/context/PortfolioContext.tsx
new file mode 100644
index 0000000..e1c3bd6
--- /dev/null
+++ b/web/src/context/PortfolioContext.tsx
@@ -0,0 +1,207 @@
+'use client';
+
+import {
+ createContext,
+ useState,
+ useCallback,
+ useRef,
+ useMemo,
+ type ReactNode,
+} from 'react';
+import { portfolioCardKey } from '@/components/portfolio/portfolioCardUtils';
+import { computePortfolioSummary } from '@/lib/homePortfolio';
+import { reportApi } from '@/lib/publicBase';
+import { useReport } from './useReport';
+import type {
+ PortfolioContextValue,
+ PortfolioLoadStatus,
+ PortfolioWidgetKey,
+} from './portfolioContextTypes';
+import type { PortfolioCrawlHistoryPoint } from '@/types/api';
+import type { PortfolioGroup } from '@/types/report';
+
+export const PortfolioContext = createContext(null);
+
+interface GroupsApiResponse {
+ groups?: PortfolioGroup[];
+ crawlHistoryByDomain?: Record;
+ error?: string;
+}
+
+interface CardApiResponse {
+ group?: PortfolioGroup | null;
+ error?: string;
+}
+
+export function PortfolioProvider({ children }: { children: ReactNode }) {
+ const { reportList, crawlRuns } = useReport();
+ const [groups, setGroups] = useState([]);
+ const [crawlHistoryByDomain, setCrawlHistoryByDomain] = useState<
+ Record
+ >({});
+ const [summary, setSummary] = useState(null);
+ const [cardByKey, setCardByKey] = useState>({});
+ const [widgetStatus, setWidgetStatus] = useState<
+ Partial>
+ >({});
+ const [cardStatus, setCardStatus] = useState>>({});
+
+ const groupsInFlightRef = useRef(false);
+ const cardInFlightRef = useRef(new Set());
+ const cardQueueRef = useRef([]);
+ const queuedCardKeysRef = useRef(new Set());
+ const cardDrainActiveRef = useRef(false);
+ const cardByKeyRef = useRef(cardByKey);
+ cardByKeyRef.current = cardByKey;
+ const cacheKeyRef = useRef('');
+ const groupsLoadedRef = useRef(false);
+
+ const reportIdsKey = useMemo(
+ () => reportList.map((r) => r.id).join(','),
+ [reportList],
+ );
+
+ const loadGroups = useCallback(async () => {
+ if (!reportList.length && !crawlRuns.length) {
+ setGroups([]);
+ setCrawlHistoryByDomain({});
+ setSummary(computePortfolioSummary([]));
+ setWidgetStatus({ groups: 'loaded', summary: 'loaded' });
+ groupsLoadedRef.current = true;
+ return;
+ }
+
+ const cacheKey = `${reportIdsKey}:${crawlRuns.length}`;
+ if (groupsLoadedRef.current && cacheKeyRef.current === cacheKey) return;
+ if (groupsInFlightRef.current) return;
+
+ groupsInFlightRef.current = true;
+ cacheKeyRef.current = cacheKey;
+ setWidgetStatus((prev) => ({ ...prev, groups: 'loading', summary: 'loading' }));
+
+ try {
+ const ids = reportList.map((r) => r.id).join(',');
+ const qs = ids ? `?widget=groups&ids=${encodeURIComponent(ids)}` : '?widget=groups';
+ const res = await fetch(reportApi(`/portfolio${qs}`));
+ const body = (await res.json().catch(() => ({}))) as GroupsApiResponse;
+ if (!res.ok) throw new Error(body.error || res.statusText);
+
+ const nextGroups = Array.isArray(body.groups) ? body.groups : [];
+ const crawlHistory =
+ body.crawlHistoryByDomain && typeof body.crawlHistoryByDomain === 'object'
+ ? body.crawlHistoryByDomain
+ : {};
+
+ setGroups(nextGroups);
+ setCrawlHistoryByDomain(crawlHistory);
+ setSummary(computePortfolioSummary(nextGroups));
+ setWidgetStatus({ groups: 'loaded', summary: 'loaded' });
+ groupsLoadedRef.current = true;
+ } catch {
+ setGroups([]);
+ setCrawlHistoryByDomain({});
+ setSummary(computePortfolioSummary([]));
+ setWidgetStatus({ groups: 'error', summary: 'error' });
+ groupsLoadedRef.current = false;
+ } finally {
+ groupsInFlightRef.current = false;
+ }
+ }, [reportList, crawlRuns.length, reportIdsKey]);
+
+ const fetchCardData = useCallback(async (group: PortfolioGroup, key: string) => {
+ const params = new URLSearchParams({ widget: 'card' });
+ if (group.reportId != null) {
+ params.set('reportId', String(group.reportId));
+ } else if (group.crawlRunId != null) {
+ params.set('crawlRunId', String(group.crawlRunId));
+ } else {
+ throw new Error('Missing report or crawl id');
+ }
+
+ const res = await fetch(reportApi(`/portfolio?${params.toString()}`));
+ const body = (await res.json().catch(() => ({}))) as CardApiResponse;
+ if (!res.ok) throw new Error(body.error || res.statusText);
+ if (body.group) {
+ setCardByKey((prev) => ({ ...prev, [key]: body.group! }));
+ }
+ setCardStatus((prev) => ({ ...prev, [key]: 'loaded' }));
+ }, []);
+
+ const drainCardQueue = useCallback(async () => {
+ if (cardDrainActiveRef.current) return;
+ cardDrainActiveRef.current = true;
+ try {
+ while (cardQueueRef.current.length > 0) {
+ const group = cardQueueRef.current.shift()!;
+ const key = portfolioCardKey(group);
+ if (cardByKeyRef.current[key]) {
+ queuedCardKeysRef.current.delete(key);
+ continue;
+ }
+ cardInFlightRef.current.add(key);
+ try {
+ await fetchCardData(group, key);
+ } catch {
+ setCardStatus((prev) => ({ ...prev, [key]: 'error' }));
+ } finally {
+ cardInFlightRef.current.delete(key);
+ queuedCardKeysRef.current.delete(key);
+ }
+ }
+ } finally {
+ cardDrainActiveRef.current = false;
+ }
+ }, [fetchCardData]);
+
+ const loadCard = useCallback(
+ (group: PortfolioGroup) => {
+ const key = portfolioCardKey(group);
+ if (cardByKeyRef.current[key]) return;
+ if (queuedCardKeysRef.current.has(key) || cardInFlightRef.current.has(key)) return;
+
+ queuedCardKeysRef.current.add(key);
+ cardQueueRef.current.push(group);
+ setCardStatus((prev) => ({ ...prev, [key]: 'loading' }));
+ void drainCardQueue();
+ },
+ [drainCardQueue],
+ );
+
+ const refreshPortfolio = useCallback(async () => {
+ cacheKeyRef.current = '';
+ groupsLoadedRef.current = false;
+ cardQueueRef.current = [];
+ queuedCardKeysRef.current = new Set();
+ setCardByKey({});
+ setCardStatus({});
+ setWidgetStatus({});
+ await loadGroups();
+ }, [loadGroups]);
+
+ const value = useMemo(
+ () => ({
+ groups,
+ crawlHistoryByDomain,
+ summary,
+ cardByKey,
+ widgetStatus,
+ cardStatus,
+ loadGroups,
+ loadCard,
+ refreshPortfolio,
+ }),
+ [
+ groups,
+ crawlHistoryByDomain,
+ summary,
+ cardByKey,
+ widgetStatus,
+ cardStatus,
+ loadGroups,
+ loadCard,
+ refreshPortfolio,
+ ],
+ );
+
+ return {children};
+}
diff --git a/web/src/context/ReportContext.tsx b/web/src/context/ReportContext.tsx
index 22e9325..3c0869d 100644
--- a/web/src/context/ReportContext.tsx
+++ b/web/src/context/ReportContext.tsx
@@ -49,6 +49,10 @@ function viewNeedsFullComparePayload(pathname: string): boolean {
return pathname.includes('compare') || pathname.includes('site-structure');
}
+function isHomeRoute(pathname: string): boolean {
+ return pathname === '/home';
+}
+
interface MetaApiResponse extends Partial {
error?: string;
}
@@ -125,6 +129,8 @@ export function ReportProvider({ children, domainSlug = null }: ReportProviderPr
const compareSummaryKeyRef = useRef('');
const domainSlugRef = useRef(domainSlug);
domainSlugRef.current = domainSlug;
+ const pathnameRef = useRef(pathname);
+ pathnameRef.current = pathname;
const [sectionStatus, setSectionStatus] = useState<
Partial>
@@ -244,11 +250,6 @@ export function ReportProvider({ children, domainSlug = null }: ReportProviderPr
}
} finally {
setLoading(false);
- // Background-prefetch remaining sections in priority order (fire-and-forget)
- const nonCore = SECTION_KEYS.filter((s) => s !== 'core');
- for (const section of nonCore) {
- void loadSection(section, reportId);
- }
}
}, [loadSection]);
@@ -291,6 +292,10 @@ export function ReportProvider({ children, domainSlug = null }: ReportProviderPr
if (latestId != null) {
setSelectedReportId(latestId);
}
+ if (isHomeRoute(pathnameRef.current)) {
+ setLoading(false);
+ return;
+ }
await applyPayload(latestId);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
@@ -374,6 +379,11 @@ export function ReportProvider({ children, domainSlug = null }: ReportProviderPr
return;
}
+ if (isHomeRoute(pathname)) {
+ setLoading(false);
+ return;
+ }
+
if (domainSlug && scopedList.length === 0) {
setError(strings.app.noReportForDomain);
setData(null);
@@ -398,7 +408,16 @@ export function ReportProvider({ children, domainSlug = null }: ReportProviderPr
return;
}
applyPayload(id);
- }, [reportListFull, crawlRuns, domainSlug, scopedList, selectedReportId, applyPayload, crawlPreviewRunId]);
+ }, [
+ reportListFull,
+ crawlRuns,
+ domainSlug,
+ scopedList,
+ selectedReportId,
+ applyPayload,
+ crawlPreviewRunId,
+ pathname,
+ ]);
const setSelectedReportIdWrapped = useCallback((id: number | null) => {
setCrawlPreviewRunId(null);
diff --git a/web/src/context/portfolioContextTypes.ts b/web/src/context/portfolioContextTypes.ts
new file mode 100644
index 0000000..657cdac
--- /dev/null
+++ b/web/src/context/portfolioContextTypes.ts
@@ -0,0 +1,19 @@
+import type { PortfolioCrawlHistoryPoint } from '@/types/api';
+import type { PortfolioGroup } from '@/types/report';
+import type { PortfolioSummary } from '@/lib/homePortfolio';
+
+export type PortfolioWidgetKey = 'groups' | 'summary';
+
+export type PortfolioLoadStatus = 'idle' | 'loading' | 'loaded' | 'error';
+
+export interface PortfolioContextValue {
+ groups: PortfolioGroup[];
+ crawlHistoryByDomain: Record;
+ summary: PortfolioSummary | null;
+ cardByKey: Record;
+ widgetStatus: Partial>;
+ cardStatus: Partial>;
+ loadGroups: () => Promise;
+ loadCard: (group: PortfolioGroup) => void;
+ refreshPortfolio: () => Promise;
+}
diff --git a/web/src/context/usePortfolio.ts b/web/src/context/usePortfolio.ts
new file mode 100644
index 0000000..71d97c8
--- /dev/null
+++ b/web/src/context/usePortfolio.ts
@@ -0,0 +1,17 @@
+'use client';
+
+import { useContext } from 'react';
+import { PortfolioContext } from './PortfolioContext';
+import type { PortfolioContextValue } from './portfolioContextTypes';
+
+export function usePortfolio(): PortfolioContextValue {
+ const ctx = useContext(PortfolioContext);
+ if (!ctx) {
+ throw new Error('usePortfolio must be used within PortfolioProvider');
+ }
+ return ctx;
+}
+
+export function useOptionalPortfolio(): PortfolioContextValue | null {
+ return useContext(PortfolioContext);
+}
diff --git a/web/src/hooks/usePortfolioCard.ts b/web/src/hooks/usePortfolioCard.ts
new file mode 100644
index 0000000..230d5c2
--- /dev/null
+++ b/web/src/hooks/usePortfolioCard.ts
@@ -0,0 +1,28 @@
+'use client';
+
+import { useEffect } from 'react';
+import { portfolioCardKey } from '@/components/portfolio/portfolioCardUtils';
+import { usePortfolio } from '@/context/usePortfolio';
+import type { PortfolioLoadStatus } from '@/context/portfolioContextTypes';
+import type { PortfolioGroup } from '@/types/report';
+
+export function usePortfolioCard(
+ liteGroup: PortfolioGroup,
+ fetchEnabled: boolean,
+): { group: PortfolioGroup; status: PortfolioLoadStatus; isFullCard: boolean } {
+ const { cardByKey, cardStatus, loadCard } = usePortfolio();
+ const key = portfolioCardKey(liteGroup);
+ const status = cardStatus[key] ?? 'idle';
+ const loaded = cardByKey[key];
+ const group = loaded ?? liteGroup;
+ const isFullCard = Boolean(loaded);
+
+ useEffect(() => {
+ if (!fetchEnabled) return;
+ if (loaded) return;
+ if (status === 'loaded' || status === 'loading') return;
+ loadCard(liteGroup);
+ }, [fetchEnabled, liteGroup, loaded, status, loadCard]);
+
+ return { group, status, isFullCard };
+}
diff --git a/web/src/hooks/usePortfolioCardHistory.ts b/web/src/hooks/usePortfolioCardHistory.ts
new file mode 100644
index 0000000..df72274
--- /dev/null
+++ b/web/src/hooks/usePortfolioCardHistory.ts
@@ -0,0 +1,54 @@
+'use client';
+
+import { useEffect, useRef, useState } from 'react';
+import { parsePortfolioAuditHistory, type PortfolioAuditHistoryPoint } from '@/lib/portfolioAuditHistory';
+import { apiUrl } from '@/lib/publicBase';
+import type { PortfolioLoadStatus } from '@/context/portfolioContextTypes';
+
+export function usePortfolioCardHistory(
+ domainParam: string,
+ enabled: boolean,
+): { auditHistory: PortfolioAuditHistoryPoint[]; status: PortfolioLoadStatus } {
+ const [auditHistory, setAuditHistory] = useState([]);
+ const [status, setStatus] = useState('idle');
+ const inFlightRef = useRef(false);
+
+ useEffect(() => {
+ setAuditHistory([]);
+ setStatus('idle');
+ inFlightRef.current = false;
+ }, [domainParam]);
+
+ useEffect(() => {
+ if (!enabled || !domainParam) return;
+ if (inFlightRef.current) return;
+
+ let cancelled = false;
+ inFlightRef.current = true;
+ setStatus('loading');
+
+ void fetch(apiUrl(`/report/history?domain=${encodeURIComponent(domainParam)}&limit=8`))
+ .then((res) => res.json())
+ .then((body) => {
+ if (cancelled) return;
+ setAuditHistory(parsePortfolioAuditHistory(body.history || []));
+ setStatus('loaded');
+ })
+ .catch(() => {
+ if (!cancelled) {
+ setAuditHistory([]);
+ setStatus('error');
+ }
+ })
+ .finally(() => {
+ if (!cancelled) inFlightRef.current = false;
+ });
+
+ return () => {
+ cancelled = true;
+ inFlightRef.current = false;
+ };
+ }, [domainParam, enabled]);
+
+ return { auditHistory, status };
+}
diff --git a/web/src/hooks/usePortfolioWidget.ts b/web/src/hooks/usePortfolioWidget.ts
new file mode 100644
index 0000000..2f8a7f1
--- /dev/null
+++ b/web/src/hooks/usePortfolioWidget.ts
@@ -0,0 +1,25 @@
+'use client';
+
+import { useEffect } from 'react';
+import { usePortfolio } from '@/context/usePortfolio';
+import type { PortfolioLoadStatus, PortfolioWidgetKey } from '@/context/portfolioContextTypes';
+
+function usePortfolioWidget(widget: PortfolioWidgetKey): PortfolioLoadStatus {
+ const { widgetStatus, loadGroups } = usePortfolio();
+
+ useEffect(() => {
+ const status = widgetStatus[widget];
+ if (status === 'loaded' || status === 'loading') return;
+ void loadGroups();
+ }, [widget, widgetStatus, loadGroups]);
+
+ return widgetStatus[widget] ?? 'idle';
+}
+
+export function usePortfolioGroups(): PortfolioLoadStatus {
+ return usePortfolioWidget('groups');
+}
+
+export function usePortfolioSummary(): PortfolioLoadStatus {
+ return usePortfolioWidget('summary');
+}
diff --git a/web/src/hooks/useSectionData.ts b/web/src/hooks/useSectionData.ts
index 365c5a7..8fb8706 100644
--- a/web/src/hooks/useSectionData.ts
+++ b/web/src/hooks/useSectionData.ts
@@ -8,15 +8,18 @@ import type { SectionKey } from '@/lib/reportSections';
* Triggers a section fetch on mount (if not already loaded) and returns its status.
* The section's data merges into the shared `data` object in ReportContext.
*/
-export function useSectionData(section: SectionKey): 'idle' | 'loading' | 'loaded' | 'error' {
+export function useSectionData(
+ section: SectionKey,
+ enabled = true,
+): 'idle' | 'loading' | 'loaded' | 'error' {
const { sectionStatus, loadSection, selectedReportId, data } = useReport();
useEffect(() => {
- if (data === null) return;
+ if (!enabled || data === null) return;
const status = sectionStatus[section];
if (status === 'loaded' || status === 'loading') return;
void loadSection(section, selectedReportId ?? null);
- }, [section, sectionStatus, loadSection, selectedReportId, data]);
+ }, [section, enabled, sectionStatus, loadSection, selectedReportId, data]);
return sectionStatus[section] ?? 'idle';
}
diff --git a/web/src/hooks/useTabSections.ts b/web/src/hooks/useTabSections.ts
new file mode 100644
index 0000000..7099ba3
--- /dev/null
+++ b/web/src/hooks/useTabSections.ts
@@ -0,0 +1,35 @@
+'use client';
+
+import { useEffect, useMemo } from 'react';
+import { useReport } from '@/context/useReport';
+import type { SectionKey } from '@/lib/reportSections';
+import type { SectionLoadStatus } from '@/lib/reportViewSections';
+
+/**
+ * Parallel-fetch multiple report sections when enabled.
+ * Returns per-section status from ReportContext.
+ */
+export function useTabSections(
+ sections: readonly SectionKey[],
+ enabled: boolean,
+): Partial> {
+ const { sectionStatus, loadSection, selectedReportId, data } = useReport();
+ const sectionKey = sections.join('\0');
+
+ useEffect(() => {
+ if (!enabled || data === null) return;
+ for (const section of sections) {
+ const status = sectionStatus[section];
+ if (status === 'loaded' || status === 'loading') continue;
+ void loadSection(section, selectedReportId ?? null);
+ }
+ }, [enabled, sectionKey, sections, sectionStatus, loadSection, selectedReportId, data]);
+
+ return useMemo(() => {
+ const out: Partial> = {};
+ for (const section of sections) {
+ out[section] = sectionStatus[section] ?? 'idle';
+ }
+ return out;
+ }, [sections, sectionStatus]);
+}
diff --git a/web/src/hooks/useViewSections.ts b/web/src/hooks/useViewSections.ts
new file mode 100644
index 0000000..3557d19
--- /dev/null
+++ b/web/src/hooks/useViewSections.ts
@@ -0,0 +1,11 @@
+'use client';
+
+import { VIEW_SECTIONS } from '@/lib/reportViewSections';
+import type { ViewId } from '@/routes';
+import { useTabSections } from './useTabSections';
+
+/** Trigger parallel section loads for the active report view. */
+export function useViewSections(viewId: ViewId | null, enabled = true): void {
+ const sections = viewId != null ? VIEW_SECTIONS[viewId] ?? [] : [];
+ useTabSections(sections, enabled && viewId != null && viewId !== 'home');
+}
diff --git a/web/src/lib/homePortfolio.test.ts b/web/src/lib/homePortfolio.test.ts
index f1d5916..515ef99 100644
--- a/web/src/lib/homePortfolio.test.ts
+++ b/web/src/lib/homePortfolio.test.ts
@@ -1,5 +1,9 @@
import { describe, expect, it } from 'vitest';
-import { computeCrawlOnlyGroups, computeDomainGroups } from './homePortfolio';
+import {
+ computeCrawlOnlyGroups,
+ computeDomainGroups,
+ computePortfolioSummary,
+} from './homePortfolio';
import type { CrawlRunSummary, PortfolioGroup, ReportListRow, ReportPayload } from '@/types/report';
describe('computeCrawlOnlyGroups', () => {
@@ -127,3 +131,79 @@ describe('computeDomainGroups', () => {
expect(groups[0]?.dataSources).toEqual(['crawl', 'lighthouse', 'search_console']);
});
});
+
+describe('computePortfolioSummary', () => {
+ it('aggregates brand count, urls, and average health', () => {
+ const groups: PortfolioGroup[] = [
+ {
+ domainName: 'a.com',
+ crawlUrl: 'https://a.com',
+ urlCount: 10,
+ healthScore: 80,
+ statusCounts: { s2xx: 10, s3xx: 0, s4xx: 0, s5xx: 0, other: 0 },
+ lastCrawl: '',
+ lastAudit: '',
+ totalIssues: 0,
+ issueCounts: { critical: 0, high: 0, medium: 0, low: 0 },
+ successRate: null,
+ titleCoverage: null,
+ avgWordCount: null,
+ thinPages: null,
+ technicalSeoScore: null,
+ perfScore: null,
+ seoScore: null,
+ crawlDurationS: null,
+ categorySnapshots: [],
+ seoSignals: null,
+ securityFindings: 0,
+ duplicateClusters: 0,
+ medianWordCount: null,
+ medianResponseMs: null,
+ reportId: 1,
+ generatedAtMs: 1000,
+ domainParam: 'a.com',
+ },
+ {
+ domainName: 'b.com',
+ crawlUrl: 'https://b.com',
+ urlCount: 20,
+ healthScore: 60,
+ statusCounts: { s2xx: 20, s3xx: 0, s4xx: 0, s5xx: 0, other: 0 },
+ lastCrawl: '',
+ lastAudit: '',
+ totalIssues: 0,
+ issueCounts: { critical: 0, high: 0, medium: 0, low: 0 },
+ successRate: null,
+ titleCoverage: null,
+ avgWordCount: null,
+ thinPages: null,
+ technicalSeoScore: null,
+ perfScore: null,
+ seoScore: null,
+ crawlDurationS: null,
+ categorySnapshots: [],
+ seoSignals: null,
+ securityFindings: 0,
+ duplicateClusters: 0,
+ medianWordCount: null,
+ medianResponseMs: null,
+ reportId: 2,
+ generatedAtMs: 900,
+ domainParam: 'b.com',
+ },
+ ];
+ expect(computePortfolioSummary(groups)).toEqual({
+ totalBrands: 2,
+ totalUrls: 30,
+ avgHealth: 70,
+ });
+ });
+
+ it('returns null avgHealth for empty groups', () => {
+ expect(computePortfolioSummary([])).toEqual({
+ totalBrands: 0,
+ totalUrls: 0,
+ avgHealth: null,
+ });
+ });
+});
diff --git a/web/src/lib/homePortfolio.ts b/web/src/lib/homePortfolio.ts
index 93b6387..d9d051e 100644
--- a/web/src/lib/homePortfolio.ts
+++ b/web/src/lib/homePortfolio.ts
@@ -130,6 +130,22 @@ function toLocalDateTime(value: string | null | undefined): string {
type GetPayloadFn = (reportId: number) => Promise | ReportPayload;
+export interface PortfolioSummary {
+ totalBrands: number;
+ totalUrls: number;
+ avgHealth: number | null;
+}
+
+/** Aggregate stats for the Home stat row. */
+export function computePortfolioSummary(groups: PortfolioGroup[]): PortfolioSummary {
+ const totalBrands = groups.length;
+ const totalUrls = groups.reduce((sum, g) => sum + g.urlCount, 0);
+ const avgHealth = totalBrands
+ ? Math.round(groups.reduce((sum, g) => sum + g.healthScore, 0) / totalBrands)
+ : null;
+ return { totalBrands, totalUrls, avgHealth };
+}
+
export type CrawlRunMeta = {
render_mode?: string;
discovery_mode?: string;
@@ -366,3 +382,62 @@ export function mergePortfolioGroups(
): PortfolioGroup[] {
return [...reportGroups, ...crawlOnlyGroups].sort((a, b) => b.generatedAtMs - a.generatedAtMs);
}
+
+export interface BuildPortfolioCardOpts {
+ reportId?: number;
+ crawlRunId?: number;
+}
+
+/**
+ * Build a single full portfolio card (full report payload) for the card widget.
+ */
+export async function buildPortfolioCard(
+ reportList: ReportListRow[],
+ startUrlByRunId: Map,
+ runCreatedAtByRunId: Map,
+ runMetaByRunId: Map,
+ crawlSummaries: CrawlRunSummary[],
+ unknownBrand: string,
+ emDash: string,
+ getPayload: GetPayloadFn,
+ opts: BuildPortfolioCardOpts,
+): Promise {
+ const reportId = opts.reportId;
+ const crawlRunId = opts.crawlRunId;
+
+ if (reportId != null && Number.isFinite(reportId)) {
+ const row = reportList.find((r) => r.id === reportId);
+ if (!row) return null;
+ const groups = await computeDomainGroups(
+ [row],
+ startUrlByRunId,
+ runCreatedAtByRunId,
+ unknownBrand,
+ emDash,
+ getPayload,
+ runMetaByRunId,
+ );
+ return groups[0] ?? null;
+ }
+
+ if (crawlRunId != null && Number.isFinite(crawlRunId)) {
+ const reportGroups = await computeDomainGroups(
+ reportList,
+ startUrlByRunId,
+ runCreatedAtByRunId,
+ unknownBrand,
+ emDash,
+ getPayload,
+ runMetaByRunId,
+ );
+ const fromReport = reportGroups.find((g) => g.crawlRunId === crawlRunId);
+ if (fromReport) return fromReport;
+
+ const summary = crawlSummaries.find((s) => Number(s.crawl_run_id) === crawlRunId);
+ if (!summary) return null;
+ const crawlOnly = computeCrawlOnlyGroups([summary], reportGroups, unknownBrand, emDash);
+ return crawlOnly[0] ?? null;
+ }
+
+ return null;
+}
diff --git a/web/src/lib/reportViewSections.ts b/web/src/lib/reportViewSections.ts
new file mode 100644
index 0000000..ace8b4e
--- /dev/null
+++ b/web/src/lib/reportViewSections.ts
@@ -0,0 +1,78 @@
+import type { OverviewTabId } from '@/components/overview/types';
+import type { SectionKey } from '@/lib/reportSections';
+import type { ViewId } from '@/routes';
+
+export const OVERVIEW_TAB_SECTIONS: Record = {
+ summary: ['traffic', 'keywords', 'content', 'indexation', 'tech'],
+ charts: ['content', 'lighthouse', 'structure', 'indexation', 'gallery', 'links'],
+ health: ['tech'],
+ pages: [],
+};
+
+/** Per-view sections loaded on mount. Tab-gated views use empty arrays here. */
+export const VIEW_SECTIONS: Partial> = {
+ overview: [],
+ issues: ['issues', 'traffic'],
+ links: ['links'],
+ 'site-structure': [],
+ redirects: ['issues'],
+ content: ['content'],
+ lighthouse: ['lighthouse'],
+ security: ['security'],
+ 'javascript-errors': ['links'],
+ accessibility: ['links'],
+ 'image-seo': ['content'],
+ 'content-analytics': ['content', 'indexation'],
+ 'text-content-analysis': ['content', 'indexation', 'keywords'],
+ 'tech-stack': ['tech'],
+ network: ['structure', 'links'],
+ gallery: ['gallery', 'links'],
+ 'search-performance': ['traffic'],
+ indexation: ['indexation'],
+ subdomains: ['tech'],
+ contacts: ['tech'],
+ backlinks: ['gsc-links', 'keywords'],
+ traffic: ['traffic'],
+ 'keywords-explorer': ['keywords', 'traffic'],
+};
+
+export const SITE_STRUCTURE_TAB_SECTIONS: Record = {
+ overview: ['links'],
+ tree: ['structure'],
+ map: ['structure'],
+ graph: ['structure'],
+};
+
+export const TRAFFIC_TAB_SECTIONS: readonly SectionKey[] = ['traffic'];
+
+export const SEARCH_PERFORMANCE_TAB_SECTIONS: readonly SectionKey[] = ['traffic'];
+
+export const LINKS_TAB_SECTIONS: readonly SectionKey[] = ['links'];
+
+export const BACKLINKS_TAB_SECTIONS: readonly SectionKey[] = ['gsc-links', 'keywords'];
+
+export const KEYWORDS_EXPLORER_TAB_SECTIONS: readonly SectionKey[] = ['keywords', 'traffic'];
+
+export type SectionLoadStatus = 'idle' | 'loading' | 'loaded' | 'error';
+
+export function sectionStatusFor(
+ sections: readonly SectionKey[],
+ statusMap: Partial>,
+): SectionLoadStatus {
+ if (!sections.length) return 'loaded';
+ let sawLoading = false;
+ for (const key of sections) {
+ const s = statusMap[key] ?? 'idle';
+ if (s === 'error') return 'error';
+ if (s === 'loading' || s === 'idle') sawLoading = true;
+ }
+ return sawLoading ? 'loading' : 'loaded';
+}
+
+export function isSectionPending(
+ sections: readonly SectionKey[],
+ statusMap: Partial>,
+): boolean {
+ const status = sectionStatusFor(sections, statusMap);
+ return status === 'idle' || status === 'loading' || status === 'error';
+}
diff --git a/web/src/views/Accessibility.tsx b/web/src/views/Accessibility.tsx
index c489cd4..82cdc8f 100644
--- a/web/src/views/Accessibility.tsx
+++ b/web/src/views/Accessibility.tsx
@@ -3,6 +3,8 @@
import { Fragment, useEffect, useMemo, useState } from 'react';
import { Accessibility, ChevronDown, ChevronRight, List } from 'lucide-react';
import { useReport } from '@/context/useReport';
+import { useSectionData } from '@/hooks/useSectionData';
+import { ViewSectionLoading } from '@/components/ViewSectionLoading';
import { useOptionalPipeline } from '@/context/PipelineContext';
import {
PageLayout,
@@ -36,6 +38,7 @@ type TabId = (typeof TABS)[number];
export default function AccessibilityView({ searchQuery = '' }: ViewProps) {
const { data, selectedReportId } = useReport();
+ const linksStatus = useSectionData('links');
const pipeline = useOptionalPipeline();
const va = strings.views.accessibility;
const [activeTab, setActiveTab] = useUrlTab(TABS, 'summary');
@@ -105,6 +108,10 @@ export default function AccessibilityView({ searchQuery = '' }: ViewProps) {
const emptyBecauseNoAxe = allRows.length === 0 && !scope.usesBrowser;
+ if (linksStatus === 'idle' || linksStatus === 'loading') {
+ return ;
+ }
+
return (
-
-
-
- {strings.app.loading}
-
-
- );
+ return ;
}
return (
diff --git a/web/src/views/Contacts.tsx b/web/src/views/Contacts.tsx
index 11fcbfb..92764e8 100644
--- a/web/src/views/Contacts.tsx
+++ b/web/src/views/Contacts.tsx
@@ -3,6 +3,8 @@
import { useMemo } from 'react';
import { Contact2, ExternalLink } from 'lucide-react';
import { useReport } from '../context/useReport';
+import { useSectionData } from '@/hooks/useSectionData';
+import { ViewSectionLoading } from '@/components/ViewSectionLoading';
import { strings, format } from '../lib/strings';
import { metricHelpHint } from '@/lib/metricHelp';
import {
@@ -109,6 +111,7 @@ function ContactSectionTable({
export default function Contacts({ searchQuery = '' }: ViewProps) {
const { data } = useReport();
+ const techStatus = useSectionData('tech');
const vc = strings.views.contacts;
const intel = data?.contact_intelligence;
const q = (searchQuery || '').toLowerCase().trim();
@@ -129,6 +132,10 @@ export default function Contacts({ searchQuery = '' }: ViewProps) {
(intel?.addresses?.length ?? 0) +
(intel?.organization_names?.length ?? 0);
+ if (techStatus === 'idle' || techStatus === 'loading') {
+ return ;
+ }
+
if (!intel || totalSignals === 0) {
return (
diff --git a/web/src/views/Content.tsx b/web/src/views/Content.tsx
index 2ab5528..8d03965 100644
--- a/web/src/views/Content.tsx
+++ b/web/src/views/Content.tsx
@@ -4,6 +4,8 @@ import { Bar } from 'react-chartjs-2';
import type { TooltipItem } from 'chart.js';
import { ExternalLink, CheckCircle2, FileText, Copy, BarChart3, List } from 'lucide-react';
import { useReport } from '../context/useReport';
+import { useSectionData } from '@/hooks/useSectionData';
+import { ViewSectionLoading } from '@/components/ViewSectionLoading';
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';
@@ -26,6 +28,7 @@ export default function Content({ searchQuery = '' }: ViewProps) {
const vlp = strings.views.links;
const CONTENT_FILTERS = vc.filters;
const { data } = useReport();
+ const contentStatus = useSectionData('content');
const [filter, setFilter] = useState('missing_h1');
const [page, setPage] = useState(1);
const [activeTab, setActiveTab] = useUrlTab(CONTENT_TABS, 'issues');
@@ -109,7 +112,9 @@ export default function Content({ searchQuery = '' }: ViewProps) {
},
], [vc.tabs, totalIssues, list.length]);
- if (!data) return null;
+ if (contentStatus === 'idle' || contentStatus === 'loading') {
+ return ;
+ }
const activeFilter = CONTENT_FILTERS.find((f) => f.key === filter);
@@ -140,7 +145,7 @@ export default function Content({ searchQuery = '' }: ViewProps) {
{activeTab === 'overview' && (
- {(data.content_duplicates?.length ?? 0) > 0 && (
+ {(data?.content_duplicates?.length ?? 0) > 0 && (
@@ -157,7 +162,7 @@ export default function Content({ searchQuery = '' }: ViewProps) {
- {(data.content_duplicates || []).slice(0, 40).map((g) => (
+ {(data?.content_duplicates || []).slice(0, 40).map((g) => (
{g.id}
diff --git a/web/src/views/ContentAnalytics.tsx b/web/src/views/ContentAnalytics.tsx
index baa852e..e5f48eb 100644
--- a/web/src/views/ContentAnalytics.tsx
+++ b/web/src/views/ContentAnalytics.tsx
@@ -1,6 +1,9 @@
import type { Chart, TooltipItem } from 'chart.js';
import { useState, useMemo } from 'react';
import { useUrlTab } from '@/hooks/useUrlTab';
+import { useTabSections } from '@/hooks/useTabSections';
+import { ViewSectionLoading } from '@/components/ViewSectionLoading';
+import { isSectionPending } from '@/lib/reportViewSections';
import type {
ContentAnalyticsData,
ContentUrlsMap,
@@ -281,6 +284,7 @@ export default function ContentAnalytics({ searchQuery = '' }: ViewProps) {
const ch = strings.charts;
const { data } = useReport();
const [activeTab, setActiveTab] = useUrlTab(CONTENT_ANALYTICS_TABS, 'summary');
+ const sectionStatus = useTabSections(['content', 'indexation'], true);
const q = (searchQuery || '').toLowerCase().trim();
const thinPages = useMemo((): ThinPageEntry[] => {
@@ -366,18 +370,20 @@ export default function ContentAnalytics({ searchQuery = '' }: ViewProps) {
[data?.semantic_keyword_clusters],
);
- if (!data) return null;
+ if (isSectionPending(['content', 'indexation'], sectionStatus)) {
+ return ;
+ }
- const summary: ReportSummary = data.summary ?? EMPTY_SUMMARY;
+ const summary: ReportSummary = data?.summary ?? EMPTY_SUMMARY;
const crawledCount = crawledUrlCount(data);
- const rtStats: ResponseTimeStats = data.response_time_stats ?? EMPTY_RT;
+ const rtStats: ResponseTimeStats = data?.response_time_stats ?? EMPTY_RT;
const rtDist = rtStats.distribution || {};
- const contentUrls: ContentUrlsMap = data.content_urls ?? EMPTY_CONTENT_URLS;
+ const contentUrls: ContentUrlsMap = data?.content_urls ?? EMPTY_CONTENT_URLS;
- const ca: ContentAnalyticsData = data.content_analytics ?? EMPTY_CA;
- const sc: SocialCoverageStats = data.social_coverage ?? EMPTY_SC;
- const seoHealth: SeoHealthStats = data.seo_health ?? EMPTY_SEO;
- const depthDist: DepthDistribution = data.depth_distribution ?? EMPTY_DEPTH;
+ const ca: ContentAnalyticsData = data?.content_analytics ?? EMPTY_CA;
+ const sc: SocialCoverageStats = data?.social_coverage ?? EMPTY_SC;
+ const seoHealth: SeoHealthStats = data?.seo_health ?? EMPTY_SEO;
+ const depthDist: DepthDistribution = data?.depth_distribution ?? EMPTY_DEPTH;
const wcStats = ca.word_count_stats || {};
const wcDist = ca.word_count_distribution || {};
const rlDist = ca.reading_level_distribution || {};
@@ -498,8 +504,8 @@ export default function ContentAnalytics({ searchQuery = '' }: ViewProps) {
const wcPercValues = wcPercRaw.map((v) => (v != null && !Number.isNaN(Number(v)) ? Number(v) : null));
const hasWcPercBar = wcPercValues.every((v) => v != null) && (wcStats.max ?? 0) > 0;
- const hreflang = data.hreflang_summary;
- const outboundDomains = data.outbound_link_domains ?? [];
+ const hreflang = data?.hreflang_summary;
+ const outboundDomains = data?.outbound_link_domains ?? [];
return (
diff --git a/web/src/views/Gallery.tsx b/web/src/views/Gallery.tsx
index 09ddee8..ffab3c4 100644
--- a/web/src/views/Gallery.tsx
+++ b/web/src/views/Gallery.tsx
@@ -13,6 +13,7 @@ import {
} from 'lucide-react';
import { useReport } from '../context/useReport';
import { useSectionData } from '@/hooks/useSectionData';
+import { ViewSectionLoading } from '@/components/ViewSectionLoading';
import { strings } from '../lib/strings';
import { PageLayout, PageHeader, Card, LabelWithHint } from '../components';
import type { GalleryImageItem, ReportLink, ViewProps } from '@/types';
@@ -255,6 +256,7 @@ export default function Gallery({ searchQuery = '' }: ViewProps) {
const vg = strings.views.gallery;
const { data } = useReport();
const linksStatus = useSectionData('links');
+ const galleryStatus = useSectionData('gallery');
const [density, setDensity] = useState('md');
const [layoutMode, setLayoutMode] = useState<'grid' | 'masonry'>('grid');
const [kindFilter, setKindFilter] = useState<'all' | GalleryKind>('all');
@@ -313,18 +315,11 @@ export default function Gallery({ searchQuery = '' }: ViewProps) {
};
}, [lightbox, closeLightbox]);
- if (!data) return null;
-
- if (linksStatus === 'loading' || linksStatus === 'idle') {
- return (
-
-
-
-
- {strings.app.loading}
-
-
- );
+ if (
+ linksStatus === 'idle' || linksStatus === 'loading' ||
+ galleryStatus === 'idle' || galleryStatus === 'loading'
+ ) {
+ return ;
}
const masonryColsClass =
diff --git a/web/src/views/Home.tsx b/web/src/views/Home.tsx
index cb6eee0..1d2e79f 100644
--- a/web/src/views/Home.tsx
+++ b/web/src/views/Home.tsx
@@ -1,60 +1,35 @@
import {
- Building2,
- ChevronDown,
Cpu,
Gauge,
MessageSquare,
Plus,
Search,
- Sparkles,
} from 'lucide-react';
import Link from 'next/link';
-import { useMemo, useState, useEffect, useCallback } from 'react';
-import type { CSSProperties } from 'react';
-import { PageLayout, Button, StatCard, EmptyState, LabelWithHint } from '../components';
-import PortfolioPropertyCard from '@/components/portfolio/PortfolioPropertyCard';
-import { healthScoreClass, portfolioCardKey } from '@/components/portfolio/portfolioCardUtils';
-import { Skeleton, SkeletonDomainCard } from '../components/Skeleton';
+import { useState, useEffect, useCallback } from 'react';
+import { PageLayout, Button } from '../components';
+import PortfolioGroupList from '@/components/portfolio/home/PortfolioGroupList';
+import PortfolioResumeSection from '@/components/portfolio/home/PortfolioResumeSection';
+import PortfolioStatsRow from '@/components/portfolio/home/PortfolioStatsRow';
+import { portfolioCardKey } from '@/components/portfolio/portfolioCardUtils';
+import { usePortfolio } from '@/context/usePortfolio';
import { useReport } from '../context/useReport';
-import { format, strings } from '../lib/strings';
-import { extractHostname } from '@/lib/domainSlug';
-import { apiUrl, reportApi } from '../lib/publicBase';
-import {
- parsePortfolioAuditHistory,
- type PortfolioAuditHistoryPoint,
-} from '@/lib/portfolioAuditHistory';
-import type { PortfolioCrawlHistoryPoint } from '@/types/api';
+import { strings } from '../lib/strings';
+import { apiUrl } from '../lib/publicBase';
import type { PortfolioGroup, ViewProps } from '@/types';
-function portfolioRootDomain(group: PortfolioGroup): string {
- const host = extractHostname(group.crawlUrl) || group.domainName.trim().toLowerCase();
- if (!host) return group.domainName || 'unknown';
- const parts = host.split('.').filter(Boolean);
- if (parts.length <= 2) return host;
- return parts.slice(-2).join('.');
-}
-
export default function Home({ onNavigate }: ViewProps) {
- const { reportList, crawlRuns, loadCrawlPreview, refreshReports } = useReport();
+ const { loadCrawlPreview, refreshReports } = useReport();
+ const { refreshPortfolio } = usePortfolio();
const vh = strings.views.home;
- const sj = strings.common;
const [filterQuery, setFilterQuery] = useState('');
const [greeting, setGreeting] = useState(vh.greetingMorning);
- const [domainGroups, setDomainGroups] = useState([]);
- const [portfolioLoading, setPortfolioLoading] = useState(false);
const [openingCrawlId, setOpeningCrawlId] = useState(null);
const [pendingDeleteKey, setPendingDeleteKey] = useState(null);
const [deletingKey, setDeletingKey] = useState(null);
const [deleteError, setDeleteError] = useState(null);
- const [auditHistoryByDomain, setAuditHistoryByDomain] = useState<
- Record
- >({});
- const [crawlHistoryByDomain, setCrawlHistoryByDomain] = useState<
- Record
- >({});
const [collapsedGroups, setCollapsedGroups] = useState>(() => new Set());
- // Time-aware greeting computed after mount to avoid SSR/timezone hydration mismatch.
useEffect(() => {
const h = new Date().getHours();
setGreeting(h < 12 ? vh.greetingMorning : h < 18 ? vh.greetingAfternoon : vh.greetingEvening);
@@ -105,144 +80,20 @@ export default function Home({ onNavigate }: ViewProps) {
return;
}
setPendingDeleteKey(null);
- setDomainGroups((prev) => prev.filter((g) => portfolioCardKey(g) !== key));
- await refreshReports();
+ await Promise.all([refreshPortfolio(), refreshReports()]);
} catch {
setDeleteError(vh.deleteFailed);
} finally {
setDeletingKey(null);
}
},
- [refreshReports, vh.deleteFailed],
- );
-
- useEffect(() => {
- if (!reportList.length && !crawlRuns.length) {
- setDomainGroups([]);
- setCrawlHistoryByDomain({});
- setPortfolioLoading(false);
- return;
- }
- let cancelled = false;
- setPortfolioLoading(true);
- const ids = reportList.map((r) => r.id).join(',');
- const qs = ids ? `?ids=${encodeURIComponent(ids)}` : '';
- fetch(reportApi(`/portfolio${qs}`))
- .then((res) => res.json())
- .then((body) => {
- if (cancelled) return;
- setDomainGroups(Array.isArray(body.groups) ? body.groups : []);
- const crawlHistory = body.crawlHistoryByDomain;
- setCrawlHistoryByDomain(
- crawlHistory && typeof crawlHistory === 'object' ? crawlHistory : {},
- );
- })
- .catch(() => {
- if (!cancelled) {
- setDomainGroups([]);
- setCrawlHistoryByDomain({});
- }
- })
- .finally(() => {
- if (!cancelled) setPortfolioLoading(false);
- });
- return () => {
- cancelled = true;
- };
- }, [reportList, crawlRuns]);
-
- useEffect(() => {
- if (!domainGroups.length) {
- setAuditHistoryByDomain({});
- return;
- }
- let cancelled = false;
- void Promise.all(
- domainGroups
- .filter((g) => !g.crawlOnly && g.domainParam)
- .map(async (g) => {
- try {
- const res = await fetch(
- apiUrl(`/report/history?domain=${encodeURIComponent(g.domainParam)}&limit=8`),
- );
- const body = await res.json();
- const points = parsePortfolioAuditHistory(body.history || []);
- return [g.domainParam, points] as [string, PortfolioAuditHistoryPoint[]];
- } catch {
- return [g.domainParam, [] as PortfolioAuditHistoryPoint[]] as [
- string,
- PortfolioAuditHistoryPoint[],
- ];
- }
- }),
- ).then((entries) => {
- if (cancelled) return;
- const map: Record = {};
- for (const [domain, points] of entries) {
- if (points.length) map[domain] = points;
- }
- setAuditHistoryByDomain(map);
- });
- return () => {
- cancelled = true;
- };
- }, [domainGroups]);
-
- const portfolioTotals = useMemo(() => {
- const totalBrands = domainGroups.length;
- const totalUrls = domainGroups.reduce((sum, g) => sum + g.urlCount, 0);
- const avgHealth = totalBrands
- ? Math.round(domainGroups.reduce((sum, g) => sum + g.healthScore, 0) / totalBrands)
- : null;
- return { totalBrands, totalUrls, avgHealth };
- }, [domainGroups]);
-
- const filteredGroups = useMemo(() => {
- const q = filterQuery.toLowerCase().trim();
- if (!q) return domainGroups;
- return domainGroups.filter((group) => (
- group.domainName.toLowerCase().includes(q) ||
- group.crawlUrl.toLowerCase().includes(q)
- ));
- }, [domainGroups, filterQuery]);
-
- const groupedPortfolio = useMemo(() => {
- const map = new Map();
- for (const group of filteredGroups) {
- const key = portfolioRootDomain(group);
- const items = map.get(key) ?? [];
- items.push(group);
- map.set(key, items);
- }
- return Array.from(map.entries())
- .map(([rootDomain, items]) => ({
- rootDomain,
- items: items.toSorted((a, b) => b.generatedAtMs - a.generatedAtMs),
- }))
- .toSorted((a, b) => (b.items[0]?.generatedAtMs ?? 0) - (a.items[0]?.generatedAtMs ?? 0));
- }, [filteredGroups]);
-
- // "Jump back in" — the most recently generated audits, derived from existing data.
- const recentAudits = useMemo(
- () => domainGroups.toSorted((a, b) => b.generatedAtMs - a.generatedAtMs).slice(0, 4),
- [domainGroups],
- );
- const showResume = !filterQuery && !portfolioLoading && recentAudits.length > 1;
-
- // Inline (span) shimmer for StatCard values — a block here would nest
- // a inside StatCard's value
, which is invalid DOM.
- const statSkeleton = (
-
+ [refreshPortfolio, refreshReports, vh.deleteFailed],
);
return (
- {/* Welcome header + quick actions */}
- {/* Search */}
- {/* Portfolio stat row */}
-
- }
- value={portfolioLoading ? statSkeleton : portfolioTotals.totalBrands.toLocaleString()}
- icon={}
- size="lg"
- shadow
- />
- }
- value={portfolioLoading ? statSkeleton : portfolioTotals.totalUrls.toLocaleString()}
- icon={}
- size="lg"
- shadow
- />
- }
- value={
- portfolioLoading ? statSkeleton : (portfolioTotals.avgHealth ?? sj.emDash)
- }
- valueClassName={
- portfolioTotals.avgHealth != null ? healthScoreClass(portfolioTotals.avgHealth) : 'text-bright'
- }
- icon={}
- size="lg"
- shadow
- />
-
+
{deleteError ? (
@@ -317,140 +139,27 @@ export default function Home({ onNavigate }: ViewProps) {
) : null}
- {/* Jump back in */}
- {showResume ? (
-
- {vh.resumeHeading}
-
- {recentAudits.map((group, i) => {
- const opening = openingCrawlId != null && openingCrawlId === group.crawlRunId;
- return (
-
- );
- })}
-
-
- ) : null}
-
- {/* Portfolio groups */}
- {portfolioLoading ? (
-
-
{strings.app.loading}
-
-
- ) : filteredGroups.length > 0 ? (
-
- {groupedPortfolio.map(({ rootDomain, items }) => {
- const collapsed = collapsedGroups.has(rootDomain);
- return (
-
-
- {!collapsed ? (
-
- {items.map((group) => {
- const cardKey = portfolioCardKey(group);
- return (
-
{ void openSite(group); }}
- onDeleteToggle={() => {
- setDeleteError(null);
- setPendingDeleteKey(pendingDeleteKey === cardKey ? null : cardKey);
- }}
- onDeleteCancel={() => setPendingDeleteKey(null)}
- onDeleteConfirm={() => { void handleDeletePortfolioItem(group); }}
- />
- );
- })}
-
- ) : null}
-
- );
- })}
-
- ) : filterQuery ? (
-
-
-
- ) : (
-
-
-
- )}
+ { void openSite(group); }}
+ openingCrawlId={openingCrawlId}
+ />
+
+ { void openSite(group); }}
+ onDeleteToggle={(cardKey) => {
+ setDeleteError(null);
+ setPendingDeleteKey(pendingDeleteKey === cardKey ? null : cardKey);
+ }}
+ onDeleteCancel={() => setPendingDeleteKey(null)}
+ onDeleteConfirm={(group) => { void handleDeletePortfolioItem(group); }}
+ />
);
}
diff --git a/web/src/views/ImageSeo.tsx b/web/src/views/ImageSeo.tsx
index d31099a..012d6b9 100644
--- a/web/src/views/ImageSeo.tsx
+++ b/web/src/views/ImageSeo.tsx
@@ -4,6 +4,8 @@ import Link from 'next/link';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { ImageIcon } from 'lucide-react';
import { useReport } from '@/context/useReport';
+import { useSectionData } from '@/hooks/useSectionData';
+import { ViewSectionLoading } from '@/components/ViewSectionLoading';
import { useOptionalPipeline } from '@/context/PipelineContext';
import { PageLayout, PageHeader, Card, ViewTabs, ViewTabPanel, Table, TableHead, TableHeadCell, TableBody, TableRow, TableCell } from '@/components';
import ImageAuditSummaryCards, { type ImageAuditSummaryData } from '@/components/imageSeo/ImageAuditSummaryCards';
@@ -43,6 +45,7 @@ function summaryFromApi(raw: Record): ImageAuditSummaryData {
export default function ImageSeo({ searchQuery = '' }: ViewProps) {
const vi = strings.views.imageSeo;
const { selectedReportId } = useReport();
+ const contentStatus = useSectionData('content');
const pipeline = useOptionalPipeline();
const propertyId = Number(pipeline?.configState.active_property_id || 0) || null;
const reportId = selectedReportId ?? null;
@@ -121,6 +124,10 @@ export default function ImageSeo({ searchQuery = '' }: ViewProps) {
const inventoryGated = activeTab === 'largest' || activeTab === 'unoptimized';
+ if (contentStatus === 'idle' || contentStatus === 'loading') {
+ return ;
+ }
+
return (
;
+ }
+
return (
} />
diff --git a/web/src/views/Issues.tsx b/web/src/views/Issues.tsx
index 017ecf0..6dfa599 100644
--- a/web/src/views/Issues.tsx
+++ b/web/src/views/Issues.tsx
@@ -3,6 +3,8 @@ import { Bar, Doughnut } from 'react-chartjs-2';
import type { TooltipItem } from 'chart.js';
import { AlertTriangle, AlertCircle, Info, ExternalLink, Flame, BarChart2, ListChecks } from 'lucide-react';
import { useReport } from '../context/useReport';
+import { useSectionData } from '@/hooks/useSectionData';
+import { ViewSectionLoading } from '@/components/ViewSectionLoading';
import { useOptionalPipeline } from '../context/PipelineContext';
import { strings, format } from '../lib/strings';
import { PageLayout, PageHeader, Card, Badge, ViewTabs, ViewTabPanel, Button, LabelWithHint } from '../components';
@@ -101,6 +103,8 @@ function IssueCard({ item, vi, emDash }: IssueCardProps) {
export default function Issues({ searchQuery = '' }: ViewProps) {
const { data, selectedReportId } = useReport();
+ const issuesStatus = useSectionData('issues');
+ useSectionData('traffic');
const pipeline = useOptionalPipeline();
const propertyId = Number(pipeline?.configState.active_property_id || 0) || null;
const vi = strings.views.issues;
@@ -269,7 +273,9 @@ export default function Issues({ searchQuery = '' }: ViewProps) {
};
}, [vi, categoryChartLabels]);
- if (!data) return null;
+ if (issuesStatus === 'idle' || issuesStatus === 'loading') {
+ return ;
+ }
const showCharts = list.length > 0 && forCharts.length > 0;
const subtitle = `${vi.subtitlePrefix} ${format(vi.subtitleTotal, {
diff --git a/web/src/views/JavaScriptErrors.tsx b/web/src/views/JavaScriptErrors.tsx
index d863210..75a683d 100644
--- a/web/src/views/JavaScriptErrors.tsx
+++ b/web/src/views/JavaScriptErrors.tsx
@@ -6,6 +6,8 @@ import { useSearchParams } from 'next/navigation';
import { useUrlTab } from '@/hooks/useUrlTab';
import { Bug, ChevronDown, ChevronRight, ExternalLink, BarChart3, List } from 'lucide-react';
import { useReport } from '../context/useReport';
+import { useSectionData } from '@/hooks/useSectionData';
+import { ViewSectionLoading } from '@/components/ViewSectionLoading';
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';
@@ -30,6 +32,7 @@ type JsErrorsTabId = (typeof JS_ERRORS_TABS)[number];
export default function JavaScriptErrors({ searchQuery = '' }: ViewProps) {
const { data } = useReport();
+ const linksStatus = useSectionData('links');
const searchParams = useSearchParams();
const trailingQuery = searchParams.toString() ? `?${searchParams.toString()}` : '';
const [typeFilter, setTypeFilter] = useState('All');
@@ -101,7 +104,9 @@ export default function JavaScriptErrors({ searchQuery = '' }: ViewProps) {
},
], [vj.tabs, topMessages.length, filteredRows.length]);
- if (!data) return null;
+ if (linksStatus === 'idle' || linksStatus === 'loading') {
+ return ;
+ }
const agg = scopeInfo.browserDiagnostics;
const pagesWithConsole = Number(agg?.pages_with_console_errors ?? 0);
diff --git a/web/src/views/KeywordsExplorer.tsx b/web/src/views/KeywordsExplorer.tsx
index a6a43ad..abbf8e5 100644
--- a/web/src/views/KeywordsExplorer.tsx
+++ b/web/src/views/KeywordsExplorer.tsx
@@ -8,6 +8,9 @@ import { useRouter } from 'next/navigation';
import { useUrlTab } from '@/hooks/useUrlTab';
import { useReport } from '../context/useReport';
import { useSectionData } from '@/hooks/useSectionData';
+import { useTabSections } from '@/hooks/useTabSections';
+import { ViewSectionLoading } from '@/components/ViewSectionLoading';
+import { KEYWORDS_EXPLORER_TAB_SECTIONS } from '@/lib/reportViewSections';
import { useOptionalPipeline } from '../context/PipelineContext';
import { useKeywordBrandQuery } from '@/hooks/useKeywordBrandQuery';
import { filterKeywordRowsForDomain } from '@/lib/filterKeywordsForDomain';
@@ -75,6 +78,7 @@ export default function KeywordsExplorer({ onOpenIntegrations }: ViewProps) {
const brandName = String(kwData?.brand_name || deriveBrandFromUrl(startUrl) || '').trim();
const [activeTab, setActiveTab] = useUrlTab(KEYWORD_TABS, 'overview');
+ useTabSections(KEYWORDS_EXPLORER_TAB_SECTIONS, true);
const [intentFilter, setIntentFilter] = useState('');
const [brandedFilter, setBrandedFilter] = useState('');
const [sourceFilter, setSourceFilter] = useState('');
@@ -300,22 +304,11 @@ export default function KeywordsExplorer({ onOpenIntegrations }: ViewProps) {
);
}, [activeTab, tableRows.length, hasActiveFilters, tabBaseCount, ke, clearFilters]);
+ if (keywordsStatus === 'loading' || keywordsStatus === 'idle') {
+ return ;
+ }
+
if (!kwData || rows.length === 0) {
- if (keywordsStatus === 'loading' || keywordsStatus === 'idle') {
- return (
-
- }
- title={ke.title}
- subtitle={ke.subtitle}
- />
-
-
- {strings.app.loading}
-
-
- );
- }
return (
) : null;
+ if (lighthouseStatus === 'idle' || lighthouseStatus === 'loading') {
+ return ;
+ }
+
if (!hasData) {
- if (lighthouseStatus === 'loading' || lighthouseStatus === 'idle') {
- return (
-
- }
- title={vlh.emptyTitle}
- />
-
-
- {strings.app.loading}
-
-
- );
- }
return (
-
-
-
- {strings.app.loading}
-
-
- );
+ if (linksStatus === 'idle' || linksStatus === 'loading') {
+ return ;
}
const totalPages = Math.max(1, Math.ceil(filtered.length / perPage));
@@ -579,7 +573,7 @@ export default function Links({ searchQuery = '' }: ViewProps) {
{linkForInspector ? (
document.removeEventListener('fullscreenchange', onFs);
}, []);
- if (!data) return null;
-
- if (structureStatus === 'loading' || structureStatus === 'idle') {
- return (
-
-
-
-
- {strings.app.loading}
-
-
- );
+ if (
+ structureStatus === 'idle' || structureStatus === 'loading' ||
+ linksStatus === 'idle' || linksStatus === 'loading'
+ ) {
+ return ;
}
const hasGraph =
- (data.graph_nodes?.length ?? 0) > 0 || (data.graph_edges?.length ?? 0) > 0;
+ (data?.graph_nodes?.length ?? 0) > 0 || (data?.graph_edges?.length ?? 0) > 0;
const searchEmpty =
graphPayload?.searchActive &&
diff --git a/web/src/views/Overview.tsx b/web/src/views/Overview.tsx
index 78c5b3f..a47c76e 100644
--- a/web/src/views/Overview.tsx
+++ b/web/src/views/Overview.tsx
@@ -2,6 +2,7 @@ import { Globe, CheckCircle, TrendingUp, BarChart3 } from 'lucide-react';
import { useMemo } from 'react';
import { useSearchParams } from 'next/navigation';
import { useUrlTab } from '@/hooks/useUrlTab';
+import { useTabSections } from '@/hooks/useTabSections';
import { useReport } from '../context/useReport';
import {
canonicalDomainFromPayload,
@@ -13,6 +14,8 @@ import { PageLayout, PageHeader, ViewTabs } from '../components';
import type { ViewTabItem } from '../components';
import type { ReportCategory, ViewProps } from '@/types';
import CrawlScopeBanner from '../components/CrawlScopeBanner';
+import { OverviewHeaderSkeleton } from '@/components/ViewSectionLoading';
+import { OVERVIEW_TAB_SECTIONS, isSectionPending } from '@/lib/reportViewSections';
import { viewIdToPathSlug } from '@/routes';
import {
type OverviewTabId,
@@ -25,9 +28,11 @@ import {
} from '../components/overview';
export default function Overview({ searchQuery = '' }: ViewProps) {
- const { data, reportList, startUrlByRunId } = useReport();
+ const { data, reportList, startUrlByRunId, sectionStatus } = useReport();
const searchParams = useSearchParams();
const [activeTab, setActiveTab] = useUrlTab(OVERVIEW_TABS, 'summary');
+ useTabSections(OVERVIEW_TAB_SECTIONS[activeTab], Boolean(data));
+ const chartsSectionsPending = isSectionPending(OVERVIEW_TAB_SECTIONS.charts, sectionStatus);
const vo = strings.views.overview;
const q = (searchQuery || '').toLowerCase().trim();
@@ -73,7 +78,13 @@ export default function Overview({ searchQuery = '' }: ViewProps) {
const catCount = data?.categories?.length ?? 0;
const pageCount = data?.top_pages?.length ?? 0;
const chartsBadge =
- charts.concernCount > 0 ? charts.concernCount : charts.chartCount > 0 ? charts.chartCount : null;
+ !chartsSectionsPending
+ ? charts.concernCount > 0
+ ? charts.concernCount
+ : charts.chartCount > 0
+ ? charts.chartCount
+ : null
+ : null;
return [
{ id: 'summary', label: vo.tabs.summary, icon: },
{
@@ -95,9 +106,15 @@ export default function Overview({ searchQuery = '' }: ViewProps) {
badge: pageCount > 0 ? pageCount : null,
},
];
- }, [vo.tabs, charts.concernCount, charts.chartCount, data?.categories?.length, data?.top_pages?.length]);
+ }, [vo.tabs, charts.concernCount, charts.chartCount, data?.categories?.length, data?.top_pages?.length, chartsSectionsPending]);
- if (!data) return null;
+ if (!data) {
+ return (
+
+
+
+ );
+ }
const s = data.summary || {};
const siteName = data.site_name || strings.app.defaultSiteName;
diff --git a/web/src/views/Redirects.tsx b/web/src/views/Redirects.tsx
index bca3138..743ec71 100644
--- a/web/src/views/Redirects.tsx
+++ b/web/src/views/Redirects.tsx
@@ -2,6 +2,8 @@ import { useMemo } from 'react';
import { Bar } from 'react-chartjs-2';
import type { TooltipItem } from 'chart.js';
import { useReport } from '../context/useReport';
+import { useSectionData } from '@/hooks/useSectionData';
+import { ViewSectionLoading } from '@/components/ViewSectionLoading';
import { strings } from '../lib/strings';
import { metricHelpHint } from '@/lib/metricHelp';
import { PageLayout, PageHeader, Card, Table, TableHead, TableHeadCell, TableBody, TableRow, TableCell, Badge } from '../components';
@@ -16,6 +18,7 @@ registerChartJsBase();
export default function Redirects({ searchQuery = '' }: ViewProps) {
const vr = strings.views.redirects;
const { data } = useReport();
+ const issuesStatus = useSectionData('issues');
const q = (searchQuery || '').toLowerCase().trim();
const redirects = useMemo((): ReportRedirect[] => {
const all = (data?.redirects || []) as ReportRedirect[];
@@ -59,7 +62,9 @@ export default function Redirects({ searchQuery = '' }: ViewProps) {
};
}, [statusLabels]);
- if (!data) return null;
+ if (issuesStatus === 'idle' || issuesStatus === 'loading') {
+ return ;
+ }
return (
@@ -113,7 +118,7 @@ export default function Redirects({ searchQuery = '' }: ViewProps) {
))}
- ) : (data.redirects || []).length > 0 ? (
+ ) : (data?.redirects || []).length > 0 ? (
{vr.noSearchMatch}
) : (
{vr.noneFound}
diff --git a/web/src/views/SearchPerformance.tsx b/web/src/views/SearchPerformance.tsx
index 1d61482..ecc99a9 100644
--- a/web/src/views/SearchPerformance.tsx
+++ b/web/src/views/SearchPerformance.tsx
@@ -7,6 +7,9 @@ import type { TableColumn } from '@/types/components';
import { TrendingUp, Search, AlertCircle, Settings2, Download, Loader2 } from 'lucide-react';
import { useReport } from '../context/useReport';
import { useSectionData } from '@/hooks/useSectionData';
+import { useTabSections } from '@/hooks/useTabSections';
+import { ViewSectionLoading } from '@/components/ViewSectionLoading';
+import { SEARCH_PERFORMANCE_TAB_SECTIONS } from '@/lib/reportViewSections';
import { strings, format } from '../lib/strings';
import { metricHelpHint } from '@/lib/metricHelp';
import { PageLayout, PageHeader, Card, AlertBanner, StatCard, ViewTabs } from '../components';
@@ -58,6 +61,7 @@ export default function SearchPerformance() {
const sp = strings.views.searchPerformance;
const searchParams = useSearchParams();
const [activeTab, setActiveTab] = useUrlTab(TABS, 'overview');
+ useTabSections(SEARCH_PERFORMANCE_TAB_SECTIONS, true);
const [querySearch, setQuerySearch] = useState('');
const [pageSearch, setPageSearch] = useState('');
@@ -222,15 +226,7 @@ export default function SearchPerformance() {
if (!google) {
if (trafficStatus === 'loading' || trafficStatus === 'idle') {
- return (
-
-
-
-
- {strings.app.loading}
-
-
- );
+ return ;
}
return (
diff --git a/web/src/views/Security.tsx b/web/src/views/Security.tsx
index beef692..cc051a0 100644
--- a/web/src/views/Security.tsx
+++ b/web/src/views/Security.tsx
@@ -5,6 +5,7 @@ import type { TooltipItem } from 'chart.js';
import { Shield, Flame, AlertTriangle, AlertCircle, Info, ExternalLink, BarChart3, List, Loader2 } from 'lucide-react';
import { useReport } from '../context/useReport';
import { useSectionData } from '@/hooks/useSectionData';
+import { ViewSectionLoading } from '@/components/ViewSectionLoading';
import { strings, format } from '../lib/strings';
import { PageLayout, PageHeader, Card, Badge, ViewTabs, ViewTabPanel, Button, StatCard, ChartTitleWithHint } from '../components';
import { metricHelpHint } from '@/lib/metricHelp';
@@ -216,21 +217,8 @@ export default function Security({ searchQuery = '' }: ViewProps) {
];
}, [vs.tabs, allFindings.length, typeLabels.length]);
- if (!data) return null;
-
- if ((securityStatus === 'loading' || securityStatus === 'idle') && !allFindings.length) {
- return (
-
- }
- title={vs.title}
- />
-
-
- {strings.app.loading}
-
-
- );
+ if (securityStatus === 'idle' || securityStatus === 'loading') {
+ return ;
}
const severityCounts = SEVERITY_ORDER.reduce>((acc, s) => {
diff --git a/web/src/views/SiteStructure.tsx b/web/src/views/SiteStructure.tsx
index 0134a33..64d8050 100644
--- a/web/src/views/SiteStructure.tsx
+++ b/web/src/views/SiteStructure.tsx
@@ -15,6 +15,9 @@ import {
} from 'lucide-react';
import { useReport } from '../context/useReport';
import { useSectionData } from '@/hooks/useSectionData';
+import { useTabSections } from '@/hooks/useTabSections';
+import { ViewSectionLoading } from '@/components/ViewSectionLoading';
+import { SITE_STRUCTURE_TAB_SECTIONS } from '@/lib/reportViewSections';
import { strings, format } from '../lib/strings';
import { canonicalDomainFromPayload } from '../lib/domainSlug';
import {
@@ -251,11 +254,12 @@ function fmtMetric(n: unknown): string {
export default function SiteStructure({ searchQuery = '' }: ViewProps) {
const s = strings.views.siteStructure;
const { data, compareData, startUrlByRunId, selectedReportId, compareReportId } = useReport();
- useSectionData('links');
- useSectionData('structure');
+ const linksStatus = useSectionData('links');
+ const structureStatus = useSectionData('structure');
const [showCompareCharts, setShowCompareCharts] = useState(true);
const [pathPrefixFilter, setPathPrefixFilter] = useState(null);
const [activeTab, setActiveTab] = useUrlTab(SITE_STRUCTURE_TABS, 'overview');
+ useTabSections(SITE_STRUCTURE_TAB_SECTIONS[activeTab] ?? ['structure'], true);
const expectedHost = useMemo(
() => canonicalDomainFromPayload(data, startUrlByRunId),
@@ -347,7 +351,10 @@ export default function SiteStructure({ searchQuery = '' }: ViewProps) {
];
}, [s.tabs, merged.size, filteredLinks.length, data?.graph_nodes?.length]);
- if (!data) return null;
+ const primaryStatus = activeTab === 'overview' ? linksStatus : structureStatus;
+ if (primaryStatus === 'idle' || primaryStatus === 'loading') {
+ return ;
+ }
return (
diff --git a/web/src/views/Subdomains.tsx b/web/src/views/Subdomains.tsx
index 79956f7..e77badb 100644
--- a/web/src/views/Subdomains.tsx
+++ b/web/src/views/Subdomains.tsx
@@ -5,6 +5,8 @@ import Link from 'next/link';
import { useSearchParams } from 'next/navigation';
import { Globe2 } from 'lucide-react';
import { useReport } from '../context/useReport';
+import { useSectionData } from '@/hooks/useSectionData';
+import { ViewSectionLoading } from '@/components/ViewSectionLoading';
import { strings, format } from '../lib/strings';
import {
PageLayout,
@@ -28,6 +30,7 @@ function yesNo(value: boolean | undefined): string {
export default function Subdomains({ searchQuery = '' }: ViewProps) {
const { data } = useReport();
+ const techStatus = useSectionData('tech');
const searchParams = useSearchParams();
const vs = strings.views.subdomains;
const inv = data?.subdomains;
@@ -49,6 +52,10 @@ export default function Subdomains({ searchQuery = '' }: ViewProps) {
const gscGapHosts = inv?.gsc_hosts_not_crawled || [];
const outOfScope = inv?.out_of_scope_discovered || [];
+ if (techStatus === 'idle' || techStatus === 'loading') {
+ return ;
+ }
+
if (!inv || inv.disabled) {
return (
diff --git a/web/src/views/TechStack.tsx b/web/src/views/TechStack.tsx
index 2b81aff..f4f8f0f 100644
--- a/web/src/views/TechStack.tsx
+++ b/web/src/views/TechStack.tsx
@@ -5,6 +5,8 @@ import { Cpu, List } from 'lucide-react';
import { Chart as ChartJS, CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend } from 'chart.js';
import { Bar } from 'react-chartjs-2';
import { useReport } from '../context/useReport';
+import { useSectionData } from '@/hooks/useSectionData';
+import { ViewSectionLoading } from '@/components/ViewSectionLoading';
import { strings, format } from '../lib/strings';
import { metricHelpHint } from '@/lib/metricHelp';
import { PageLayout, PageHeader, Card, Table, TableHead, TableHeadCell, TableBody, TableRow, TableCell, ViewTabs, ViewTabPanel, StatCard, ChartTitleWithHint } from '../components';
@@ -64,6 +66,7 @@ type TechStackTabId = (typeof TECH_STACK_TABS)[number];
export default function TechStack({ searchQuery = '' }: ViewProps) {
const vr = strings.views.techStack;
const { data } = useReport();
+ const techStatus = useSectionData('tech');
const [activeTab, setActiveTab] = useUrlTab(TECH_STACK_TABS, 'overview');
const q = (searchQuery || '').toLowerCase().trim();
const ts: TechStackSummary = data?.tech_stack_summary ?? EMPTY_TS;
@@ -93,7 +96,9 @@ export default function TechStack({ searchQuery = '' }: ViewProps) {
},
], [vr.tabs, techs.length]);
- if (!data) return null;
+ if (techStatus === 'idle' || techStatus === 'loading') {
+ return ;
+ }
const totalAnalyzed = ts.total_pages_analyzed || 0;
const chartLabels = techs.map((t) => t.name);
diff --git a/web/src/views/TextContentAnalysis.tsx b/web/src/views/TextContentAnalysis.tsx
index 888fba9..1a0bd9e 100644
--- a/web/src/views/TextContentAnalysis.tsx
+++ b/web/src/views/TextContentAnalysis.tsx
@@ -3,6 +3,9 @@
import type { Chart, TooltipItem } from 'chart.js';
import { Fragment, useState, useMemo, useEffect } from 'react';
import { useUrlTab } from '@/hooks/useUrlTab';
+import { useTabSections } from '@/hooks/useTabSections';
+import { ViewSectionLoading } from '@/components/ViewSectionLoading';
+import { isSectionPending } from '@/lib/reportViewSections';
import type {
ContentAnalyticsData,
TextContentAnalysisData,
@@ -250,6 +253,7 @@ export default function TextContentAnalysis({ searchQuery = '' }: ViewProps) {
const ch = strings.charts;
const { data } = useReport();
const [activeTab, setActiveTab] = useUrlTab(TEXT_TABS, 'overview');
+ const sectionStatus = useTabSections(['content', 'indexation', 'keywords'], true);
const [keywordsChartPage, setKeywordsChartPage] = useState(1);
const tca: TextContentAnalysisData = data?.text_content_analysis ?? EMPTY_TCA;
@@ -370,7 +374,9 @@ export default function TextContentAnalysis({ searchQuery = '' }: ViewProps) {
[vtca],
);
- if (!data) return null;
+ if (isSectionPending(['content', 'indexation', 'keywords'], sectionStatus)) {
+ return ;
+ }
return (
diff --git a/web/src/views/Traffic.tsx b/web/src/views/Traffic.tsx
index ef9732c..d69346f 100644
--- a/web/src/views/Traffic.tsx
+++ b/web/src/views/Traffic.tsx
@@ -7,6 +7,9 @@ import type { TableColumn } from '@/types/components';
import { Users, AlertCircle, Settings2, Download, Loader2 } from 'lucide-react';
import { useReport } from '../context/useReport';
import { useSectionData } from '@/hooks/useSectionData';
+import { useTabSections } from '@/hooks/useTabSections';
+import { ViewSectionLoading } from '@/components/ViewSectionLoading';
+import { TRAFFIC_TAB_SECTIONS } from '@/lib/reportViewSections';
import { strings, format } from '../lib/strings';
import { metricHelpHint } from '@/lib/metricHelp';
import { PageLayout, PageHeader, Card, AlertBanner, StatCard, ViewTabs, EmptyState } from '../components';
@@ -58,6 +61,7 @@ export default function Traffic() {
const sp = strings.views.searchPerformance;
const searchParams = useSearchParams();
const [activeTab, setActiveTab] = useUrlTab(TABS, 'overview');
+ useTabSections(TRAFFIC_TAB_SECTIONS, true);
const [pathSearch, setPathSearch] = useState('');
useEffect(() => {
@@ -186,15 +190,7 @@ export default function Traffic() {
if (!google) {
if (trafficStatus === 'loading' || trafficStatus === 'idle') {
- return (
-
-
-
-
- {strings.app.loading}
-
-
- );
+ return ;
}
return (
From 1acca9a334d7361cc70692de3b451817c1074772 Mon Sep 17 00:00:00 2001
From: PrashantUnity
Date: Tue, 16 Jun 2026 19:12:01 +0530
Subject: [PATCH 06/10] URl Web Things
---
web/src/components/CountUp.tsx | 19 ++
web/src/components/UrlInspectorDrawer.tsx | 81 +++++-
web/src/components/ViewTabs.tsx | 2 +-
.../components/links/ConnectionInsights.tsx | 153 ++++++++++
web/src/components/links/InspectorTabs.tsx | 10 +-
web/src/components/links/LinkFlow.tsx | 180 ++++++++++++
.../components/links/tabs/ConnectionsTab.tsx | 270 ++++++++++++++++++
.../overview/OverviewCrawlMetrics.tsx | 11 +-
.../components/overview/OverviewPagesTab.tsx | 2 +
web/src/context/UrlInspectorContext.tsx | 59 +++-
web/src/hooks/useCountUp.ts | 43 +++
web/src/hooks/usePrefersReducedMotion.ts | 23 ++
web/src/lib/linkGraph.ts | 117 ++++++++
web/src/strings.json | 53 +++-
web/src/views/Network.tsx | 160 +++++++++--
15 files changed, 1139 insertions(+), 44 deletions(-)
create mode 100644 web/src/components/CountUp.tsx
create mode 100644 web/src/components/links/ConnectionInsights.tsx
create mode 100644 web/src/components/links/LinkFlow.tsx
create mode 100644 web/src/components/links/tabs/ConnectionsTab.tsx
create mode 100644 web/src/hooks/useCountUp.ts
create mode 100644 web/src/hooks/usePrefersReducedMotion.ts
create mode 100644 web/src/lib/linkGraph.ts
diff --git a/web/src/components/CountUp.tsx b/web/src/components/CountUp.tsx
new file mode 100644
index 0000000..5d3fb57
--- /dev/null
+++ b/web/src/components/CountUp.tsx
@@ -0,0 +1,19 @@
+'use client';
+
+import { useCountUp } from '@/hooks/useCountUp';
+
+export interface CountUpProps {
+ value: number;
+ durationMs?: number;
+ /** Custom formatter for the (rounded) display value. Defaults to locale string. */
+ format?: (n: number) => string;
+ className?: string;
+}
+
+/** Renders a number that animates up to `value` (respects prefers-reduced-motion). */
+export default function CountUp({ value, durationMs, format, className }: CountUpProps) {
+ const animated = useCountUp(value, durationMs);
+ const rounded = Math.round(animated);
+ const display = format ? format(rounded) : rounded.toLocaleString();
+ return {display};
+}
diff --git a/web/src/components/UrlInspectorDrawer.tsx b/web/src/components/UrlInspectorDrawer.tsx
index caf702f..f4dbebb 100644
--- a/web/src/components/UrlInspectorDrawer.tsx
+++ b/web/src/components/UrlInspectorDrawer.tsx
@@ -1,9 +1,13 @@
'use client';
-import { useMemo } from 'react';
-import { X } from 'lucide-react';
+import { Fragment, useMemo } from 'react';
+import { ChevronLeft, ChevronRight, X } from 'lucide-react';
import { useReport } from '@/context/useReport';
+import { useUrlInspector } from '@/context/UrlInspectorContext';
+import { useSectionData } from '@/hooks/useSectionData';
import InspectorTabs from '@/components/links/InspectorTabs';
+import { shortPath } from '@/lib/linkGraph';
+import { strings } from '@/lib/strings';
import type { InspectorDetails, LinkDetail, ReportLink } from '@/types/report';
interface UrlInspectorDrawerProps {
@@ -60,6 +64,11 @@ function buildInspectorDetails(data: NonNullable['d
export default function UrlInspectorDrawer({ url, onClose }: UrlInspectorDrawerProps) {
const { data } = useReport();
+ const { trail, back, forward, goTo, canGoBack, canGoForward } = useUrlInspector();
+ const ui = strings.components.urlInspector;
+ // Ensure link-graph data (link_edges, inlink_anchor_matrix) is loaded while the
+ // inspector is open, even if it was launched from a view that didn't need it.
+ useSectionData('links', Boolean(url));
const links = (data?.links || []) as ReportLink[];
const link = useMemo((): LinkDetail | null => {
@@ -77,12 +86,68 @@ export default function UrlInspectorDrawer({ url, onClose }: UrlInspectorDrawerP
if (!url || !link) return null;
return (
-
-
-
-
-
{url}
-