stats.py 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258
  1. """
  2. Reading statistics service.
  3. Calculates various statistics about user's reading habits.
  4. """
  5. from datetime import datetime, timedelta
  6. from typing import Dict, Any, List, Optional
  7. from sqlalchemy.ext.asyncio import AsyncSession
  8. from sqlalchemy import select, func, and_
  9. import json
  10. from app.models import ListeningSession, Book
  11. class ReadingStatsService:
  12. """Service for calculating reading statistics."""
  13. def __init__(self, db: AsyncSession, user_id: int):
  14. """
  15. Initialize statistics service for a user.
  16. Args:
  17. db: Database session
  18. user_id: User ID to calculate stats for
  19. """
  20. self.db = db
  21. self.user_id = user_id
  22. async def calculate_stats(
  23. self,
  24. start_date: Optional[datetime] = None,
  25. end_date: Optional[datetime] = None
  26. ) -> Dict[str, Any]:
  27. """
  28. Calculate comprehensive reading statistics.
  29. Args:
  30. start_date: Optional start date filter
  31. end_date: Optional end date filter
  32. Returns:
  33. Dictionary with various statistics
  34. """
  35. # Build base query
  36. query = select(ListeningSession).where(
  37. ListeningSession.user_id == self.user_id
  38. )
  39. if start_date:
  40. query = query.where(ListeningSession.started_at >= start_date)
  41. if end_date:
  42. query = query.where(ListeningSession.started_at <= end_date)
  43. result = await self.db.execute(query)
  44. sessions = result.scalars().all()
  45. # Calculate finished books
  46. finished_sessions = [s for s in sessions if s.is_finished and s.finished_at]
  47. # Calculate total listening time
  48. total_hours = 0.0
  49. for session in finished_sessions:
  50. if session.started_at and session.finished_at:
  51. duration = (session.finished_at - session.started_at).total_seconds() / 3600
  52. total_hours += duration
  53. # Get book details for finished books
  54. finished_book_ids = [s.book_id for s in finished_sessions]
  55. books_dict = {}
  56. if finished_book_ids:
  57. books_result = await self.db.execute(
  58. select(Book).where(Book.id.in_(finished_book_ids))
  59. )
  60. books_dict = {book.id: book for book in books_result.scalars().all()}
  61. # Calculate average rating
  62. rated_sessions = [s for s in finished_sessions if s.rating]
  63. avg_rating = (
  64. sum(s.rating for s in rated_sessions) / len(rated_sessions)
  65. if rated_sessions else None
  66. )
  67. # Calculate books per month
  68. books_by_month = await self._calculate_books_by_month(finished_sessions)
  69. # Calculate books by genre
  70. books_by_genre = await self._calculate_books_by_genre(finished_sessions, books_dict)
  71. # Calculate current streak
  72. streak = await self._calculate_streak(finished_sessions)
  73. # Calculate recent books
  74. recent_books = await self._get_recent_books(finished_sessions, books_dict, limit=10)
  75. return {
  76. "total_books": len(finished_sessions),
  77. "total_hours": round(total_hours, 1),
  78. "average_rating": round(avg_rating, 1) if avg_rating else None,
  79. "books_in_progress": len([s for s in sessions if not s.is_finished]),
  80. "books_by_month": books_by_month,
  81. "books_by_genre": books_by_genre,
  82. "current_streak": streak,
  83. "recent_books": recent_books,
  84. "total_sessions": len(sessions)
  85. }
  86. async def _calculate_books_by_month(
  87. self,
  88. finished_sessions: List[ListeningSession]
  89. ) -> List[Dict[str, Any]]:
  90. """
  91. Calculate books finished per month.
  92. Returns:
  93. List of {month, year, count} dictionaries
  94. """
  95. books_by_month = {}
  96. for session in finished_sessions:
  97. if not session.finished_at:
  98. continue
  99. month_key = (session.finished_at.year, session.finished_at.month)
  100. books_by_month[month_key] = books_by_month.get(month_key, 0) + 1
  101. # Convert to list and sort
  102. result = [
  103. {
  104. "year": year,
  105. "month": month,
  106. "count": count,
  107. "month_name": datetime(year, month, 1).strftime("%B")
  108. }
  109. for (year, month), count in sorted(books_by_month.items())
  110. ]
  111. return result
  112. async def _calculate_books_by_genre(
  113. self,
  114. finished_sessions: List[ListeningSession],
  115. books_dict: Dict[str, Book]
  116. ) -> List[Dict[str, Any]]:
  117. """
  118. Calculate books finished by genre.
  119. Returns:
  120. List of {genre, count} dictionaries sorted by count
  121. """
  122. genre_counts = {}
  123. for session in finished_sessions:
  124. book = books_dict.get(session.book_id)
  125. if not book or not book.genres:
  126. continue
  127. try:
  128. genres = json.loads(book.genres) if isinstance(book.genres, str) else book.genres
  129. for genre in genres:
  130. genre_counts[genre] = genre_counts.get(genre, 0) + 1
  131. except (json.JSONDecodeError, TypeError):
  132. continue
  133. # Sort by count descending
  134. result = [
  135. {"genre": genre, "count": count}
  136. for genre, count in sorted(
  137. genre_counts.items(),
  138. key=lambda x: x[1],
  139. reverse=True
  140. )
  141. ]
  142. return result
  143. async def _calculate_streak(
  144. self,
  145. finished_sessions: List[ListeningSession]
  146. ) -> int:
  147. """
  148. Calculate current reading streak (consecutive days with finished books).
  149. Returns:
  150. Number of consecutive days
  151. """
  152. if not finished_sessions:
  153. return 0
  154. # Get unique finish dates, sorted descending
  155. finish_dates = sorted(
  156. {s.finished_at.date() for s in finished_sessions if s.finished_at},
  157. reverse=True
  158. )
  159. if not finish_dates:
  160. return 0
  161. # Check if most recent is today or yesterday
  162. today = datetime.now().date()
  163. if finish_dates[0] not in [today, today - timedelta(days=1)]:
  164. return 0
  165. # Count consecutive days
  166. streak = 1
  167. for i in range(len(finish_dates) - 1):
  168. diff = (finish_dates[i] - finish_dates[i + 1]).days
  169. if diff == 1:
  170. streak += 1
  171. elif diff == 0:
  172. # Same day, continue
  173. continue
  174. else:
  175. break
  176. return streak
  177. async def _get_recent_books(
  178. self,
  179. finished_sessions: List[ListeningSession],
  180. books_dict: Dict[str, Book],
  181. limit: int = 10
  182. ) -> List[Dict[str, Any]]:
  183. """
  184. Get recently finished books with details.
  185. Returns:
  186. List of book details with finish date and rating
  187. """
  188. # Sort by finish date descending
  189. sorted_sessions = sorted(
  190. [s for s in finished_sessions if s.finished_at],
  191. key=lambda x: x.finished_at,
  192. reverse=True
  193. )[:limit]
  194. recent = []
  195. for session in sorted_sessions:
  196. book = books_dict.get(session.book_id)
  197. if not book:
  198. continue
  199. listening_duration = None
  200. if session.started_at and session.finished_at:
  201. duration_hours = (session.finished_at - session.started_at).total_seconds() / 3600
  202. listening_duration = round(duration_hours, 1)
  203. recent.append({
  204. "book_id": book.id,
  205. "title": book.title,
  206. "author": book.author,
  207. "finished_at": session.finished_at.isoformat(),
  208. "rating": session.rating,
  209. "listening_duration": listening_duration,
  210. "cover_url": book.cover_url
  211. })
  212. return recent