diff --git a/backend/alembic/versions/e2f3a4b5c6d7_add_acquisition_status.py b/backend/alembic/versions/e2f3a4b5c6d7_add_acquisition_status.py new file mode 100644 index 00000000..b6385f88 --- /dev/null +++ b/backend/alembic/versions/e2f3a4b5c6d7_add_acquisition_status.py @@ -0,0 +1,32 @@ +"""add acquisition status to books + +Revision ID: e2f3a4b5c6d7 +Revises: 1a2b3c4d5e6f +Create Date: 2026-08-22 20:00:00.000000 +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "e2f3a4b5c6d7" +down_revision: Union[str, Sequence[str], None] = "784de5d2bf69" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + with op.batch_alter_table("book") as batch_op: + batch_op.add_column(sa.Column("acquisition_status", sa.String(length=32), nullable=True)) + op.execute("UPDATE book SET acquisition_status = 'owned' WHERE acquisition_status IS NULL") + with op.batch_alter_table("book") as batch_op: + batch_op.alter_column("acquisition_status", nullable=False) + batch_op.create_index("ix_book_acquisition_status", ["acquisition_status"]) + + +def downgrade() -> None: + with op.batch_alter_table("book") as batch_op: + batch_op.drop_index("ix_book_acquisition_status") + batch_op.drop_column("acquisition_status") diff --git a/backend/app/models.py b/backend/app/models.py index ade3412a..b3a48178 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -44,6 +44,15 @@ class ReadingStatus(str, Enum): did_not_finish = "did_not_finish" +class AcquisitionStatus(str, Enum): + """Enum of a book's current availability to the user.""" + + owned = "owned" + borrowed = "borrowed" + digital_access = "digital_access" + to_acquire = "to_acquire" + + class UserRole(str, Enum): """Enum of possible user roles.""" @@ -79,6 +88,7 @@ def normalize_empty_cover_url(cls, data: dict) -> dict: blurb: Optional[str] = None rating: Optional[int] = Field(default=None, ge=1, le=5) reading_status: ReadingStatus = Field(default=ReadingStatus.want_to_read, index=True) + acquisition_status: AcquisitionStatus = Field(default=AcquisitionStatus.owned, index=True) user_id: Optional[int] = Field(default=None, foreign_key="user.id", index=True) date_added: datetime = Field( default_factory=utcnow, @@ -97,7 +107,7 @@ def normalize_empty_cover_url(cls, data: dict) -> dict: class Tag(SQLModel, table=True): """A user-specific tag that can be applied to books.""" - __tablename__ = "tag" + __tablename__: str = "tag" __table_args__ = (sa.UniqueConstraint("user_id", "name", name="uq_tag_user_id_name"),) id: Optional[int] = Field(default=None, primary_key=True) @@ -112,7 +122,7 @@ class Tag(SQLModel, table=True): class BookTag(SQLModel, table=True): """Many-to-many association between books and tags.""" - __tablename__ = "book_tag" + __tablename__: str = "book_tag" book_id: int = Field(foreign_key="book.id", primary_key=True) tag_id: int = Field(foreign_key="tag.id", primary_key=True, index=True) @@ -178,7 +188,7 @@ class ApiKey(SQLModel, table=True): class ReadingProgress(SQLModel, table=True): """A page-number reading progress entry for a book.""" - __tablename__ = "reading_progress" + __tablename__: str = "reading_progress" id: Optional[int] = Field(default=None, primary_key=True) book_id: int = Field(foreign_key="book.id", index=True) @@ -212,7 +222,7 @@ class OidcLink(SQLModel, table=True): class EmbedToken(SQLModel, table=True): """A scoped embed token for iframe/dashboard integrations.""" - __tablename__ = "embed_token" + __tablename__: str = "embed_token" id: Optional[int] = Field(default=None, primary_key=True) user_id: int = Field(foreign_key="user.id", index=True) @@ -242,7 +252,7 @@ class EmbedToken(SQLModel, table=True): class ImportMapping(SQLModel, table=True): """A saved column-mapping configuration for data import.""" - __tablename__ = "import_mapping" + __tablename__: str = "import_mapping" __table_args__ = ( sa.UniqueConstraint("user_id", "name", name="uq_import_mapping_user_id_name"), ) diff --git a/backend/app/routers/books.py b/backend/app/routers/books.py index 05f96f30..a0694a1a 100644 --- a/backend/app/routers/books.py +++ b/backend/app/routers/books.py @@ -7,12 +7,12 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status import sqlalchemy as sa from sqlalchemy.exc import IntegrityError -from sqlmodel import Session, func, or_, select +from sqlmodel import Session, col, func, or_, select from app.auth import require_user from app.config import settings from app.database import get_session -from app.models import Book, BookTag, ReadingProgress, ReadingStatus, Tag, User +from app.models import AcquisitionStatus, Book, BookTag, ReadingProgress, ReadingStatus, Tag, User from app.schemas import ( BookCreate, BookListResponse, @@ -140,6 +140,7 @@ def _build_book_read_with_tags(book: Book, tags_text: str | None) -> BookRead: @router.get("", response_model=BookListResponse) def list_books( status: Optional[ReadingStatus] = Query(default=None), + acquisition_status: Optional[AcquisitionStatus] = Query(default=None), q: Optional[str] = Query(default=None), has_cover: Optional[bool] = Query(default=None), sort: Literal["title", "date_added", "date_started", "date_finished", "rating"] = Query( @@ -167,11 +168,14 @@ def list_books( if status is not None: base_statement = base_statement.where(Book.reading_status == status) + if acquisition_status is not None: + base_statement = base_statement.where(Book.acquisition_status == acquisition_status) + if q: pattern = f"%{q}%" - matching_tag_book_ids = select(BookTag.book_id).join(Tag, Tag.id == BookTag.tag_id).where( + matching_tag_book_ids = select(BookTag.book_id).join(Tag, col(Tag.id) == BookTag.tag_id).where( Tag.user_id == current_user.id, - Tag.name.ilike(pattern), + col(Tag.name).ilike(pattern), ) base_statement = base_statement.where( or_( diff --git a/backend/app/routers/data.py b/backend/app/routers/data.py index 699e4784..9bf21379 100644 --- a/backend/app/routers/data.py +++ b/backend/app/routers/data.py @@ -261,6 +261,7 @@ def validate_import_data( payload = validate_import( body.file_id, current_user, body.mapping, session, create_progress_for_read=body.create_progress_for_read, + require_acquisition_status=True, ) except FileNotFoundError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc @@ -274,7 +275,7 @@ def preview_import_data( ) -> DataImportPreviewResponse: """Preview how a mapping and transforms will affect the first rows.""" try: - payload = preview_import(body.file_id, current_user, body.mapping) + payload = preview_import(body.file_id, current_user, body.mapping, require_acquisition_status=True) except FileNotFoundError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc return DataImportPreviewResponse.model_validate(payload) @@ -302,6 +303,7 @@ async def event_generator(): session=session, import_mode=body.import_mode, create_progress_for_read=body.create_progress_for_read, + require_acquisition_status=True, ): if event.get("event") == "complete": completed = True diff --git a/backend/app/routers/import_.py b/backend/app/routers/import_.py index 3690966e..a940cc5e 100644 --- a/backend/app/routers/import_.py +++ b/backend/app/routers/import_.py @@ -153,6 +153,7 @@ async def import_book( language=_normalize_language(c.language), blurb=c.blurb, reading_status=body.reading_status, + acquisition_status=body.acquisition_status, user_id=current_user.id, ) session.add(book) diff --git a/backend/app/routers/statistics.py b/backend/app/routers/statistics.py index ec5028b4..91e40a16 100644 --- a/backend/app/routers/statistics.py +++ b/backend/app/routers/statistics.py @@ -14,8 +14,9 @@ from app.auth import require_user from app.database import get_session -from app.models import Book, ReadingProgress, ReadingStatus, User, UserSettings +from app.models import AcquisitionStatus, Book, ReadingProgress, ReadingStatus, User, UserSettings from app.schemas import ( + AcquisitionStatusDistribution, DailyPages, DailyPagesResponse, LanguageDistribution, @@ -357,6 +358,14 @@ def get_statistics( did_not_finish=status_counts.get(ReadingStatus.did_not_finish, 0), ) + acquisition_counts = Counter(book.acquisition_status for book in books) + acquisition_status_distribution = AcquisitionStatusDistribution( + owned=acquisition_counts.get(AcquisitionStatus.owned, 0), + borrowed=acquisition_counts.get(AcquisitionStatus.borrowed, 0), + digital_access=acquisition_counts.get(AcquisitionStatus.digital_access, 0), + to_acquire=acquisition_counts.get(AcquisitionStatus.to_acquire, 0), + ) + page_values = [book.page_count for book in books if book.page_count is not None] avg_page_count = round(mean(page_values), 2) if page_values else None @@ -584,6 +593,7 @@ def get_statistics( most_popular_language_count=most_popular_language_count, language_distribution=language_distribution, status_distribution=status_distribution, + acquisition_status_distribution=acquisition_status_distribution, page_buckets=page_buckets, pages_read_per_month=pages_read_per_month, books_finished_per_month=books_finished_per_month, diff --git a/backend/app/schemas.py b/backend/app/schemas.py index e44ec19c..4d127e69 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -5,10 +5,11 @@ from enum import Enum from typing import Literal -from pydantic import BaseModel, ConfigDict, field_validator +from pydantic import BaseModel, field_validator from sqlmodel import Field, SQLModel +from sqlmodel._compat import SQLModelConfig -from app.models import ReadingStatus, UserRole +from app.models import AcquisitionStatus, ReadingStatus, UserRole class ReadingProgressCreate(SQLModel): @@ -52,6 +53,7 @@ class BookCreate(SQLModel): blurb: Optional[str] = None rating: Optional[int] = Field(default=None, ge=1, le=5) reading_status: ReadingStatus = ReadingStatus.want_to_read + acquisition_status: AcquisitionStatus = AcquisitionStatus.owned date_started: Optional[datetime] = None date_finished: Optional[datetime] = None @@ -72,6 +74,7 @@ class BookUpdate(SQLModel): blurb: Optional[str] = None rating: Optional[int] = Field(default=None, ge=1, le=5) reading_status: Optional[ReadingStatus] = None + acquisition_status: Optional[AcquisitionStatus] = None date_started: Optional[datetime] = None date_finished: Optional[datetime] = None @@ -118,6 +121,7 @@ class BookImportRequest(SQLModel): """Persists a BookImportCandidate into the local DB.""" candidate: BookImportCandidate reading_status: ReadingStatus = ReadingStatus.want_to_read + acquisition_status: AcquisitionStatus = AcquisitionStatus.owned class BookRead(SQLModel): @@ -137,6 +141,7 @@ class BookRead(SQLModel): blurb: Optional[str] rating: Optional[int] reading_status: ReadingStatus + acquisition_status: AcquisitionStatus date_added: datetime date_started: Optional[datetime] date_finished: Optional[datetime] @@ -188,6 +193,14 @@ class StatusDistribution(SQLModel): did_not_finish: int +class AcquisitionStatusDistribution(SQLModel): + """Count of books per acquisition status.""" + owned: int + borrowed: int + digital_access: int + to_acquire: int + + class PageBuckets(SQLModel): """Page count buckets for the statistics dashboard.""" pages_to_read: int @@ -248,6 +261,7 @@ class StatisticsResponse(SQLModel): most_popular_language_count: Optional[int] language_distribution: list[LanguageDistribution] status_distribution: StatusDistribution + acquisition_status_distribution: AcquisitionStatusDistribution page_buckets: PageBuckets pages_read_per_month: list[MonthlyPages] books_finished_per_month: list[MonthlyBooks] @@ -315,7 +329,7 @@ class UserUpdate(SQLModel): class ProfileUpdate(SQLModel): """Profile update request (non-admin).""" - model_config = ConfigDict(extra="forbid") + model_config = SQLModelConfig(extra="forbid") firstname: Optional[str] = None lastname: Optional[str] = None diff --git a/backend/app/services/data_export.py b/backend/app/services/data_export.py index 2cd309be..f3db57a5 100644 --- a/backend/app/services/data_export.py +++ b/backend/app/services/data_export.py @@ -30,6 +30,7 @@ "blurb", "rating", "reading_status", + "acquisition_status", "date_added", "date_started", "date_finished", @@ -63,6 +64,7 @@ def _book_to_dict(session: Session, book: Book) -> dict: "blurb": book.blurb, "rating": book.rating, "reading_status": book.reading_status.value, + "acquisition_status": book.acquisition_status.value, "date_added": _serialize_datetime(book.date_added), "date_started": _serialize_datetime(book.date_started), "date_finished": _serialize_datetime(book.date_finished), diff --git a/backend/app/services/data_import.py b/backend/app/services/data_import.py index 6605a245..360da228 100644 --- a/backend/app/services/data_import.py +++ b/backend/app/services/data_import.py @@ -15,7 +15,7 @@ from sqlmodel import Session, select from app.config import settings -from app.models import Book, ReadingProgress, ReadingStatus, User +from app.models import AcquisitionStatus, Book, ReadingProgress, ReadingStatus, User from app.schemas import ImportFieldConfig logger = logging.getLogger(__name__) @@ -38,6 +38,7 @@ "blurb", "rating", "reading_status", + "acquisition_status", "date_started", "date_finished", "cover_url", @@ -74,6 +75,10 @@ "my rating": "rating", "status": "reading_status", "reading status": "reading_status", + "acquisition status": "acquisition_status", + "acquisition": "acquisition_status", + "availability": "acquisition_status", + "ownership": "acquisition_status", "date started": "date_started", "started": "date_started", "date finished": "date_finished", @@ -278,6 +283,18 @@ def _parse_int(value: object, field: str) -> int | None: ) +def _parse_acquisition_status(value: object) -> AcquisitionStatus: + """Parse a required acquisition-status value from an import row.""" + if value is None or not str(value).strip(): + raise ValueError("Missing required field 'acquisition_status'") + normalized = str(value).strip().lower().replace("-", "_").replace(" ", "_") + try: + return AcquisitionStatus(normalized) + except ValueError as exc: + choices = ", ".join(status.value for status in AcquisitionStatus) + raise ValueError(_format_value_error("acquisition_status", f"one of: {choices}", value)) from exc + + def _parse_year(value: object, field: str) -> int | None: """Parse a year value, accepting 4-digit integers and date strings.""" if value is None or value == "": @@ -411,15 +428,16 @@ def _mapped_row( def _validate_mapping( - mapping: dict[str, ImportFieldConfig], source_fields: set[str] + mapping: dict[str, ImportFieldConfig], source_fields: set[str], require_acquisition_status: bool = False ) -> tuple[list[str], list[str]]: """Validate an import mapping, returning (warnings, errors).""" warnings: list[str] = [] errors: list[str] = [] mapped_targets = [target for target in mapping.keys() if target] - if "title" not in mapped_targets: - errors.append("Mapping missing required field: title") + for field in (["title", "acquisition_status"] if require_acquisition_status else ["title"]): + if field not in mapped_targets: + errors.append(f"Mapping missing required field: {field}") invalid_targets = sorted({target for target in mapped_targets if target not in BOOK_IMPORT_FIELDS}) for target in invalid_targets: @@ -448,6 +466,7 @@ def validate_import( mapping: dict[str, ImportFieldConfig], session: Session, create_progress_for_read: bool = False, + require_acquisition_status: bool = False, ) -> dict: """Validate a parsed import file against the DB schema and existing data. @@ -465,7 +484,7 @@ def validate_import( rows = parsed.get("rows", []) source_fields = set(parsed.get("source_fields", [])) - warnings, errors = _validate_mapping(mapping, source_fields) + warnings, errors = _validate_mapping(mapping, source_fields, require_acquisition_status) if errors: return {"valid": False, "row_count": len(rows), "warnings": warnings, "errors": errors} @@ -495,6 +514,8 @@ def validate_import( _parse_year(row_data.get("published_year"), "published_year") _parse_int(row_data.get("page_count"), "page_count") reading_status = _parse_reading_status(row_data.get("reading_status")) + if require_acquisition_status: + _parse_acquisition_status(row_data.get("acquisition_status")) _normalize_language( None if row_data.get("language") is None else str(row_data.get("language")) ) @@ -564,6 +585,7 @@ def preview_import( user: User, mapping: dict[str, ImportFieldConfig], limit: int = 5, + require_acquisition_status: bool = False, ) -> dict: """Preview how a mapping and transforms will affect the first *limit* rows. @@ -573,7 +595,7 @@ def preview_import( rows = parsed.get("rows", []) source_fields = set(parsed.get("source_fields", [])) - _warnings, mapping_errors = _validate_mapping(mapping, source_fields) + _warnings, mapping_errors = _validate_mapping(mapping, source_fields, require_acquisition_status) if mapping_errors: return {"preview_rows": [], "row_count": len(rows), "errors": mapping_errors} @@ -597,6 +619,8 @@ def preview_import( _parse_year(row_data.get("published_year"), "published_year") _parse_int(row_data.get("page_count"), "page_count") reading_status = _parse_reading_status(row_data.get("reading_status")) + if require_acquisition_status: + _parse_acquisition_status(row_data.get("acquisition_status")) _normalize_language( None if row_data.get("language") is None else str(row_data.get("language")) ) @@ -649,6 +673,7 @@ async def execute_import( session: Session, import_mode: str, create_progress_for_read: bool = False, + require_acquisition_status: bool = False, ): """Execute an import, yielding progress and result events. @@ -675,7 +700,7 @@ async def execute_import( rollback_all = import_mode == "rollback_all" source_fields = set(parsed.get("source_fields", [])) - _warnings, mapping_errors = _validate_mapping(mapping, source_fields) + _warnings, mapping_errors = _validate_mapping(mapping, source_fields, require_acquisition_status) if mapping_errors: yield {"event": "error", "message": "; ".join(mapping_errors)} return @@ -699,6 +724,11 @@ async def execute_import( rating = None reading_status = _parse_reading_status(row_data.get("reading_status")) + acquisition_status = ( + _parse_acquisition_status(row_data.get("acquisition_status")) + if require_acquisition_status + else AcquisitionStatus.owned + ) language = _normalize_language( None if row_data.get("language") is None else str(row_data.get("language")) @@ -749,6 +779,7 @@ async def execute_import( blurb=None if row_data.get("blurb") in (None, "") else str(row_data.get("blurb")), rating=rating, reading_status=reading_status, + acquisition_status=acquisition_status, date_started=date_started, date_finished=date_finished, user_id=user.id, diff --git a/backend/tests/test_admin.py b/backend/tests/test_admin.py index fbed0eb4..fd607e3f 100644 --- a/backend/tests/test_admin.py +++ b/backend/tests/test_admin.py @@ -207,7 +207,7 @@ def test_admin_restore_success(admin_client_with_file_db: tuple[TestClient, str] # 2. Modify the database (add a new book) conn = sqlite3.connect(db_path) - conn.execute("INSERT INTO book (title, author, page_count, user_id, reading_status) VALUES ('New Book', '', 0, 1, 'read')") + conn.execute("INSERT INTO book (title, author, page_count, user_id, reading_status, acquisition_status) VALUES ('New Book', '', 0, 1, 'read', 'owned')") conn.commit() row = conn.execute("SELECT COUNT(*) FROM book").fetchone() assert row[0] == 2 diff --git a/backend/tests/test_auth_profile_users.py b/backend/tests/test_auth_profile_users.py index c70edda3..62d8133a 100644 --- a/backend/tests/test_auth_profile_users.py +++ b/backend/tests/test_auth_profile_users.py @@ -3,6 +3,7 @@ import pytest from fastapi import HTTPException from fastapi.testclient import TestClient +from pytest import MonkeyPatch from sqlmodel import Session, select from app.auth import ( @@ -505,7 +506,9 @@ def test_users_delete_user_not_found(client: TestClient) -> None: assert resp.json()["detail"] == "User not found" -def test_oidc_config_disabled_by_default(client: TestClient) -> None: +def test_oidc_config_disabled_by_default(client: TestClient, monkeypatch: MonkeyPatch) -> None: + from app import config + monkeypatch.setattr(config.settings, "oidc_enabled", False) resp = client.get("/api/oidc/config") assert resp.status_code == 200 assert resp.json()["enabled"] is False diff --git a/backend/tests/test_books.py b/backend/tests/test_books.py index 06c1f478..24645579 100644 --- a/backend/tests/test_books.py +++ b/backend/tests/test_books.py @@ -48,6 +48,7 @@ def test_create_book_with_all_fields(client: TestClient) -> None: "notes": "A classic", "rating": 5, "reading_status": "read", + "acquisition_status": "borrowed", "date_started": "2024-01-01", "date_finished": "2024-01-15", } @@ -59,6 +60,15 @@ def test_create_book_with_all_fields(client: TestClient) -> None: assert data["language"] == "EN" assert data["rating"] == 5 assert data["reading_status"] == "read" + assert data["acquisition_status"] == "borrowed" + + +def test_create_book_invalid_acquisition_status_returns_422(client: TestClient) -> None: + resp = client.post( + "/api/books", + json={"title": "Dune", "author": "Frank Herbert", "page_count": 412, "acquisition_status": "unknown"}, + ) + assert resp.status_code == 422 def test_create_book_missing_title_returns_422(client: TestClient) -> None: @@ -102,6 +112,16 @@ def test_list_books_filter_by_status(client: TestClient) -> None: assert body["books"][0]["title"] == "Reading" +def test_list_books_filter_by_acquisition_status(client: TestClient) -> None: + _create_book(client, title="Owned", acquisition_status="owned") + _create_book(client, title="To Acquire", acquisition_status="to_acquire") + + resp = client.get("/api/books?acquisition_status=to_acquire") + + assert resp.status_code == 200 + assert [book["title"] for book in resp.json()["books"]] == ["To Acquire"] + + def test_list_books_search_by_title(client: TestClient) -> None: _create_book(client, title="Dune") _create_book(client, title="Foundation") diff --git a/backend/tests/test_cover_candidates.py b/backend/tests/test_cover_candidates.py index 11d8e6c1..6b8351d7 100644 --- a/backend/tests/test_cover_candidates.py +++ b/backend/tests/test_cover_candidates.py @@ -14,6 +14,7 @@ def test_cover_candidates_search_requires_valid_isbn(client: TestClient) -> None def test_cover_candidates_search_returns_candidates(client: TestClient, monkeypatch) -> None: from app import config monkeypatch.setattr(config.settings, "thalia_cover_search_enabled", False) + monkeypatch.setattr(config.settings, "hardcover_app_api_token", "") requested_urls: list[str] = [] diff --git a/backend/tests/test_data.py b/backend/tests/test_data.py index 687ebb11..dd00f3c8 100644 --- a/backend/tests/test_data.py +++ b/backend/tests/test_data.py @@ -210,7 +210,7 @@ def test_data_import_mapping_crud(client: TestClient) -> None: def test_data_import_validate_and_execute_continue_on_error(client: TestClient, monkeypatch: MonkeyPatch, tmp_path: Path) -> None: monkeypatch.setattr(settings, "import_temp_dir", str(tmp_path / "import_temp")) - csv_payload = "Title,Author\nDune,Frank Herbert\n,No Title\n" + csv_payload = "Title,Author,Availability\nDune,Frank Herbert,owned\n,No Title,owned\n" parse_resp = client.post( "/api/data/import/parse", files={"file": ("books.csv", csv_payload, "text/csv")}, @@ -219,14 +219,14 @@ def test_data_import_validate_and_execute_continue_on_error(client: TestClient, validate_resp = client.post( "/api/data/import/validate", - json={"file_id": file_id, "mapping": {"title": {"source": "Title", "transform": None}, "author": {"source": "Author", "transform": None}}}, + json={"file_id": file_id, "mapping": {"title": {"source": "Title", "transform": None}, "author": {"source": "Author", "transform": None}, "acquisition_status": {"source": "Availability", "transform": None}}}, ) assert validate_resp.status_code == 200 assert validate_resp.json()["valid"] is False preview_resp = client.post( "/api/data/import/preview", - json={"file_id": file_id, "mapping": {"title": {"source": "Title", "transform": None}, "author": {"source": "Author", "transform": None}}}, + json={"file_id": file_id, "mapping": {"title": {"source": "Title", "transform": None}, "author": {"source": "Author", "transform": None}, "acquisition_status": {"source": "Availability", "transform": None}}}, ) assert preview_resp.status_code == 200 preview = preview_resp.json() @@ -238,7 +238,7 @@ def test_data_import_validate_and_execute_continue_on_error(client: TestClient, "/api/data/import/execute", json={ "file_id": file_id, - "mapping": {"title": {"source": "Title", "transform": None}, "author": {"source": "Author", "transform": None}}, + "mapping": {"title": {"source": "Title", "transform": None}, "author": {"source": "Author", "transform": None}, "acquisition_status": {"source": "Availability", "transform": None}}, "import_mode": "continue_on_error", }, ) @@ -298,7 +298,7 @@ def test_data_import_execute_rejects_invalid_target_mapping(client: TestClient, def test_data_import_validate_rejects_invalid_reading_status_enum(client: TestClient, monkeypatch: MonkeyPatch, tmp_path: Path) -> None: monkeypatch.setattr(settings, "import_temp_dir", str(tmp_path / "import_temp")) - csv_payload = "Title,Status\nDune,uxnread\n" + csv_payload = "Title,Status,Availability\nDune,uxnread,owned\n" parse_resp = client.post( "/api/data/import/parse", files={"file": ("books.csv", csv_payload, "text/csv")}, @@ -307,7 +307,7 @@ def test_data_import_validate_rejects_invalid_reading_status_enum(client: TestCl validate_resp = client.post( "/api/data/import/validate", - json={"file_id": file_id, "mapping": {"title": {"source": "Title", "transform": None}, "reading_status": {"source": "Status", "transform": None}}}, + json={"file_id": file_id, "mapping": {"title": {"source": "Title", "transform": None}, "reading_status": {"source": "Status", "transform": None}, "acquisition_status": {"source": "Availability", "transform": None}}}, ) assert validate_resp.status_code == 200 payload = validate_resp.json() @@ -320,7 +320,7 @@ def test_data_import_execute_progress_uses_date_finished_for_read_books( client: TestClient, monkeypatch: MonkeyPatch, tmp_path: Path ) -> None: monkeypatch.setattr(settings, "import_temp_dir", str(tmp_path / "import_temp")) - csv_payload = "Title,Status,Pages,Date Finished\nDune,read,412,2024-01-15T10:30:00Z\n" + csv_payload = "Title,Status,Pages,Date Finished,Availability\nDune,read,412,2024-01-15T10:30:00Z,owned\n" parse_resp = client.post( "/api/data/import/parse", files={"file": ("books.csv", csv_payload, "text/csv")}, @@ -336,6 +336,7 @@ def test_data_import_execute_progress_uses_date_finished_for_read_books( "reading_status": {"source": "Status", "transform": None}, "page_count": {"source": "Pages", "transform": None}, "date_finished": {"source": "Date Finished", "transform": None}, + "acquisition_status": {"source": "Availability", "transform": None}, }, "import_mode": "continue_on_error", "create_progress_for_read": True, @@ -362,7 +363,7 @@ def test_data_import_execute_read_book_without_date_finished_skips_progress( client: TestClient, monkeypatch: MonkeyPatch, tmp_path: Path ) -> None: monkeypatch.setattr(settings, "import_temp_dir", str(tmp_path / "import_temp")) - csv_payload = "Title,Status,Pages\nDune,read,412\n" + csv_payload = "Title,Status,Pages,Availability\nDune,read,412,owned\n" parse_resp = client.post( "/api/data/import/parse", files={"file": ("books.csv", csv_payload, "text/csv")}, @@ -377,6 +378,7 @@ def test_data_import_execute_read_book_without_date_finished_skips_progress( "title": {"source": "Title", "transform": None}, "reading_status": {"source": "Status", "transform": None}, "page_count": {"source": "Pages", "transform": None}, + "acquisition_status": {"source": "Availability", "transform": None}, }, "import_mode": "continue_on_error", "create_progress_for_read": True, diff --git a/backend/tests/test_data_import.py b/backend/tests/test_data_import.py index 5694c196..faa4970f 100644 --- a/backend/tests/test_data_import.py +++ b/backend/tests/test_data_import.py @@ -267,11 +267,26 @@ def test_validate_mapping_transform_invalid() -> None: def test_validate_mapping_transform_valid() -> None: - mapping = {"title": ImportFieldConfig(source="A", transform="value.upper()")} - warnings, errors = di._validate_mapping(mapping, {"A"}) + mapping = { + "title": ImportFieldConfig(source="A", transform="value.upper()"), + "acquisition_status": ImportFieldConfig(source="B"), + } + warnings, errors = di._validate_mapping(mapping, {"A", "B"}) assert len(errors) == 0 +def test_validate_mapping_requires_acquisition_status() -> None: + _warnings, errors = di._validate_mapping( + {"title": ImportFieldConfig(source="A")}, {"A"}, require_acquisition_status=True + ) + assert "Mapping missing required field: acquisition_status" in errors + + +def test_parse_acquisition_status_rejects_invalid_value() -> None: + with pytest.raises(ValueError, match="acquisition_status"): + di._parse_acquisition_status("wishlist") + + # ── preview_import ──────────────────────────────────────────────────────────── def test_preview_import_basic(session: Session, tmp_path: Path, monkeypatch: MonkeyPatch) -> None: diff --git a/backend/tests/test_statistics.py b/backend/tests/test_statistics.py index 936c9259..8c271274 100644 --- a/backend/tests/test_statistics.py +++ b/backend/tests/test_statistics.py @@ -38,6 +38,12 @@ def test_statistics_empty_library(client: Any) -> None: "read": 0, "did_not_finish": 0, } + assert data["acquisition_status_distribution"] == { + "owned": 0, + "borrowed": 0, + "digital_access": 0, + "to_acquire": 0, + } assert data["page_buckets"] == {"pages_to_read": 0, "pages_read": 0, "pages_wasted": 0} assert data["pages_read_per_month"] == [] assert data["books_finished_per_month"] == [] @@ -115,6 +121,40 @@ def test_statistics_core_metrics_and_distributions(client: Any) -> None: assert data["top_authors"][1]["book_count"] == 2 +def test_statistics_acquisition_status_distribution(client: Any) -> None: + _create_book(client, title="Owned 1", acquisition_status="owned") + _create_book(client, title="Owned 2", acquisition_status="owned") + _create_book(client, title="Borrowed 1", acquisition_status="borrowed") + _create_book(client, title="Digital 1", acquisition_status="digital_access") + _create_book(client, title="To Acquire 1", acquisition_status="to_acquire") + + resp = client.get("/api/statistics") + assert resp.status_code == 200 + data = resp.json() + assert data["acquisition_status_distribution"] == { + "owned": 2, + "borrowed": 1, + "digital_access": 1, + "to_acquire": 1, + } + + +def test_statistics_acquisition_status_defaults_to_owned(client: Any) -> None: + _create_book(client, title="Default 1") + _create_book(client, title="Default 2") + _create_book(client, title="Borrowed 1", acquisition_status="borrowed") + + resp = client.get("/api/statistics") + assert resp.status_code == 200 + data = resp.json() + assert data["acquisition_status_distribution"] == { + "owned": 2, + "borrowed": 1, + "digital_access": 0, + "to_acquire": 0, + } + + def test_statistics_top_authors_limit_and_tiebreaker(client: Any) -> None: _create_book(client, title="A1", author="Author Z", reading_status="read") _create_book(client, title="A2", author="Author Z", reading_status="read") diff --git a/docs/guide/using-librislog/import-export.md b/docs/guide/using-librislog/import-export.md index 4531abc3..dd5fbe75 100644 --- a/docs/guide/using-librislog/import-export.md +++ b/docs/guide/using-librislog/import-export.md @@ -15,6 +15,7 @@ The most common way to add books is by searching external sources: - **Google Books** (if `GOOGLE_BOOKS_API_KEY` is set — see [API Keys](/guide/api-keys)) - **Hardcover.app** (if `HARDCOVER_APP_API_TOKEN` is set — see [API Keys](/guide/api-keys)) 4. Select a result to import with full metadata and cover +5. Choose an availability value (owned, borrowed, digital access, or to acquire) before saving ### ISBN Barcode Scan @@ -22,10 +23,11 @@ On mobile devices: 1. Tap the scan button in the import dialog 2. Point the camera at an ISBN barcode 3. The app detects the barcode and searches automatically +4. Pick the search result and select an availability value before saving ### Manual Entry -If no search results are found, enter book details manually. All fields are optional except title. +If no search results are found, enter book details manually. Title, author, page count, and availability are required; all other fields are optional. ## Data Export @@ -72,6 +74,8 @@ When importing CSV, map source columns to LibrisLog fields: - Target field shows available LibrisLog properties - Optional transform expressions (Python) for data conversion +`acquisition_status` is required for imports. Map it to one of `owned`, `borrowed`, `digital_access`, or `to_acquire`; use a transform when the source file uses different names. + ### Transform DSL Per-field Python expressions allow data transformation: @@ -108,4 +112,4 @@ Backup and restore are admin-only features. See [Administration](./administratio ## API Access -For programmatic import/export, use the REST API. See the [API documentation](../../api/) for details. \ No newline at end of file +For programmatic import/export, use the REST API. See the [API documentation](../../api/) for details. diff --git a/docs/guide/using-librislog/library.md b/docs/guide/using-librislog/library.md index 2715c54d..f8a0de29 100644 --- a/docs/guide/using-librislog/library.md +++ b/docs/guide/using-librislog/library.md @@ -15,6 +15,10 @@ Books are categorized into four statuses: Each status has its own tab in the library view, making it easy to browse your collection by reading state. +## Availability + +Availability is separate from reading status. Choose whether a book is owned, borrowed, available digitally, or still needs to be acquired. In the Want to Read view, books that still need to be acquired show a shopping-cart indicator. Use the availability filter to narrow the list without changing its newest-first order. + ![Library](/screenshots/library-read.png) ## Navigation @@ -101,4 +105,4 @@ Downloaded covers are cached locally in the `COVERS_DIR` directory to avoid repe ## View Modes -Switch between grid view (cover-focused) and list view (compact) using the view toggle. \ No newline at end of file +Switch between grid view (cover-focused) and list view (compact) using the view toggle. diff --git a/docs/guide/using-librislog/statistics.md b/docs/guide/using-librislog/statistics.md index a29d5040..bc934127 100644 --- a/docs/guide/using-librislog/statistics.md +++ b/docs/guide/using-librislog/statistics.md @@ -29,6 +29,14 @@ A stacked bar showing how your library is divided among the four reading statuse - Read (green) - Did Not Finish (red) +### Acquisition Status Distribution + +A stacked bar showing how your library is divided among the four acquisition (ownership) statuses: +- Owned +- Borrowed +- Digital Access +- Needs to be acquired + ### Page Buckets A stacked bar showing: diff --git a/frontend/e2e/fixtures/seed-data.ts b/frontend/e2e/fixtures/seed-data.ts index f536505d..bf5f28a9 100644 --- a/frontend/e2e/fixtures/seed-data.ts +++ b/frontend/e2e/fixtures/seed-data.ts @@ -15,6 +15,7 @@ export interface SeedBook { tags?: string; date_started?: string; date_finished?: string; + acquisition_status?: 'owned' | 'borrowed' | 'digital_access' | 'to_acquire'; } export const SEED_BOOKS: SeedBook[] = [ diff --git a/frontend/e2e/fixtures/seed.api.ts b/frontend/e2e/fixtures/seed.api.ts index 92621910..cfb81987 100644 --- a/frontend/e2e/fixtures/seed.api.ts +++ b/frontend/e2e/fixtures/seed.api.ts @@ -31,7 +31,7 @@ export async function seedBooks(page: Page, books: SeedBook[]): Promise { export async function deleteAllBooks(page: Page): Promise { const resp = await page.request.get(bookApiPath() + '?limit=200'); const body = await resp.json(); - const books: { id: number }[] = body.books; + const books: { id: number }[] = Array.isArray(body?.books) ? body.books : []; for (const book of books) { const csrf = await getCsrfToken(page); await page.request.delete(`${bookApiPath()}/${book.id}`, { diff --git a/frontend/e2e/specs/03-library-browsing.spec.ts b/frontend/e2e/specs/03-library-browsing.spec.ts index 6c68b5aa..d4f4be17 100644 --- a/frontend/e2e/specs/03-library-browsing.spec.ts +++ b/frontend/e2e/specs/03-library-browsing.spec.ts @@ -4,6 +4,20 @@ import { seedBooks, deleteAllBooks } from '../fixtures/seed.api'; import { SEED_USER, SEED_BOOKS } from '../fixtures/seed-data'; import { LibraryPage } from '../fixtures/pages/library.page'; +async function createWantToReadBook( + page: import('@playwright/test').Page, + title: string, + acquisition_status: 'owned' | 'borrowed' | 'digital_access' | 'to_acquire' +) { + const csrf = await page.request.get('/api/auth/csrf'); + const { csrf_token } = await csrf.json(); + const response = await page.request.post('/api/books', { + data: { title, author: 'E2E Author', page_count: 200, reading_status: 'want_to_read', acquisition_status }, + headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf_token }, + }); + expect(response.ok()).toBeTruthy(); +} + test.describe('Library Browsing', () => { test.beforeEach(async ({ page }) => { await loginViaUi(page, SEED_USER.email, SEED_USER.password); @@ -52,4 +66,41 @@ test.describe('Library Browsing', () => { const body = page.locator('body'); await expect(body).toContainText(/no books|empty/i); }); + + test('3.5 manual creation requires availability and persists the selected value', async ({ page }) => { + await deleteAllBooks(page); + const library = new LibraryPage(page); + await library.goto(); + + await page.getByRole('button', { name: '+ Add Book' }).click(); + const modal = page.locator('.modal-box'); + const availability = modal.getByRole('combobox', { name: /Availability/ }); + await expect(availability).toHaveValue(''); + + await modal.getByLabel('Title *').fill('Digital E2E Book'); + await modal.getByRole('searchbox', { name: /Author/ }).fill('E2E Author'); + await modal.getByLabel(/Pages/).fill('200'); + await availability.selectOption('digital_access'); + await modal.getByRole('button', { name: 'Add Book' }).click(); + + await expect(page.getByText('Digital E2E Book')).toBeVisible(); + const response = await page.request.get('/api/books?q=Digital%20E2E%20Book'); + expect((await response.json()).books[0].acquisition_status).toBe('digital_access'); + }); + + test('3.6 filters Want to Read books by availability and marks books to acquire', async ({ page }) => { + await deleteAllBooks(page); + await createWantToReadBook(page, 'Owned E2E Book', 'owned'); + await createWantToReadBook(page, 'Acquire E2E Book', 'to_acquire'); + const library = new LibraryPage(page); + await library.goto(); + + await expect(page.getByText('Owned E2E Book')).toBeVisible(); + await expect(page.getByText('Acquire E2E Book')).toBeVisible(); + await expect(page.locator('span[aria-label="Needs to be acquired"]')).toBeVisible(); + + await page.locator('select[name="acquisition_filter"]').selectOption('to_acquire'); + await expect(page.getByText('Acquire E2E Book')).toBeVisible(); + await expect(page.getByText('Owned E2E Book')).not.toBeVisible(); + }); }); diff --git a/frontend/e2e/specs/09-data-import.spec.ts b/frontend/e2e/specs/09-data-import.spec.ts index 62d12d76..6364d8f5 100644 --- a/frontend/e2e/specs/09-data-import.spec.ts +++ b/frontend/e2e/specs/09-data-import.spec.ts @@ -1,11 +1,16 @@ import { test, expect } from '@playwright/test'; import { loginViaUi } from '../fixtures/auth.fixture'; +import { deleteAllBooks } from '../fixtures/seed.api'; import { SEED_USER } from '../fixtures/seed-data'; test.describe('Data Import', () => { - const CSV = `title,author,isbn,pages,status -"The Imported Book","Import Author","1234567890",300,want_to_read -"Second Imported","Another Author","9876543210",250,want_to_read`; + const CSV = `title,author,isbn,pages,status,availability +"The Imported Book","Import Author","1234567890",300,want_to_read,owned +"Second Imported","Another Author","9876543210",250,want_to_read,to_acquire`; + + const CSV_ACQUISITION = `title,author,isbn,pages,status,availability +"Acquisition Test A","Author A","1111111111",300,want_to_read,owned +"Acquisition Test B","Author B","2222222222",250,want_to_read,to_acquire`; test.beforeEach(async ({ page }) => { await loginViaUi(page, SEED_USER.email, SEED_USER.password); @@ -38,6 +43,7 @@ test.describe('Data Import', () => { await page.locator('select[name="mapping-target-isbn"]').selectOption('isbn'); await page.locator('select[name="mapping-target-page_count"]').selectOption('pages'); await page.locator('select[name="mapping-target-reading_status"]').selectOption('status'); + await page.locator('select[name="mapping-target-acquisition_status"]').selectOption('availability'); await page.locator('textarea[name="mapping-transform-title"]').fill('value.strip().upper()'); @@ -73,7 +79,7 @@ test.describe('Data Import', () => { } }); - test('9.3 transform syntax error shown in preview', async ({ page }) => { + test('9.3 transform syntax error shown in preview', async ({ page }) => { await page.goto('/data?tab=import'); await page.waitForTimeout(1000); @@ -93,6 +99,7 @@ test.describe('Data Import', () => { await page.locator('select[name="mapping-target-isbn"]').selectOption('isbn'); await page.locator('select[name="mapping-target-page_count"]').selectOption('pages'); await page.locator('select[name="mapping-target-reading_status"]').selectOption('status'); + await page.locator('select[name="mapping-target-acquisition_status"]').selectOption('availability'); await page.locator('textarea[name="mapping-transform-title"]').fill('value.upper('); @@ -102,4 +109,75 @@ test.describe('Data Import', () => { const body = page.locator('body'); await expect(body).toContainText(/error|invalid|syntax/i, { timeout: 10000 }); }); + + test('9.4 import validation rejects missing acquisition_status mapping', async ({ page }) => { + await page.goto('/data?tab=import'); + await page.waitForTimeout(1000); + + await page.locator('input[type="file"]').setInputFiles({ + name: 'test-books.csv', + mimeType: 'text/csv', + buffer: Buffer.from(CSV), + }); + + await page.locator('button').filter({ hasText: 'Parse file' }).click(); + await page.waitForTimeout(2000); + + await page.locator('select[name="mapping-target-title"]').selectOption('title'); + await page.locator('select[name="mapping-target-author"]').selectOption('author'); + await page.locator('select[name="mapping-target-isbn"]').selectOption('isbn'); + await page.locator('select[name="mapping-target-page_count"]').selectOption('pages'); + await page.locator('select[name="mapping-target-reading_status"]').selectOption('status'); + // intentionally omit acquisition_status mapping + + await page.locator('button').filter({ hasText: 'Simulate' }).click(); + await page.waitForTimeout(2000); + + const body = page.locator('body'); + await expect(body).toContainText(/acquisition_status/i, { timeout: 10000 }); + await expect(body).toContainText(/required/i, { timeout: 10000 }); + }); + + test('9.5 imported books retain their acquisition status', async ({ page }) => { + await deleteAllBooks(page); + await page.goto('/data?tab=import'); + await page.waitForTimeout(1000); + + await page.locator('input[type="file"]').setInputFiles({ + name: 'test-books.csv', + mimeType: 'text/csv', + buffer: Buffer.from(CSV_ACQUISITION), + }); + + await page.locator('button').filter({ hasText: 'Parse file' }).click(); + await page.waitForTimeout(2000); + + await page.locator('select[name="mapping-target-title"]').selectOption('title'); + await page.locator('select[name="mapping-target-author"]').selectOption('author'); + await page.locator('select[name="mapping-target-isbn"]').selectOption('isbn'); + await page.locator('select[name="mapping-target-page_count"]').selectOption('pages'); + await page.locator('select[name="mapping-target-reading_status"]').selectOption('status'); + await page.locator('select[name="mapping-target-acquisition_status"]').selectOption('availability'); + + await page.locator('button').filter({ hasText: 'Generate' }).click(); + await page.waitForTimeout(2000); + + await page.locator('button').filter({ hasText: 'Simulate' }).click(); + await page.waitForTimeout(2000); + + const body = page.locator('body'); + await expect(body).toContainText('Validation passed.', { timeout: 10000 }); + + await page.locator('button.btn-secondary.btn-sm').filter({ hasText: 'Import now' }).click(); + await page.locator('dialog.modal-open .btn-secondary').filter({ hasText: 'Import now' }).waitFor({ state: 'visible', timeout: 5000 }); + await page.locator('dialog.modal-open .btn-secondary').filter({ hasText: 'Import now' }).click(); + await page.waitForTimeout(2000); + + await expect(body).toContainText(/Import complete/i, { timeout: 10000 }); + + const response = await page.request.get('/api/books?q=Acquisition%20Test%20B'); + const books = (await response.json()).books; + expect(books).toHaveLength(1); + expect(books[0].acquisition_status).toBe('to_acquire'); + }); }); diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 53c89443..b6d25b04 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -31,6 +31,7 @@ import type { StatusTransitionResponse, ImportSearchMode, ReadingStatus, + AcquisitionStatus, SearchStage, TagCloudEntry, SortField, @@ -329,6 +330,7 @@ export const api = { list(params?: { status?: ReadingStatus; + acquisition_status?: AcquisitionStatus; q?: string; has_cover?: boolean; sort?: SortField; @@ -339,6 +341,7 @@ export const api = { }): Promise { const qs = new URLSearchParams(); if (params?.status) qs.set('status', params.status); + if (params?.acquisition_status) qs.set('acquisition_status', params.acquisition_status); if (params?.q) qs.set('q', params.q); if (params?.has_cover !== undefined) qs.set('has_cover', String(params.has_cover)); if (params?.sort) qs.set('sort', params.sort); @@ -465,10 +468,10 @@ export const api = { ); }, - importBook(candidate: BookImportCandidate, status: ReadingStatus = 'want_to_read'): Promise { + importBook(candidate: BookImportCandidate, status: ReadingStatus, acquisitionStatus: AcquisitionStatus): Promise { return request('/import', { method: 'POST', - body: JSON.stringify({ candidate, reading_status: status }) + body: JSON.stringify({ candidate, reading_status: status, acquisition_status: acquisitionStatus }) }); }, diff --git a/frontend/src/lib/chartjs/register.ts b/frontend/src/lib/chartjs/register.ts index c28b11e0..997e11bc 100644 --- a/frontend/src/lib/chartjs/register.ts +++ b/frontend/src/lib/chartjs/register.ts @@ -31,13 +31,16 @@ Chart.register( import dayjs from 'dayjs'; import utc from 'dayjs/plugin/utc'; +import timezone from 'dayjs/plugin/timezone'; import customParseFormat from 'dayjs/plugin/customParseFormat'; import advancedFormat from 'dayjs/plugin/advancedFormat'; import localizedFormat from 'dayjs/plugin/localizedFormat'; import quarterOfYear from 'dayjs/plugin/quarterOfYear'; import weekday from 'dayjs/plugin/weekday'; +import { getTimezone } from '$lib/stores/timezone'; dayjs.extend(utc); +dayjs.extend(timezone); dayjs.extend(customParseFormat); dayjs.extend(advancedFormat); dayjs.extend(localizedFormat); @@ -69,16 +72,17 @@ _adapters._date.override({ const d = dayjs.utc(value as string | number | Date); return d.isValid() ? d.valueOf() : null; }, - format: (time: unknown, format: string) => dayjs.utc(time as number).format(format), - add: (time: unknown, amount: number, unit: string) => dayjs.utc(time as number).add(amount, unit as dayjs.ManipulateType).valueOf(), - diff: (max: unknown, min: unknown, unit: string) => dayjs.utc(max as number).diff(dayjs.utc(min as number), unit as dayjs.OpUnitType), + format: (time: unknown, format: string) => dayjs.utc(time as number).tz(getTimezone()).format(format), + add: (time: unknown, amount: number, unit: string) => dayjs.utc(time as number).tz(getTimezone()).add(amount, unit as dayjs.ManipulateType).valueOf(), + diff: (max: unknown, min: unknown, unit: string) => dayjs.utc(max as number).tz(getTimezone()).diff(dayjs.utc(min as number).tz(getTimezone()), unit as dayjs.OpUnitType), startOf: (time: unknown, unit: string, weekday?: number) => { + const date = dayjs.utc(time as number).tz(getTimezone()); if (unit === 'isoWeek') { - return (dayjs.utc(time as number) as unknown as { weekday: (w: number) => { valueOf: () => number } }).weekday(weekday ?? 1).valueOf(); + return (date as unknown as { weekday: (w: number) => { valueOf: () => number } }).weekday(weekday ?? 1).valueOf(); } - return dayjs.utc(time as number).startOf(unit as dayjs.OpUnitType).valueOf(); + return date.startOf(unit as dayjs.OpUnitType).valueOf(); }, - endOf: (time: unknown, unit: string) => dayjs.utc(time as number).endOf(unit as dayjs.OpUnitType).valueOf(), + endOf: (time: unknown, unit: string) => dayjs.utc(time as number).tz(getTimezone()).endOf(unit as dayjs.OpUnitType).valueOf(), } as unknown as Parameters[0]); export { Chart as ChartJS }; diff --git a/frontend/src/lib/components/AddBookModal.svelte b/frontend/src/lib/components/AddBookModal.svelte index 3fd7c446..e2a67784 100644 --- a/frontend/src/lib/components/AddBookModal.svelte +++ b/frontend/src/lib/components/AddBookModal.svelte @@ -1,5 +1,5 @@ {#if open} @@ -201,6 +211,15 @@ {/each} +