statistics
This commit is contained in:
491
frontend/src/app/statistics/page.tsx
Normal file
491
frontend/src/app/statistics/page.tsx
Normal file
@@ -0,0 +1,491 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import AppLayout from '@/components/layout/AppLayout';
|
||||
import { statisticsApi, getCurrentLibraryId } from '@/lib/api';
|
||||
import {
|
||||
LibraryOverviewStats,
|
||||
TopTagsStats,
|
||||
TopAuthorsStats,
|
||||
RatingStats,
|
||||
SourceDomainStats,
|
||||
ReadingProgressStats,
|
||||
ReadingActivityStats
|
||||
} from '@/types/api';
|
||||
|
||||
function StatisticsContent() {
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Statistics state
|
||||
const [overviewStats, setOverviewStats] = useState<LibraryOverviewStats | null>(null);
|
||||
const [topTags, setTopTags] = useState<TopTagsStats | null>(null);
|
||||
const [topAuthors, setTopAuthors] = useState<TopAuthorsStats | null>(null);
|
||||
const [ratingStats, setRatingStats] = useState<RatingStats | null>(null);
|
||||
const [sourceDomains, setSourceDomains] = useState<SourceDomainStats | null>(null);
|
||||
const [readingProgress, setReadingProgress] = useState<ReadingProgressStats | null>(null);
|
||||
const [readingActivity, setReadingActivity] = useState<ReadingActivityStats | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadStatistics();
|
||||
}, []);
|
||||
|
||||
const loadStatistics = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const libraryId = getCurrentLibraryId();
|
||||
if (!libraryId) {
|
||||
router.push('/library');
|
||||
return;
|
||||
}
|
||||
|
||||
// Load all statistics in parallel
|
||||
const [overview, tags, authors, ratings, domains, progress, activity] = await Promise.all([
|
||||
statisticsApi.getOverviewStatistics(libraryId),
|
||||
statisticsApi.getTopTags(libraryId, 20),
|
||||
statisticsApi.getTopAuthors(libraryId, 10),
|
||||
statisticsApi.getRatingStats(libraryId),
|
||||
statisticsApi.getSourceDomainStats(libraryId, 10),
|
||||
statisticsApi.getReadingProgress(libraryId),
|
||||
statisticsApi.getReadingActivity(libraryId),
|
||||
]);
|
||||
|
||||
setOverviewStats(overview);
|
||||
setTopTags(tags);
|
||||
setTopAuthors(authors);
|
||||
setRatingStats(ratings);
|
||||
setSourceDomains(domains);
|
||||
setReadingProgress(progress);
|
||||
setReadingActivity(activity);
|
||||
} catch (err) {
|
||||
console.error('Failed to load statistics:', err);
|
||||
setError('Failed to load statistics. Please try again.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatNumber = (num: number): string => {
|
||||
return num.toLocaleString();
|
||||
};
|
||||
|
||||
const formatTime = (minutes: number): string => {
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const mins = Math.round(minutes % 60);
|
||||
|
||||
if (hours > 24) {
|
||||
const days = Math.floor(hours / 24);
|
||||
const remainingHours = hours % 24;
|
||||
return `${days}d ${remainingHours}h`;
|
||||
}
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${mins}m`;
|
||||
}
|
||||
|
||||
return `${mins}m`;
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
||||
<p className="text-gray-600 dark:text-gray-400">Loading statistics...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-6">
|
||||
<h3 className="text-lg font-semibold text-red-800 dark:text-red-200 mb-2">Error</h3>
|
||||
<p className="text-red-600 dark:text-red-400">{error}</p>
|
||||
<button
|
||||
onClick={loadStatistics}
|
||||
className="mt-4 px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700 transition-colors"
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white mb-2">Library Statistics</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Insights and analytics for your story collection
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Collection Overview */}
|
||||
{overviewStats && (
|
||||
<section className="mb-8">
|
||||
<h2 className="text-2xl font-semibold text-gray-800 dark:text-gray-200 mb-4">Collection Overview</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<StatCard title="Total Stories" value={formatNumber(overviewStats.totalStories)} />
|
||||
<StatCard title="Total Authors" value={formatNumber(overviewStats.totalAuthors)} />
|
||||
<StatCard title="Total Series" value={formatNumber(overviewStats.totalSeries)} />
|
||||
<StatCard title="Total Tags" value={formatNumber(overviewStats.totalTags)} />
|
||||
<StatCard title="Total Collections" value={formatNumber(overviewStats.totalCollections)} />
|
||||
<StatCard title="Source Domains" value={formatNumber(overviewStats.uniqueSourceDomains)} />
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Content Metrics */}
|
||||
{overviewStats && (
|
||||
<section className="mb-8">
|
||||
<h2 className="text-2xl font-semibold text-gray-800 dark:text-gray-200 mb-4">Content Metrics</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<StatCard
|
||||
title="Total Words"
|
||||
value={formatNumber(overviewStats.totalWordCount)}
|
||||
subtitle={`${formatTime(overviewStats.totalReadingTimeMinutes)} reading time`}
|
||||
/>
|
||||
<StatCard
|
||||
title="Average Words per Story"
|
||||
value={formatNumber(Math.round(overviewStats.averageWordsPerStory))}
|
||||
subtitle={`${formatTime(overviewStats.averageReadingTimeMinutes)} avg reading time`}
|
||||
/>
|
||||
{overviewStats.longestStory && (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<h3 className="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">Longest Story</h3>
|
||||
<p className="text-2xl font-bold text-gray-900 dark:text-white mb-1">
|
||||
{formatNumber(overviewStats.longestStory.wordCount)} words
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 truncate" title={overviewStats.longestStory.title}>
|
||||
{overviewStats.longestStory.title}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-500">
|
||||
by {overviewStats.longestStory.authorName}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{overviewStats.shortestStory && (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<h3 className="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">Shortest Story</h3>
|
||||
<p className="text-2xl font-bold text-gray-900 dark:text-white mb-1">
|
||||
{formatNumber(overviewStats.shortestStory.wordCount)} words
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 truncate" title={overviewStats.shortestStory.title}>
|
||||
{overviewStats.shortestStory.title}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-500">
|
||||
by {overviewStats.shortestStory.authorName}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Reading Progress & Activity - Side by side */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 mb-8">
|
||||
{/* Reading Progress */}
|
||||
{readingProgress && (
|
||||
<section>
|
||||
<h2 className="text-2xl font-semibold text-gray-800 dark:text-gray-200 mb-4">Reading Progress</h2>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<div className="mb-6">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<span className="text-sm font-medium text-gray-600 dark:text-gray-400">
|
||||
{formatNumber(readingProgress.readStories)} of {formatNumber(readingProgress.totalStories)} stories read
|
||||
</span>
|
||||
<span className="text-sm font-semibold text-blue-600 dark:text-blue-400">
|
||||
{readingProgress.percentageRead.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-3">
|
||||
<div
|
||||
className="bg-blue-600 h-3 rounded-full transition-all duration-500"
|
||||
style={{ width: `${readingProgress.percentageRead}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Words Read</p>
|
||||
<p className="text-xl font-semibold text-green-600 dark:text-green-400">
|
||||
{formatNumber(readingProgress.totalWordsRead)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Words Remaining</p>
|
||||
<p className="text-xl font-semibold text-orange-600 dark:text-orange-400">
|
||||
{formatNumber(readingProgress.totalWordsUnread)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Reading Activity - Last Week */}
|
||||
{readingActivity && (
|
||||
<section>
|
||||
<h2 className="text-2xl font-semibold text-gray-800 dark:text-gray-200 mb-4">Last Week Activity</h2>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<div className="grid grid-cols-3 gap-4 mb-6">
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Stories</p>
|
||||
<p className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{formatNumber(readingActivity.storiesReadLastWeek)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Words</p>
|
||||
<p className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{formatNumber(readingActivity.wordsReadLastWeek)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Time</p>
|
||||
<p className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{formatTime(readingActivity.readingTimeMinutesLastWeek)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Daily Activity Chart */}
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium text-gray-600 dark:text-gray-400 mb-3">Daily Breakdown</p>
|
||||
{readingActivity.dailyActivity.map((day) => {
|
||||
const maxWords = Math.max(...readingActivity.dailyActivity.map(d => d.wordsRead), 1);
|
||||
const percentage = (day.wordsRead / maxWords) * 100;
|
||||
|
||||
return (
|
||||
<div key={day.date} className="flex items-center gap-3">
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 w-20">
|
||||
{new Date(day.date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
|
||||
</span>
|
||||
<div className="flex-1 bg-gray-200 dark:bg-gray-700 rounded-full h-6 relative">
|
||||
<div
|
||||
className="bg-blue-500 h-6 rounded-full transition-all duration-300"
|
||||
style={{ width: `${percentage}%` }}
|
||||
></div>
|
||||
{day.storiesRead > 0 && (
|
||||
<span className="absolute inset-0 flex items-center justify-center text-xs font-medium text-gray-700 dark:text-gray-300">
|
||||
{day.storiesRead} {day.storiesRead === 1 ? 'story' : 'stories'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Ratings & Source Domains - Side by side */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 mb-8">
|
||||
{/* Rating Statistics */}
|
||||
{ratingStats && (
|
||||
<section>
|
||||
<h2 className="text-2xl font-semibold text-gray-800 dark:text-gray-200 mb-4">Rating Statistics</h2>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<div className="text-center mb-6">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1">Average Rating</p>
|
||||
<p className="text-4xl font-bold text-yellow-500">
|
||||
{ratingStats.averageRating.toFixed(1)} ⭐
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-2">
|
||||
{formatNumber(ratingStats.totalRatedStories)} rated • {formatNumber(ratingStats.totalUnratedStories)} unrated
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Rating Distribution */}
|
||||
<div className="space-y-2">
|
||||
{[5, 4, 3, 2, 1].map(rating => {
|
||||
const count = ratingStats.ratingDistribution[rating] || 0;
|
||||
const percentage = ratingStats.totalRatedStories > 0
|
||||
? (count / ratingStats.totalRatedStories) * 100
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div key={rating} className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-gray-600 dark:text-gray-400 w-12">
|
||||
{rating} ⭐
|
||||
</span>
|
||||
<div className="flex-1 bg-gray-200 dark:bg-gray-700 rounded-full h-4">
|
||||
<div
|
||||
className="bg-yellow-500 h-4 rounded-full transition-all duration-300"
|
||||
style={{ width: `${percentage}%` }}
|
||||
></div>
|
||||
</div>
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400 w-16 text-right">
|
||||
{formatNumber(count)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Source Domains */}
|
||||
{sourceDomains && (
|
||||
<section>
|
||||
<h2 className="text-2xl font-semibold text-gray-800 dark:text-gray-200 mb-4">Source Domains</h2>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<div className="grid grid-cols-2 gap-4 mb-6">
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">With Source</p>
|
||||
<p className="text-2xl font-bold text-green-600 dark:text-green-400">
|
||||
{formatNumber(sourceDomains.storiesWithSource)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">No Source</p>
|
||||
<p className="text-2xl font-bold text-gray-500 dark:text-gray-400">
|
||||
{formatNumber(sourceDomains.storiesWithoutSource)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm font-medium text-gray-600 dark:text-gray-400">Top Domains</p>
|
||||
{sourceDomains.topDomains.slice(0, 5).map((domain, index) => (
|
||||
<div key={domain.domain} className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<span className="text-sm font-medium text-gray-500 dark:text-gray-400 w-5">
|
||||
{index + 1}.
|
||||
</span>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300 truncate" title={domain.domain}>
|
||||
{domain.domain}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-sm font-semibold text-blue-600 dark:text-blue-400 ml-2">
|
||||
{formatNumber(domain.storyCount)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Top Tags & Top Authors - Side by side */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
{/* Top Tags */}
|
||||
{topTags && (
|
||||
<section>
|
||||
<h2 className="text-2xl font-semibold text-gray-800 dark:text-gray-200 mb-4">Most Used Tags</h2>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<div className="space-y-3">
|
||||
{topTags.topTags.slice(0, 10).map((tag, index) => {
|
||||
const maxCount = topTags.topTags[0]?.storyCount || 1;
|
||||
const percentage = (tag.storyCount / maxCount) * 100;
|
||||
|
||||
return (
|
||||
<div key={tag.tagName} className="flex items-center gap-3">
|
||||
<span className="text-sm font-medium text-gray-500 dark:text-gray-400 w-6">
|
||||
{index + 1}
|
||||
</span>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{tag.tagName}
|
||||
</span>
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{formatNumber(tag.storyCount)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
||||
<div
|
||||
className="bg-purple-500 h-2 rounded-full transition-all duration-300"
|
||||
style={{ width: `${percentage}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Top Authors */}
|
||||
{topAuthors && (
|
||||
<section>
|
||||
<h2 className="text-2xl font-semibold text-gray-800 dark:text-gray-200 mb-4">Top Authors</h2>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
{/* Tab switcher */}
|
||||
<div className="flex gap-2 mb-4">
|
||||
<button
|
||||
onClick={() => {/* Could add tab switching if needed */}}
|
||||
className="flex-1 px-4 py-2 text-sm font-medium bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 rounded-lg"
|
||||
>
|
||||
By Stories
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {/* Could add tab switching if needed */}}
|
||||
className="flex-1 px-4 py-2 text-sm font-medium text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg"
|
||||
>
|
||||
By Words
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{topAuthors.topAuthorsByStories.slice(0, 5).map((author, index) => (
|
||||
<div key={author.authorId} className="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
<span className="text-lg font-bold text-gray-400 dark:text-gray-500 w-6">
|
||||
{index + 1}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white truncate" title={author.authorName}>
|
||||
{author.authorName}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{formatNumber(author.storyCount)} stories • {formatNumber(author.totalWords)} words
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function StatisticsPage() {
|
||||
return (
|
||||
<AppLayout>
|
||||
<StatisticsContent />
|
||||
</AppLayout>
|
||||
);
|
||||
}
|
||||
|
||||
// Reusable stat card component
|
||||
function StatCard({ title, value, subtitle }: { title: string; value: string; subtitle?: string }) {
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<h3 className="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">{title}</h3>
|
||||
<p className="text-2xl font-bold text-gray-900 dark:text-white">{value}</p>
|
||||
{subtitle && (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">{subtitle}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -75,12 +75,18 @@ export default function Header() {
|
||||
>
|
||||
Collections
|
||||
</Link>
|
||||
<Link
|
||||
href="/authors"
|
||||
<Link
|
||||
href="/authors"
|
||||
className="theme-text hover:theme-accent transition-colors font-medium"
|
||||
>
|
||||
Authors
|
||||
</Link>
|
||||
<Link
|
||||
href="/statistics"
|
||||
className="theme-text hover:theme-accent transition-colors font-medium"
|
||||
>
|
||||
Statistics
|
||||
</Link>
|
||||
<Dropdown
|
||||
trigger="Add Story"
|
||||
items={addStoryItems}
|
||||
@@ -146,13 +152,20 @@ export default function Header() {
|
||||
>
|
||||
Collections
|
||||
</Link>
|
||||
<Link
|
||||
href="/authors"
|
||||
<Link
|
||||
href="/authors"
|
||||
className="theme-text hover:theme-accent transition-colors font-medium px-2 py-1"
|
||||
onClick={() => setIsMenuOpen(false)}
|
||||
>
|
||||
Authors
|
||||
</Link>
|
||||
<Link
|
||||
href="/statistics"
|
||||
className="theme-text hover:theme-accent transition-colors font-medium px-2 py-1"
|
||||
onClick={() => setIsMenuOpen(false)}
|
||||
>
|
||||
Statistics
|
||||
</Link>
|
||||
<div className="px-2 py-1">
|
||||
<div className="font-medium theme-text mb-1">Add Story</div>
|
||||
<div className="pl-4 space-y-1">
|
||||
|
||||
@@ -1096,6 +1096,42 @@ export const statisticsApi = {
|
||||
const response = await api.get(`/libraries/${libraryId}/statistics/overview`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getTopTags: async (libraryId: string, limit: number = 20): Promise<import('../types/api').TopTagsStats> => {
|
||||
const response = await api.get(`/libraries/${libraryId}/statistics/top-tags`, {
|
||||
params: { limit }
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getTopAuthors: async (libraryId: string, limit: number = 10): Promise<import('../types/api').TopAuthorsStats> => {
|
||||
const response = await api.get(`/libraries/${libraryId}/statistics/top-authors`, {
|
||||
params: { limit }
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getRatingStats: async (libraryId: string): Promise<import('../types/api').RatingStats> => {
|
||||
const response = await api.get(`/libraries/${libraryId}/statistics/ratings`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getSourceDomainStats: async (libraryId: string, limit: number = 10): Promise<import('../types/api').SourceDomainStats> => {
|
||||
const response = await api.get(`/libraries/${libraryId}/statistics/source-domains`, {
|
||||
params: { limit }
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getReadingProgress: async (libraryId: string): Promise<import('../types/api').ReadingProgressStats> => {
|
||||
const response = await api.get(`/libraries/${libraryId}/statistics/reading-progress`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getReadingActivity: async (libraryId: string): Promise<import('../types/api').ReadingActivityStats> => {
|
||||
const response = await api.get(`/libraries/${libraryId}/statistics/reading-activity`);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
// Image utility - now library-aware
|
||||
|
||||
@@ -233,4 +233,71 @@ export interface StoryWordCount {
|
||||
authorName: string;
|
||||
wordCount: number;
|
||||
readingTimeMinutes: number;
|
||||
}
|
||||
|
||||
// Top Tags Statistics
|
||||
export interface TopTagsStats {
|
||||
topTags: TagStats[];
|
||||
}
|
||||
|
||||
export interface TagStats {
|
||||
tagName: string;
|
||||
storyCount: number;
|
||||
}
|
||||
|
||||
// Top Authors Statistics
|
||||
export interface TopAuthorsStats {
|
||||
topAuthorsByStories: AuthorStats[];
|
||||
topAuthorsByWords: AuthorStats[];
|
||||
}
|
||||
|
||||
export interface AuthorStats {
|
||||
authorId: string;
|
||||
authorName: string;
|
||||
storyCount: number;
|
||||
totalWords: number;
|
||||
}
|
||||
|
||||
// Rating Statistics
|
||||
export interface RatingStats {
|
||||
averageRating: number;
|
||||
totalRatedStories: number;
|
||||
totalUnratedStories: number;
|
||||
ratingDistribution: Record<number, number>; // rating -> count
|
||||
}
|
||||
|
||||
// Source Domain Statistics
|
||||
export interface SourceDomainStats {
|
||||
topDomains: DomainStats[];
|
||||
storiesWithSource: number;
|
||||
storiesWithoutSource: number;
|
||||
}
|
||||
|
||||
export interface DomainStats {
|
||||
domain: string;
|
||||
storyCount: number;
|
||||
}
|
||||
|
||||
// Reading Progress Statistics
|
||||
export interface ReadingProgressStats {
|
||||
totalStories: number;
|
||||
readStories: number;
|
||||
unreadStories: number;
|
||||
percentageRead: number;
|
||||
totalWordsRead: number;
|
||||
totalWordsUnread: number;
|
||||
}
|
||||
|
||||
// Reading Activity Statistics
|
||||
export interface ReadingActivityStats {
|
||||
storiesReadLastWeek: number;
|
||||
wordsReadLastWeek: number;
|
||||
readingTimeMinutesLastWeek: number;
|
||||
dailyActivity: DailyActivity[];
|
||||
}
|
||||
|
||||
export interface DailyActivity {
|
||||
date: string; // YYYY-MM-DD
|
||||
storiesRead: number;
|
||||
wordsRead: number;
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user