Compare commits
3 Commits
ff49589f32
...
924ae12b5b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
924ae12b5b | ||
|
|
16983fd871 | ||
|
|
378265c3a3 |
@@ -0,0 +1,183 @@
|
|||||||
|
package com.storycove.controller;
|
||||||
|
|
||||||
|
import com.storycove.dto.LibraryOverviewStatsDto;
|
||||||
|
import com.storycove.service.LibraryService;
|
||||||
|
import com.storycove.service.LibraryStatisticsService;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/libraries/{libraryId}/statistics")
|
||||||
|
public class LibraryStatisticsController {
|
||||||
|
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger(LibraryStatisticsController.class);
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private LibraryStatisticsService statisticsService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private LibraryService libraryService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get overview statistics for a library
|
||||||
|
*/
|
||||||
|
@GetMapping("/overview")
|
||||||
|
public ResponseEntity<?> getOverviewStatistics(@PathVariable String libraryId) {
|
||||||
|
try {
|
||||||
|
// Verify library exists
|
||||||
|
if (libraryService.getLibraryById(libraryId) == null) {
|
||||||
|
return ResponseEntity.notFound().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
LibraryOverviewStatsDto stats = statisticsService.getOverviewStatistics(libraryId);
|
||||||
|
return ResponseEntity.ok(stats);
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Failed to get overview statistics for library: {}", libraryId, e);
|
||||||
|
return ResponseEntity.internalServerError()
|
||||||
|
.body(new ErrorResponse("Failed to retrieve statistics: " + e.getMessage()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get top tags statistics
|
||||||
|
*/
|
||||||
|
@GetMapping("/top-tags")
|
||||||
|
public ResponseEntity<?> getTopTagsStatistics(
|
||||||
|
@PathVariable String libraryId,
|
||||||
|
@RequestParam(defaultValue = "20") int limit) {
|
||||||
|
try {
|
||||||
|
if (libraryService.getLibraryById(libraryId) == null) {
|
||||||
|
return ResponseEntity.notFound().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
var stats = statisticsService.getTopTagsStatistics(libraryId, limit);
|
||||||
|
return ResponseEntity.ok(stats);
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Failed to get top tags statistics for library: {}", libraryId, e);
|
||||||
|
return ResponseEntity.internalServerError()
|
||||||
|
.body(new ErrorResponse("Failed to retrieve statistics: " + e.getMessage()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get top authors statistics
|
||||||
|
*/
|
||||||
|
@GetMapping("/top-authors")
|
||||||
|
public ResponseEntity<?> getTopAuthorsStatistics(
|
||||||
|
@PathVariable String libraryId,
|
||||||
|
@RequestParam(defaultValue = "10") int limit) {
|
||||||
|
try {
|
||||||
|
if (libraryService.getLibraryById(libraryId) == null) {
|
||||||
|
return ResponseEntity.notFound().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
var stats = statisticsService.getTopAuthorsStatistics(libraryId, limit);
|
||||||
|
return ResponseEntity.ok(stats);
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Failed to get top authors statistics for library: {}", libraryId, e);
|
||||||
|
return ResponseEntity.internalServerError()
|
||||||
|
.body(new ErrorResponse("Failed to retrieve statistics: " + e.getMessage()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get rating statistics
|
||||||
|
*/
|
||||||
|
@GetMapping("/ratings")
|
||||||
|
public ResponseEntity<?> getRatingStatistics(@PathVariable String libraryId) {
|
||||||
|
try {
|
||||||
|
if (libraryService.getLibraryById(libraryId) == null) {
|
||||||
|
return ResponseEntity.notFound().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
var stats = statisticsService.getRatingStatistics(libraryId);
|
||||||
|
return ResponseEntity.ok(stats);
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Failed to get rating statistics for library: {}", libraryId, e);
|
||||||
|
return ResponseEntity.internalServerError()
|
||||||
|
.body(new ErrorResponse("Failed to retrieve statistics: " + e.getMessage()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get source domain statistics
|
||||||
|
*/
|
||||||
|
@GetMapping("/source-domains")
|
||||||
|
public ResponseEntity<?> getSourceDomainStatistics(
|
||||||
|
@PathVariable String libraryId,
|
||||||
|
@RequestParam(defaultValue = "10") int limit) {
|
||||||
|
try {
|
||||||
|
if (libraryService.getLibraryById(libraryId) == null) {
|
||||||
|
return ResponseEntity.notFound().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
var stats = statisticsService.getSourceDomainStatistics(libraryId, limit);
|
||||||
|
return ResponseEntity.ok(stats);
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Failed to get source domain statistics for library: {}", libraryId, e);
|
||||||
|
return ResponseEntity.internalServerError()
|
||||||
|
.body(new ErrorResponse("Failed to retrieve statistics: " + e.getMessage()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get reading progress statistics
|
||||||
|
*/
|
||||||
|
@GetMapping("/reading-progress")
|
||||||
|
public ResponseEntity<?> getReadingProgressStatistics(@PathVariable String libraryId) {
|
||||||
|
try {
|
||||||
|
if (libraryService.getLibraryById(libraryId) == null) {
|
||||||
|
return ResponseEntity.notFound().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
var stats = statisticsService.getReadingProgressStatistics(libraryId);
|
||||||
|
return ResponseEntity.ok(stats);
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Failed to get reading progress statistics for library: {}", libraryId, e);
|
||||||
|
return ResponseEntity.internalServerError()
|
||||||
|
.body(new ErrorResponse("Failed to retrieve statistics: " + e.getMessage()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get reading activity statistics (last week)
|
||||||
|
*/
|
||||||
|
@GetMapping("/reading-activity")
|
||||||
|
public ResponseEntity<?> getReadingActivityStatistics(@PathVariable String libraryId) {
|
||||||
|
try {
|
||||||
|
if (libraryService.getLibraryById(libraryId) == null) {
|
||||||
|
return ResponseEntity.notFound().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
var stats = statisticsService.getReadingActivityStatistics(libraryId);
|
||||||
|
return ResponseEntity.ok(stats);
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Failed to get reading activity statistics for library: {}", libraryId, e);
|
||||||
|
return ResponseEntity.internalServerError()
|
||||||
|
.body(new ErrorResponse("Failed to retrieve statistics: " + e.getMessage()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error response DTO
|
||||||
|
private static class ErrorResponse {
|
||||||
|
private String error;
|
||||||
|
|
||||||
|
public ErrorResponse(String error) {
|
||||||
|
this.error = error;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getError() {
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
package com.storycove.dto;
|
||||||
|
|
||||||
|
public class LibraryOverviewStatsDto {
|
||||||
|
|
||||||
|
// Collection Overview
|
||||||
|
private long totalStories;
|
||||||
|
private long totalAuthors;
|
||||||
|
private long totalSeries;
|
||||||
|
private long totalTags;
|
||||||
|
private long totalCollections;
|
||||||
|
private long uniqueSourceDomains;
|
||||||
|
|
||||||
|
// Content Metrics
|
||||||
|
private long totalWordCount;
|
||||||
|
private double averageWordsPerStory;
|
||||||
|
private StoryWordCountDto longestStory;
|
||||||
|
private StoryWordCountDto shortestStory;
|
||||||
|
|
||||||
|
// Reading Time (based on 250 words/minute)
|
||||||
|
private long totalReadingTimeMinutes;
|
||||||
|
private double averageReadingTimeMinutes;
|
||||||
|
|
||||||
|
// Constructor
|
||||||
|
public LibraryOverviewStatsDto() {
|
||||||
|
}
|
||||||
|
|
||||||
|
// Getters and Setters
|
||||||
|
public long getTotalStories() {
|
||||||
|
return totalStories;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTotalStories(long totalStories) {
|
||||||
|
this.totalStories = totalStories;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getTotalAuthors() {
|
||||||
|
return totalAuthors;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTotalAuthors(long totalAuthors) {
|
||||||
|
this.totalAuthors = totalAuthors;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getTotalSeries() {
|
||||||
|
return totalSeries;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTotalSeries(long totalSeries) {
|
||||||
|
this.totalSeries = totalSeries;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getTotalTags() {
|
||||||
|
return totalTags;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTotalTags(long totalTags) {
|
||||||
|
this.totalTags = totalTags;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getTotalCollections() {
|
||||||
|
return totalCollections;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTotalCollections(long totalCollections) {
|
||||||
|
this.totalCollections = totalCollections;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getUniqueSourceDomains() {
|
||||||
|
return uniqueSourceDomains;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setUniqueSourceDomains(long uniqueSourceDomains) {
|
||||||
|
this.uniqueSourceDomains = uniqueSourceDomains;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getTotalWordCount() {
|
||||||
|
return totalWordCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTotalWordCount(long totalWordCount) {
|
||||||
|
this.totalWordCount = totalWordCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public double getAverageWordsPerStory() {
|
||||||
|
return averageWordsPerStory;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAverageWordsPerStory(double averageWordsPerStory) {
|
||||||
|
this.averageWordsPerStory = averageWordsPerStory;
|
||||||
|
}
|
||||||
|
|
||||||
|
public StoryWordCountDto getLongestStory() {
|
||||||
|
return longestStory;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setLongestStory(StoryWordCountDto longestStory) {
|
||||||
|
this.longestStory = longestStory;
|
||||||
|
}
|
||||||
|
|
||||||
|
public StoryWordCountDto getShortestStory() {
|
||||||
|
return shortestStory;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setShortestStory(StoryWordCountDto shortestStory) {
|
||||||
|
this.shortestStory = shortestStory;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getTotalReadingTimeMinutes() {
|
||||||
|
return totalReadingTimeMinutes;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTotalReadingTimeMinutes(long totalReadingTimeMinutes) {
|
||||||
|
this.totalReadingTimeMinutes = totalReadingTimeMinutes;
|
||||||
|
}
|
||||||
|
|
||||||
|
public double getAverageReadingTimeMinutes() {
|
||||||
|
return averageReadingTimeMinutes;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAverageReadingTimeMinutes(double averageReadingTimeMinutes) {
|
||||||
|
this.averageReadingTimeMinutes = averageReadingTimeMinutes;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nested DTO for story word count info
|
||||||
|
public static class StoryWordCountDto {
|
||||||
|
private String id;
|
||||||
|
private String title;
|
||||||
|
private String authorName;
|
||||||
|
private int wordCount;
|
||||||
|
private long readingTimeMinutes;
|
||||||
|
|
||||||
|
public StoryWordCountDto() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public StoryWordCountDto(String id, String title, String authorName, int wordCount, long readingTimeMinutes) {
|
||||||
|
this.id = id;
|
||||||
|
this.title = title;
|
||||||
|
this.authorName = authorName;
|
||||||
|
this.wordCount = wordCount;
|
||||||
|
this.readingTimeMinutes = readingTimeMinutes;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setId(String id) {
|
||||||
|
this.id = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getTitle() {
|
||||||
|
return title;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTitle(String title) {
|
||||||
|
this.title = title;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getAuthorName() {
|
||||||
|
return authorName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAuthorName(String authorName) {
|
||||||
|
this.authorName = authorName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getWordCount() {
|
||||||
|
return wordCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setWordCount(int wordCount) {
|
||||||
|
this.wordCount = wordCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getReadingTimeMinutes() {
|
||||||
|
return readingTimeMinutes;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setReadingTimeMinutes(long readingTimeMinutes) {
|
||||||
|
this.readingTimeMinutes = readingTimeMinutes;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
45
backend/src/main/java/com/storycove/dto/RatingStatsDto.java
Normal file
45
backend/src/main/java/com/storycove/dto/RatingStatsDto.java
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
package com.storycove.dto;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
public class RatingStatsDto {
|
||||||
|
private double averageRating;
|
||||||
|
private long totalRatedStories;
|
||||||
|
private long totalUnratedStories;
|
||||||
|
private Map<Integer, Long> ratingDistribution; // rating (1-5) -> count
|
||||||
|
|
||||||
|
public RatingStatsDto() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public double getAverageRating() {
|
||||||
|
return averageRating;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAverageRating(double averageRating) {
|
||||||
|
this.averageRating = averageRating;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getTotalRatedStories() {
|
||||||
|
return totalRatedStories;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTotalRatedStories(long totalRatedStories) {
|
||||||
|
this.totalRatedStories = totalRatedStories;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getTotalUnratedStories() {
|
||||||
|
return totalUnratedStories;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTotalUnratedStories(long totalUnratedStories) {
|
||||||
|
this.totalUnratedStories = totalUnratedStories;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<Integer, Long> getRatingDistribution() {
|
||||||
|
return ratingDistribution;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setRatingDistribution(Map<Integer, Long> ratingDistribution) {
|
||||||
|
this.ratingDistribution = ratingDistribution;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
package com.storycove.dto;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public class ReadingActivityStatsDto {
|
||||||
|
private long storiesReadLastWeek;
|
||||||
|
private long wordsReadLastWeek;
|
||||||
|
private long readingTimeMinutesLastWeek;
|
||||||
|
private List<DailyActivityDto> dailyActivity;
|
||||||
|
|
||||||
|
public ReadingActivityStatsDto() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getStoriesReadLastWeek() {
|
||||||
|
return storiesReadLastWeek;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setStoriesReadLastWeek(long storiesReadLastWeek) {
|
||||||
|
this.storiesReadLastWeek = storiesReadLastWeek;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getWordsReadLastWeek() {
|
||||||
|
return wordsReadLastWeek;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setWordsReadLastWeek(long wordsReadLastWeek) {
|
||||||
|
this.wordsReadLastWeek = wordsReadLastWeek;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getReadingTimeMinutesLastWeek() {
|
||||||
|
return readingTimeMinutesLastWeek;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setReadingTimeMinutesLastWeek(long readingTimeMinutesLastWeek) {
|
||||||
|
this.readingTimeMinutesLastWeek = readingTimeMinutesLastWeek;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<DailyActivityDto> getDailyActivity() {
|
||||||
|
return dailyActivity;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDailyActivity(List<DailyActivityDto> dailyActivity) {
|
||||||
|
this.dailyActivity = dailyActivity;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class DailyActivityDto {
|
||||||
|
private String date; // YYYY-MM-DD format
|
||||||
|
private long storiesRead;
|
||||||
|
private long wordsRead;
|
||||||
|
|
||||||
|
public DailyActivityDto() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public DailyActivityDto(String date, long storiesRead, long wordsRead) {
|
||||||
|
this.date = date;
|
||||||
|
this.storiesRead = storiesRead;
|
||||||
|
this.wordsRead = wordsRead;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getDate() {
|
||||||
|
return date;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDate(String date) {
|
||||||
|
this.date = date;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getStoriesRead() {
|
||||||
|
return storiesRead;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setStoriesRead(long storiesRead) {
|
||||||
|
this.storiesRead = storiesRead;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getWordsRead() {
|
||||||
|
return wordsRead;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setWordsRead(long wordsRead) {
|
||||||
|
this.wordsRead = wordsRead;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package com.storycove.dto;
|
||||||
|
|
||||||
|
public class ReadingProgressStatsDto {
|
||||||
|
private long totalStories;
|
||||||
|
private long readStories;
|
||||||
|
private long unreadStories;
|
||||||
|
private double percentageRead;
|
||||||
|
private long totalWordsRead;
|
||||||
|
private long totalWordsUnread;
|
||||||
|
|
||||||
|
public ReadingProgressStatsDto() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getTotalStories() {
|
||||||
|
return totalStories;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTotalStories(long totalStories) {
|
||||||
|
this.totalStories = totalStories;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getReadStories() {
|
||||||
|
return readStories;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setReadStories(long readStories) {
|
||||||
|
this.readStories = readStories;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getUnreadStories() {
|
||||||
|
return unreadStories;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setUnreadStories(long unreadStories) {
|
||||||
|
this.unreadStories = unreadStories;
|
||||||
|
}
|
||||||
|
|
||||||
|
public double getPercentageRead() {
|
||||||
|
return percentageRead;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPercentageRead(double percentageRead) {
|
||||||
|
this.percentageRead = percentageRead;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getTotalWordsRead() {
|
||||||
|
return totalWordsRead;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTotalWordsRead(long totalWordsRead) {
|
||||||
|
this.totalWordsRead = totalWordsRead;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getTotalWordsUnread() {
|
||||||
|
return totalWordsUnread;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTotalWordsUnread(long totalWordsUnread) {
|
||||||
|
this.totalWordsUnread = totalWordsUnread;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package com.storycove.dto;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public class SourceDomainStatsDto {
|
||||||
|
private List<DomainStatsDto> topDomains;
|
||||||
|
private long storiesWithSource;
|
||||||
|
private long storiesWithoutSource;
|
||||||
|
|
||||||
|
public SourceDomainStatsDto() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<DomainStatsDto> getTopDomains() {
|
||||||
|
return topDomains;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTopDomains(List<DomainStatsDto> topDomains) {
|
||||||
|
this.topDomains = topDomains;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getStoriesWithSource() {
|
||||||
|
return storiesWithSource;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setStoriesWithSource(long storiesWithSource) {
|
||||||
|
this.storiesWithSource = storiesWithSource;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getStoriesWithoutSource() {
|
||||||
|
return storiesWithoutSource;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setStoriesWithoutSource(long storiesWithoutSource) {
|
||||||
|
this.storiesWithoutSource = storiesWithoutSource;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class DomainStatsDto {
|
||||||
|
private String domain;
|
||||||
|
private long storyCount;
|
||||||
|
|
||||||
|
public DomainStatsDto() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public DomainStatsDto(String domain, long storyCount) {
|
||||||
|
this.domain = domain;
|
||||||
|
this.storyCount = storyCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getDomain() {
|
||||||
|
return domain;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDomain(String domain) {
|
||||||
|
this.domain = domain;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getStoryCount() {
|
||||||
|
return storyCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setStoryCount(long storyCount) {
|
||||||
|
this.storyCount = storyCount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package com.storycove.dto;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public class TopAuthorsStatsDto {
|
||||||
|
private List<AuthorStatsDto> topAuthorsByStories;
|
||||||
|
private List<AuthorStatsDto> topAuthorsByWords;
|
||||||
|
|
||||||
|
public TopAuthorsStatsDto() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<AuthorStatsDto> getTopAuthorsByStories() {
|
||||||
|
return topAuthorsByStories;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTopAuthorsByStories(List<AuthorStatsDto> topAuthorsByStories) {
|
||||||
|
this.topAuthorsByStories = topAuthorsByStories;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<AuthorStatsDto> getTopAuthorsByWords() {
|
||||||
|
return topAuthorsByWords;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTopAuthorsByWords(List<AuthorStatsDto> topAuthorsByWords) {
|
||||||
|
this.topAuthorsByWords = topAuthorsByWords;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class AuthorStatsDto {
|
||||||
|
private String authorId;
|
||||||
|
private String authorName;
|
||||||
|
private long storyCount;
|
||||||
|
private long totalWords;
|
||||||
|
|
||||||
|
public AuthorStatsDto() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public AuthorStatsDto(String authorId, String authorName, long storyCount, long totalWords) {
|
||||||
|
this.authorId = authorId;
|
||||||
|
this.authorName = authorName;
|
||||||
|
this.storyCount = storyCount;
|
||||||
|
this.totalWords = totalWords;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getAuthorId() {
|
||||||
|
return authorId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAuthorId(String authorId) {
|
||||||
|
this.authorId = authorId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getAuthorName() {
|
||||||
|
return authorName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAuthorName(String authorName) {
|
||||||
|
this.authorName = authorName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getStoryCount() {
|
||||||
|
return storyCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setStoryCount(long storyCount) {
|
||||||
|
this.storyCount = storyCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getTotalWords() {
|
||||||
|
return totalWords;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTotalWords(long totalWords) {
|
||||||
|
this.totalWords = totalWords;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
51
backend/src/main/java/com/storycove/dto/TopTagsStatsDto.java
Normal file
51
backend/src/main/java/com/storycove/dto/TopTagsStatsDto.java
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
package com.storycove.dto;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public class TopTagsStatsDto {
|
||||||
|
private List<TagStatsDto> topTags;
|
||||||
|
|
||||||
|
public TopTagsStatsDto() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public TopTagsStatsDto(List<TagStatsDto> topTags) {
|
||||||
|
this.topTags = topTags;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<TagStatsDto> getTopTags() {
|
||||||
|
return topTags;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTopTags(List<TagStatsDto> topTags) {
|
||||||
|
this.topTags = topTags;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class TagStatsDto {
|
||||||
|
private String tagName;
|
||||||
|
private long storyCount;
|
||||||
|
|
||||||
|
public TagStatsDto() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public TagStatsDto(String tagName, long storyCount) {
|
||||||
|
this.tagName = tagName;
|
||||||
|
this.storyCount = storyCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getTagName() {
|
||||||
|
return tagName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTagName(String tagName) {
|
||||||
|
this.tagName = tagName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getStoryCount() {
|
||||||
|
return storyCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setStoryCount(long storyCount) {
|
||||||
|
this.storyCount = storyCount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,643 @@
|
|||||||
|
package com.storycove.service;
|
||||||
|
|
||||||
|
import com.storycove.config.SolrProperties;
|
||||||
|
import com.storycove.dto.*;
|
||||||
|
import com.storycove.dto.LibraryOverviewStatsDto.StoryWordCountDto;
|
||||||
|
import com.storycove.repository.CollectionRepository;
|
||||||
|
import org.apache.solr.client.solrj.SolrClient;
|
||||||
|
import org.apache.solr.client.solrj.SolrQuery;
|
||||||
|
import org.apache.solr.client.solrj.SolrServerException;
|
||||||
|
import org.apache.solr.client.solrj.response.FacetField;
|
||||||
|
import org.apache.solr.client.solrj.response.QueryResponse;
|
||||||
|
import org.apache.solr.common.SolrDocument;
|
||||||
|
import org.apache.solr.common.params.StatsParams;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.ZoneOffset;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.*;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@ConditionalOnProperty(
|
||||||
|
value = "storycove.search.engine",
|
||||||
|
havingValue = "solr",
|
||||||
|
matchIfMissing = false
|
||||||
|
)
|
||||||
|
public class LibraryStatisticsService {
|
||||||
|
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger(LibraryStatisticsService.class);
|
||||||
|
private static final int WORDS_PER_MINUTE = 250;
|
||||||
|
|
||||||
|
@Autowired(required = false)
|
||||||
|
private SolrClient solrClient;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private SolrProperties properties;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private LibraryService libraryService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private CollectionRepository collectionRepository;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get overview statistics for a library
|
||||||
|
*/
|
||||||
|
public LibraryOverviewStatsDto getOverviewStatistics(String libraryId) throws IOException, SolrServerException {
|
||||||
|
LibraryOverviewStatsDto stats = new LibraryOverviewStatsDto();
|
||||||
|
|
||||||
|
// Collection Overview
|
||||||
|
stats.setTotalStories(getTotalStories(libraryId));
|
||||||
|
stats.setTotalAuthors(getTotalAuthors(libraryId));
|
||||||
|
stats.setTotalSeries(getTotalSeries(libraryId));
|
||||||
|
stats.setTotalTags(getTotalTags(libraryId));
|
||||||
|
stats.setTotalCollections(getTotalCollections(libraryId));
|
||||||
|
stats.setUniqueSourceDomains(getUniqueSourceDomains(libraryId));
|
||||||
|
|
||||||
|
// Content Metrics - use Solr Stats Component
|
||||||
|
WordCountStats wordStats = getWordCountStatistics(libraryId);
|
||||||
|
stats.setTotalWordCount(wordStats.sum);
|
||||||
|
stats.setAverageWordsPerStory(wordStats.mean);
|
||||||
|
stats.setLongestStory(getLongestStory(libraryId));
|
||||||
|
stats.setShortestStory(getShortestStory(libraryId));
|
||||||
|
|
||||||
|
// Reading Time
|
||||||
|
stats.setTotalReadingTimeMinutes(wordStats.sum / WORDS_PER_MINUTE);
|
||||||
|
stats.setAverageReadingTimeMinutes(wordStats.mean / WORDS_PER_MINUTE);
|
||||||
|
|
||||||
|
return stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get total number of stories in library
|
||||||
|
*/
|
||||||
|
private long getTotalStories(String libraryId) throws IOException, SolrServerException {
|
||||||
|
SolrQuery query = new SolrQuery("*:*");
|
||||||
|
query.addFilterQuery("libraryId:" + libraryId);
|
||||||
|
query.setRows(0); // We only want the count
|
||||||
|
|
||||||
|
QueryResponse response = solrClient.query(properties.getCores().getStories(), query);
|
||||||
|
return response.getResults().getNumFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get total number of authors in library
|
||||||
|
*/
|
||||||
|
private long getTotalAuthors(String libraryId) throws IOException, SolrServerException {
|
||||||
|
SolrQuery query = new SolrQuery("*:*");
|
||||||
|
query.addFilterQuery("libraryId:" + libraryId);
|
||||||
|
query.setRows(0);
|
||||||
|
|
||||||
|
QueryResponse response = solrClient.query(properties.getCores().getAuthors(), query);
|
||||||
|
return response.getResults().getNumFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get total number of series using faceting on seriesId
|
||||||
|
*/
|
||||||
|
private long getTotalSeries(String libraryId) throws IOException, SolrServerException {
|
||||||
|
SolrQuery query = new SolrQuery("*:*");
|
||||||
|
query.addFilterQuery("libraryId:" + libraryId);
|
||||||
|
query.addFilterQuery("seriesId:[* TO *]"); // Only stories that have a series
|
||||||
|
query.setRows(0);
|
||||||
|
query.setFacet(true);
|
||||||
|
query.addFacetField("seriesId");
|
||||||
|
query.setFacetLimit(-1); // Get all unique series
|
||||||
|
|
||||||
|
QueryResponse response = solrClient.query(properties.getCores().getStories(), query);
|
||||||
|
FacetField seriesFacet = response.getFacetField("seriesId");
|
||||||
|
|
||||||
|
return (seriesFacet != null && seriesFacet.getValues() != null)
|
||||||
|
? seriesFacet.getValueCount()
|
||||||
|
: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get total number of unique tags using faceting
|
||||||
|
*/
|
||||||
|
private long getTotalTags(String libraryId) throws IOException, SolrServerException {
|
||||||
|
SolrQuery query = new SolrQuery("*:*");
|
||||||
|
query.addFilterQuery("libraryId:" + libraryId);
|
||||||
|
query.setRows(0);
|
||||||
|
query.setFacet(true);
|
||||||
|
query.addFacetField("tagNames");
|
||||||
|
query.setFacetLimit(-1); // Get all unique tags
|
||||||
|
|
||||||
|
QueryResponse response = solrClient.query(properties.getCores().getStories(), query);
|
||||||
|
FacetField tagsFacet = response.getFacetField("tagNames");
|
||||||
|
|
||||||
|
return (tagsFacet != null && tagsFacet.getValues() != null)
|
||||||
|
? tagsFacet.getValueCount()
|
||||||
|
: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get total number of collections
|
||||||
|
*/
|
||||||
|
private long getTotalCollections(String libraryId) {
|
||||||
|
// Collections are stored in the database, not indexed in Solr
|
||||||
|
return collectionRepository.countByIsArchivedFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get number of unique source domains using faceting
|
||||||
|
*/
|
||||||
|
private long getUniqueSourceDomains(String libraryId) throws IOException, SolrServerException {
|
||||||
|
SolrQuery query = new SolrQuery("*:*");
|
||||||
|
query.addFilterQuery("libraryId:" + libraryId);
|
||||||
|
query.addFilterQuery("sourceDomain:[* TO *]"); // Only stories with a source domain
|
||||||
|
query.setRows(0);
|
||||||
|
query.setFacet(true);
|
||||||
|
query.addFacetField("sourceDomain");
|
||||||
|
query.setFacetLimit(-1);
|
||||||
|
|
||||||
|
QueryResponse response = solrClient.query(properties.getCores().getStories(), query);
|
||||||
|
FacetField domainFacet = response.getFacetField("sourceDomain");
|
||||||
|
|
||||||
|
return (domainFacet != null && domainFacet.getValues() != null)
|
||||||
|
? domainFacet.getValueCount()
|
||||||
|
: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get word count statistics using Solr Stats Component
|
||||||
|
*/
|
||||||
|
private WordCountStats getWordCountStatistics(String libraryId) throws IOException, SolrServerException {
|
||||||
|
SolrQuery query = new SolrQuery("*:*");
|
||||||
|
query.addFilterQuery("libraryId:" + libraryId);
|
||||||
|
query.setRows(0);
|
||||||
|
query.setParam(StatsParams.STATS, true);
|
||||||
|
query.setParam(StatsParams.STATS_FIELD, "wordCount");
|
||||||
|
|
||||||
|
QueryResponse response = solrClient.query(properties.getCores().getStories(), query);
|
||||||
|
|
||||||
|
WordCountStats stats = new WordCountStats();
|
||||||
|
|
||||||
|
// Extract stats from response
|
||||||
|
var fieldStatsInfo = response.getFieldStatsInfo();
|
||||||
|
if (fieldStatsInfo != null && fieldStatsInfo.get("wordCount") != null) {
|
||||||
|
var fieldStat = fieldStatsInfo.get("wordCount");
|
||||||
|
|
||||||
|
Object sumObj = fieldStat.getSum();
|
||||||
|
Object meanObj = fieldStat.getMean();
|
||||||
|
|
||||||
|
stats.sum = (sumObj != null) ? ((Number) sumObj).longValue() : 0L;
|
||||||
|
stats.mean = (meanObj != null) ? ((Number) meanObj).doubleValue() : 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the longest story in the library
|
||||||
|
*/
|
||||||
|
private StoryWordCountDto getLongestStory(String libraryId) throws IOException, SolrServerException {
|
||||||
|
SolrQuery query = new SolrQuery("*:*");
|
||||||
|
query.addFilterQuery("libraryId:" + libraryId);
|
||||||
|
query.addFilterQuery("wordCount:[1 TO *]"); // Exclude stories with 0 words
|
||||||
|
query.setSort("wordCount", SolrQuery.ORDER.desc);
|
||||||
|
query.setRows(1);
|
||||||
|
query.setFields("id", "title", "authorName", "wordCount");
|
||||||
|
|
||||||
|
QueryResponse response = solrClient.query(properties.getCores().getStories(), query);
|
||||||
|
|
||||||
|
if (response.getResults().isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
SolrDocument doc = response.getResults().get(0);
|
||||||
|
return createStoryWordCountDto(doc);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the shortest story in the library (excluding 0 word count)
|
||||||
|
*/
|
||||||
|
private StoryWordCountDto getShortestStory(String libraryId) throws IOException, SolrServerException {
|
||||||
|
SolrQuery query = new SolrQuery("*:*");
|
||||||
|
query.addFilterQuery("libraryId:" + libraryId);
|
||||||
|
query.addFilterQuery("wordCount:[1 TO *]"); // Exclude stories with 0 words
|
||||||
|
query.setSort("wordCount", SolrQuery.ORDER.asc);
|
||||||
|
query.setRows(1);
|
||||||
|
query.setFields("id", "title", "authorName", "wordCount");
|
||||||
|
|
||||||
|
QueryResponse response = solrClient.query(properties.getCores().getStories(), query);
|
||||||
|
|
||||||
|
if (response.getResults().isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
SolrDocument doc = response.getResults().get(0);
|
||||||
|
return createStoryWordCountDto(doc);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper method to create StoryWordCountDto from Solr document
|
||||||
|
*/
|
||||||
|
private StoryWordCountDto createStoryWordCountDto(SolrDocument doc) {
|
||||||
|
String id = (String) doc.getFieldValue("id");
|
||||||
|
String title = (String) doc.getFieldValue("title");
|
||||||
|
String authorName = (String) doc.getFieldValue("authorName");
|
||||||
|
Object wordCountObj = doc.getFieldValue("wordCount");
|
||||||
|
int wordCount = (wordCountObj != null) ? ((Number) wordCountObj).intValue() : 0;
|
||||||
|
long readingTime = wordCount / WORDS_PER_MINUTE;
|
||||||
|
|
||||||
|
return new StoryWordCountDto(id, title, authorName, wordCount, readingTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper class to hold word count statistics
|
||||||
|
*/
|
||||||
|
private static class WordCountStats {
|
||||||
|
long sum = 0;
|
||||||
|
double mean = 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get top tags statistics
|
||||||
|
*/
|
||||||
|
public TopTagsStatsDto getTopTagsStatistics(String libraryId, int limit) throws IOException, SolrServerException {
|
||||||
|
SolrQuery query = new SolrQuery("*:*");
|
||||||
|
query.addFilterQuery("libraryId:" + libraryId);
|
||||||
|
query.setRows(0);
|
||||||
|
query.setFacet(true);
|
||||||
|
query.addFacetField("tagNames");
|
||||||
|
query.setFacetLimit(limit);
|
||||||
|
query.setFacetSort("count"); // Sort by count (most popular first)
|
||||||
|
|
||||||
|
QueryResponse response = solrClient.query(properties.getCores().getStories(), query);
|
||||||
|
FacetField tagsFacet = response.getFacetField("tagNames");
|
||||||
|
|
||||||
|
List<TopTagsStatsDto.TagStatsDto> topTags = new ArrayList<>();
|
||||||
|
if (tagsFacet != null && tagsFacet.getValues() != null) {
|
||||||
|
for (FacetField.Count count : tagsFacet.getValues()) {
|
||||||
|
topTags.add(new TopTagsStatsDto.TagStatsDto(count.getName(), count.getCount()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new TopTagsStatsDto(topTags);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get top authors statistics
|
||||||
|
*/
|
||||||
|
public TopAuthorsStatsDto getTopAuthorsStatistics(String libraryId, int limit) throws IOException, SolrServerException {
|
||||||
|
TopAuthorsStatsDto stats = new TopAuthorsStatsDto();
|
||||||
|
|
||||||
|
// Top authors by story count
|
||||||
|
stats.setTopAuthorsByStories(getTopAuthorsByStoryCount(libraryId, limit));
|
||||||
|
|
||||||
|
// Top authors by total words
|
||||||
|
stats.setTopAuthorsByWords(getTopAuthorsByWordCount(libraryId, limit));
|
||||||
|
|
||||||
|
return stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<TopAuthorsStatsDto.AuthorStatsDto> getTopAuthorsByStoryCount(String libraryId, int limit)
|
||||||
|
throws IOException, SolrServerException {
|
||||||
|
SolrQuery query = new SolrQuery("*:*");
|
||||||
|
query.addFilterQuery("libraryId:" + libraryId);
|
||||||
|
query.setRows(0);
|
||||||
|
query.setFacet(true);
|
||||||
|
query.addFacetField("authorId");
|
||||||
|
query.setFacetLimit(limit);
|
||||||
|
query.setFacetSort("count");
|
||||||
|
|
||||||
|
QueryResponse response = solrClient.query(properties.getCores().getStories(), query);
|
||||||
|
FacetField authorFacet = response.getFacetField("authorId");
|
||||||
|
|
||||||
|
List<TopAuthorsStatsDto.AuthorStatsDto> topAuthors = new ArrayList<>();
|
||||||
|
if (authorFacet != null && authorFacet.getValues() != null) {
|
||||||
|
for (FacetField.Count count : authorFacet.getValues()) {
|
||||||
|
String authorId = count.getName();
|
||||||
|
long storyCount = count.getCount();
|
||||||
|
|
||||||
|
// Get author name and total words
|
||||||
|
SolrQuery authorQuery = new SolrQuery("authorId:" + authorId);
|
||||||
|
authorQuery.addFilterQuery("libraryId:" + libraryId);
|
||||||
|
authorQuery.setRows(1);
|
||||||
|
authorQuery.setFields("authorName");
|
||||||
|
|
||||||
|
QueryResponse authorResponse = solrClient.query(properties.getCores().getStories(), authorQuery);
|
||||||
|
String authorName = "";
|
||||||
|
if (!authorResponse.getResults().isEmpty()) {
|
||||||
|
authorName = (String) authorResponse.getResults().get(0).getFieldValue("authorName");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get total words for this author
|
||||||
|
long totalWords = getAuthorTotalWords(libraryId, authorId);
|
||||||
|
|
||||||
|
topAuthors.add(new TopAuthorsStatsDto.AuthorStatsDto(authorId, authorName, storyCount, totalWords));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return topAuthors;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<TopAuthorsStatsDto.AuthorStatsDto> getTopAuthorsByWordCount(String libraryId, int limit)
|
||||||
|
throws IOException, SolrServerException {
|
||||||
|
// First get all unique authors
|
||||||
|
SolrQuery query = new SolrQuery("*:*");
|
||||||
|
query.addFilterQuery("libraryId:" + libraryId);
|
||||||
|
query.setRows(0);
|
||||||
|
query.setFacet(true);
|
||||||
|
query.addFacetField("authorId");
|
||||||
|
query.setFacetLimit(-1); // Get all authors
|
||||||
|
query.setFacetSort("count");
|
||||||
|
|
||||||
|
QueryResponse response = solrClient.query(properties.getCores().getStories(), query);
|
||||||
|
FacetField authorFacet = response.getFacetField("authorId");
|
||||||
|
|
||||||
|
List<TopAuthorsStatsDto.AuthorStatsDto> allAuthors = new ArrayList<>();
|
||||||
|
if (authorFacet != null && authorFacet.getValues() != null) {
|
||||||
|
for (FacetField.Count count : authorFacet.getValues()) {
|
||||||
|
String authorId = count.getName();
|
||||||
|
long storyCount = count.getCount();
|
||||||
|
|
||||||
|
// Get author name
|
||||||
|
SolrQuery authorQuery = new SolrQuery("authorId:" + authorId);
|
||||||
|
authorQuery.addFilterQuery("libraryId:" + libraryId);
|
||||||
|
authorQuery.setRows(1);
|
||||||
|
authorQuery.setFields("authorName");
|
||||||
|
|
||||||
|
QueryResponse authorResponse = solrClient.query(properties.getCores().getStories(), authorQuery);
|
||||||
|
String authorName = "";
|
||||||
|
if (!authorResponse.getResults().isEmpty()) {
|
||||||
|
authorName = (String) authorResponse.getResults().get(0).getFieldValue("authorName");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get total words for this author
|
||||||
|
long totalWords = getAuthorTotalWords(libraryId, authorId);
|
||||||
|
|
||||||
|
allAuthors.add(new TopAuthorsStatsDto.AuthorStatsDto(authorId, authorName, storyCount, totalWords));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by total words and return top N
|
||||||
|
return allAuthors.stream()
|
||||||
|
.sorted(Comparator.comparingLong(TopAuthorsStatsDto.AuthorStatsDto::getTotalWords).reversed())
|
||||||
|
.limit(limit)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
private long getAuthorTotalWords(String libraryId, String authorId) throws IOException, SolrServerException {
|
||||||
|
SolrQuery query = new SolrQuery("authorId:" + authorId);
|
||||||
|
query.addFilterQuery("libraryId:" + libraryId);
|
||||||
|
query.setRows(0);
|
||||||
|
query.setParam(StatsParams.STATS, true);
|
||||||
|
query.setParam(StatsParams.STATS_FIELD, "wordCount");
|
||||||
|
|
||||||
|
QueryResponse response = solrClient.query(properties.getCores().getStories(), query);
|
||||||
|
|
||||||
|
var fieldStatsInfo = response.getFieldStatsInfo();
|
||||||
|
if (fieldStatsInfo != null && fieldStatsInfo.get("wordCount") != null) {
|
||||||
|
var fieldStat = fieldStatsInfo.get("wordCount");
|
||||||
|
Object sumObj = fieldStat.getSum();
|
||||||
|
return (sumObj != null) ? ((Number) sumObj).longValue() : 0L;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0L;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get rating statistics
|
||||||
|
*/
|
||||||
|
public RatingStatsDto getRatingStatistics(String libraryId) throws IOException, SolrServerException {
|
||||||
|
RatingStatsDto stats = new RatingStatsDto();
|
||||||
|
|
||||||
|
// Get average rating using stats component
|
||||||
|
SolrQuery query = new SolrQuery("*:*");
|
||||||
|
query.addFilterQuery("libraryId:" + libraryId);
|
||||||
|
query.addFilterQuery("rating:[* TO *]"); // Only rated stories
|
||||||
|
query.setRows(0);
|
||||||
|
query.setParam(StatsParams.STATS, true);
|
||||||
|
query.setParam(StatsParams.STATS_FIELD, "rating");
|
||||||
|
|
||||||
|
QueryResponse response = solrClient.query(properties.getCores().getStories(), query);
|
||||||
|
long totalRated = response.getResults().getNumFound();
|
||||||
|
|
||||||
|
var fieldStatsInfo = response.getFieldStatsInfo();
|
||||||
|
if (fieldStatsInfo != null && fieldStatsInfo.get("rating") != null) {
|
||||||
|
var fieldStat = fieldStatsInfo.get("rating");
|
||||||
|
Object meanObj = fieldStat.getMean();
|
||||||
|
stats.setAverageRating((meanObj != null) ? ((Number) meanObj).doubleValue() : 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
stats.setTotalRatedStories(totalRated);
|
||||||
|
|
||||||
|
// Get total stories to calculate unrated
|
||||||
|
long totalStories = getTotalStories(libraryId);
|
||||||
|
stats.setTotalUnratedStories(totalStories - totalRated);
|
||||||
|
|
||||||
|
// Get rating distribution using faceting
|
||||||
|
SolrQuery distQuery = new SolrQuery("*:*");
|
||||||
|
distQuery.addFilterQuery("libraryId:" + libraryId);
|
||||||
|
distQuery.addFilterQuery("rating:[* TO *]");
|
||||||
|
distQuery.setRows(0);
|
||||||
|
distQuery.setFacet(true);
|
||||||
|
distQuery.addFacetField("rating");
|
||||||
|
distQuery.setFacetLimit(-1);
|
||||||
|
|
||||||
|
QueryResponse distResponse = solrClient.query(properties.getCores().getStories(), distQuery);
|
||||||
|
FacetField ratingFacet = distResponse.getFacetField("rating");
|
||||||
|
|
||||||
|
Map<Integer, Long> distribution = new HashMap<>();
|
||||||
|
if (ratingFacet != null && ratingFacet.getValues() != null) {
|
||||||
|
for (FacetField.Count count : ratingFacet.getValues()) {
|
||||||
|
try {
|
||||||
|
int rating = Integer.parseInt(count.getName());
|
||||||
|
distribution.put(rating, count.getCount());
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
// Skip invalid ratings
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stats.setRatingDistribution(distribution);
|
||||||
|
|
||||||
|
return stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get source domain statistics
|
||||||
|
*/
|
||||||
|
public SourceDomainStatsDto getSourceDomainStatistics(String libraryId, int limit) throws IOException, SolrServerException {
|
||||||
|
SourceDomainStatsDto stats = new SourceDomainStatsDto();
|
||||||
|
|
||||||
|
// Get top domains using faceting
|
||||||
|
SolrQuery query = new SolrQuery("*:*");
|
||||||
|
query.addFilterQuery("libraryId:" + libraryId);
|
||||||
|
query.addFilterQuery("sourceDomain:[* TO *]"); // Only stories with source
|
||||||
|
query.setRows(0);
|
||||||
|
query.setFacet(true);
|
||||||
|
query.addFacetField("sourceDomain");
|
||||||
|
query.setFacetLimit(limit);
|
||||||
|
query.setFacetSort("count");
|
||||||
|
|
||||||
|
QueryResponse response = solrClient.query(properties.getCores().getStories(), query);
|
||||||
|
long storiesWithSource = response.getResults().getNumFound();
|
||||||
|
|
||||||
|
FacetField domainFacet = response.getFacetField("sourceDomain");
|
||||||
|
|
||||||
|
List<SourceDomainStatsDto.DomainStatsDto> topDomains = new ArrayList<>();
|
||||||
|
if (domainFacet != null && domainFacet.getValues() != null) {
|
||||||
|
for (FacetField.Count count : domainFacet.getValues()) {
|
||||||
|
topDomains.add(new SourceDomainStatsDto.DomainStatsDto(count.getName(), count.getCount()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stats.setTopDomains(topDomains);
|
||||||
|
stats.setStoriesWithSource(storiesWithSource);
|
||||||
|
|
||||||
|
long totalStories = getTotalStories(libraryId);
|
||||||
|
stats.setStoriesWithoutSource(totalStories - storiesWithSource);
|
||||||
|
|
||||||
|
return stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get reading progress statistics
|
||||||
|
*/
|
||||||
|
public ReadingProgressStatsDto getReadingProgressStatistics(String libraryId) throws IOException, SolrServerException {
|
||||||
|
ReadingProgressStatsDto stats = new ReadingProgressStatsDto();
|
||||||
|
|
||||||
|
long totalStories = getTotalStories(libraryId);
|
||||||
|
stats.setTotalStories(totalStories);
|
||||||
|
|
||||||
|
// Get read stories count
|
||||||
|
SolrQuery readQuery = new SolrQuery("*:*");
|
||||||
|
readQuery.addFilterQuery("libraryId:" + libraryId);
|
||||||
|
readQuery.addFilterQuery("isRead:true");
|
||||||
|
readQuery.setRows(0);
|
||||||
|
|
||||||
|
QueryResponse readResponse = solrClient.query(properties.getCores().getStories(), readQuery);
|
||||||
|
long readStories = readResponse.getResults().getNumFound();
|
||||||
|
|
||||||
|
stats.setReadStories(readStories);
|
||||||
|
stats.setUnreadStories(totalStories - readStories);
|
||||||
|
|
||||||
|
if (totalStories > 0) {
|
||||||
|
stats.setPercentageRead((readStories * 100.0) / totalStories);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get total words read
|
||||||
|
SolrQuery readWordsQuery = new SolrQuery("*:*");
|
||||||
|
readWordsQuery.addFilterQuery("libraryId:" + libraryId);
|
||||||
|
readWordsQuery.addFilterQuery("isRead:true");
|
||||||
|
readWordsQuery.setRows(0);
|
||||||
|
readWordsQuery.setParam(StatsParams.STATS, true);
|
||||||
|
readWordsQuery.setParam(StatsParams.STATS_FIELD, "wordCount");
|
||||||
|
|
||||||
|
QueryResponse readWordsResponse = solrClient.query(properties.getCores().getStories(), readWordsQuery);
|
||||||
|
var readFieldStats = readWordsResponse.getFieldStatsInfo();
|
||||||
|
if (readFieldStats != null && readFieldStats.get("wordCount") != null) {
|
||||||
|
var fieldStat = readFieldStats.get("wordCount");
|
||||||
|
Object sumObj = fieldStat.getSum();
|
||||||
|
stats.setTotalWordsRead((sumObj != null) ? ((Number) sumObj).longValue() : 0L);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get total words unread
|
||||||
|
SolrQuery unreadWordsQuery = new SolrQuery("*:*");
|
||||||
|
unreadWordsQuery.addFilterQuery("libraryId:" + libraryId);
|
||||||
|
unreadWordsQuery.addFilterQuery("isRead:false");
|
||||||
|
unreadWordsQuery.setRows(0);
|
||||||
|
unreadWordsQuery.setParam(StatsParams.STATS, true);
|
||||||
|
unreadWordsQuery.setParam(StatsParams.STATS_FIELD, "wordCount");
|
||||||
|
|
||||||
|
QueryResponse unreadWordsResponse = solrClient.query(properties.getCores().getStories(), unreadWordsQuery);
|
||||||
|
var unreadFieldStats = unreadWordsResponse.getFieldStatsInfo();
|
||||||
|
if (unreadFieldStats != null && unreadFieldStats.get("wordCount") != null) {
|
||||||
|
var fieldStat = unreadFieldStats.get("wordCount");
|
||||||
|
Object sumObj = fieldStat.getSum();
|
||||||
|
stats.setTotalWordsUnread((sumObj != null) ? ((Number) sumObj).longValue() : 0L);
|
||||||
|
}
|
||||||
|
|
||||||
|
return stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get reading activity statistics for the last week
|
||||||
|
*/
|
||||||
|
public ReadingActivityStatsDto getReadingActivityStatistics(String libraryId) throws IOException, SolrServerException {
|
||||||
|
ReadingActivityStatsDto stats = new ReadingActivityStatsDto();
|
||||||
|
|
||||||
|
LocalDateTime oneWeekAgo = LocalDateTime.now().minusWeeks(1);
|
||||||
|
String oneWeekAgoStr = oneWeekAgo.toInstant(ZoneOffset.UTC).toString();
|
||||||
|
|
||||||
|
// Get stories read in last week
|
||||||
|
SolrQuery query = new SolrQuery("*:*");
|
||||||
|
query.addFilterQuery("libraryId:" + libraryId);
|
||||||
|
query.addFilterQuery("lastReadAt:[" + oneWeekAgoStr + " TO *]");
|
||||||
|
query.setRows(0);
|
||||||
|
|
||||||
|
QueryResponse response = solrClient.query(properties.getCores().getStories(), query);
|
||||||
|
long storiesReadLastWeek = response.getResults().getNumFound();
|
||||||
|
stats.setStoriesReadLastWeek(storiesReadLastWeek);
|
||||||
|
|
||||||
|
// Get words read in last week
|
||||||
|
SolrQuery wordsQuery = new SolrQuery("*:*");
|
||||||
|
wordsQuery.addFilterQuery("libraryId:" + libraryId);
|
||||||
|
wordsQuery.addFilterQuery("lastReadAt:[" + oneWeekAgoStr + " TO *]");
|
||||||
|
wordsQuery.setRows(0);
|
||||||
|
wordsQuery.setParam(StatsParams.STATS, true);
|
||||||
|
wordsQuery.setParam(StatsParams.STATS_FIELD, "wordCount");
|
||||||
|
|
||||||
|
QueryResponse wordsResponse = solrClient.query(properties.getCores().getStories(), wordsQuery);
|
||||||
|
var fieldStatsInfo = wordsResponse.getFieldStatsInfo();
|
||||||
|
long wordsReadLastWeek = 0L;
|
||||||
|
if (fieldStatsInfo != null && fieldStatsInfo.get("wordCount") != null) {
|
||||||
|
var fieldStat = fieldStatsInfo.get("wordCount");
|
||||||
|
Object sumObj = fieldStat.getSum();
|
||||||
|
wordsReadLastWeek = (sumObj != null) ? ((Number) sumObj).longValue() : 0L;
|
||||||
|
}
|
||||||
|
|
||||||
|
stats.setWordsReadLastWeek(wordsReadLastWeek);
|
||||||
|
stats.setReadingTimeMinutesLastWeek(wordsReadLastWeek / WORDS_PER_MINUTE);
|
||||||
|
|
||||||
|
// Get daily activity (last 7 days)
|
||||||
|
List<ReadingActivityStatsDto.DailyActivityDto> dailyActivity = new ArrayList<>();
|
||||||
|
for (int i = 6; i >= 0; i--) {
|
||||||
|
LocalDate date = LocalDate.now().minusDays(i);
|
||||||
|
LocalDateTime dayStart = date.atStartOfDay();
|
||||||
|
LocalDateTime dayEnd = date.atTime(23, 59, 59);
|
||||||
|
|
||||||
|
String dayStartStr = dayStart.toInstant(ZoneOffset.UTC).toString();
|
||||||
|
String dayEndStr = dayEnd.toInstant(ZoneOffset.UTC).toString();
|
||||||
|
|
||||||
|
SolrQuery dayQuery = new SolrQuery("*:*");
|
||||||
|
dayQuery.addFilterQuery("libraryId:" + libraryId);
|
||||||
|
dayQuery.addFilterQuery("lastReadAt:[" + dayStartStr + " TO " + dayEndStr + "]");
|
||||||
|
dayQuery.setRows(0);
|
||||||
|
dayQuery.setParam(StatsParams.STATS, true);
|
||||||
|
dayQuery.setParam(StatsParams.STATS_FIELD, "wordCount");
|
||||||
|
|
||||||
|
QueryResponse dayResponse = solrClient.query(properties.getCores().getStories(), dayQuery);
|
||||||
|
long storiesRead = dayResponse.getResults().getNumFound();
|
||||||
|
|
||||||
|
long wordsRead = 0L;
|
||||||
|
var dayFieldStats = dayResponse.getFieldStatsInfo();
|
||||||
|
if (dayFieldStats != null && dayFieldStats.get("wordCount") != null) {
|
||||||
|
var fieldStat = dayFieldStats.get("wordCount");
|
||||||
|
Object sumObj = fieldStat.getSum();
|
||||||
|
wordsRead = (sumObj != null) ? ((Number) sumObj).longValue() : 0L;
|
||||||
|
}
|
||||||
|
|
||||||
|
dailyActivity.add(new ReadingActivityStatsDto.DailyActivityDto(
|
||||||
|
date.format(DateTimeFormatter.ISO_LOCAL_DATE),
|
||||||
|
storiesRead,
|
||||||
|
wordsRead
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
stats.setDailyActivity(dailyActivity);
|
||||||
|
|
||||||
|
return stats;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -385,9 +385,69 @@ public class SolrService {
|
|||||||
logger.warn("Could not add libraryId field to document (field may not exist in schema): {}", e.getMessage());
|
logger.warn("Could not add libraryId field to document (field may not exist in schema): {}", e.getMessage());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add derived fields for statistics (Phase 1)
|
||||||
|
addDerivedStatisticsFields(doc, story);
|
||||||
|
|
||||||
return doc;
|
return doc;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add derived fields to support statistics queries
|
||||||
|
*/
|
||||||
|
private void addDerivedStatisticsFields(SolrInputDocument doc, Story story) {
|
||||||
|
try {
|
||||||
|
// Boolean flags for filtering
|
||||||
|
doc.addField("hasDescription", story.getDescription() != null && !story.getDescription().trim().isEmpty());
|
||||||
|
doc.addField("hasCoverImage", story.getCoverPath() != null && !story.getCoverPath().trim().isEmpty());
|
||||||
|
doc.addField("hasRating", story.getRating() != null && story.getRating() > 0);
|
||||||
|
|
||||||
|
// Extract source domain from URL
|
||||||
|
if (story.getSourceUrl() != null && !story.getSourceUrl().trim().isEmpty()) {
|
||||||
|
String domain = extractDomain(story.getSourceUrl());
|
||||||
|
if (domain != null) {
|
||||||
|
doc.addField("sourceDomain", domain);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tag count for statistics
|
||||||
|
int tagCount = (story.getTags() != null) ? story.getTags().size() : 0;
|
||||||
|
doc.addField("tagCount", tagCount);
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
// Don't fail indexing if derived fields can't be added
|
||||||
|
logger.debug("Could not add some derived statistics fields: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract domain from URL for source statistics
|
||||||
|
*/
|
||||||
|
private String extractDomain(String url) {
|
||||||
|
try {
|
||||||
|
if (url == null || url.trim().isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle URLs without protocol
|
||||||
|
if (!url.startsWith("http://") && !url.startsWith("https://")) {
|
||||||
|
url = "https://" + url;
|
||||||
|
}
|
||||||
|
|
||||||
|
java.net.URL parsedUrl = new java.net.URL(url);
|
||||||
|
String host = parsedUrl.getHost();
|
||||||
|
|
||||||
|
// Remove www. prefix if present
|
||||||
|
if (host.startsWith("www.")) {
|
||||||
|
host = host.substring(4);
|
||||||
|
}
|
||||||
|
|
||||||
|
return host;
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.debug("Failed to extract domain from URL: {}", url);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private SolrInputDocument createAuthorDocument(Author author) {
|
private SolrInputDocument createAuthorDocument(Author author) {
|
||||||
SolrInputDocument doc = new SolrInputDocument();
|
SolrInputDocument doc = new SolrInputDocument();
|
||||||
|
|
||||||
|
|||||||
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
|
Collections
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href="/authors"
|
href="/authors"
|
||||||
className="theme-text hover:theme-accent transition-colors font-medium"
|
className="theme-text hover:theme-accent transition-colors font-medium"
|
||||||
>
|
>
|
||||||
Authors
|
Authors
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/statistics"
|
||||||
|
className="theme-text hover:theme-accent transition-colors font-medium"
|
||||||
|
>
|
||||||
|
Statistics
|
||||||
|
</Link>
|
||||||
<Dropdown
|
<Dropdown
|
||||||
trigger="Add Story"
|
trigger="Add Story"
|
||||||
items={addStoryItems}
|
items={addStoryItems}
|
||||||
@@ -146,13 +152,20 @@ export default function Header() {
|
|||||||
>
|
>
|
||||||
Collections
|
Collections
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href="/authors"
|
href="/authors"
|
||||||
className="theme-text hover:theme-accent transition-colors font-medium px-2 py-1"
|
className="theme-text hover:theme-accent transition-colors font-medium px-2 py-1"
|
||||||
onClick={() => setIsMenuOpen(false)}
|
onClick={() => setIsMenuOpen(false)}
|
||||||
>
|
>
|
||||||
Authors
|
Authors
|
||||||
</Link>
|
</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="px-2 py-1">
|
||||||
<div className="font-medium theme-text mb-1">Add Story</div>
|
<div className="font-medium theme-text mb-1">Add Story</div>
|
||||||
<div className="pl-4 space-y-1">
|
<div className="pl-4 space-y-1">
|
||||||
|
|||||||
@@ -1090,15 +1090,59 @@ export const clearLibraryCache = (): void => {
|
|||||||
currentLibraryId = null;
|
currentLibraryId = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Library statistics endpoints
|
||||||
|
export const statisticsApi = {
|
||||||
|
getOverviewStatistics: async (libraryId: string): Promise<import('../types/api').LibraryOverviewStats> => {
|
||||||
|
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
|
// Image utility - now library-aware
|
||||||
export const getImageUrl = (path: string): string => {
|
export const getImageUrl = (path: string): string => {
|
||||||
if (!path) return '';
|
if (!path) return '';
|
||||||
|
|
||||||
// For compatibility during transition, handle both patterns
|
// For compatibility during transition, handle both patterns
|
||||||
if (path.startsWith('http')) {
|
if (path.startsWith('http')) {
|
||||||
return path; // External URL
|
return path; // External URL
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use library-aware API endpoint
|
// Use library-aware API endpoint
|
||||||
const libraryId = getCurrentLibraryId();
|
const libraryId = getCurrentLibraryId();
|
||||||
return `/api/files/images/${libraryId}/${path}`;
|
return `/api/files/images/${libraryId}/${path}`;
|
||||||
|
|||||||
@@ -204,4 +204,100 @@ export interface FilterPreset {
|
|||||||
description?: string;
|
description?: string;
|
||||||
filters: Partial<AdvancedFilters>;
|
filters: Partial<AdvancedFilters>;
|
||||||
category: 'length' | 'date' | 'rating' | 'reading' | 'content' | 'organization';
|
category: 'length' | 'date' | 'rating' | 'reading' | 'content' | 'organization';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Library Statistics
|
||||||
|
export interface LibraryOverviewStats {
|
||||||
|
// Collection Overview
|
||||||
|
totalStories: number;
|
||||||
|
totalAuthors: number;
|
||||||
|
totalSeries: number;
|
||||||
|
totalTags: number;
|
||||||
|
totalCollections: number;
|
||||||
|
uniqueSourceDomains: number;
|
||||||
|
|
||||||
|
// Content Metrics
|
||||||
|
totalWordCount: number;
|
||||||
|
averageWordsPerStory: number;
|
||||||
|
longestStory: StoryWordCount | null;
|
||||||
|
shortestStory: StoryWordCount | null;
|
||||||
|
|
||||||
|
// Reading Time
|
||||||
|
totalReadingTimeMinutes: number;
|
||||||
|
averageReadingTimeMinutes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StoryWordCount {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
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
@@ -112,6 +112,13 @@
|
|||||||
<field name="searchScore" type="pdouble" indexed="false" stored="true"/>
|
<field name="searchScore" type="pdouble" indexed="false" stored="true"/>
|
||||||
<field name="highlights" type="strings" indexed="false" stored="true"/>
|
<field name="highlights" type="strings" indexed="false" stored="true"/>
|
||||||
|
|
||||||
|
<!-- Statistics-specific Fields -->
|
||||||
|
<field name="hasDescription" type="boolean" indexed="true" stored="true"/>
|
||||||
|
<field name="hasCoverImage" type="boolean" indexed="true" stored="true"/>
|
||||||
|
<field name="hasRating" type="boolean" indexed="true" stored="true"/>
|
||||||
|
<field name="sourceDomain" type="string" indexed="true" stored="true"/>
|
||||||
|
<field name="tagCount" type="pint" indexed="true" stored="true"/>
|
||||||
|
|
||||||
<!-- Combined search field for general queries -->
|
<!-- Combined search field for general queries -->
|
||||||
<field name="text" type="text_general" indexed="true" stored="false" multiValued="true"/>
|
<field name="text" type="text_general" indexed="true" stored="false" multiValued="true"/>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user