From 573972026a9c3a4ded85af79c704b3fa7bd186f9 Mon Sep 17 00:00:00 2001 From: codebude Date: Tue, 8 Sep 2026 19:01:32 +0200 Subject: [PATCH] Add selectable time range to statistics --- ...e2_add_statistics_range_to_usersettings.py | 30 ++ backend/app/models.py | 5 +- backend/app/routers/profile.py | 18 ++ backend/app/routers/statistics.py | 273 +++++++++++++++--- backend/app/schemas.py | 18 +- backend/tests/test_profile.py | 51 ++++ backend/tests/test_statistics.py | 70 +++++ frontend/src/lib/api.ts | 8 +- .../components/StatisticsRangeSelector.svelte | 66 +++++ .../StatisticsRangeSelector.test.ts | 31 ++ frontend/src/lib/i18n/locales/de.json | 11 + frontend/src/lib/i18n/locales/en.json | 11 + frontend/src/lib/i18n/locales/es.json | 11 + frontend/src/lib/i18n/locales/fr.json | 11 + frontend/src/lib/i18n/locales/zh.json | 11 + frontend/src/lib/types.ts | 5 + frontend/src/routes/statistics/+page.svelte | 106 ++++++- 17 files changed, 689 insertions(+), 47 deletions(-) create mode 100644 backend/alembic/versions/f3a5b7c9d1e2_add_statistics_range_to_usersettings.py create mode 100644 frontend/src/lib/components/StatisticsRangeSelector.svelte create mode 100644 frontend/src/lib/components/StatisticsRangeSelector.test.ts diff --git a/backend/alembic/versions/f3a5b7c9d1e2_add_statistics_range_to_usersettings.py b/backend/alembic/versions/f3a5b7c9d1e2_add_statistics_range_to_usersettings.py new file mode 100644 index 00000000..92075b4a --- /dev/null +++ b/backend/alembic/versions/f3a5b7c9d1e2_add_statistics_range_to_usersettings.py @@ -0,0 +1,30 @@ +"""add statistics range to usersettings + +Revision ID: f3a5b7c9d1e2 +Revises: c3d4e5f6a7b8 +Create Date: 2026-09-08 15:30:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'f3a5b7c9d1e2' +down_revision: Union[str, Sequence[str], None] = 'c3d4e5f6a7b8' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column('usersettings', sa.Column('statistics_range', sa.String(length=20), nullable=False, server_default='alltime')) + op.add_column('usersettings', sa.Column('statistics_custom_from', sa.Date(), nullable=True)) + op.add_column('usersettings', sa.Column('statistics_custom_to', sa.Date(), nullable=True)) + + +def downgrade() -> None: + op.drop_column('usersettings', 'statistics_custom_to') + op.drop_column('usersettings', 'statistics_custom_from') + op.drop_column('usersettings', 'statistics_range') diff --git a/backend/app/models.py b/backend/app/models.py index 67d2d656..e214ab22 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -2,7 +2,7 @@ from enum import Enum from typing import Optional -from datetime import datetime, timezone +from datetime import date, datetime, timezone import sqlalchemy as sa from pydantic import model_validator @@ -192,6 +192,9 @@ class UserSettings(SQLModel, table=True): goal_books_per_year_enabled: bool = Field(default=False) goal_books_per_year: int = Field(default=25, ge=1) gamification_enabled: bool = Field(default=True) + statistics_range: str = Field(default="alltime", max_length=20) + statistics_custom_from: Optional[date] = Field(default=None) + statistics_custom_to: Optional[date] = Field(default=None) class ApiKey(SQLModel, table=True): diff --git a/backend/app/routers/profile.py b/backend/app/routers/profile.py index 00bf1c2f..a34dfc37 100644 --- a/backend/app/routers/profile.py +++ b/backend/app/routers/profile.py @@ -36,6 +36,7 @@ UserSettingsRead, UserSettingsUpdate, ) +from app.routers.statistics import MAX_CUSTOM_RANGE_DAYS from app.time_utils import utcnow from app.services.user_deletion import ( assert_not_last_admin, @@ -113,6 +114,9 @@ def get_settings( goal_books_per_year_enabled=settings.goal_books_per_year_enabled, goal_books_per_year=settings.goal_books_per_year, gamification_enabled=settings.gamification_enabled, + statistics_range=settings.statistics_range, + statistics_custom_from=settings.statistics_custom_from, + statistics_custom_to=settings.statistics_custom_to, ) @@ -130,6 +134,17 @@ def update_settings( if not settings: settings = UserSettings(user_id=current_user.id, language="en") update_data = body.model_dump(exclude_unset=True) + if "statistics_range" in update_data and update_data["statistics_range"] is None: + raise HTTPException(status_code=422, detail="statistics_range cannot be null") + custom_from = update_data.get("statistics_custom_from", settings.statistics_custom_from) + custom_to = update_data.get("statistics_custom_to", settings.statistics_custom_to) + statistics_range = update_data.get("statistics_range", settings.statistics_range) + if statistics_range == "custom" and (custom_from is None or custom_to is None): + raise HTTPException(status_code=422, detail="Custom range requires both dates") + if custom_from is not None and custom_to is not None and custom_from > custom_to: + raise HTTPException(status_code=422, detail="statistics_custom_from cannot be after statistics_custom_to") + if custom_from is not None and custom_to is not None and (custom_to - custom_from).days > MAX_CUSTOM_RANGE_DAYS: + raise HTTPException(status_code=422, detail="Statistics custom range cannot exceed 25 years") settings.sqlmodel_update(update_data) if settings.theme != 'custom': settings.custom_theme = None @@ -151,6 +166,9 @@ def update_settings( goal_books_per_year_enabled=settings.goal_books_per_year_enabled, goal_books_per_year=settings.goal_books_per_year, gamification_enabled=settings.gamification_enabled, + statistics_range=settings.statistics_range, + statistics_custom_from=settings.statistics_custom_from, + statistics_custom_to=settings.statistics_custom_to, ) diff --git a/backend/app/routers/statistics.py b/backend/app/routers/statistics.py index 1f33f345..056fb296 100644 --- a/backend/app/routers/statistics.py +++ b/backend/app/routers/statistics.py @@ -2,13 +2,13 @@ import calendar from collections import Counter, defaultdict -from datetime import date, datetime, timedelta, timezone +from datetime import date, datetime, time, timedelta, timezone from statistics import mean from types import SimpleNamespace from typing import Optional from zoneinfo import ZoneInfo, ZoneInfoNotFoundError -from fastapi import APIRouter, Depends, Query +from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy import func from sqlmodel import Session, col, select @@ -27,6 +27,7 @@ MonthlyBooks, MonthlyPages, PageBuckets, + StatisticsRange, StatisticsResponse, StatusDistribution, TopAuthor, @@ -36,6 +37,7 @@ ) router = APIRouter(prefix="/api/statistics", tags=["statistics"]) +MAX_CUSTOM_RANGE_DAYS = 25 * 366 def _zone_from_name(timezone_name: str | None) -> ZoneInfo: @@ -110,6 +112,71 @@ def _naive_utc(dt: datetime) -> datetime: return dt +def _subtract_months(dt: datetime, months: int) -> datetime: + """Return *dt* shifted back by *months*, clamping the day if needed.""" + year, month = dt.year, dt.month - months + while month <= 0: + month += 12 + year -= 1 + last_dom = calendar.monthrange(year, month)[1] + day = min(dt.day, last_dom) + return dt.replace(year=year, month=month, day=day) + + +def _subtract_years(dt: datetime, years: int) -> datetime: + """Return *dt* shifted back by *years*, handling Feb 29 gracefully.""" + year = dt.year - years + try: + return dt.replace(year=year) + except ValueError: + return dt.replace(year=year, month=2, day=28) + + +def _statistics_window( + range_value: StatisticsRange, + custom_from: date | None, + custom_to: date | None, + tz: ZoneInfo, + now: datetime, +) -> tuple[datetime | None, datetime | None]: + """Return the inclusive statistics window as naive UTC datetimes. + + Returns ``(None, None)`` for "All time". For bounded ranges the window is + expressed in the user's timezone and converted to naive UTC to match the + DB filtering convention used by :func:`_clamp_window`. + + - Custom -> from start of the custom *from* day to end of the custom *to* + day (inclusive) in *tz*. + - Predefined -> ``now - delta`` (inclusive) to ``now``. + """ + if range_value == StatisticsRange.alltime: + return (None, None) + + if range_value == StatisticsRange.custom: + if custom_from is None or custom_to is None: + raise HTTPException(status_code=400, detail="Custom range requires both dates.") + if custom_from > custom_to: + raise HTTPException(status_code=400, detail="'from' cannot be after 'to'.") + if (custom_to - custom_from).days > MAX_CUSTOM_RANGE_DAYS: + raise HTTPException(status_code=400, detail="Custom range cannot exceed 25 years.") + start = datetime.combine(custom_from, time.min, tzinfo=tz) + end = datetime.combine(custom_to, time.max, tzinfo=tz) + return (_naive_utc(start), _naive_utc(end)) + + end = now + if range_value == StatisticsRange.thirty_days: + start = now - timedelta(days=30) + elif range_value == StatisticsRange.six_months: + start = _subtract_months(now, 6) + elif range_value == StatisticsRange.one_year: + start = _subtract_years(now, 1) + elif range_value == StatisticsRange.three_years: + start = _subtract_years(now, 3) + else: + start = now + return (_naive_utc(start), _naive_utc(end)) + + def _extract_progress_daily_pages( entries: list, tz: ZoneInfo, window_start: datetime | None = None, window_end: datetime | None = None, @@ -196,8 +263,16 @@ def _allocate_daily_avg_across_months( return monthly -def _compute_pages_per_month_from_progress(entries: list, tz: ZoneInfo) -> dict[str, float]: - """Compute pages read per month from reading progress entries.""" +def _compute_pages_per_month_from_progress( + entries: list, tz: ZoneInfo, + window_start: datetime | None = None, window_end: datetime | None = None, +) -> dict[str, float]: + """Compute pages read per month from reading progress entries. + + When *window_start*/*window_end* are provided, only the portion of each + reading span that overlaps the window is allocated to months. The daily + average is still computed from the full span so the values stay correct. + """ monthly: dict[str, float] = defaultdict(float) grouped: dict[int, list] = {} for entry in entries: @@ -211,14 +286,26 @@ def _compute_pages_per_month_from_progress(entries: list, tz: ZoneInfo) -> dict[ day_diff = (curr.created_at - prev.created_at).days + 1 if day_diff <= 0: continue - m = _allocate_daily_avg_across_months(delta / day_diff, prev.created_at, curr.created_at, tz) + start, end = _clamp_window(prev.created_at, curr.created_at, window_start, window_end) + if start is None or end is None: + continue + m = _allocate_daily_avg_across_months(delta / day_diff, start, end, tz) for k, v in m.items(): monthly[k] += v return monthly -def _compute_pages_per_month_from_books(books: list[Book], tz: ZoneInfo) -> dict[str, float]: - """Compute pages read per month for finished books without progress entries.""" +def _compute_pages_per_month_from_books( + books: list[Book], tz: ZoneInfo, + window_start: datetime | None = None, window_end: datetime | None = None, +) -> dict[str, float]: + """Compute pages read per month for finished books without progress entries. + + When *window_start*/*window_end* are provided, only the portion of each + book's reading period that overlaps the window is allocated to months. + The daily average is still computed from the full period so the values + stay correct. + """ monthly: dict[str, float] = defaultdict(float) for book in books: if not (book.date_started and book.date_finished and book.page_count): @@ -228,8 +315,11 @@ def _compute_pages_per_month_from_books(books: list[Book], tz: ZoneInfo) -> dict total_days = (book.date_finished - book.date_started).days + 1 if total_days <= 0: continue + start, end = _clamp_window(book.date_started, book.date_finished, window_start, window_end) + if start is None or end is None: + continue m = _allocate_daily_avg_across_months( - book.page_count / total_days, book.date_started, book.date_finished, tz + book.page_count / total_days, start, end, tz ) for k, v in m.items(): monthly[k] += v @@ -598,13 +688,45 @@ def get_pages_per_day( @router.get("", response_model=StatisticsResponse) def get_statistics( + range_value: StatisticsRange = Query(default=StatisticsRange.alltime, alias="range"), + custom_from: Optional[date] = Query(default=None, alias="from"), + custom_to: Optional[date] = Query(default=None, alias="to"), current_user: User = Depends(require_user), session: Session = Depends(get_session), ) -> StatisticsResponse: - """Return the full statistics dashboard for the authenticated user.""" + """Return the full statistics dashboard for the authenticated user. + + The *range* query parameter selects a shared time window for the three + trend charts (pages read per month, books finished per month/year). When + *range* is ``custom``, the ``from``/``to`` dates bound the window inclusive. + All other statistics (status/acquisition distributions, top authors, + ratings, page buckets) are computed over the full library. + """ assert current_user.id is not None + + if range_value == StatisticsRange.custom: + if custom_from is None or custom_to is None: + raise HTTPException( + status_code=400, + detail="Both 'from' and 'to' are required when range is 'custom'.", + ) + if custom_from > custom_to: + raise HTTPException( + status_code=400, + detail="'from' cannot be after 'to'.", + ) + else: + if custom_from is not None or custom_to is not None: + raise HTTPException( + status_code=400, + detail="'from'/'to' are only allowed when range is 'custom'.", + ) + tz = _user_timezone(session, current_user.id) now = datetime.now(tz) + window_start, window_end = _statistics_window( + range_value, custom_from, custom_to, tz, now + ) current_month_key = f"{now.year:04d}-{now.month:02d}" current_year = now.year books = list(session.exec(select(Book).where(Book.user_id == current_user.id)).all()) @@ -675,11 +797,26 @@ def get_statistics( pages_wasted=pages_wasted, ) - finished_books = [ + all_finished_books = [ book for book in books if book.reading_status == ReadingStatus.read and book.date_finished is not None ] + finished_books_per_month_all_time: Counter[str] = Counter() + for book in all_finished_books: + assert book.date_finished is not None + finished_books_per_month_all_time[_month_key(book.date_finished, tz)] += 1 + + finished_books = all_finished_books + + if window_start is not None and window_end is not None: + finished_books = [ + book + for book in finished_books + if book.date_finished is not None + and _naive_utc(book.date_finished) >= window_start + and _naive_utc(book.date_finished) <= window_end + ] finished_books_per_month: Counter[str] = Counter() for book in finished_books: @@ -687,19 +824,70 @@ def get_statistics( month = _month_key(book.date_finished, tz) finished_books_per_month[month] += 1 - progress_entries = list( + # For bounded ranges the chart axis spans the whole selected window, so + # months/years outside any real data still appear (with zero counts). + if window_start is not None and window_end is not None: + window_start_aware = window_start.replace(tzinfo=timezone.utc) + window_end_aware = window_end.replace(tzinfo=timezone.utc) + window_start_month_key = _month_key(window_start_aware, tz) + window_end_month_key = _month_key(window_end_aware, tz) + window_start_year = window_start_aware.astimezone(tz).year + window_end_year = window_end_aware.astimezone(tz).year + else: + window_start_month_key = None + window_end_month_key = None + window_start_year = None + window_end_year = None + + if window_start is not None and window_end is not None: + # Only books with at least one progress entry inside the window can + # contribute pages to the window; load their full entry chains so the + # prev→curr deltas and day spans are complete. Mirrors pages-per-day. + book_ids_with_window_progress = set( + session.exec( + select(ReadingProgress.book_id) + .where( + ReadingProgress.user_id == current_user.id, + ReadingProgress.created_at >= window_start, + ) + .distinct() + ).all() + ) + if book_ids_with_window_progress: + progress_entries = list( + session.exec( + select(ReadingProgress) + .where( + ReadingProgress.user_id == current_user.id, + col(ReadingProgress.book_id).in_(book_ids_with_window_progress), + ) + .order_by(col(ReadingProgress.book_id), col(ReadingProgress.created_at)) + ).all() + ) + else: + progress_entries = [] + else: + progress_entries = list( + session.exec( + select(ReadingProgress) + .where(ReadingProgress.user_id == current_user.id) + .order_by(col(ReadingProgress.book_id), col(ReadingProgress.created_at)) + ).all() + ) + + # All book_ids with *any* progress entry — used to exclude books from the + # fallback computation and to build virtual entries. + all_book_ids_with_progress = set( session.exec( - select(ReadingProgress) + select(ReadingProgress.book_id) .where(ReadingProgress.user_id == current_user.id) - .order_by(col(ReadingProgress.book_id), col(ReadingProgress.created_at)) + .distinct() ).all() ) - books_with_progress = {e.book_id for e in progress_entries} - virtual_entries = [] for book in books: - if book.id not in books_with_progress or not book.date_started: + if book.id not in all_book_ids_with_progress or not book.date_started: continue if book.reading_status == ReadingStatus.read and not book.date_finished: continue @@ -712,60 +900,77 @@ def get_statistics( ) all_progress_entries = list(progress_entries) + virtual_entries - pages_read_per_month_counter = _compute_pages_per_month_from_progress(all_progress_entries, tz) + pages_read_per_month_counter = _compute_pages_per_month_from_progress( + all_progress_entries, tz, window_start, window_end + ) fallback_books = [ b for b in books - if b.id not in books_with_progress + if b.id not in all_book_ids_with_progress and b.reading_status == ReadingStatus.read and b.date_started and b.date_finished and b.page_count ] - fallback_monthly = _compute_pages_per_month_from_books(fallback_books, tz) + fallback_monthly = _compute_pages_per_month_from_books( + fallback_books, tz, window_start, window_end + ) for k, v in fallback_monthly.items(): pages_read_per_month_counter[k] += v - if finished_books_per_month: + if finished_books_per_month_all_time: avg_books_per_month = round( - sum(finished_books_per_month.values()) / len(finished_books_per_month), + sum(finished_books_per_month_all_time.values()) / len(finished_books_per_month_all_time), 2, ) busiest_month, busiest_month_count = min( ( (month, count) - for month, count in finished_books_per_month.items() + for month, count in finished_books_per_month_all_time.items() ), key=lambda item: (-item[1], item[0]), ) - month_keys = _month_range(min(finished_books_per_month), max(max(finished_books_per_month), current_month_key)) - books_finished_per_month = [ - MonthlyBooks(month=month, count=finished_books_per_month.get(month, 0)) for month in month_keys - ] else: avg_books_per_month = None busiest_month = None busiest_month_count = None + + if finished_books_per_month or (window_start_month_key is not None and window_end_month_key is not None): + if window_start_month_key is not None and window_end_month_key is not None: + month_keys = _month_range(window_start_month_key, window_end_month_key) + else: + month_keys = _month_range(min(finished_books_per_month), max(max(finished_books_per_month), current_month_key)) + books_finished_per_month = [ + MonthlyBooks(month=month, count=finished_books_per_month.get(month, 0)) for month in month_keys + ] + else: books_finished_per_month = [] - if pages_read_per_month_counter: - all_months = set(pages_read_per_month_counter) | {current_month_key} - if finished_books_per_month: - all_months |= set(finished_books_per_month) - month_keys = _month_range(min(all_months), max(all_months)) + if pages_read_per_month_counter or (window_start_month_key is not None and window_end_month_key is not None): + if window_start_month_key is not None and window_end_month_key is not None: + month_keys = _month_range(window_start_month_key, window_end_month_key) + else: + all_months = set(pages_read_per_month_counter) | {current_month_key} + if finished_books_per_month: + all_months |= set(finished_books_per_month) + month_keys = _month_range(min(all_months), max(all_months)) pages_read_per_month = [ MonthlyPages(month=month, pages=int(round(pages_read_per_month_counter.get(month, 0)))) for month in month_keys ] else: pages_read_per_month = [] - if finished_books_per_month: + if finished_books_per_month or (window_start_year is not None and window_end_year is not None): yearly_counts: Counter[int] = Counter() for month_key, count in finished_books_per_month.items(): yearly_counts[int(month_key.split("-")[0])] += count - year_start = min(yearly_counts) - year_end = max(max(yearly_counts), current_year) + if window_start_year is not None and window_end_year is not None: + year_start = window_start_year + year_end = window_end_year + else: + year_start = min(yearly_counts) if yearly_counts else current_year + year_end = max(max(yearly_counts), current_year) if yearly_counts else current_year books_finished_per_year = [ YearlyBooks(year=year, count=yearly_counts.get(year, 0)) for year in range(year_start, year_end + 1) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index f2662452..99eb262b 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -1,7 +1,7 @@ """Pydantic / SQLModel request and response schemas for the API.""" from typing import Optional, Any -from datetime import datetime +from datetime import date, datetime from enum import Enum from typing import Literal @@ -249,6 +249,16 @@ class YearlyBooks(SQLModel): count: int +class StatisticsRange(str, Enum): + """Shared statistics time-range selector options.""" + alltime = "alltime" + three_years = "3years" + one_year = "1year" + six_months = "6months" + thirty_days = "30days" + custom = "custom" + + class TopAuthor(SQLModel): """An author with the most books in the library.""" author: str @@ -413,6 +423,9 @@ class UserSettingsRead(SQLModel): goal_books_per_year_enabled: bool goal_books_per_year: int gamification_enabled: bool + statistics_range: StatisticsRange + statistics_custom_from: Optional[date] = None + statistics_custom_to: Optional[date] = None class UserSettingsUpdate(SQLModel): @@ -430,6 +443,9 @@ class UserSettingsUpdate(SQLModel): goal_books_per_year_enabled: Optional[bool] = None goal_books_per_year: Optional[int] = Field(default=None, ge=1) gamification_enabled: Optional[bool] = None + statistics_range: Optional[StatisticsRange] = None + statistics_custom_from: Optional[date] = None + statistics_custom_to: Optional[date] = None @field_validator('theme') @classmethod diff --git a/backend/tests/test_profile.py b/backend/tests/test_profile.py index 5f0f383f..9c48d846 100644 --- a/backend/tests/test_profile.py +++ b/backend/tests/test_profile.py @@ -70,6 +70,57 @@ def test_update_settings_creates_default_when_missing(client: TestClient, sessio assert data["user_id"] == user.id +def test_statistics_range_settings_are_persisted(client: TestClient) -> None: + response = client.patch( + "/api/profile/settings", + json={ + "statistics_range": "custom", + "statistics_custom_from": "2026-01-01", + "statistics_custom_to": "2026-02-01", + }, + ) + assert response.status_code == 200 + data = response.json() + assert data["statistics_range"] == "custom" + assert data["statistics_custom_from"] == "2026-01-01" + assert data["statistics_custom_to"] == "2026-02-01" + + restored = client.get("/api/profile/settings") + assert restored.status_code == 200 + assert restored.json()["statistics_range"] == "custom" + + +def test_statistics_range_settings_reject_invalid_dates(client: TestClient) -> None: + response = client.patch( + "/api/profile/settings", + json={ + "statistics_range": "custom", + "statistics_custom_from": "2026-03-01", + "statistics_custom_to": "2026-02-01", + }, + ) + assert response.status_code == 422 + + incomplete = client.patch( + "/api/profile/settings", + json={"statistics_range": "custom", "statistics_custom_from": "2026-01-01", "statistics_custom_to": None}, + ) + assert incomplete.status_code == 422 + + excessive = client.patch( + "/api/profile/settings", + json={ + "statistics_range": "custom", + "statistics_custom_from": "1900-01-01", + "statistics_custom_to": "2026-02-01", + }, + ) + assert excessive.status_code == 422 + + null_range = client.patch("/api/profile/settings", json={"statistics_range": None}) + assert null_range.status_code == 422 + + def test_reset_data_rolls_back_on_exception(client: TestClient, monkeypatch) -> None: """An exception during data reset should be propagated.""" import app.routers.profile as profile_module diff --git a/backend/tests/test_statistics.py b/backend/tests/test_statistics.py index 5c1b4589..ff6b9f2e 100644 --- a/backend/tests/test_statistics.py +++ b/backend/tests/test_statistics.py @@ -674,6 +674,26 @@ def test_extract_book_level_daily_pages_skips_outside_window() -> None: assert result == {} +def test_statistics_monthly_pages_clamp_to_selected_window() -> None: + from app.routers.statistics import _compute_pages_per_month_from_books + + book = Book( + title="Windowed", + reading_status=ReadingStatus.read, + user_id=1, + page_count=100, + date_started=datetime(2026, 1, 1, tzinfo=timezone.utc), + date_finished=datetime(2026, 1, 10, tzinfo=timezone.utc), + ) + result = _compute_pages_per_month_from_books( + [book], + ZoneInfo("UTC"), + datetime(2026, 1, 6), + datetime(2026, 1, 10, 23, 59, 59), + ) + assert result == {"2026-01": 50.0} + + # ── Rating stats ───────────────────────────────────────────────────────── @@ -690,3 +710,53 @@ def test_statistics_top_and_worst_rated_books(client: Any) -> None: assert data["average_rating"] == 3.5 assert [b["title"] for b in data["top_rated_books"]] == ["Best", "Good", "Okay", "Bad"] assert [b["title"] for b in data["worst_rated_books"]] == ["Bad", "Okay", "Good", "Best"] + + +def test_statistics_range_filters_finished_books(client: Any) -> None: + now = datetime.now(timezone.utc) + _create_book( + client, + title="Outside", + reading_status="read", + date_started=(now - timedelta(days=45)).isoformat(), + date_finished=(now - timedelta(days=40)).isoformat(), + ) + _create_book( + client, + title="Inside", + reading_status="read", + date_started=(now - timedelta(days=5)).isoformat(), + date_finished=(now - timedelta(days=2)).isoformat(), + ) + + response = client.get("/api/statistics?range=30days") + assert response.status_code == 200 + data = response.json() + assert sum(item["count"] for item in data["books_finished_per_month"]) == 1 + assert sum(item["count"] for item in data["books_finished_per_year"]) == 1 + + +def test_statistics_custom_range_and_validation(client: Any) -> None: + _create_book( + client, + title="Included", + reading_status="read", + date_started="2026-01-01T00:00:00Z", + date_finished="2026-02-01T00:00:00Z", + ) + _create_book( + client, + title="Excluded", + reading_status="read", + date_started="2026-03-01T00:00:00Z", + date_finished="2026-04-01T00:00:00Z", + ) + + response = client.get("/api/statistics?range=custom&from=2026-01-01&to=2026-02-28") + assert response.status_code == 200 + assert sum(item["count"] for item in response.json()["books_finished_per_month"]) == 1 + + assert client.get("/api/statistics?range=custom&from=2026-01-01").status_code == 400 + assert client.get("/api/statistics?range=custom&from=2026-03-01&to=2026-02-01").status_code == 400 + assert client.get("/api/statistics?range=alltime&from=2026-01-01&to=2026-02-01").status_code == 400 + assert client.get("/api/statistics?range=custom&from=1900-01-01&to=2026-02-01").status_code == 400 diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 573f457d..500c9d76 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -26,6 +26,7 @@ import type { DashboardQuote, GamificationResponse, StatisticsResponse, + StatisticsRange, LibraryStats, ReadingProgressEntry, StatusTransitionRequest, @@ -258,8 +259,11 @@ export const api = { }, statistics: { - get(): Promise { - return request('/statistics'); + get(range: StatisticsRange = 'alltime', customFrom?: string | null, customTo?: string | null): Promise { + const params = new URLSearchParams({ range }); + if (customFrom) params.set('from', customFrom); + if (customTo) params.set('to', customTo); + return request(`/statistics?${params.toString()}`); }, getPagesPerDay(days: number = 365): Promise { diff --git a/frontend/src/lib/components/StatisticsRangeSelector.svelte b/frontend/src/lib/components/StatisticsRangeSelector.svelte new file mode 100644 index 00000000..fd3b03f0 --- /dev/null +++ b/frontend/src/lib/components/StatisticsRangeSelector.svelte @@ -0,0 +1,66 @@ + + +
+ + + {#if range === 'custom'} +
+ + +
+ {#if invalid} + + {/if} + {/if} +
diff --git a/frontend/src/lib/components/StatisticsRangeSelector.test.ts b/frontend/src/lib/components/StatisticsRangeSelector.test.ts new file mode 100644 index 00000000..b89222e2 --- /dev/null +++ b/frontend/src/lib/components/StatisticsRangeSelector.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import StatisticsRangeSelector from './StatisticsRangeSelector.svelte'; + +describe('StatisticsRangeSelector', () => { + it('renders exactly the six supported ranges', () => { + render(StatisticsRangeSelector); + const options = screen.getByRole('combobox').querySelectorAll('option'); + expect([...options].map((option) => option.textContent)).toEqual([ + 'All time', + 'Last 3 years', + 'Last year', + 'Last 6 months', + 'Last 30 days', + 'Custom' + ]); + }); + + it('shows custom date inputs and validates their order', async () => { + render(StatisticsRangeSelector, { props: { range: 'custom' } }); + expect(screen.getByText('From')).toBeInTheDocument(); + expect(screen.getByText('To')).toBeInTheDocument(); + }); + + it('reports an invalid custom range', () => { + render(StatisticsRangeSelector, { + props: { range: 'custom', customFrom: '2026-03-01', customTo: '2026-02-01' } + }); + expect(screen.getByRole('alert')).toHaveTextContent('From date cannot be after To date'); + }); +}); diff --git a/frontend/src/lib/i18n/locales/de.json b/frontend/src/lib/i18n/locales/de.json index 12149b04..4152d29c 100644 --- a/frontend/src/lib/i18n/locales/de.json +++ b/frontend/src/lib/i18n/locales/de.json @@ -34,6 +34,17 @@ "pagesReadPerMonth": "Gelesene Seiten pro Monat", "booksFinishedPerMonth": "Beendete Bücher pro Monat", "booksFinishedPerYear": "Beendete Bücher pro Jahr", + "rangeLabel": "Zeitraum", + "refreshing": "Statistiken werden aktualisiert", + "rangeAllTime": "Gesamte Zeit", + "rangeLast3Years": "Letzte 3 Jahre", + "rangeLastYear": "Letztes Jahr", + "rangeLast6Months": "Letzte 6 Monate", + "rangeLast30Days": "Letzte 30 Tage", + "rangeCustom": "Benutzerdefiniert", + "from": "Von", + "to": "Bis", + "invalidDateRange": "Das Startdatum darf nicht nach dem Enddatum liegen", "topAuthors": "Top-Autoren", "rankedNumber": "#{rank}", "coversForAuthor": "Buchcover für {author}", diff --git a/frontend/src/lib/i18n/locales/en.json b/frontend/src/lib/i18n/locales/en.json index 81634b1c..c8d4073a 100644 --- a/frontend/src/lib/i18n/locales/en.json +++ b/frontend/src/lib/i18n/locales/en.json @@ -36,6 +36,17 @@ "pagesReadPerMonth": "Pages Read Per Month", "booksFinishedPerMonth": "Books Finished Per Month", "booksFinishedPerYear": "Books Finished Per Year", + "rangeLabel": "Time range", + "refreshing": "Updating statistics", + "rangeAllTime": "All time", + "rangeLast3Years": "Last 3 years", + "rangeLastYear": "Last year", + "rangeLast6Months": "Last 6 months", + "rangeLast30Days": "Last 30 days", + "rangeCustom": "Custom", + "from": "From", + "to": "To", + "invalidDateRange": "From date cannot be after To date", "topAuthors": "Top Authors", "rankedNumber": "#{rank}", "coversForAuthor": "Book covers for {author}", diff --git a/frontend/src/lib/i18n/locales/es.json b/frontend/src/lib/i18n/locales/es.json index 2c0471c7..276e4ac3 100644 --- a/frontend/src/lib/i18n/locales/es.json +++ b/frontend/src/lib/i18n/locales/es.json @@ -34,6 +34,17 @@ "pagesReadPerMonth": "Páginas leídas por mes", "booksFinishedPerMonth": "Libros terminados por mes", "booksFinishedPerYear": "Libros terminados por año", + "rangeLabel": "Período", + "refreshing": "Actualizando estadísticas", + "rangeAllTime": "Todo el tiempo", + "rangeLast3Years": "Últimos 3 años", + "rangeLastYear": "Último año", + "rangeLast6Months": "Últimos 6 meses", + "rangeLast30Days": "Últimos 30 días", + "rangeCustom": "Personalizado", + "from": "Desde", + "to": "Hasta", + "invalidDateRange": "La fecha inicial no puede ser posterior a la fecha final", "topAuthors": "Autores destacados", "rankedNumber": "#{rank}", "coversForAuthor": "Portadas de {author}", diff --git a/frontend/src/lib/i18n/locales/fr.json b/frontend/src/lib/i18n/locales/fr.json index 18b6de07..017d855c 100644 --- a/frontend/src/lib/i18n/locales/fr.json +++ b/frontend/src/lib/i18n/locales/fr.json @@ -34,6 +34,17 @@ "pagesReadPerMonth": "Pages lues par mois", "booksFinishedPerMonth": "Livres terminés par mois", "booksFinishedPerYear": "Livres terminés par an", + "rangeLabel": "Période", + "refreshing": "Mise à jour des statistiques", + "rangeAllTime": "Depuis toujours", + "rangeLast3Years": "3 dernières années", + "rangeLastYear": "Dernière année", + "rangeLast6Months": "6 derniers mois", + "rangeLast30Days": "30 derniers jours", + "rangeCustom": "Personnalisé", + "from": "Du", + "to": "Au", + "invalidDateRange": "La date de début ne peut pas être après la date de fin", "topAuthors": "Auteurs populaires", "rankedNumber": "#{rank}", "coversForAuthor": "Couvertures de {author}", diff --git a/frontend/src/lib/i18n/locales/zh.json b/frontend/src/lib/i18n/locales/zh.json index d4b35f27..4aee640d 100644 --- a/frontend/src/lib/i18n/locales/zh.json +++ b/frontend/src/lib/i18n/locales/zh.json @@ -34,6 +34,17 @@ "pagesReadPerMonth": "每月阅读页数", "booksFinishedPerMonth": "每月读完数量", "booksFinishedPerYear": "每年读完数量", + "rangeLabel": "时间范围", + "refreshing": "正在更新统计数据", + "rangeAllTime": "全部时间", + "rangeLast3Years": "最近3年", + "rangeLastYear": "最近一年", + "rangeLast6Months": "最近6个月", + "rangeLast30Days": "最近30天", + "rangeCustom": "自定义", + "from": "从", + "to": "到", + "invalidDateRange": "开始日期不能晚于结束日期", "topAuthors": "热门作者", "rankedNumber": "#{rank}", "coversForAuthor": "{author} 的图书封面", diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index dbb10bcb..e1c87d37 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -198,6 +198,8 @@ export interface StatisticsResponse { worst_rated_books: TopRatedBook[]; } +export type StatisticsRange = 'alltime' | '3years' | '1year' | '6months' | '30days' | 'custom'; + export type UserRole = 'admin' | 'user'; export interface User { @@ -236,6 +238,9 @@ export interface UserSettings { goal_books_per_year_enabled: boolean; goal_books_per_year: number; gamification_enabled: boolean; + statistics_range: StatisticsRange; + statistics_custom_from: string | null; + statistics_custom_to: string | null; } export type GoalType = 'pages_per_day' | 'pages_per_month' | 'books_per_month' | 'books_per_year'; diff --git a/frontend/src/routes/statistics/+page.svelte b/frontend/src/routes/statistics/+page.svelte index bfe4cea4..dce98449 100644 --- a/frontend/src/routes/statistics/+page.svelte +++ b/frontend/src/routes/statistics/+page.svelte @@ -3,7 +3,7 @@ import { onMount } from 'svelte'; import { _, locale } from '$lib/i18n'; import { api } from '$lib/api'; - import type { Book, DailyPagesResponse, StatisticsResponse } from '$lib/types'; + import type { Book, DailyPagesResponse, StatisticsRange, StatisticsResponse } from '$lib/types'; import { toasts } from '$lib/toasts'; import { formatLanguageCode } from '$lib/utils/language'; import BarChart from '$lib/components/BarChart.svelte'; @@ -11,6 +11,7 @@ import BookDetailDialog from '$lib/components/BookDetailDialog.svelte'; import BookDrawer from '$lib/components/BookDrawer.svelte'; import RatedBooksSection from '$lib/components/RatedBooksSection.svelte'; + import StatisticsRangeSelector from '$lib/components/StatisticsRangeSelector.svelte'; import { RotateCcw } from '@lucide/svelte'; type Segment = { @@ -25,6 +26,7 @@ }; let loading = $state(true); + let refreshing = $state(false); let stats = $state(null); let calendarData = $state(null); let calendarLoading = $state(false); @@ -35,36 +37,112 @@ let pagesChart = $state | null>(null); let booksMonthChart = $state | null>(null); let booksYearChart = $state | null>(null); + let statisticsRange = $state('alltime'); + let customFrom = $state(''); + let customTo = $state(''); + let rangeInvalid = $state(false); + let settingsLoading = $state(true); + let rangeReady = false; + let rangeReloadTimer: ReturnType | null = null; + let isPageActive: () => boolean = () => true; + let statisticsRequestId = 0; onMount(() => { let active = true; - void loadStatistics(() => active); - void loadCalendarData(() => active); + isPageActive = () => active; + void loadSettingsAndStatistics(() => active); return () => { active = false; + if (rangeReloadTimer) clearTimeout(rangeReloadTimer); }; }); - async function loadStatistics(isActive: () => boolean) { - loading = true; + async function loadSettingsAndStatistics(isActive: () => boolean) { + settingsLoading = true; try { - const data = await api.statistics.get(); + try { + const settings = await api.profile.getSettings(); + if (!isActive()) return; + statisticsRange = settings.statistics_range ?? 'alltime'; + customFrom = settings.statistics_custom_from ?? ''; + customTo = settings.statistics_custom_to ?? ''; + } catch { + // Keep the legacy all-time default if settings are unavailable. + } + if (!isActive()) return; + await loadStatistics(isActive); + await loadCalendarData(isActive); + rangeReady = true; + } catch (e: unknown) { if (isActive()) { + const message = e instanceof Error ? e.message : $_('common.actionFailed', { values: { action: 'load' } }); + toasts.add(message, 'error'); + } + } finally { + if (isActive()) settingsLoading = false; + } + } + + async function loadStatistics(isActive: () => boolean) { + const requestId = ++statisticsRequestId; + const isInitialLoad = stats === null; + if (isInitialLoad) loading = true; + else refreshing = true; + try { + const requestedRange = validCustomRange() ? statisticsRange : 'alltime'; + const data = await api.statistics.get(requestedRange, requestedRange === 'custom' ? customFrom : null, requestedRange === 'custom' ? customTo : null); + if (isActive() && requestId === statisticsRequestId) { stats = data; } } catch (e: unknown) { - if (isActive()) { + if (isActive() && requestId === statisticsRequestId) { const message = e instanceof Error ? e.message : $_('common.actionFailed', { values: { action: 'load' } }); toasts.add(message, 'error'); stats = null; } } finally { - if (isActive()) { - loading = false; + if (isActive() && requestId === statisticsRequestId) { + if (isInitialLoad) loading = false; + else refreshing = false; } } } + function validCustomRange(): boolean { + return statisticsRange !== 'custom' || (!!customFrom && !!customTo && customFrom <= customTo); + } + + function scheduleRangeReload() { + if (!rangeReady || !validCustomRange()) return; + if (rangeReloadTimer) clearTimeout(rangeReloadTimer); + rangeReloadTimer = setTimeout(() => { + void persistAndReload(); + }, 300); + } + + async function persistAndReload() { + if (!validCustomRange()) return; + try { + await api.profile.updateSettings({ + statistics_range: statisticsRange, + statistics_custom_from: customFrom || null, + statistics_custom_to: customTo || null + }); + await loadStatistics(isPageActive); + } catch (e: unknown) { + const message = e instanceof Error ? e.message : $_('common.actionFailed', { values: { action: 'save' } }); + toasts.add(message, 'error'); + } + } + + $effect(() => { + // Track these values so edits schedule a debounced persistence/reload. + statisticsRange; + customFrom; + customTo; + if (rangeReady) scheduleRangeReload(); + }); + async function loadCalendarData(isActive: () => boolean) { calendarLoading = true; try { @@ -356,6 +434,16 @@
{$_('statistics.sectionCharts')}
+
+ +