diff --git a/backend/alembic/versions/9a8b7c6d5e4f_add_reading_goals_to_usersettings.py b/backend/alembic/versions/9a8b7c6d5e4f_add_reading_goals_to_usersettings.py new file mode 100644 index 00000000..a72cfddf --- /dev/null +++ b/backend/alembic/versions/9a8b7c6d5e4f_add_reading_goals_to_usersettings.py @@ -0,0 +1,40 @@ +"""add reading goals to usersettings + +Revision ID: 9a8b7c6d5e4f +Revises: f1b2c3d4e5a6 +Create Date: 2026-08-26 13:15:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '9a8b7c6d5e4f' +down_revision: Union[str, Sequence[str], None] = 'f1b2c3d4e5a6' +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('goal_pages_per_day_enabled', sa.Boolean(), nullable=False, server_default=sa.false())) + op.add_column('usersettings', sa.Column('goal_pages_per_day', sa.Integer(), nullable=False, server_default='20')) + op.add_column('usersettings', sa.Column('goal_pages_per_month_enabled', sa.Boolean(), nullable=False, server_default=sa.false())) + op.add_column('usersettings', sa.Column('goal_pages_per_month', sa.Integer(), nullable=False, server_default='300')) + op.add_column('usersettings', sa.Column('goal_books_per_month_enabled', sa.Boolean(), nullable=False, server_default=sa.false())) + op.add_column('usersettings', sa.Column('goal_books_per_month', sa.Integer(), nullable=False, server_default='2')) + op.add_column('usersettings', sa.Column('goal_books_per_year_enabled', sa.Boolean(), nullable=False, server_default=sa.false())) + op.add_column('usersettings', sa.Column('goal_books_per_year', sa.Integer(), nullable=False, server_default='25')) + + +def downgrade() -> None: + op.drop_column('usersettings', 'goal_books_per_year') + op.drop_column('usersettings', 'goal_books_per_year_enabled') + op.drop_column('usersettings', 'goal_books_per_month') + op.drop_column('usersettings', 'goal_books_per_month_enabled') + op.drop_column('usersettings', 'goal_pages_per_month') + op.drop_column('usersettings', 'goal_pages_per_month_enabled') + op.drop_column('usersettings', 'goal_pages_per_day') + op.drop_column('usersettings', 'goal_pages_per_day_enabled') \ No newline at end of file diff --git a/backend/alembic/versions/a1b2c3d4e5f7_add_gamification_enabled_to_usersettings.py b/backend/alembic/versions/a1b2c3d4e5f7_add_gamification_enabled_to_usersettings.py new file mode 100644 index 00000000..9e77848e --- /dev/null +++ b/backend/alembic/versions/a1b2c3d4e5f7_add_gamification_enabled_to_usersettings.py @@ -0,0 +1,29 @@ +"""add gamification enabled to usersettings + +Revision ID: a1b2c3d4e5f7 +Revises: 9a8b7c6d5e4f +Create Date: 2026-08-26 15:20:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'a1b2c3d4e5f7' +down_revision: Union[str, Sequence[str], None] = '9a8b7c6d5e4f' +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('gamification_enabled', sa.Boolean(), nullable=False, server_default=sa.true()) + ) + + +def downgrade() -> None: + op.drop_column('usersettings', 'gamification_enabled') \ No newline at end of file diff --git a/backend/app/models.py b/backend/app/models.py index 425b2cc2..99e771ea 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -183,6 +183,15 @@ class UserSettings(SQLModel, table=True): timezone: str = Field(default="UTC", max_length=64) theme: str = Field(default="light", max_length=20) custom_theme: Optional[str] = Field(default=None, max_length=30) + goal_pages_per_day_enabled: bool = Field(default=False) + goal_pages_per_day: int = Field(default=20, ge=1) + goal_pages_per_month_enabled: bool = Field(default=False) + goal_pages_per_month: int = Field(default=300, ge=1) + goal_books_per_month_enabled: bool = Field(default=False) + goal_books_per_month: int = Field(default=2, ge=1) + 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) class ApiKey(SQLModel, table=True): diff --git a/backend/app/routers/profile.py b/backend/app/routers/profile.py index b838b98e..00bf1c2f 100644 --- a/backend/app/routers/profile.py +++ b/backend/app/routers/profile.py @@ -104,6 +104,15 @@ def get_settings( timezone=settings.timezone, theme=settings.theme, custom_theme=settings.custom_theme, + goal_pages_per_day_enabled=settings.goal_pages_per_day_enabled, + goal_pages_per_day=settings.goal_pages_per_day, + goal_pages_per_month_enabled=settings.goal_pages_per_month_enabled, + goal_pages_per_month=settings.goal_pages_per_month, + goal_books_per_month_enabled=settings.goal_books_per_month_enabled, + goal_books_per_month=settings.goal_books_per_month, + 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, ) @@ -133,6 +142,15 @@ def update_settings( timezone=settings.timezone, theme=settings.theme, custom_theme=settings.custom_theme, + goal_pages_per_day_enabled=settings.goal_pages_per_day_enabled, + goal_pages_per_day=settings.goal_pages_per_day, + goal_pages_per_month_enabled=settings.goal_pages_per_month_enabled, + goal_pages_per_month=settings.goal_pages_per_month, + goal_books_per_month_enabled=settings.goal_books_per_month_enabled, + goal_books_per_month=settings.goal_books_per_month, + 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, ) diff --git a/backend/app/routers/statistics.py b/backend/app/routers/statistics.py index 5be6f446..900f25a0 100644 --- a/backend/app/routers/statistics.py +++ b/backend/app/routers/statistics.py @@ -2,7 +2,7 @@ import calendar from collections import Counter, defaultdict -from datetime import datetime, timedelta, timezone +from datetime import date, datetime, timedelta, timezone from statistics import mean from types import SimpleNamespace from typing import Optional @@ -20,6 +20,9 @@ AcquisitionStatusDistribution, DailyPages, DailyPagesResponse, + GamificationResponse, + GoalProgress, + GoalType, LanguageDistribution, MonthlyBooks, MonthlyPages, @@ -35,16 +38,20 @@ router = APIRouter(prefix="/api/statistics", tags=["statistics"]) -def _user_timezone(session: Session, user_id: int) -> ZoneInfo: - """Return the user's configured timezone, falling back to UTC.""" - settings = session.exec(select(UserSettings).where(UserSettings.user_id == user_id)).first() - timezone_name = settings.timezone if settings and settings.timezone else "UTC" +def _zone_from_name(timezone_name: str | None) -> ZoneInfo: + """Return a ZoneInfo for *timezone_name*, falling back to UTC.""" try: - return ZoneInfo(timezone_name) + return ZoneInfo(timezone_name or "UTC") except ZoneInfoNotFoundError: return ZoneInfo("UTC") +def _user_timezone(session: Session, user_id: int) -> ZoneInfo: + """Return the user's configured timezone, falling back to UTC.""" + settings = session.exec(select(UserSettings).where(UserSettings.user_id == user_id)).first() + return _zone_from_name(settings.timezone if settings else None) + + def _month_key(dt: datetime, tz: ZoneInfo) -> str: """Format a datetime as ``YYYY-MM`` in the given timezone.""" local = dt.astimezone(tz) @@ -224,6 +231,253 @@ def _compute_pages_per_month_from_books(books: list[Book], tz: ZoneInfo) -> dict return monthly +def _day_key(dt: datetime, tz: ZoneInfo) -> str: + """Return the ``YYYY-MM-DD`` calendar day of *dt* in *tz*.""" + return dt.astimezone(tz).strftime("%Y-%m-%d") + + +def current_streak(active_dates: set[str], today: date) -> int: + """Return the number of consecutive active days ending at *today*. + + Today counts as the first day when it is active; otherwise the streak + starts at yesterday, so a not-yet-logged today does not break an ongoing + streak. The streak is 0 when neither today nor yesterday are active. + """ + streak = 0 + day = today + first = True + while True: + if day.isoformat() in active_dates: + streak += 1 + elif not first: + break + first = False + day -= timedelta(days=1) + return streak + + +def longest_streak(active_dates: set[str]) -> tuple[int, Optional[str], Optional[str]]: + """Return the longest consecutive run of active dates. + + Returns ``(length, start, end)`` with ``YYYY-MM-DD`` keys. Ties are + broken in favour of the most recent run. When there is no activity at + all the result is ``(0, None, None)``. + """ + if not active_dates: + return 0, None, None + ordered = sorted(active_dates) + best_len, best_start, best_end = 0, None, None + run_start = ordered[0] + run_len = 1 + prev = ordered[0] + for current in ordered[1:]: + if (date.fromisoformat(current) - date.fromisoformat(prev)).days == 1: + run_len += 1 + else: + if run_len >= best_len: + best_len, best_start, best_end = run_len, run_start, prev + run_start, run_len = current, 1 + prev = current + if run_len >= best_len: + best_len, best_start, best_end = run_len, run_start, prev + return best_len, best_start, best_end + + +def _pages_logged_on_day(entries: list, tz: ZoneInfo, day_key: str) -> int: + """Sum the positive page-deltas logged on *day_key*. + + A delta is the page gain between two consecutive progress entries of the + same book, attributed to the calendar day (in *tz*) of the later entry. + """ + grouped: dict[int, list] = {} + for entry in entries: + grouped.setdefault(entry.book_id, []).append(entry) + total = 0 + for book_entries in grouped.values(): + book_entries.sort(key=lambda e: (e.created_at, e.page)) + for prev, curr in zip(book_entries, book_entries[1:]): + delta = curr.page - prev.page + if delta > 0 and _day_key(curr.created_at, tz) == day_key: + total += delta + return total + + +def _compute_goal_progress( + tz: ZoneInfo, + settings: UserSettings, + today: datetime, + entries: list, + books: list, + book_ids_with_progress: set[int], +) -> list[GoalProgress]: + """Compute current progress for every enabled reading goal. + + Disabled goals are omitted from the response; the dashboard only shows + goals the user opted into. + """ + today_key = today.strftime("%Y-%m-%d") + current_month_key = today.strftime("%Y-%m") + current_year = today.year + + fallback_books = [ + b + for b in books + if b.id not in book_ids_with_progress + and b.reading_status == ReadingStatus.read + and b.date_started + and b.date_finished + and b.page_count + ] + + # Mirror get_statistics: anchor every book with progress at page 0 on its + # start date so the first progress delta is attributed to the reading span, + # keeping the pages-per-month goal consistent with the statistics chart. + virtual_entries = [ + SimpleNamespace(book_id=b.id, page=0, created_at=b.date_started) + for b in books + if b.id in book_ids_with_progress + and b.date_started + and not (b.reading_status == ReadingStatus.read and not b.date_finished) + ] + + goals_spec = [ + (GoalType.pages_per_day, settings.goal_pages_per_day_enabled, settings.goal_pages_per_day), + (GoalType.pages_per_month, settings.goal_pages_per_month_enabled, settings.goal_pages_per_month), + (GoalType.books_per_month, settings.goal_books_per_month_enabled, settings.goal_books_per_month), + (GoalType.books_per_year, settings.goal_books_per_year_enabled, settings.goal_books_per_year), + ] + + results: list[GoalProgress] = [] + for goal_type, enabled, target in goals_spec: + if not enabled: + continue + current = _goal_current_value( + goal_type, tz, today_key, current_month_key, current_year, + entries, books, fallback_books, virtual_entries, + ) + results.append( + GoalProgress(type=goal_type, target=target, current=current, reached=current >= target) + ) + return results + + +def _goal_current_value( + goal_type: GoalType, + tz: ZoneInfo, + today_key: str, + current_month_key: str, + current_year: int, + entries: list, + books: list, + fallback_books: list, + virtual_entries: list, +) -> int: + """Return the current value for a single reading goal.""" + if goal_type == GoalType.pages_per_day: + total = _pages_logged_on_day(entries, tz, today_key) + for b in fallback_books: + if _day_key(b.date_finished, tz) == today_key: + total += b.page_count + return total + + if goal_type == GoalType.pages_per_month: + monthly = _compute_pages_per_month_from_progress(entries + virtual_entries, tz) + for k, v in _compute_pages_per_month_from_books(fallback_books, tz).items(): + monthly[k] += v + return int(round(monthly.get(current_month_key, 0))) + + if goal_type == GoalType.books_per_month: + return sum( + 1 + for b in books + if b.reading_status == ReadingStatus.read + and b.date_finished is not None + and _month_key(b.date_finished, tz) == current_month_key + ) + + if goal_type == GoalType.books_per_year: + return sum( + 1 + for b in books + if b.reading_status == ReadingStatus.read + and b.date_finished is not None + and b.date_finished.astimezone(tz).year == current_year + ) + + return 0 + + +@router.get("/gamification", response_model=GamificationResponse) +def get_gamification( + current_user: User = Depends(require_user), + session: Session = Depends(get_session), +) -> GamificationResponse: + """Return dashboard gamification data — reading streaks and goal progress.""" + assert current_user.id is not None + + settings = session.exec( + select(UserSettings).where(UserSettings.user_id == current_user.id) + ).first() + tz = _zone_from_name(settings.timezone if settings else None) + today = datetime.now(tz) + if not settings: + settings = UserSettings(user_id=current_user.id, language="en") + + if not settings.gamification_enabled: + return GamificationResponse( + enabled=False, + current_streak=0, + longest_streak=0, + longest_streak_start=None, + longest_streak_end=None, + goals=[], + ) + + # Only the columns needed for streaks and goal progress are loaded; the + # per-request cost still grows with lifetime library size, which is + # acceptable for typical personal-library volumes. + entries = list( + session.exec( + select(ReadingProgress) + .where(ReadingProgress.user_id == current_user.id) + .order_by(col(ReadingProgress.book_id), col(ReadingProgress.created_at)) + ).all() + ) + books = list( + session.exec( + select(Book) + .where(Book.user_id == current_user.id) + .order_by(col(Book.id)) + ).all() + ) + book_ids_with_progress = {entry.book_id for entry in entries} + + active_dates = {_day_key(entry.created_at, tz) for entry in entries} + for book in books: + if ( + book.id not in book_ids_with_progress + and book.reading_status == ReadingStatus.read + and book.date_finished is not None + ): + active_dates.add(_day_key(book.date_finished, tz)) + + current = current_streak(active_dates, today.date()) + longest, longest_start, longest_end = longest_streak(active_dates) + + goals = _compute_goal_progress( + tz, settings, today, entries, books, book_ids_with_progress + ) + + return GamificationResponse( + enabled=True, + current_streak=current, + longest_streak=longest, + longest_streak_start=longest_start, + longest_streak_end=longest_end, + goals=goals, + ) + + @router.get("/pages-per-day", response_model=DailyPagesResponse) def get_pages_per_day( days: int = Query(default=365, ge=1, le=730), diff --git a/backend/app/schemas.py b/backend/app/schemas.py index c5ec3c06..f2662452 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -300,6 +300,32 @@ class StatisticsResponse(SQLModel): worst_rated_books: list[TopRatedBook] +class GoalType(str, Enum): + """Reading goal types tracked on the dashboard.""" + pages_per_day = "pages_per_day" + pages_per_month = "pages_per_month" + books_per_month = "books_per_month" + books_per_year = "books_per_year" + + +class GoalProgress(SQLModel): + """Progress toward a single reading goal (enabled goals only).""" + type: GoalType + target: int + current: int + reached: bool + + +class GamificationResponse(SQLModel): + """Dashboard gamification data — reading streaks and goal progress.""" + enabled: bool + current_streak: int + longest_streak: int + longest_streak_start: Optional[str] = None + longest_streak_end: Optional[str] = None + goals: list[GoalProgress] + + class UserLogin(SQLModel): """Login request body.""" email: str @@ -378,6 +404,15 @@ class UserSettingsRead(SQLModel): timezone: str theme: str custom_theme: Optional[str] = None + goal_pages_per_day_enabled: bool + goal_pages_per_day: int + goal_pages_per_month_enabled: bool + goal_pages_per_month: int + goal_books_per_month_enabled: bool + goal_books_per_month: int + goal_books_per_year_enabled: bool + goal_books_per_year: int + gamification_enabled: bool class UserSettingsUpdate(SQLModel): @@ -386,6 +421,15 @@ class UserSettingsUpdate(SQLModel): timezone: Optional[str] = None theme: Optional[str] = None custom_theme: Optional[str] = None + goal_pages_per_day_enabled: Optional[bool] = None + goal_pages_per_day: Optional[int] = Field(default=None, ge=1) + goal_pages_per_month_enabled: Optional[bool] = None + goal_pages_per_month: Optional[int] = Field(default=None, ge=1) + goal_books_per_month_enabled: Optional[bool] = None + goal_books_per_month: Optional[int] = Field(default=None, ge=1) + goal_books_per_year_enabled: Optional[bool] = None + goal_books_per_year: Optional[int] = Field(default=None, ge=1) + gamification_enabled: Optional[bool] = None @field_validator('theme') @classmethod diff --git a/backend/tests/test_gamification.py b/backend/tests/test_gamification.py new file mode 100644 index 00000000..0d426199 --- /dev/null +++ b/backend/tests/test_gamification.py @@ -0,0 +1,371 @@ +"""Tests for dashboard gamification — reading streaks and reading goals.""" + +from datetime import date, datetime, timedelta, timezone +from typing import Any + +from sqlmodel import Session, select + +from app.models import Book, ReadingProgress, UserSettings +from app.routers.statistics import current_streak, longest_streak + + +def _create_book(client: Any, **overrides: Any) -> dict[str, Any]: + """Helper to create a book via the API and return the JSON response.""" + payload = {"title": "Book", "author": "Test Author", "page_count": 100, **overrides} + resp = client.post("/api/books", json=payload) + assert resp.status_code == 201 + return resp.json() + + +def _add_progress(session: Session, book: dict[str, Any], page: int, when: datetime) -> None: + db_book = session.get(Book, book["id"]) + assert db_book is not None + assert db_book.id is not None + assert db_book.user_id is not None + session.add( + ReadingProgress( + book_id=db_book.id, user_id=db_book.user_id, page=page, created_at=when + ) + ) + session.commit() + + +def _utc_now() -> datetime: + return datetime.now(timezone.utc) + + +def _days_ago(days: int) -> datetime: + return _utc_now() - timedelta(days=days) + + +def test_current_streak_today_active() -> None: + today = date(2026, 8, 26) + assert current_streak({"2026-08-26"}, today) == 1 + assert current_streak({"2026-08-26", "2026-08-25", "2026-08-24"}, today) == 3 + assert current_streak({"2026-08-26", "2026-08-24"}, today) == 1 + + +def test_current_streak_does_not_break_when_today_not_logged() -> None: + today = date(2026, 8, 26) + assert current_streak({"2026-08-25"}, today) == 1 + assert current_streak({"2026-08-25", "2026-08-24", "2026-08-23"}, today) == 3 + + +def test_current_streak_breaks_when_yesterday_not_logged() -> None: + today = date(2026, 8, 26) + assert current_streak({"2026-08-24"}, today) == 0 + assert current_streak({"2026-08-26", "2026-08-24"}, today) == 1 + assert current_streak(set(), today) == 0 + + +def test_longest_streak_finds_longest_run_and_most_recent_tie() -> None: + active = { + "2026-01-01", "2026-01-02", "2026-01-03", "2026-01-04", "2026-01-05", + "2026-01-10", "2026-01-11", "2026-01-12", + } + assert longest_streak(active) == (5, "2026-01-01", "2026-01-05") + + tie = {"2026-01-01", "2026-01-02", "2026-03-01", "2026-03-02"} + assert longest_streak(tie) == (2, "2026-03-01", "2026-03-02") + + +def test_longest_streak_single_day_and_empty() -> None: + assert longest_streak({"2026-06-01"}) == (1, "2026-06-01", "2026-06-01") + assert longest_streak(set()) == (0, None, None) + + +# ── API: streaks ─────────────────────────────────────────────────────────────── + + +def test_gamification_empty_library(client: Any) -> None: + resp = client.get("/api/statistics/gamification") + assert resp.status_code == 200 + data = resp.json() + assert data["current_streak"] == 0 + assert data["longest_streak"] == 0 + assert data["longest_streak_start"] is None + assert data["longest_streak_end"] is None + assert data["goals"] == [] + + +def test_gamification_current_streak_today_logged(client: Any, session: Session) -> None: + book = _create_book(client) + _add_progress(session, book, 10, _utc_now()) + + data = client.get("/api/statistics/gamification").json() + assert data["current_streak"] >= 1 + assert data["longest_streak"] >= 1 + + +def test_gamification_current_streak_multiple_consecutive_days(client: Any, session: Session) -> None: + book = _create_book(client) + for day in range(3): + _add_progress(session, book, 10 + day, _days_ago(day)) + + data = client.get("/api/statistics/gamification").json() + assert data["current_streak"] == 3 + + +def test_gamification_streak_does_not_break_without_today(client: Any, session: Session) -> None: + book = _create_book(client) + for day in (1, 2): + _add_progress(session, book, 10 + day, _days_ago(day)) + + data = client.get("/api/statistics/gamification").json() + assert data["current_streak"] == 2 + + +def test_gamification_streak_breaks_after_gap(client: Any, session: Session) -> None: + book = _create_book(client) + _add_progress(session, book, 5, _days_ago(3)) + + data = client.get("/api/statistics/gamification").json() + assert data["current_streak"] == 0 + + +def test_gamification_longest_streak_returns_window(client: Any, session: Session) -> None: + book = _create_book(client) + # two runs: 3 consecutive days ending today, and a longer 4-day run 10+ days ago + for day in range(3): + _add_progress(session, book, 20 + day, _days_ago(day)) + for day in range(10, 14): + _add_progress(session, book, 30 + day, _days_ago(day)) + + data = client.get("/api/statistics/gamification").json() + assert data["longest_streak"] == 4 + assert data["longest_streak_start"] is not None + assert data["longest_streak_end"] is not None + + +def test_gamification_timezone_aware_streak(client: Any, session: Session) -> None: + settings = session.exec(select(UserSettings)).first() + assert settings is not None + settings.timezone = "America/New_York" + session.add(settings) + session.commit() + + book = _create_book(client) + _add_progress(session, book, 10, _utc_now()) + + data = client.get("/api/statistics/gamification").json() + assert data["current_streak"] == 1 + + +# ── API: goals ───────────────────────────────────────────────────────────────── + + +def _set_goal(session: Session, enabled_col: str, target_col: str, enabled: bool, target: int) -> None: + settings = session.exec(select(UserSettings)).first() + assert settings is not None + setattr(settings, enabled_col, enabled) + setattr(settings, target_col, target) + session.add(settings) + session.commit() + + +def test_gamification_goals_disabled_by_default(client: Any, session: Session) -> None: + book = _create_book(client) + _add_progress(session, book, 10, _utc_now()) + + data = client.get("/api/statistics/gamification").json() + assert data["goals"] == [] + + +def test_gamification_goal_pages_per_day(client: Any, session: Session) -> None: + _set_goal(session, "goal_pages_per_day_enabled", "goal_pages_per_day", True, 20) + book = _create_book(client) + _add_progress(session, book, 5, _days_ago(1)) + _add_progress(session, book, 15, _utc_now()) + + goals = client.get("/api/statistics/gamification").json()["goals"] + assert len(goals) == 1 + assert goals[0]["type"] == "pages_per_day" + assert goals[0]["target"] == 20 + assert goals[0]["current"] == 10 + assert goals[0]["reached"] is False + + +def test_gamification_goal_pages_per_day_reached(client: Any, session: Session) -> None: + _set_goal(session, "goal_pages_per_day_enabled", "goal_pages_per_day", True, 20) + book = _create_book(client) + _add_progress(session, book, 5, _days_ago(1)) + _add_progress(session, book, 30, _utc_now()) + + goals = client.get("/api/statistics/gamification").json()["goals"] + assert goals[0]["current"] == 25 + assert goals[0]["reached"] is True + + +def test_gamification_goal_pages_per_month(client: Any, session: Session) -> None: + _set_goal(session, "goal_pages_per_month_enabled", "goal_pages_per_month", True, 300) + book = _create_book(client) + # Two entries on the same day keep the delta fully inside the current month. + now = _utc_now() + _add_progress(session, book, 50, now) + _add_progress(session, book, 200, now) + + goals = client.get("/api/statistics/gamification").json()["goals"] + assert len(goals) == 1 + assert goals[0]["type"] == "pages_per_month" + assert goals[0]["current"] == 150 + assert goals[0]["reached"] is False + + +def test_gamification_goal_pages_per_month_fallback_book(client: Any, session: Session) -> None: + _set_goal(session, "goal_pages_per_month_enabled", "goal_pages_per_month", True, 300) + now = _utc_now() + _create_book( + client, + title="Fallback month pages", + reading_status="read", + page_count=100, + date_started=now.strftime("%Y-%m-%dT%H:%M:%SZ"), + date_finished=now.strftime("%Y-%m-%dT%H:%M:%SZ"), + ) + + goals = client.get("/api/statistics/gamification").json()["goals"] + assert goals[0]["current"] == 100 + + +def test_gamification_goal_books_per_month(client: Any, session: Session) -> None: + _set_goal(session, "goal_books_per_month_enabled", "goal_books_per_month", True, 2) + now = _utc_now() + for i in range(2): + _create_book( + client, + title=f"Finished {i}", + reading_status="read", + date_started=(now - timedelta(days=5)).strftime("%Y-%m-%dT%H:%M:%SZ"), + date_finished=now.strftime("%Y-%m-%dT%H:%M:%SZ"), + ) + + goals = client.get("/api/statistics/gamification").json()["goals"] + assert len(goals) == 1 + assert goals[0]["type"] == "books_per_month" + assert goals[0]["current"] == 2 + assert goals[0]["reached"] is True + + +def test_gamification_goal_books_per_year(client: Any, session: Session) -> None: + _set_goal(session, "goal_books_per_year_enabled", "goal_books_per_year", True, 2) + now = _utc_now() + _create_book( + client, + title="Finished this year", + reading_status="read", + date_started=(now - timedelta(days=5)).strftime("%Y-%m-%dT%H:%M:%SZ"), + date_finished=now.strftime("%Y-%m-%dT%H:%M:%SZ"), + ) + + goals = client.get("/api/statistics/gamification").json()["goals"] + assert len(goals) == 1 + assert goals[0]["type"] == "books_per_year" + assert goals[0]["current"] == 1 + assert goals[0]["reached"] is False + + +def test_gamification_fallback_finished_book_counts_as_active_day(client: Any, session: Session) -> None: + now = _utc_now() + _create_book( + client, + title="No progress, finished today", + reading_status="read", + page_count=150, + date_started=(now - timedelta(days=5)).strftime("%Y-%m-%dT%H:%M:%SZ"), + date_finished=now.strftime("%Y-%m-%dT%H:%M:%SZ"), + ) + # fallback books must have NO progress entries at all + assert session.exec(select(ReadingProgress)).first() is None + + data = client.get("/api/statistics/gamification").json() + assert data["current_streak"] == 1 + assert data["longest_streak"] == 1 + assert data["longest_streak_end"] is not None + + +def test_gamification_fallback_book_page_count_counts_toward_daily_goal( + client: Any, session: Session, +) -> None: + _set_goal(session, "goal_pages_per_day_enabled", "goal_pages_per_day", True, 200) + now = _utc_now() + _create_book( + client, + title="Fallback pages", + reading_status="read", + page_count=150, + date_started=(now - timedelta(days=5)).strftime("%Y-%m-%dT%H:%M:%SZ"), + date_finished=now.strftime("%Y-%m-%dT%H:%M:%SZ"), + ) + + goals = client.get("/api/statistics/gamification").json()["goals"] + assert goals[0]["current"] == 150 + + +# ── API: goal settings validation ───────────────────────────────────────────── + + +def test_settings_goal_target_below_minimum_rejected(client: Any) -> None: + resp = client.patch("/api/profile/settings", json={"goal_pages_per_day": 0}) + assert resp.status_code == 422 + + +def test_settings_goal_target_negative_rejected(client: Any) -> None: + resp = client.patch("/api/profile/settings", json={"goal_books_per_year": -3}) + assert resp.status_code == 422 + + +def test_settings_goal_target_valid_saved(client: Any) -> None: + resp = client.patch( + "/api/profile/settings", + json={"goal_pages_per_day": 25, "goal_pages_per_day_enabled": True}, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["goal_pages_per_day"] == 25 + assert data["goal_pages_per_day_enabled"] is True + assert data["goal_books_per_month"] == 2 + assert data["goal_books_per_year"] == 25 + + +def test_gamification_without_settings_row_uses_defaults(client: Any, session: Session) -> None: + for settings in session.exec(select(UserSettings)).all(): + session.delete(settings) + session.commit() + + resp = client.get("/api/statistics/gamification") + assert resp.status_code == 200 + data = resp.json() + assert data["current_streak"] == 0 + assert data["goals"] == [] + + +def test_gamification_enabled_by_default(client: Any, session: Session) -> None: + book = _create_book(client) + _add_progress(session, book, 10, _utc_now()) + + data = client.get("/api/statistics/gamification").json() + assert data["enabled"] is True + + +def test_gamification_disabled_returns_empty(client: Any, session: Session) -> None: + _set_goal(session, "goal_pages_per_day_enabled", "goal_pages_per_day", True, 20) + settings = session.exec(select(UserSettings)).first() + assert settings is not None + settings.gamification_enabled = False + session.add(settings) + session.commit() + + book = _create_book(client) + _add_progress(session, book, 10, _utc_now()) + + data = client.get("/api/statistics/gamification").json() + assert data["enabled"] is False + assert data["current_streak"] == 0 + assert data["longest_streak"] == 0 + assert data["goals"] == [] + + +def test_settings_gamification_enabled_saved(client: Any) -> None: + resp = client.patch("/api/profile/settings", json={"gamification_enabled": False}) + assert resp.status_code == 200 + assert resp.json()["gamification_enabled"] is False \ No newline at end of file diff --git a/docs/guide/using-librislog/dashboard.md b/docs/guide/using-librislog/dashboard.md index 008909cb..eec49c29 100644 --- a/docs/guide/using-librislog/dashboard.md +++ b/docs/guide/using-librislog/dashboard.md @@ -29,6 +29,19 @@ Books from your "Want to Read" list are shown as suggestions — pick one to sta A random quote is displayed at the top of the dashboard (configurable via `DASHBOARD_QUOTE_ENABLED` in `.env`). +## Reading Streaks & Goals + +A gamification section on the dashboard keeps you motivated: + +- **Current Streak** — the number of consecutive days with reading activity, counted backwards from today. Today counts as the first day when you have logged progress; a not-yet-logged today does **not** break your streak. +- **Longest Streak** — your all-time longest run, with the start and end dates shown as a subtitle. + +A day counts as a reading day when you logged at least one reading-progress entry on it, or when you finished a book that has no progress entries. Streaks are computed on the fly in your configured [timezone](/guide/using-librislog/profile#timezone). + +When you enable a reading goal on your [profile page](/guide/using-librislog/profile#reading-goals), the section also shows playful progress cards for every active goal — a progress bar, the current value vs. the target, and a **"Goal reached"** badge once you hit it. The card updates immediately when you save reading progress or delete a log entry from the book detail view. + +The whole section can be hidden from the profile page under **Reading Goals → "Show reading streaks & goals on dashboard"**. + ## Tag Cloud The most common tags in your library are shown, sized by frequency. Click any tag to filter your library by it. diff --git a/docs/guide/using-librislog/profile.md b/docs/guide/using-librislog/profile.md index 1655bfd8..44411787 100644 --- a/docs/guide/using-librislog/profile.md +++ b/docs/guide/using-librislog/profile.md @@ -26,6 +26,23 @@ Set your preferred timezone for date/time displays (e.g., for the calendar heatm Choose a custom DaisyUI theme from the dropdown. The theme previews in real-time as you browse the dropdown, and the selection is saved to your profile so it persists across sessions. +## Reading Goals + +Set personal reading targets that are tracked on the dashboard's **Reading Streaks & Goals** section: + +| Goal | Default target | +|---|---| +| Pages per Day | 20 | +| Pages per Month | 300 | +| Books per Month | 2 | +| Books per Year | 25 | + +Every goal is **disabled by default**. Toggle a goal on and set its target (at least 1) — enabled goals then appear as progress cards on the dashboard. Saving shows a confirmation notification like the other settings sections. + +The **"Show reading streaks & goals on dashboard"** switch above the goals disables or re-enables the entire streaks & goals section on the dashboard. + +See [Dashboard → Reading Streaks & Goals](/guide/using-librislog/dashboard#reading-streaks-goals) for details on how streaks and goal progress are calculated. + ## API Keys Create and manage API keys for headless access to the REST API. Each key can have an optional description. Keys are shown once at creation — copy it immediately, as it cannot be retrieved later. diff --git a/docs/releases.md b/docs/releases.md index 2f2ecac5..20e9088a 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -6,14 +6,43 @@ All LibrisLog releases, newest first — what's new, what was fixed, and anythin You can also browse the [GitHub Releases](https://github.com/codebude/librislog/releases) page and the [full changelog](https://github.com/codebude/librislog/commits/main). -## vNext — Unreleased +## Latest Release + +::: tip ⭐ v1.7.0 — Reading Streaks & Goals +LibrisLog v1.7.0 brings reading streaks and reading goals to the dashboard, a richer book model with multiple authors, a new field-specific search syntax, and a more flexible file import. +::: + +### All releases + +| Version | Date | Type | +|---|---|---| +| [v1.7.0](#v1-7-0-—-reading-streaks-goals) | 2026-08-26 | Feature release | +| [v1.6.0](#v1-6-0-—-reading-progress-possession-tracking) | 2026-08-23 | Feature release | +| [v1.5.2](#v1-5-2-—-maintenance) | 2026-06-22 | Maintenance | +| [v1.5.1](#v1-5-1-—-maintenance) | 2026-06-22 | Maintenance | +| [v1.5.0](#v1-5-0-—-password-reset-usability) | 2026-06-22 | Feature release | +| [v1.4.0](#v1-4-0-—-embeddable-views-arm64) | 2026-06-14 | Feature release | +| [v1.3.1](#v1-3-1-—-maintenance) | 2026-06-09 | Maintenance | +| [v1.3.0](#v1-3-0-—-more-languages) | 2026-06-09 | Feature release | +| [v1.2.2](#v1-2-2-—-maintenance) | 2026-06-08 | Maintenance | +| [v1.2.1](#v1-2-1-—-import-reliability-multi-user-consistency) | 2026-06-08 | Feature release | +| [v1.2.0](#v1-2-0-—-startup-screen-update-checks) | 2026-06-01 | Feature release | +| [v1.1.1](#v1-1-1-—-maintenance) | 2026-06-01 | Maintenance | +| [v1.1.0](#v1-1-0-—-polish-missing-covers) | 2026-05-31 | Feature release | +| [v1.0.0](#v1-0-0-—-initial-release) | 2026-05-28 | Initial release | + +--- - +## v1.7.0 — Reading Streaks & Goals -**Summary:** Everything merged into the development branch since v1.6.0, not yet released. Focused on a richer book model (multiple authors), a new search syntax, and a more flexible file import. + + +**Summary:** Adds a gamification section to the dashboard with reading streaks and reading goals, a richer book model with multiple authors, a new field-specific search syntax, and a more flexible file import. **Features** - 👥 **Multiple authors per book** — a book can have any number of authors (normalized per-user author model). The legacy API `author` field is deprecated in favor of the `authors` list +- 🏆 **Reading streaks & goals** — a gamification section on the dashboard shows your current and all-time longest reading streak (with date range) plus playful progress cards for reading goals. Goals (pages/day, pages/month, books/month, books/year) are configured on the profile page and disabled by default; the whole section can be switched off +- 🎲 **Random "Next to Read" suggestions** — the dashboard's suggestion shelf now picks a random selection from your want-to-read list on every visit - 🔍 **Enhanced search** — field-specific prefixes (`author:`, `title:`, `publisher:`, `tag:`, `language:`, `possession:`, `notes:`, `description:`), quoted phrases, and `-` negation - 📚 **"All books" library tab** — browse every book regardless of reading status, with the usual search and sort controls - 📈 **Author statistics card** — total book count and distinct author count on the statistics page @@ -31,35 +60,10 @@ You can also browse the [GitHub Releases](https://github.com/codebude/librislog/ **Breaking changes** - ⚠️ Creating a book now requires **at least one author** (via `authors` or the legacy `author` field) — API requests without any author are rejected -- ⚠️ The `availability:` search prefix is renamed to `possession:` (the extended search is new and unreleased, so impact is limited) +- ⚠️ The `availability:` search prefix is renamed to `possession:` [Compare with v1.6.0](https://github.com/codebude/librislog/compare/v1.6.0...main) -## Latest Release - -::: tip ⭐ v1.6.0 — Reading Progress & Possession Tracking -LibrisLog v1.6.0 brings improved reading-progress tracking with automatic synchronization across cards and detail views, a new possession (book ownership) tracking model, and a range of UI, statistics, and reliability improvements. -::: - -### All releases - -| Version | Date | Type | -|---|---|---| -| [vNext](#vnext-—-unreleased) | — | Unreleased | -| [v1.6.0](#v1-6-0-—-reading-progress-possession-tracking) | 2026-08-23 | Feature release | -| [v1.5.2](#v1-5-2-—-maintenance) | 2026-06-22 | Maintenance | -| [v1.5.1](#v1-5-1-—-maintenance) | 2026-06-22 | Maintenance | -| [v1.5.0](#v1-5-0-—-password-reset-usability) | 2026-06-22 | Feature release | -| [v1.4.0](#v1-4-0-—-embeddable-views-arm64) | 2026-06-14 | Feature release | -| [v1.3.1](#v1-3-1-—-maintenance) | 2026-06-09 | Maintenance | -| [v1.3.0](#v1-3-0-—-more-languages) | 2026-06-09 | Feature release | -| [v1.2.2](#v1-2-2-—-maintenance) | 2026-06-08 | Maintenance | -| [v1.2.1](#v1-2-1-—-import-reliability-multi-user-consistency) | 2026-06-08 | Feature release | -| [v1.2.0](#v1-2-0-—-startup-screen-update-checks) | 2026-06-01 | Feature release | -| [v1.1.1](#v1-1-1-—-maintenance) | 2026-06-01 | Maintenance | -| [v1.1.0](#v1-1-0-—-polish-missing-covers) | 2026-05-31 | Feature release | -| [v1.0.0](#v1-0-0-—-initial-release) | 2026-05-28 | Initial release | - --- ## v1.6.0 — Reading Progress & Possession Tracking diff --git a/frontend/e2e/fixtures/seed.api.ts b/frontend/e2e/fixtures/seed.api.ts index b1c30086..52636b57 100644 --- a/frontend/e2e/fixtures/seed.api.ts +++ b/frontend/e2e/fixtures/seed.api.ts @@ -43,3 +43,40 @@ export async function deleteAllBooks(page: Page): Promise { }); } } + +export async function getBookId(page: Page, title: string): Promise { + const resp = await page.request.get(`${bookApiPath()}?q=${encodeURIComponent(title)}&limit=20`); + const body = await resp.json(); + const books: { id: number; title: string }[] = Array.isArray(body?.books) ? body.books : []; + const book = books.find((b) => b.title === title); + if (!book) throw new Error(`Book "${title}" not found`); + return book.id; +} + +export async function seedProgress(page: Page, bookId: number, pageNo: number): Promise { + const csrf = await getCsrfToken(page); + const resp = await page.request.post(`${bookApiPath()}/${bookId}/progress`, { + data: { page: pageNo }, + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': csrf, + }, + }); + if (!resp.ok()) { + throw new Error(`Seeding progress failed: ${resp.status()} ${await resp.text()}`); + } +} + +export async function updateProfileSettings(page: Page, data: Record): Promise { + const csrf = await getCsrfToken(page); + const resp = await page.request.patch('/api/profile/settings', { + data, + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': csrf, + }, + }); + if (!resp.ok()) { + throw new Error(`Updating profile settings failed: ${resp.status()} ${await resp.text()}`); + } +} diff --git a/frontend/e2e/specs/02-dashboard.spec.ts b/frontend/e2e/specs/02-dashboard.spec.ts index a18f167f..58e113fd 100644 --- a/frontend/e2e/specs/02-dashboard.spec.ts +++ b/frontend/e2e/specs/02-dashboard.spec.ts @@ -1,6 +1,6 @@ import { test, expect } from '@playwright/test'; import { loginViaUi } from '../fixtures/auth.fixture'; -import { seedBooks } from '../fixtures/seed.api'; +import { seedBooks, seedProgress, getBookId, updateProfileSettings } from '../fixtures/seed.api'; import { SEED_USER, SEED_BOOKS } from '../fixtures/seed-data'; test.describe('Dashboard', () => { @@ -80,4 +80,65 @@ test.describe('Dashboard', () => { await expect(page.locator('[role="dialog"]')).toBeVisible({ timeout: 5000 }); await expect(page.locator('[role="dialog"]')).toContainText(/The Great Gatsby/i); }); + + test('2.8 gamification card shows current and longest streak', async ({ page }) => { + const card = page.locator('text=Reading Streaks & Goals'); + await expect(card).toBeVisible({ timeout: 5000 }); + await expect(page.locator('text=Current Streak')).toBeVisible(); + await expect(page.locator('text=Longest Streak')).toBeVisible(); + }); + + test('2.9 gamification streak updates after logging reading progress', async ({ page }) => { + await seedBooks(page, SEED_BOOKS); + const bookId = await getBookId(page, 'The Three-Body Problem'); + await seedProgress(page, bookId, 10); + + await page.reload(); + await page.waitForSelector('h1'); + + const streakValue = page.locator('div.stat:has-text("Current Streak") .stat-value'); + await expect(streakValue).toHaveText(/[1-9]/, { timeout: 5000 }); + }); + + test('2.10 gamification goal refreshes after deleting a log entry from the drawer', async ({ page }) => { + await seedBooks(page, SEED_BOOKS); + await updateProfileSettings(page, { goal_pages_per_day_enabled: true, goal_pages_per_day: 20 }); + const bookId = await getBookId(page, 'The Three-Body Problem'); + await seedProgress(page, bookId, 10); + await seedProgress(page, bookId, 20); + + await page.reload(); + await page.waitForSelector('h1'); + + await expect(page.locator('progress[aria-label="10 of 20"]')).toBeVisible({ timeout: 5000 }); + + // Open the detail drawer and delete the newest log entry (page 20). + await page.locator('button:has-text("The Three-Body Problem")').first().click(); + await page.getByRole('button', { name: 'Progress Log' }).click(); + await page.waitForTimeout(300); + + const page20Row = page.locator('tr', { has: page.locator('td.font-mono:has-text("20")') }); + await page20Row.getByRole('button', { name: 'Delete' }).click(); + await page20Row.getByRole('button', { name: 'Confirm?' }).click(); + await page.waitForTimeout(800); + + // Close the log modal and the detail drawer. + await page.locator('[role="dialog"][aria-label="Progress Log"] button[aria-label="Close"]').click(); + await page.locator('button[aria-label="Close"]').last().click(); + await page.waitForTimeout(1500); + + // The pages-per-day goal drops back to 0 without a manual reload. + await expect(page.locator('progress[aria-label="0 of 20"]')).toBeVisible({ timeout: 5000 }); + }); + + test('2.11 disabling gamification hides the section on dashboard', async ({ page }) => { + await updateProfileSettings(page, { gamification_enabled: false }); + await page.reload(); + await page.waitForSelector('h1'); + + await expect(page.locator('text=Reading Streaks & Goals')).toHaveCount(0); + + // Restore the default so later tests keep the section visible. + await updateProfileSettings(page, { gamification_enabled: true }); + }); }); diff --git a/frontend/e2e/specs/11-profile.spec.ts b/frontend/e2e/specs/11-profile.spec.ts index 8815933d..a810b5b7 100644 --- a/frontend/e2e/specs/11-profile.spec.ts +++ b/frontend/e2e/specs/11-profile.spec.ts @@ -113,4 +113,43 @@ test.describe('Profile', () => { // Now the embed tokens section should be visible await expect(page.locator('#section-embed-tokens')).toBeVisible(); }); + + test('11.7 enable and configure reading goals', async ({ page }) => { + await page.goto('/profile'); + await page.waitForTimeout(1000); + + await page.locator('#section-goals').scrollIntoViewIfNeeded(); + await page.waitForTimeout(500); + + await page.locator('input[name="goal-pages-per-day-enabled"]').check(); + await page.locator('input[name="goal-pages-per-day"]').fill('25'); + await page.locator('input[name="goal-books-per-year-enabled"]').check(); + + await page.locator('#section-goals button[class*="btn-primary"]').click(); + await page.waitForTimeout(1000); + + const alert = page.locator('#section-goals .alert'); + await expect(alert).toBeVisible({ timeout: 5000 }); + + await page.reload(); + await page.waitForTimeout(1000); + + await expect(page.locator('input[name="goal-pages-per-day-enabled"]')).toBeChecked(); + await expect(page.locator('input[name="goal-pages-per-day"]')).toHaveValue('25'); + await expect(page.locator('input[name="goal-books-per-year-enabled"]')).toBeChecked(); + }); + + test('11.8 goal input is disabled until enabled', async ({ page }) => { + await page.goto('/profile'); + await page.waitForTimeout(1000); + + await page.locator('#section-goals').scrollIntoViewIfNeeded(); + await page.waitForTimeout(500); + + const input = page.locator('input[name="goal-books-per-month"]'); + await expect(input).toBeDisabled(); + + await page.locator('input[name="goal-books-per-month-enabled"]').check(); + await expect(input).toBeEnabled(); + }); }); diff --git a/frontend/src/lib/api.test.ts b/frontend/src/lib/api.test.ts index f6491ae4..ff1fb970 100644 --- a/frontend/src/lib/api.test.ts +++ b/frontend/src/lib/api.test.ts @@ -219,3 +219,69 @@ describe('api.profile.embedTokens', () => { expect(fetchMock.mock.calls[0][0]).toContain('/profile/embed-tokens/1'); }); }); + +describe('api.statistics.gamification', () => { + afterEach(() => { + apiKey.set(null); + csrfToken.set(null); + vi.restoreAllMocks(); + }); + + it('calls GET /statistics/gamification', async () => { + apiKey.set('test-key'); + csrfToken.set('test-csrf'); + + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + headers: { get: () => 'application/json' }, + json: async () => ({ + enabled: true, + current_streak: 2, + longest_streak: 5, + longest_streak_start: '2026-01-01', + longest_streak_end: '2026-01-05', + goals: [ + { type: 'pages_per_day', target: 20, current: 15, reached: false } + ] + }) + } as unknown as Response); + + const data = await api.statistics.gamification(); + expect(fetchMock.mock.calls[0][0]).toContain('/statistics/gamification'); + expect(data.current_streak).toBe(2); + expect(data.goals[0].type).toBe('pages_per_day'); + }); + + it('updateSettings sends goal fields', async () => { + apiKey.set('test-key'); + csrfToken.set('test-csrf'); + + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + headers: { get: () => 'application/json' }, + json: async () => ({ + user_id: 1, + language: 'en', + timezone: 'UTC', + theme: 'light', + custom_theme: null, + goal_pages_per_day_enabled: true, + goal_pages_per_day: 25, + goal_pages_per_month_enabled: false, + goal_pages_per_month: 300, + goal_books_per_month_enabled: false, + goal_books_per_month: 2, + goal_books_per_year_enabled: true, + goal_books_per_year: 25, + gamification_enabled: true + }) + } as unknown as Response); + + await api.profile.updateSettings({ goal_pages_per_day_enabled: true, goal_pages_per_day: 25 }); + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(init.method).toBe('PATCH'); + expect(fetchMock.mock.calls[0][0]).toContain('/profile/settings'); + const body = JSON.parse(String(init.body)); + expect(body).toMatchObject({ goal_pages_per_day_enabled: true, goal_pages_per_day: 25 }); + }); +}); diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 30ae7ffb..3dcdd415 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -24,6 +24,7 @@ import type { BookProgress, DailyPagesResponse, DashboardQuote, + GamificationResponse, StatisticsResponse, LibraryStats, ReadingProgressEntry, @@ -161,7 +162,7 @@ export const api = { return request('/profile/settings'); }, - updateSettings(data: { language?: string; timezone?: string; theme?: string; custom_theme?: string | null }): Promise { + updateSettings(data: Partial): Promise { return request('/profile/settings', { method: 'PATCH', body: JSON.stringify(data) @@ -264,6 +265,10 @@ export const api = { getPagesPerDay(days: number = 365): Promise { const qs = new URLSearchParams({ days: String(days) }); return request(`/statistics/pages-per-day?${qs}`); + }, + + gamification(): Promise { + return request('/statistics/gamification'); } }, diff --git a/frontend/src/lib/components/GamificationCard.svelte b/frontend/src/lib/components/GamificationCard.svelte new file mode 100644 index 00000000..914d665a --- /dev/null +++ b/frontend/src/lib/components/GamificationCard.svelte @@ -0,0 +1,135 @@ + + +
+
+

+ + + {$_('dashboard.gamificationTitle')} + +

+ + {#if loading} +
+ {:else} +
+
+
+ +
+
{$_('dashboard.currentStreak')}
+
{currentStreak}
+
{$_('dashboard.currentStreakHint')}
+
+ +
+
+ +
+
{$_('dashboard.longestStreak')}
+
{longestStreak}
+
{dateRangeLabel(longestStreakStart, longestStreakEnd)}
+
+
+ + {#if goals.length > 0} +
+
+ {#each goals as goal (goal.type)} + {@const Icon = goalIcon(goal.type)} +
+
+ + + {$_(GOAL_LABEL_KEYS[goal.type])} + + {#if goal.reached} + + + {$_('dashboard.goalReached')} + + {/if} +
+
+ {goal.current} + {$_('dashboard.goalOf', { values: { target: goal.target } })} +
+ +
+ {$_('dashboard.goalProgress', { values: { current: goal.current, target: goal.target } })} +
+
+ {/each} +
+ {/if} + {/if} +
+
\ No newline at end of file diff --git a/frontend/src/lib/components/GamificationCard.test.ts b/frontend/src/lib/components/GamificationCard.test.ts new file mode 100644 index 00000000..c6f4f151 --- /dev/null +++ b/frontend/src/lib/components/GamificationCard.test.ts @@ -0,0 +1,106 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import GamificationCard from './GamificationCard.svelte'; +import type { GoalProgress } from '$lib/types'; + +function goal(overrides?: Partial): GoalProgress { + return { + type: 'pages_per_day', + target: 20, + current: 15, + reached: false, + ...overrides + }; +} + +const baseProps = { + currentStreak: 0, + longestStreak: 0, + longestStreakStart: null, + longestStreakEnd: null, + goals: [] as GoalProgress[] +}; + +describe('GamificationCard', () => { + it('shows the current streak and longest streak', () => { + render(GamificationCard, { + props: { + ...baseProps, + currentStreak: 3, + longestStreak: 12, + longestStreakStart: '2026-01-01', + longestStreakEnd: '2026-01-12' + } + }); + + expect(screen.getByText('Reading Streaks & Goals')).toBeInTheDocument(); + expect(screen.getByText('Current Streak')).toBeInTheDocument(); + expect(screen.getByText('3')).toBeInTheDocument(); + expect(screen.getByText('Longest Streak')).toBeInTheDocument(); + expect(screen.getByText('12')).toBeInTheDocument(); + expect(screen.getByText('2026-01-01 – 2026-01-12')).toBeInTheDocument(); + }); + + it('shows no date range subtitle when there is no longest streak', () => { + render(GamificationCard, { props: baseProps }); + expect(screen.queryByText(/2026-01-01/)).not.toBeInTheDocument(); + }); + + it('renders goal cards with progress when goals are provided', () => { + render(GamificationCard, { + props: { + ...baseProps, + goals: [goal({ current: 15, target: 20, reached: false })] + } + }); + + expect(screen.getByText('Pages per Day')).toBeInTheDocument(); + expect(screen.getByText('15')).toBeInTheDocument(); + expect(screen.getByText('of 20')).toBeInTheDocument(); + expect(screen.getByText('15 of 20')).toBeInTheDocument(); + expect(screen.queryByText('Goal reached')).not.toBeInTheDocument(); + }); + + it('shows a success badge when a goal is reached', () => { + render(GamificationCard, { + props: { + ...baseProps, + goals: [ + goal({ type: 'books_per_month', current: 2, target: 2, reached: true }) + ] + } + }); + + expect(screen.getByText('Books per Month')).toBeInTheDocument(); + expect(screen.getByText('Goal reached')).toBeInTheDocument(); + }); + + it('renders multiple goal cards', () => { + render(GamificationCard, { + props: { + ...baseProps, + goals: [ + goal({ current: 15, target: 20 }), + goal({ type: 'pages_per_month', current: 120, target: 300 }), + goal({ type: 'books_per_year', current: 25, target: 25, reached: true }) + ] + } + }); + + expect(screen.getByText('Pages per Day')).toBeInTheDocument(); + expect(screen.getByText('Pages per Month')).toBeInTheDocument(); + expect(screen.getByText('Books per Year')).toBeInTheDocument(); + expect(screen.getByText('Goal reached')).toBeInTheDocument(); + }); + + it('does not render the goal grid when there are no goals', () => { + render(GamificationCard, { props: baseProps }); + expect(screen.queryByText('Pages per Day')).not.toBeInTheDocument(); + expect(screen.queryByText('Goal reached')).not.toBeInTheDocument(); + }); + + it('shows a loading indicator while loading', () => { + render(GamificationCard, { props: { ...baseProps, loading: true } }); + expect(document.querySelector('.loading')).toBeInTheDocument(); + }); +}); \ No newline at end of file diff --git a/frontend/src/lib/i18n/locales/de.json b/frontend/src/lib/i18n/locales/de.json index 5d4ee6e9..f55bc157 100644 --- a/frontend/src/lib/i18n/locales/de.json +++ b/frontend/src/lib/i18n/locales/de.json @@ -78,7 +78,19 @@ "noSearchResults": "Keine Bücher gefunden", "noCurrentlyReading": "Du liest aktuell kein Buch.", "noNextToRead": "Noch keine Bücher in deiner Wunschliste.", - "popularTags": "Beliebte Tags" + "popularTags": "Beliebte Tags", + "gamificationTitle": "Streaks & Ziele", + "currentStreak": "Aktueller Streak", + "currentStreakHint": "Aufeinanderfolgende Tage mit Leseaktivität", + "longestStreak": "Längster Streak", + "longestStreakDateRange": "{start} – {end}", + "goalPagesPerDay": "Seiten pro Tag", + "goalPagesPerMonth": "Seiten pro Monat", + "goalBooksPerMonth": "Bücher pro Monat", + "goalBooksPerYear": "Bücher pro Jahr", + "goalReached": "Ziel erreicht", + "goalOf": "von {target}", + "goalProgress": "{current} von {target}" }, "status": { "want_to_read": "Möchte ich lesen", @@ -478,6 +490,22 @@ "profileSaveFailed": "Profil konnte nicht gespeichert werden", "passwordChangeSuccess": "Passwort geändert", "passwordChangeFailed": "Passwort konnte nicht geändert werden", + "goals": { + "title": "Leseziele", + "subtitle": "Aktiviere Ziele, um sie spielerisch auf dem Dashboard zu verfolgen.", + "dashboardToggle": "Lese-Serien & Ziele auf dem Dashboard anzeigen", + "pagesPerDay": "Seiten pro Tag", + "pagesPerMonth": "Seiten pro Monat", + "booksPerMonth": "Bücher pro Monat", + "booksPerYear": "Bücher pro Jahr", + "target": "Ziel", + "pagesPerDayTarget": "Ziel: Seiten pro Tag", + "pagesPerMonthTarget": "Ziel: Seiten pro Monat", + "booksPerMonthTarget": "Ziel: Bücher pro Monat", + "booksPerYearTarget": "Ziel: Bücher pro Jahr", + "saveSuccess": "Leseziele gespeichert", + "invalidTarget": "{name} muss eine ganze Zahl von mindestens 1 sein." + }, "dataManagement": { "title": "Meine Daten verwalten", "description": "Exportiere deine Bibliothek oder importiere Bücher aus CSV/JSON.", diff --git a/frontend/src/lib/i18n/locales/en.json b/frontend/src/lib/i18n/locales/en.json index 1c9d0376..eabd2185 100644 --- a/frontend/src/lib/i18n/locales/en.json +++ b/frontend/src/lib/i18n/locales/en.json @@ -78,7 +78,19 @@ "noSearchResults": "No books found", "noCurrentlyReading": "You are not currently reading a book.", "noNextToRead": "No books in your want-to-read list yet.", - "popularTags": "Popular Tags" + "popularTags": "Popular Tags", + "gamificationTitle": "Reading Streaks & Goals", + "currentStreak": "Current Streak", + "currentStreakHint": "Consecutive days with reading activity", + "longestStreak": "Longest Streak", + "longestStreakDateRange": "{start} – {end}", + "goalPagesPerDay": "Pages per Day", + "goalPagesPerMonth": "Pages per Month", + "goalBooksPerMonth": "Books per Month", + "goalBooksPerYear": "Books per Year", + "goalReached": "Goal reached", + "goalOf": "of {target}", + "goalProgress": "{current} of {target}" }, "status": { "want_to_read": "Want to Read", @@ -478,6 +490,22 @@ "profileSaveFailed": "Failed to save profile", "passwordChangeSuccess": "Password changed", "passwordChangeFailed": "Failed to change password", + "goals": { + "title": "Reading Goals", + "subtitle": "Enable goals to track them playfully on your dashboard.", + "dashboardToggle": "Show reading streaks & goals on dashboard", + "pagesPerDay": "Pages per Day", + "pagesPerMonth": "Pages per Month", + "booksPerMonth": "Books per Month", + "booksPerYear": "Books per Year", + "target": "Target", + "pagesPerDayTarget": "Pages per Day target", + "pagesPerMonthTarget": "Pages per Month target", + "booksPerMonthTarget": "Books per Month target", + "booksPerYearTarget": "Books per Year target", + "saveSuccess": "Reading goals saved", + "invalidTarget": "{name} must be a whole number of at least 1." + }, "dataManagement": { "title": "Manage my data", "description": "Export your library or import books from a CSV/JSON file.", diff --git a/frontend/src/lib/i18n/locales/es.json b/frontend/src/lib/i18n/locales/es.json index d3bc2a6e..23a14f13 100644 --- a/frontend/src/lib/i18n/locales/es.json +++ b/frontend/src/lib/i18n/locales/es.json @@ -78,7 +78,19 @@ "noSearchResults": "No se encontraron libros", "noCurrentlyReading": "No estás leyendo ningún libro actualmente.", "noNextToRead": "Aún no hay libros en tu lista de deseos.", - "popularTags": "Etiquetas populares" + "popularTags": "Etiquetas populares", + "gamificationTitle": "Rachas de lectura y metas", + "currentStreak": "Racha actual", + "currentStreakHint": "Días consecutivos con actividad de lectura", + "longestStreak": "Racha más larga", + "longestStreakDateRange": "{start} – {end}", + "goalPagesPerDay": "Páginas por día", + "goalPagesPerMonth": "Páginas por mes", + "goalBooksPerMonth": "Libros por mes", + "goalBooksPerYear": "Libros por año", + "goalReached": "Meta alcanzada", + "goalOf": "de {target}", + "goalProgress": "{current} de {target}" }, "status": { "want_to_read": "Quiero leer", @@ -478,6 +490,22 @@ "profileSaveFailed": "Error al guardar el perfil", "passwordChangeSuccess": "Contraseña cambiada", "passwordChangeFailed": "Error al cambiar la contraseña", + "goals": { + "title": "Metas de lectura", + "subtitle": "Activa metas para seguirlas de forma lúdica en el panel.", + "dashboardToggle": "Mostrar rachas y metas de lectura en el panel", + "pagesPerDay": "Páginas por día", + "pagesPerMonth": "Páginas por mes", + "booksPerMonth": "Libros por mes", + "booksPerYear": "Libros por año", + "target": "Meta", + "pagesPerDayTarget": "Meta de páginas por día", + "pagesPerMonthTarget": "Meta de páginas por mes", + "booksPerMonthTarget": "Meta de libros por mes", + "booksPerYearTarget": "Meta de libros por año", + "saveSuccess": "Metas de lectura guardadas", + "invalidTarget": "{name} debe ser un número entero de al menos 1." + }, "dataManagement": { "title": "Gestionar mis datos", "description": "Exporta tu biblioteca o importa libros desde un archivo CSV/JSON.", diff --git a/frontend/src/lib/i18n/locales/fr.json b/frontend/src/lib/i18n/locales/fr.json index daea5c23..9d27572c 100644 --- a/frontend/src/lib/i18n/locales/fr.json +++ b/frontend/src/lib/i18n/locales/fr.json @@ -78,7 +78,19 @@ "noSearchResults": "Aucun livre trouvé", "noCurrentlyReading": "Tu ne lis aucun livre actuellement.", "noNextToRead": "Aucun livre dans ta liste d'envies.", - "popularTags": "Étiquettes populaires" + "popularTags": "Étiquettes populaires", + "gamificationTitle": "Séries de lecture & objectifs", + "currentStreak": "Série actuelle", + "currentStreakHint": "Jours consécutifs avec activité de lecture", + "longestStreak": "Série la plus longue", + "longestStreakDateRange": "{start} – {end}", + "goalPagesPerDay": "Pages par jour", + "goalPagesPerMonth": "Pages par mois", + "goalBooksPerMonth": "Livres par mois", + "goalBooksPerYear": "Livres par an", + "goalReached": "Objectif atteint", + "goalOf": "sur {target}", + "goalProgress": "{current} sur {target}" }, "status": { "want_to_read": "À lire", @@ -478,6 +490,22 @@ "profileSaveFailed": "Échec de l'enregistrement du profil", "passwordChangeSuccess": "Mot de passe modifié", "passwordChangeFailed": "Échec de la modification du mot de passe", + "goals": { + "title": "Objectifs de lecture", + "subtitle": "Activez des objectifs pour les suivre de façon ludique sur votre tableau de bord.", + "dashboardToggle": "Afficher les séries et objectifs de lecture sur le tableau de bord", + "pagesPerDay": "Pages par jour", + "pagesPerMonth": "Pages par mois", + "booksPerMonth": "Livres par mois", + "booksPerYear": "Livres par an", + "target": "Objectif", + "pagesPerDayTarget": "Objectif de pages par jour", + "pagesPerMonthTarget": "Objectif de pages par mois", + "booksPerMonthTarget": "Objectif de livres par mois", + "booksPerYearTarget": "Objectif de livres par an", + "saveSuccess": "Objectifs de lecture enregistrés", + "invalidTarget": "{name} doit être un nombre entier d'au moins 1." + }, "dataManagement": { "title": "Gérer mes données", "description": "Exporte ta bibliothèque ou importe des livres depuis un fichier CSV/JSON.", diff --git a/frontend/src/lib/i18n/locales/zh.json b/frontend/src/lib/i18n/locales/zh.json index 15b1f227..d9f7ee4f 100644 --- a/frontend/src/lib/i18n/locales/zh.json +++ b/frontend/src/lib/i18n/locales/zh.json @@ -78,7 +78,19 @@ "noSearchResults": "未找到图书", "noCurrentlyReading": "你当前没有在读的图书。", "noNextToRead": "你的想读列表还没有图书。", - "popularTags": "热门标签" + "popularTags": "热门标签", + "gamificationTitle": "阅读连续天数与目标", + "currentStreak": "当前连续", + "currentStreakHint": "有阅读活动的连续天数", + "longestStreak": "最长连续", + "longestStreakDateRange": "{start} – {end}", + "goalPagesPerDay": "每天页数", + "goalPagesPerMonth": "每月页数", + "goalBooksPerMonth": "每月本书", + "goalBooksPerYear": "每年本书", + "goalReached": "目标达成", + "goalOf": "共 {target}", + "goalProgress": "{current} / {target}" }, "status": { "want_to_read": "想读", @@ -478,6 +490,22 @@ "profileSaveFailed": "保存个人资料失败", "passwordChangeSuccess": "密码已更改", "passwordChangeFailed": "更改密码失败", + "goals": { + "title": "阅读目标", + "subtitle": "启用目标,即可在仪表板上趣味追踪进度。", + "dashboardToggle": "在仪表板上显示阅读连续天数与目标", + "pagesPerDay": "每天页数", + "pagesPerMonth": "每月页数", + "booksPerMonth": "每月本书", + "booksPerYear": "每年本书", + "target": "目标", + "pagesPerDayTarget": "每天页数目标", + "pagesPerMonthTarget": "每月页数目标", + "booksPerMonthTarget": "每月本书目标", + "booksPerYearTarget": "每年本书目标", + "saveSuccess": "阅读目标已保存", + "invalidTarget": "{name} 必须是至少为 1 的整数。" + }, "dataManagement": { "title": "管理我的数据", "description": "导出你的书库或从 CSV/JSON 文件导入图书。", diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 9dd1a286..249d3705 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -219,6 +219,33 @@ export interface UserSettings { timezone: string; theme: string; custom_theme: string | null; + goal_pages_per_day_enabled: boolean; + goal_pages_per_day: number; + goal_pages_per_month_enabled: boolean; + goal_pages_per_month: number; + goal_books_per_month_enabled: boolean; + goal_books_per_month: number; + goal_books_per_year_enabled: boolean; + goal_books_per_year: number; + gamification_enabled: boolean; +} + +export type GoalType = 'pages_per_day' | 'pages_per_month' | 'books_per_month' | 'books_per_year'; + +export interface GoalProgress { + type: GoalType; + target: number; + current: number; + reached: boolean; +} + +export interface GamificationResponse { + enabled: boolean; + current_streak: number; + longest_streak: number; + longest_streak_start: string | null; + longest_streak_end: string | null; + goals: GoalProgress[]; } export interface ApiKeyMeta { diff --git a/frontend/src/routes/dashboard/+page.svelte b/frontend/src/routes/dashboard/+page.svelte index 45d1991a..c7ca372b 100644 --- a/frontend/src/routes/dashboard/+page.svelte +++ b/frontend/src/routes/dashboard/+page.svelte @@ -2,7 +2,7 @@ import { page } from '$app/stores'; import { goto } from '$app/navigation'; import { onMount } from 'svelte'; - import type { Book, DashboardQuote, LibraryStats } from '$lib/types'; + import type { Book, DashboardQuote, GamificationResponse, LibraryStats } from '$lib/types'; import { api } from '$lib/api'; import { _ } from '$lib/i18n'; import { toasts } from '$lib/toasts'; @@ -12,10 +12,13 @@ import BookCard from '$lib/components/BookCard.svelte'; import BookDetailDialog from '$lib/components/BookDetailDialog.svelte'; import BookDrawer from '$lib/components/BookDrawer.svelte'; + import GamificationCard from '$lib/components/GamificationCard.svelte'; import SearchHelp from '$lib/components/SearchHelp.svelte'; import { Search, X } from '@lucide/svelte'; let loading = $state(true); + let gamification = $state(null); + let gamificationLoading = $state(true); let stats = $state({ total_books: 0, books_read: 0, @@ -100,7 +103,7 @@ import { Search, X } from '@lucide/svelte'; stats = statsData; currentlyReading = readingResponse.books.slice(0, 5); - nextToRead = wantToReadResponse.books.slice(0, 5); + nextToRead = shuffle(wantToReadResponse.books).slice(0, 5); const allBooks = [...currentlyReading, ...nextToRead]; void loadProgressForBooks(allBooks); @@ -113,10 +116,35 @@ import { Search, X } from '@lucide/svelte'; loading = false; } + await loadGamification(true); await loadQuote(); await loadTagCloud(); } + function shuffle(items: T[]): T[] { + const copy = [...items]; + for (let i = copy.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [copy[i], copy[j]] = [copy[j], copy[i]]; + } + return copy; + } + + async function loadGamification(showLoading = false) { + // Gamification is optional: a failure must not blank the dashboard. + // Once the section is disabled it stays hidden until the dashboard + // is (re)loaded, so repeated progress-change callbacks are no-ops. + if (gamification && gamification.enabled === false) return; + if (showLoading) gamificationLoading = true; + try { + gamification = await api.statistics.gamification(); + } catch { + gamification = { enabled: false, current_streak: 0, longest_streak: 0, longest_streak_start: null, longest_streak_end: null, goals: [] }; + } finally { + if (showLoading) gamificationLoading = false; + } + } + async function loadTagCloud() { try { tagCloud = await api.books.tagCloud(50); @@ -181,6 +209,7 @@ import { Search, X } from '@lucide/svelte'; function handleProgressChange(bookId: number, currentPage: number) { progressMap = { ...progressMap, [bookId]: currentPage }; + void loadGamification(); } function handleDelete(id: number) { @@ -302,7 +331,7 @@ import { Search, X } from '@lucide/svelte';
-
+

{$_('dashboard.title')}

{$_('dashboard.subtitle')}

@@ -456,6 +485,17 @@ import { Search, X } from '@lucide/svelte';
+ {#if gamification === null || gamification.enabled} + + {/if} +
diff --git a/frontend/src/routes/profile/+page.svelte b/frontend/src/routes/profile/+page.svelte index b4851a62..083cdaad 100644 --- a/frontend/src/routes/profile/+page.svelte +++ b/frontend/src/routes/profile/+page.svelte @@ -37,6 +37,17 @@ let themeMode = $state(getThemeMode()); let customTheme = $state(getCustomTheme() ?? 'dracula'); let themeMessage = $state<{ type: 'success' | 'error'; text: string } | null>(null); + let goalPagesPerDayEnabled = $state(false); + let goalPagesPerDay = $state(20); + let goalPagesPerMonthEnabled = $state(false); + let goalPagesPerMonth = $state(300); + let goalBooksPerMonthEnabled = $state(false); + let goalBooksPerMonth = $state(2); + let goalBooksPerYearEnabled = $state(false); + let goalBooksPerYear = $state(25); + let gamificationEnabled = $state(true); + let goalsMessage = $state<{ type: 'success' | 'error'; text: string } | null>(null); + let goalsSaving = $state(false); let resetDataConfirmation = $state(''); let resetDataMessage = $state<{ type: 'success' | 'error'; text: string } | null>(null); let deleteAccountConfirmation = $state(''); @@ -124,6 +135,15 @@ customTheme = settings.custom_theme; setCustomTheme(customTheme); } + goalPagesPerDayEnabled = settings.goal_pages_per_day_enabled; + goalPagesPerDay = settings.goal_pages_per_day; + goalPagesPerMonthEnabled = settings.goal_pages_per_month_enabled; + goalPagesPerMonth = settings.goal_pages_per_month; + goalBooksPerMonthEnabled = settings.goal_books_per_month_enabled; + goalBooksPerMonth = settings.goal_books_per_month; + goalBooksPerYearEnabled = settings.goal_books_per_year_enabled; + goalBooksPerYear = settings.goal_books_per_year; + gamificationEnabled = settings.gamification_enabled; applyThemeToDocument(); saveThemeToStorage(); saveRestorePoint(); @@ -227,6 +247,40 @@ } } + async function saveGoals() { + goalsMessage = null; + const targets = [ + { enabled: goalPagesPerDayEnabled, value: goalPagesPerDay, key: $_('profile.goals.pagesPerDay') }, + { enabled: goalPagesPerMonthEnabled, value: goalPagesPerMonth, key: $_('profile.goals.pagesPerMonth') }, + { enabled: goalBooksPerMonthEnabled, value: goalBooksPerMonth, key: $_('profile.goals.booksPerMonth') }, + { enabled: goalBooksPerYearEnabled, value: goalBooksPerYear, key: $_('profile.goals.booksPerYear') } + ]; + const invalid = targets.find((t) => t.enabled && (!Number.isInteger(t.value) || t.value < 1)); + if (invalid) { + goalsMessage = { type: 'error', text: $_('profile.goals.invalidTarget', { values: { name: invalid.key } }) }; + return; + } + goalsSaving = true; + try { + await api.profile.updateSettings({ + goal_pages_per_day_enabled: goalPagesPerDayEnabled, + goal_pages_per_day: goalPagesPerDay, + goal_pages_per_month_enabled: goalPagesPerMonthEnabled, + goal_pages_per_month: goalPagesPerMonth, + goal_books_per_month_enabled: goalBooksPerMonthEnabled, + goal_books_per_month: goalBooksPerMonth, + goal_books_per_year_enabled: goalBooksPerYearEnabled, + goal_books_per_year: goalBooksPerYear, + gamification_enabled: gamificationEnabled + }); + goalsMessage = { type: 'success', text: $_('profile.goals.saveSuccess') }; + } catch (e: unknown) { + goalsMessage = { type: 'error', text: e instanceof Error ? e.message : $_('common.saveFailed') }; + } finally { + goalsSaving = false; + } + } + async function createKey() { const result = await api.profile.createApiKey({ description: description || null }); createdKey = result.key; @@ -565,6 +619,104 @@
+
+
+

{$_('profile.goals.title')}

+

{$_('profile.goals.subtitle')}

+ {#if goalsMessage} + (goalsMessage = null)}> + {goalsMessage.text} + + {/if} +
+ +
+
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+
+