• StatisticsPage component – visual analytics for import, match, and sync data. Displays comprehensive statistics with charts, filters, and data export capabilities.

    Returns Element

    Rendered statistics dashboard.

    export function StatisticsPage() {
    const [importStats, setImportStats] = useState<ImportStats | null>(null);
    const [syncStats, setSyncStats] = useState<SyncStats | null>(null);
    const [matchResults, setMatchResults] = useState<NormalizedMatchForStats[]>(
    [],
    );
    const [isLoading, setIsLoading] = useState(true);
    const [isRefreshing, setIsRefreshing] = useState(false);
    const [lastRefreshedAt, setLastRefreshedAt] = useState<string | null>(null);
    const [readingHistory, setReadingHistory] = useState<ReadingHistory>({
    entries: [],
    lastUpdated: 0,
    version: 1,
    });
    const [selectedTimeRange, setSelectedTimeRange] = useState<TimeRange>("30d");
    const [statisticsFilters, setStatisticsFilters] = useState<StatisticsFilters>(
    defaultStatisticsFilters,
    );
    const [drillDownData, setDrillDownData] = useState<DrillDownData | null>(
    null,
    );
    const [isDrillDownOpen, setIsDrillDownOpen] = useState(false);

    /**
    * Loads all statistics data from storage and normalizes for display.
    * @source
    */
    const loadStatistics = useCallback(async () => {
    try {
    const stats = getImportStats();
    setImportStats(stats);

    const savedMatches = getSavedMatchResults();
    const normalizedMatches = normalizeMatchResults(savedMatches as unknown);
    setMatchResults(normalizedMatches);

    const syncRaw = await storage.getItemAsync(STORAGE_KEYS.SYNC_STATS);
    const parsedSync = parseSyncStats(syncRaw);
    setSyncStats(parsedSync);

    const history = getReadingHistory();
    setReadingHistory(history);

    setLastRefreshedAt(new Date().toISOString());
    } catch (error) {
    const errorId = generateErrorId();
    captureError(
    ErrorType.STORAGE,
    "Failed to load statistics",
    error instanceof Error ? error : new Error(String(error)),
    { errorId },
    );
    toast.error(
    `Unable to load statistics. Please try again. (ref: ${errorId})`,
    );
    }
    }, []);

    useEffect(() => {
    document.title = "Statistics • Kenmei to AniList";
    }, []);

    useEffect(() => {
    let active = true;

    const fetchData = async () => {
    setIsLoading(true);
    await loadStatistics();
    if (active) {
    setIsLoading(false);
    }
    };

    void fetchData();

    return () => {
    active = false;
    };
    }, [loadStatistics]);

    /**
    * Handles manual refresh of all statistics data.
    * @source
    */
    const handleRefresh = useCallback(async () => {
    if (isRefreshing) return;
    setIsRefreshing(true);
    await loadStatistics();
    setIsRefreshing(false);
    toast.success("Statistics refreshed");
    }, [isRefreshing, loadStatistics]);

    /**
    * Handles clearing filters from error boundary.
    * @source
    */
    const handleClearFiltersFromBoundary = useCallback(() => {
    setStatisticsFilters(defaultStatisticsFilters);
    toast.success("Filters cleared");
    }, []);

    /**
    * Handles time range selection for filtered statistics views.
    * @param range - The selected time range period.
    * @source
    */
    const handleTimeRangeChange = useCallback((range: TimeRange) => {
    setSelectedTimeRange(range);
    console.debug(`[Statistics] Time range changed to: ${range}`);
    }, []);

    /**
    * Aggregates statistics using worker pool with caching
    * Replaces manual filtering and aggregation memos
    * @source
    */
    const { aggregationResult } = useStatisticsAggregation(
    matchResults,
    readingHistory,
    statisticsFilters,
    selectedTimeRange,
    );

    /**
    * Memoized date range computed from selected time range.
    * Prevents unnecessary re-runs of useReadingHistoryFilter hook.
    */
    const dateRangeForFilter = useMemo(() => {
    const now = Date.now();
    const ranges = {
    "7d": 7 * 24 * 60 * 60 * 1000,
    "30d": 30 * 24 * 60 * 60 * 1000,
    "90d": 90 * 24 * 60 * 60 * 1000,
    };

    const msBack =
    selectedTimeRange === "all" ? Infinity : ranges[selectedTimeRange];
    const start = msBack === Infinity ? 0 : now - msBack;
    return { start, end: now };
    }, [selectedTimeRange]);

    /**
    * Filters reading history for the current time range using worker pool
    * Can be used to optimize chart rendering with pre-filtered and aggregated data
    */
    const { filterResult: historyFilterResult } = useReadingHistoryFilter(
    readingHistory,
    dateRangeForFilter,
    "daily", // Use daily aggregation for charts
    );

    // Use the filtered result for chart optimization
    // This enables zero-copy passing of pre-aggregated data
    if (historyFilterResult?.aggregatedData) {
    console.debug(
    `[Statistics] History filter complete: ${historyFilterResult.stats.totalEntries} entries aggregated`,
    );
    }

    // Fallback to original behavior if hook not ready or error
    const filteredData = aggregationResult?.filteredData || {
    matchResults,
    readingHistory,
    };
    const filterOptions = aggregationResult?.filterOptions || {
    genres: [],
    formats: [],
    statuses: [],
    tags: [],
    };

    /**
    * Adapts filtered match results to MatchForExport format for export button.
    * @source
    */
    const adaptedMatchesForExport = useMemo(
    () => adaptMatchesForExport(filteredData.matchResults),
    [filteredData.matchResults],
    );

    /**
    * Handles filter changes from the filter panel.
    * @param filters - New filter state.
    * @source
    */
    const handleFiltersChange = useCallback((filters: StatisticsFilters) => {
    setStatisticsFilters(filters);
    console.debug("[Statistics] Filters updated:", filters);
    }, []);

    /**
    * Handles drill-down clicks from charts.
    * @param data - Drill-down data to display.
    * @source
    */
    const handleDrillDown = useCallback((data: DrillDownData) => {
    setDrillDownData(data);
    setIsDrillDownOpen(true);
    }, []);

    /**
    * Handles export of drill-down data in selected format (JSON, CSV, or Markdown).
    * Uses filtered and sorted rows from the modal's processed data.
    * @source
    */
    const handleDrillDownExport = useCallback(
    async (format: ExportFormat, rows: Record<string, unknown>[]) => {
    if (!drillDownData || !rows) return;

    try {
    // Build export metadata
    const metadata = buildExportMetadata(format, rows.length, undefined, [
    drillDownData.type,
    drillDownData.value,
    ]);

    const baseFilename = `drill-down-${drillDownData.type}-${drillDownData.value}`;
    let file: string;

    switch (format) {
    case "json": {
    const payload = {
    drillDownType: drillDownData.type,
    drillDownValue: drillDownData.value,
    exportedAt: new Date().toISOString(),
    data: rows,
    };
    file = await exportToJson(
    payload as Record<string, unknown>,
    baseFilename,
    );
    break;
    }
    case "csv": {
    file = await exportToCSV(rows, baseFilename);
    break;
    }
    case "markdown": {
    file = exportToMarkdown(rows, baseFilename, metadata);
    break;
    }
    default:
    throw new Error(`Unsupported export format: ${format}`);
    }

    toast.success(
    `Drill-down data exported as ${format.toUpperCase()} to ${file}`,
    );
    } catch (error) {
    const errorId = generateErrorId();
    captureError(
    ErrorType.SYSTEM,
    "Failed to export drill-down data",
    error instanceof Error ? error : new Error(String(error)),
    {
    errorId,
    type: drillDownData?.type,
    value: drillDownData?.value,
    count: rows?.length,
    },
    );
    toast.error(`Failed to export drill-down data (ref: ${errorId})`);
    }
    },
    [drillDownData],
    );

    /**
    * Computes hero metrics including total imports, matched, and pending counts.
    * @source
    */
    const heroMetrics = useMemo(() => {
    const totalImported = importStats?.total ?? 0;
    const matchedCount = matchResults.filter((match) =>
    ["matched", "manual"].includes(match.status ?? ""),
    ).length;
    const pendingCount = matchResults.filter(
    (match) => match.status === "pending",
    ).length;
    const matchRate = totalImported > 0 ? matchedCount / totalImported : 0;
    return { totalImported, matchedCount, pendingCount, matchRate };
    }, [importStats, matchResults]);

    /**
    * Formats the last updated timestamp for display.
    * @source
    */
    const lastUpdatedLabel = useMemo(
    () => formatRelativeTime(lastRefreshedAt),
    [lastRefreshedAt],
    );

    const lastSyncLabel = useMemo(
    () => formatRelativeTime(syncStats?.lastSyncTime ?? null),
    [syncStats?.lastSyncTime],
    );

    const overviewCards = useMemo<OverviewCard[]>(() => {
    const percentFormatter = new Intl.NumberFormat(undefined, {
    style: "percent",
    minimumFractionDigits: 0,
    maximumFractionDigits: 1,
    });

    const numberFormatter = new Intl.NumberFormat(undefined, {
    maximumFractionDigits: 1,
    });

    const totalChaptersRead =
    historyFilterResult?.stats?.totalChaptersRead ?? 0;
    const averageChaptersPerDay =
    historyFilterResult?.stats?.averageChaptersPerDay ?? 0;

    const matchRateValue = heroMetrics.totalImported
    ? percentFormatter.format(heroMetrics.matchRate)
    : "—";

    return [
    {
    key: "imported",
    label: "Imported Titles",
    value: heroMetrics.totalImported.toLocaleString(),
    helper: `Pending: ${heroMetrics.pendingCount.toLocaleString()}`,
    icon: BookOpen,
    accent: "from-blue-500/15 via-sky-500/10 to-indigo-500/10",
    iconClass: "text-blue-600 dark:text-blue-300",
    },
    {
    key: "match-rate",
    label: "Match Completion",
    value: matchRateValue,
    helper: heroMetrics.totalImported
    ? `${heroMetrics.matchedCount.toLocaleString()} matched`
    : "Awaiting import",
    icon: CheckCircle2,
    accent: "from-emerald-500/15 via-teal-500/10 to-lime-500/10",
    iconClass: "text-emerald-600 dark:text-emerald-300",
    },
    {
    key: "reading",
    label: "Chapters Logged",
    value: totalChaptersRead.toLocaleString(),
    helper: totalChaptersRead
    ? `${numberFormatter.format(averageChaptersPerDay)} per day`
    : "No activity yet",
    icon: Activity,
    accent: "from-fuchsia-500/15 via-purple-500/10 to-pink-500/10",
    iconClass: "text-fuchsia-600 dark:text-fuchsia-300",
    },
    {
    key: "syncs",
    label: "Sync Activity",
    value: (syncStats?.totalSyncs ?? 0).toLocaleString(),
    helper: `Last sync: ${lastSyncLabel}`,
    icon: Clock,
    accent: "from-amber-500/15 via-orange-500/10 to-yellow-500/10",
    iconClass: "text-amber-600 dark:text-amber-300",
    },
    ];
    }, [
    historyFilterResult?.stats?.averageChaptersPerDay,
    historyFilterResult?.stats?.totalChaptersRead,
    heroMetrics.matchRate,
    heroMetrics.matchedCount,
    heroMetrics.pendingCount,
    heroMetrics.totalImported,
    lastSyncLabel,
    syncStats?.totalSyncs,
    ]);

    /**
    * Determines if there is any data available to display in the current filtered scope.
    * @source
    */
    const hasAnyData = useMemo(() => {
    const hasImport = (importStats?.total ?? 0) > 0;
    const hasFilteredMatches = filteredData.matchResults.length > 0;
    const hasSync = !!(syncStats && syncStats.totalSyncs > 0);
    const hasFilteredHistory = !!(
    filteredData.readingHistory &&
    filteredData.readingHistory.entries.length > 0
    );
    return hasImport || hasFilteredMatches || hasSync || hasFilteredHistory;
    }, [importStats, filteredData, syncStats]);

    /**
    * Generates skeleton card keys for loading state.
    * @source
    */
    const skeletonKeys = useMemo(
    () => [
    "status",
    "format",
    "sync",
    "timeline",
    "genres",
    "chapters",
    "trends",
    "velocity",
    "habits",
    ],
    [],
    );

    const overviewSkeletonKeys = useMemo(
    () => ["imported", "match", "reading", "sync"],
    [],
    );

    /**
    * Renders the loading state with skeleton cards.
    */
    const renderLoadingState = (): React.ReactNode => (
    <section className="space-y-8">
    <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
    {overviewSkeletonKeys.map((key) => (
    <SkeletonCard key={`statistics-overview-skeleton-${key}`} />
    ))}
    </div>
    <section className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
    {skeletonKeys.map((key) => (
    <SkeletonCard key={`statistics-skeleton-${key}`} />
    ))}
    </section>
    </section>
    );

    /**
    * Renders the empty state when no statistics are available.
    */
    const renderEmptyState = (): React.ReactNode => (
    <section className="flex flex-col items-center justify-center gap-4 rounded-3xl border border-dashed border-slate-200 bg-white/80 p-12 text-center shadow-sm dark:border-slate-800 dark:bg-slate-950/70">
    <AlertCircle className="h-10 w-10 text-amber-500" aria-hidden="true" />
    <div className="space-y-2">
    <h2 className="text-xl font-semibold">No statistics available yet</h2>
    <p className="text-muted-foreground">
    Import your Kenmei library and review matches to unlock detailed
    analytics.
    </p>
    </div>
    <Button asChild size="lg" className="gap-2">
    <Link to="/import">
    Start Import
    <ArrowRight className="h-4 w-4" aria-hidden="true" />
    </Link>
    </Button>
    </section>
    );

    /**
    * Determines the main content to render based on loading and data availability states.
    * Extracts conditional logic to reduce cognitive complexity.
    */
    const getMainContent = (): React.ReactNode => {
    if (isLoading) {
    return renderLoadingState();
    }

    if (!hasAnyData) {
    return renderEmptyState();
    }

    return (
    <motion.section
    variants={containerVariants}
    initial="hidden"
    animate="show"
    className="space-y-10"
    >
    <motion.div variants={itemVariants} className="w-full">
    <StatisticsFilterPanel
    filters={statisticsFilters}
    onFiltersChange={handleFiltersChange}
    availableGenres={filterOptions.genres}
    availableFormats={filterOptions.formats}
    availableStatuses={
    filterOptions.statuses as import("@/api/anilist/types").MatchStatus[]
    }
    availableTags={filterOptions.tags}
    matchCount={filteredData.matchResults.length}
    />
    </motion.div>

    <motion.section variants={itemVariants} className="space-y-4">
    <div className="flex flex-col gap-1">
    <h2 className="text-xl font-semibold tracking-tight">
    Performance Snapshot
    </h2>
    <p className="text-muted-foreground text-sm">
    Monitor match coverage and sync reliability at a glance.
    </p>
    </div>
    <div className="grid grid-cols-1 gap-6 xl:grid-cols-2">
    <div className="w-full">
    <StatusDistributionChart
    data={importStats?.statusCounts ?? null}
    onDrillDown={handleDrillDown}
    matchResults={filteredData.matchResults}
    />
    </div>
    <div className="w-full">
    <FormatDistributionChart
    matchResults={filteredData.matchResults}
    onDrillDown={handleDrillDown}
    filteredMatchResults={filteredData.matchResults}
    readingHistory={filteredData.readingHistory}
    />
    </div>
    </div>
    <div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
    <div className="w-full">
    <MatchProgressChart matchResults={matchResults} />
    </div>
    <div className="w-full">
    <SyncMetricsChart syncStats={syncStats} />
    </div>
    </div>
    </motion.section>

    <motion.section variants={itemVariants} className="space-y-4">
    <div className="flex flex-col gap-1">
    <h2 className="text-xl font-semibold tracking-tight">
    Reading Engagement
    </h2>
    <p className="text-muted-foreground text-sm">
    Understand momentum and habits across your selected time range.
    </p>
    </div>
    <div className="space-y-6">
    <ReadingTrendsChart
    history={filteredData.readingHistory}
    timeRange={selectedTimeRange}
    enableZoom={true}
    matchResults={filteredData.matchResults}
    onDrillDown={handleDrillDown}
    />
    </div>
    <div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
    <div className="w-full">
    <ReadingVelocityChart
    history={filteredData.readingHistory}
    timeRange={selectedTimeRange}
    />
    </div>
    <div className="w-full">
    <ReadingHabitsChart
    history={filteredData.readingHistory}
    timeRange={selectedTimeRange}
    />
    </div>
    </div>
    </motion.section>

    <motion.section variants={itemVariants} className="space-y-4">
    <div className="flex flex-col gap-1">
    <h2 className="text-xl font-semibold tracking-tight">
    Library Discovery
    </h2>
    <p className="text-muted-foreground text-sm">
    Explore the genres and titles powering your collection.
    </p>
    </div>
    <div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
    <div className="w-full">
    <TopGenresChart
    matchResults={filteredData.matchResults}
    onDrillDown={handleDrillDown}
    filteredMatchResults={filteredData.matchResults}
    readingHistory={filteredData.readingHistory}
    />
    </div>
    <div className="w-full">
    <ChaptersReadDistributionChart
    matchResults={filteredData.matchResults}
    />
    </div>
    </div>
    </motion.section>
    </motion.section>
    );
    };

    /**
    * Determines whether to show the header controls (time range and comparison toggle).
    */
    const shouldShowHeaderControls = hasAnyData && !isLoading;

    /**
    * Renders the header overview cards conditionally based on loading and data state.
    */
    const getHeaderOverviewCards = (): React.ReactNode => {
    if (isLoading) {
    return (
    <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
    {overviewSkeletonKeys.map((key) => (
    <SkeletonCard key={`statistics-header-skeleton-${key}`} />
    ))}
    </div>
    );
    }

    if (hasAnyData) {
    return (
    <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
    {overviewCards.map((card) => (
    <OverviewCardComponent
    key={`statistics-overview-${card.key}`}
    card={card}
    />
    ))}
    </div>
    );
    }

    return null;
    };

    const content = getMainContent();
    return (
    <main className="container mx-auto px-4 py-10">
    <StatisticsErrorBoundary
    onRefresh={handleRefresh}
    onClearFilters={handleClearFiltersFromBoundary}
    >
    <motion.section
    variants={containerVariants}
    initial="hidden"
    animate="show"
    className="mb-10"
    >
    <motion.div
    variants={itemVariants}
    className="space-y-6 rounded-3xl border border-slate-200 bg-white/90 p-8 shadow-lg backdrop-blur-md dark:border-slate-800 dark:bg-slate-950/80"
    >
    <div className="flex flex-col gap-5 lg:flex-row lg:items-center lg:justify-between">
    <div className="flex items-start gap-4">
    <span className="bg-linear-to-br inline-flex h-12 w-12 items-center justify-center rounded-2xl from-blue-500/20 via-purple-500/20 to-fuchsia-500/20 text-blue-500 dark:text-blue-300">
    <BarChart3 className="h-6 w-6" aria-hidden="true" />
    </span>
    <div className="space-y-1">
    <h1 className="text-3xl font-semibold tracking-tight">
    Library Statistics
    </h1>
    <p className="text-muted-foreground text-sm">
    Comprehensive insights into your Kenmei to AniList
    migration.
    </p>
    <p className="text-muted-foreground text-xs">
    Last refreshed: {lastUpdatedLabel}
    </p>
    </div>
    </div>
    <div className="flex flex-wrap items-center gap-3">
    <Button
    variant="ghost"
    onClick={handleRefresh}
    disabled={isRefreshing}
    className="gap-2"
    >
    {isRefreshing ? (
    <Loader2
    className="h-4 w-4 animate-spin"
    aria-hidden="true"
    />
    ) : (
    <RefreshCw className="h-4 w-4" aria-hidden="true" />
    )}
    Refresh
    </Button>
    <ExportStatisticsButton
    importStats={importStats}
    syncStats={syncStats}
    matchResults={adaptedMatchesForExport}
    disabled={!hasAnyData}
    appliedFilters={statisticsFilters}
    isFiltered={areFiltersActive(
    statisticsFilters,
    defaultStatisticsFilters,
    )}
    />
    {shouldShowHeaderControls ? (
    <TimeRangeSelector
    value={selectedTimeRange}
    onChange={handleTimeRangeChange}
    />
    ) : null}
    </div>
    </div>

    {getHeaderOverviewCards()}
    </motion.div>
    </motion.section>

    {content}

    {/* Drill-Down Modal */}
    <DrillDownModal
    isOpen={isDrillDownOpen}
    onOpenChange={setIsDrillDownOpen}
    drillDownData={drillDownData}
    onExport={handleDrillDownExport}
    />
    </StatisticsErrorBoundary>
    </main>
    );
    }