Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
"""normalize statistics range settings

Revision ID: 0a1b2c3d4e5f
Revises: d3e4f5a6b7c8
Create Date: 2026-09-13 00:00:00.000000

"""
from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa


# revision identifiers, used by Alembic.
revision: str = "0a1b2c3d4e5f"
down_revision: Union[str, Sequence[str], None] = "d3e4f5a6b7c8"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
# The old rolling ranges have no direct equivalent in the calendar-based selector.
op.execute(
sa.text(
"UPDATE usersettings "
"SET statistics_range = 'alltime' "
"WHERE statistics_range IN ('6months', '30days')"
)
)
op.execute(
sa.text(
"UPDATE usersettings "
"SET statistics_range = 'this_year' "
"WHERE statistics_range = '1year'"
)
)


def downgrade() -> None:
op.execute(
sa.text(
"UPDATE usersettings "
"SET statistics_range = '1year' "
"WHERE statistics_range = 'this_year'"
)
)
6 changes: 3 additions & 3 deletions backend/app/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -262,10 +262,9 @@ class YearlyBooks(SQLModel):
class StatisticsRange(str, Enum):
"""Shared statistics time-range selector options."""
alltime = "alltime"
this_year = "this_year"
last_year = "last_year"
three_years = "3years"
one_year = "1year"
six_months = "6months"
thirty_days = "30days"
custom = "custom"


Expand Down Expand Up @@ -573,6 +572,7 @@ class HygieneAttribute(str, Enum):
subtitle = "subtitle"
page_count = "page_count"
cover_url = "cover_url"
medium = "medium"


class HygieneMissingBook(SQLModel):
Expand Down
39 changes: 10 additions & 29 deletions backend/app/services/statistics.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,26 +112,6 @@ 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,
Expand All @@ -147,7 +127,9 @@ def _statistics_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``.
- This year -> the start of the current calendar year to ``now``.
- Last year -> the complete previous calendar year.
- Last 3 years -> the start of the calendar year two years ago to ``now``.
"""
if range_value == StatisticsRange.alltime:
return (None, None)
Expand All @@ -164,14 +146,13 @@ def _statistics_window(
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)
if range_value == StatisticsRange.this_year:
start = datetime(now.year, 1, 1, tzinfo=tz)
elif range_value == StatisticsRange.last_year:
start = datetime(now.year - 1, 1, 1, tzinfo=tz)
end = datetime(now.year - 1, 12, 31, 23, 59, 59, 999999, tzinfo=tz)
elif range_value == StatisticsRange.three_years:
start = _subtract_years(now, 3)
start = datetime(now.year - 2, 1, 1, tzinfo=tz)
else:
start = now
return (_naive_utc(start), _naive_utc(end))
Expand Down Expand Up @@ -944,4 +925,4 @@ def _rating_sort_key(book: Book) -> tuple[int, float]:
average_rating=average_rating,
top_rated_books=top_rated_books,
worst_rated_books=worst_rated_books,
)
)
16 changes: 15 additions & 1 deletion backend/tests/test_hygiene.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from pytest import MonkeyPatch
from sqlmodel import Session, col, select

from app.models import Author, Book, BookAuthor, ReadingStatus, User
from app.models import Author, Book, BookAuthor, Medium, ReadingStatus, User
from app.routers import hygiene as hygiene_router
from app.services.authors import normalize_author_list

Expand All @@ -28,6 +28,7 @@ def _create_book(session: Session, user_id: int, **overrides: object) -> Book:
"blurb": "A test book.",
"cover_url": None,
"reading_status": ReadingStatus.want_to_read,
"medium": Medium.print,
"user_id": user_id,
}
defaults.update(overrides)
Expand Down Expand Up @@ -158,6 +159,19 @@ def test_missing_page_count_zero_treated_as_missing(self, client: TestClient, se
assert data["total"] == 1
assert data["books"][0]["title"] == "Zero Pages"

def test_missing_medium(self, client: TestClient, session: Session) -> None:
"""Medium is reported as missing when it has not been set."""
user_id = 1
_create_book(session, user_id, title="Print book", medium=Medium.print)
_create_book(session, user_id, title="Missing medium", medium=None)

resp = client.get("/api/hygiene/missing?attributes=medium")
assert resp.status_code == 200
data = resp.json()
assert data["total"] == 1
assert data["books"][0]["title"] == "Missing medium"
assert data["total_missing_per_attribute"]["medium"] == 1


class TestBatchUpdate:
def test_batch_update_single_field(self, client: TestClient, session: Session) -> None:
Expand Down
32 changes: 28 additions & 4 deletions backend/tests/test_statistics.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
from sqlmodel import Session, select

from app.models import Book, ReadingProgress, ReadingStatus, UserSettings
from app.services.statistics import _extract_book_level_daily_pages
from app.schemas import StatisticsRange
from app.services.statistics import _extract_book_level_daily_pages, _statistics_window


def _create_book(client: Any, **overrides: Any) -> dict[str, Any]:
Expand Down Expand Up @@ -718,8 +719,8 @@ def test_statistics_range_filters_finished_books(client: Any) -> None:
client,
title="Outside",
reading_status="read",
date_started=(now - timedelta(days=45)).isoformat(),
date_finished=(now - timedelta(days=40)).isoformat(),
date_started=f"{now.year - 1}-01-01T10:00:00+00:00",
date_finished=f"{now.year - 1}-01-02T10:00:00+00:00",
)
_create_book(
client,
Expand All @@ -729,13 +730,36 @@ def test_statistics_range_filters_finished_books(client: Any) -> None:
date_finished=(now - timedelta(days=2)).isoformat(),
)

response = client.get("/api/statistics?range=30days")
response = client.get("/api/statistics?range=this_year")
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_calendar_range_windows() -> None:
now = datetime(2026, 9, 13, 12, 0, tzinfo=timezone.utc)
tz = ZoneInfo("UTC")

this_year_start, this_year_end = _statistics_window(
StatisticsRange.this_year, None, None, tz, now
)
assert this_year_start == datetime(2026, 1, 1)
assert this_year_end == datetime(2026, 9, 13, 12, 0)

last_year_start, last_year_end = _statistics_window(
StatisticsRange.last_year, None, None, tz, now
)
assert last_year_start == datetime(2025, 1, 1)
assert last_year_end == datetime(2025, 12, 31, 23, 59, 59, 999999)

three_years_start, three_years_end = _statistics_window(
StatisticsRange.three_years, None, None, tz, now
)
assert three_years_start == datetime(2024, 1, 1)
assert three_years_end == datetime(2026, 9, 13, 12, 0)


def test_statistics_custom_range_and_validation(client: Any) -> None:
_create_book(
client,
Expand Down
1 change: 1 addition & 0 deletions docker-compose.dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ services:
- ./data:/app/data
- /etc/ssl/certs/ca-certificates.crt:/etc/ssl/certs/ca-certificates.crt:ro # only needed if you use custom certificates in you environment
environment:
TELEMETRY_DISABLED: "true" # Development runs must not send telemetry.
REQUESTS_CA_BUNDLE: /etc/ssl/certs/ca-certificates.crt # only needed if you use custom certificates in you environment
SSL_CERT_FILE: /etc/ssl/certs/ca-certificates.crt # only needed if you use custom certificates in you environment
restart: unless-stopped
Expand Down
2 changes: 1 addition & 1 deletion docs/guide/developer-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ Steps:
uv sync
cd backend
uv run alembic upgrade head
uv run uvicorn app.main:app --reload --port 8000
TELEMETRY_DISABLED=true uv run uvicorn app.main:app --reload --port 8000
```

The backend runs on http://localhost:8000 with auto-reload on code changes.
Expand Down
8 changes: 8 additions & 0 deletions frontend/src/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,14 @@
--noise: 0;
}

/* Keep dark cards calm while giving the page and nested surfaces clearer depth. */
html[data-theme="dark"] {
--color-base-100: oklch(25.5% 0.018 255);
--color-base-200: oklch(19.5% 0.016 255);
--color-base-300: oklch(15.5% 0.014 255);
--color-base-content: oklch(86% 0.012 255);
}

html {
scroll-behavior: smooth;
}
Expand Down
5 changes: 2 additions & 3 deletions frontend/src/lib/components/StatisticsRangeSelector.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,9 @@

const options: Array<[StatisticsRange, string]> = [
['alltime', 'statistics.rangeAllTime'],
['this_year', 'statistics.rangeThisYear'],
['last_year', 'statistics.rangeLastYear'],
['3years', 'statistics.rangeLast3Years'],
['1year', 'statistics.rangeLastYear'],
['6months', 'statistics.rangeLast6Months'],
['30days', 'statistics.rangeLast30Days'],
['custom', 'statistics.rangeCustom']
];

Expand Down
5 changes: 2 additions & 3 deletions frontend/src/lib/components/StatisticsRangeSelector.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,9 @@ describe('StatisticsRangeSelector', () => {
const options = screen.getByRole('combobox').querySelectorAll('option');
expect([...options].map((option) => option.textContent)).toEqual([
'All time',
'Last 3 years',
'This year',
'Last year',
'Last 6 months',
'Last 30 days',
'Last 3 years',
'Custom'
]);
});
Expand Down
8 changes: 4 additions & 4 deletions frontend/src/lib/i18n/locales/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,9 @@
"rangeLast12Months": "Letzte 12 Monate",
"refreshing": "Statistiken werden aktualisiert",
"rangeAllTime": "Gesamte Zeit",
"rangeLast3Years": "Letzte 3 Jahre",
"rangeThisYear": "Dieses Jahr",
"rangeLastYear": "Letztes Jahr",
"rangeLast6Months": "Letzte 6 Monate",
"rangeLast30Days": "Letzte 30 Tage",
"rangeLast3Years": "Letzte 3 Jahre",
"rangeCustom": "Benutzerdefiniert",
"from": "Von",
"to": "Bis",
Expand Down Expand Up @@ -803,7 +802,8 @@
"language": "Sprache",
"subtitle": "Untertitel",
"page_count": "Seitenanzahl",
"cover_url": "Cover"
"cover_url": "Cover",
"medium": "Medium"
},
"matchAny": "Beliebiges",
"matchAll": "Alle",
Expand Down
8 changes: 4 additions & 4 deletions frontend/src/lib/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,9 @@
"rangeLast12Months": "Last 12 months",
"refreshing": "Updating statistics",
"rangeAllTime": "All time",
"rangeLast3Years": "Last 3 years",
"rangeThisYear": "This year",
"rangeLastYear": "Last year",
"rangeLast6Months": "Last 6 months",
"rangeLast30Days": "Last 30 days",
"rangeLast3Years": "Last 3 years",
"rangeCustom": "Custom",
"from": "From",
"to": "To",
Expand Down Expand Up @@ -803,7 +802,8 @@
"language": "Language",
"subtitle": "Subtitle",
"page_count": "Page count",
"cover_url": "Cover"
"cover_url": "Cover",
"medium": "Medium"
},
"matchAny": "Match any",
"matchAll": "Match all",
Expand Down
8 changes: 4 additions & 4 deletions frontend/src/lib/i18n/locales/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,9 @@
"rangeLast12Months": "Últimos 12 meses",
"refreshing": "Actualizando estadísticas",
"rangeAllTime": "Todo el tiempo",
"rangeThisYear": "Este año",
"rangeLastYear": "El año pasado",
"rangeLast3Years": "Últimos 3 años",
"rangeLastYear": "Último año",
"rangeLast6Months": "Últimos 6 meses",
"rangeLast30Days": "Últimos 30 días",
"rangeCustom": "Personalizado",
"from": "Desde",
"to": "Hasta",
Expand Down Expand Up @@ -803,7 +802,8 @@
"language": "Idioma",
"subtitle": "Subtítulo",
"page_count": "Nº de páginas",
"cover_url": "Portada"
"cover_url": "Portada",
"medium": "Medio"
},
"matchAny": "Coincidir cualquiera",
"matchAll": "Coincidir todos",
Expand Down
8 changes: 4 additions & 4 deletions frontend/src/lib/i18n/locales/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,9 @@
"rangeLast12Months": "12 derniers mois",
"refreshing": "Mise à jour des statistiques",
"rangeAllTime": "Depuis toujours",
"rangeThisYear": "Cette année",
"rangeLastYear": "L'année dernière",
"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",
Expand Down Expand Up @@ -803,7 +802,8 @@
"language": "Langue",
"subtitle": "Sous-titre",
"page_count": "Nombre de pages",
"cover_url": "Couverture"
"cover_url": "Couverture",
"medium": "Support"
},
"matchAny": "Correspond à l'un",
"matchAll": "Correspond à tous",
Expand Down
Loading