diff --git a/alembic/versions/b5c6d7e8f901_add_reading_activity_and_goals.py b/alembic/versions/b5c6d7e8f901_add_reading_activity_and_goals.py new file mode 100644 index 0000000..3c1e0a5 --- /dev/null +++ b/alembic/versions/b5c6d7e8f901_add_reading_activity_and_goals.py @@ -0,0 +1,34 @@ +"""Add owned, durable goals and append-only activity without changing book data.""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +from alembic import op + +revision: str = "b5c6d7e8f901" +down_revision: str | Sequence[str] | None = "af0fea8d6317" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + for table in ("reading_goals", "reading_activities", "goal_periods"): + op.create_table( + table, + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("owner_user_id", sa.Uuid(), nullable=False), + sa.Column("payload", postgresql.JSONB(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.ForeignKeyConstraint(["owner_user_id"], ["users.user_id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(f"ix_{table}_owner_user_id", table, ["owner_user_id"]) + + +def downgrade() -> None: + for table in ("goal_periods", "reading_activities", "reading_goals"): + op.drop_index(f"ix_{table}_owner_user_id", table_name=table) + op.drop_table(table) diff --git a/deploy/bootstrap-powersync.sql b/deploy/bootstrap-powersync.sql index 9a86f74..64bdcc9 100644 --- a/deploy/bootstrap-powersync.sql +++ b/deploy/bootstrap-powersync.sql @@ -5,8 +5,8 @@ WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'powersync_role') \gexec ALTER ROLE powersync_role WITH REPLICATION BYPASSRLS LOGIN PASSWORD :'source_password'; GRANT USAGE ON SCHEMA public TO powersync_role; -GRANT SELECT ON TABLE public.books, public.shelves, public.tags, public.notes, public.annotations, public.bookmarks, public.book_shelves, public.book_tags, public.powersync_demo_items TO powersync_role; +GRANT SELECT ON TABLE public.books, public.shelves, public.tags, public.notes, public.annotations, public.bookmarks, public.book_shelves, public.book_tags, public.reading_goals, public.reading_activities, public.goal_periods, public.powersync_demo_items TO powersync_role; ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO powersync_role; SELECT 'CREATE PUBLICATION powersync' WHERE NOT EXISTS (SELECT 1 FROM pg_publication WHERE pubname = 'powersync') \gexec -ALTER PUBLICATION powersync SET TABLE public.books, public.shelves, public.tags, public.notes, public.annotations, public.bookmarks, public.book_shelves, public.book_tags, public.powersync_demo_items; +ALTER PUBLICATION powersync SET TABLE public.books, public.shelves, public.tags, public.notes, public.annotations, public.bookmarks, public.book_shelves, public.book_tags, public.reading_goals, public.reading_activities, public.goal_periods, public.powersync_demo_items; diff --git a/docs-tracking.md b/docs-tracking.md new file mode 100644 index 0000000..e16a0ef --- /dev/null +++ b/docs-tracking.md @@ -0,0 +1,37 @@ +# Reading tracking contract and rollout + +Tracking schema version 1 is advertised additively by `GET /v1/sync/settings`. +Goals/session/statistics REST endpoints now return owned persisted records and +ledger-derived progress. Existing metric strings remain valid; `reading_days` is +additive. Goal definition, activity, and period payloads are validated in the same +transaction/owner lock used by ordinary PowerSync uploads. + +`reading_goals`, `reading_activities`, and `goal_periods` store owner-scoped JSONB +payloads with UUID identities. Activity is immutable: correction/undo appends a +reversal referencing an owned original. Retries are idempotent. Shelf membership +and book-title snapshots survive book deletion. Activity/period deletion through +sync is rejected; deleting a goal retains rule/period history. Account deletion +still removes owned data. + +Progress unions overlapping time intervals and per-book/day content coverage, +counts distinct confirmed books, and applies creation cutoffs, pauses, captured +IANA timezone, Monday weeks, and historical rule revisions. Period history holds +rules rather than mutable counters, allowing delayed corrections to reproject it. +Shared fixtures in `tests/fixtures/tracking_projections.json` match Dart tests. + +## Deployment order + +1. Apply Alembic revision `b5c6d7e8f901` (`uv run alembic upgrade head`). This is + additive and preserves existing library tables/data. +2. Add the three tracking tables to the PostgreSQL PowerSync publication and grants. + Updated `deploy/bootstrap-powersync.sql` and `scripts/setup_local_powersync.sh` + include them. Existing deployments must run the updated bootstrap; creating + database tables alone does not update an existing publication. +3. Deploy the updated PowerSync stream configuration and server capability. +4. Release the integrated client after verifying the capability and transport. + +Older servers are supported by client local staging; unsupported tracking never +blocks ordinary library uploads. Versions remain unchanged in these development +PRs. Run server tests only against a separate test database; the fixtures recreate +tables. The tracking migration test verifies existing books survive and Alembic +metadata matches the upgraded schema. diff --git a/papyrus/api/routes/goals.py b/papyrus/api/routes/goals.py index d0c1170..eb4e814 100644 --- a/papyrus/api/routes/goals.py +++ b/papyrus/api/routes/goals.py @@ -1,139 +1,41 @@ -"""Goal routes.""" +"""Goal routes backed by owned definitions and real activity.""" -from datetime import UTC, date, datetime -from uuid import UUID, uuid4 +from typing import Annotated +from uuid import UUID -from fastapi import APIRouter, Response, status +from fastapi import APIRouter, Depends, Response, status +from sqlalchemy.ext.asyncio import AsyncSession from papyrus.api.deps import CurrentUserId -from papyrus.schemas.goal import ( - CreateGoalRequest, - Goal, - GoalList, - GoalType, - TimePeriod, - UpdateGoalRequest, -) +from papyrus.core.database import get_db +from papyrus.schemas.goal import CreateGoalRequest, Goal, GoalList, UpdateGoalRequest +from papyrus.services import goals as service router = APIRouter() +DBSession = Annotated[AsyncSession, Depends(get_db)] -def _example_goal(goal_id: UUID | None = None) -> Goal: - """Create an example goal for responses.""" - today = date.today() +@router.get("", response_model=GoalList, summary="List all goals") +async def list_goals(user_id: CurrentUserId, db: DBSession, is_active: bool | None = None) -> GoalList: + return GoalList(goals=await service.list_goals(db, user_id, is_active)) - return Goal( - goal_id=goal_id or uuid4(), - title="Read 12 books this year", - description="My annual reading goal", - goal_type=GoalType.BOOKS_COUNT, - target_value=12, - current_value=5, - progress_percentage=41.67, - time_period=TimePeriod.YEARLY, - start_date=today.replace(month=1, day=1), - end_date=today.replace(month=12, day=31), - is_active=True, - is_completed=False, - created_at=datetime.now(UTC), - updated_at=datetime.now(UTC), - ) +@router.post("", response_model=Goal, status_code=status.HTTP_201_CREATED, summary="Create a new goal") +async def create_goal(user_id: CurrentUserId, request: CreateGoalRequest, db: DBSession) -> Goal: + return await service.create_goal(db, user_id, request) -@router.get( - "", - response_model=GoalList, - summary="List all goals", -) -async def list_goals( - user_id: CurrentUserId, - is_active: bool | None = None, -) -> GoalList: - """Return all goals for the user.""" - return GoalList(goals=[_example_goal()]) +@router.get("/{goal_id}", response_model=Goal, summary="Get goal details") +async def get_goal(user_id: CurrentUserId, goal_id: UUID, db: DBSession) -> Goal: + return await service.get_goal(db, user_id, goal_id) -@router.post( - "", - response_model=Goal, - status_code=status.HTTP_201_CREATED, - summary="Create a new goal", -) -async def create_goal( - user_id: CurrentUserId, - request: CreateGoalRequest, -) -> Goal: - """Create a new reading goal.""" - return Goal( - goal_id=uuid4(), - title=request.title, - description=request.description, - goal_type=request.goal_type, - target_value=request.target_value, - current_value=0, - progress_percentage=0.0, - time_period=request.time_period, - start_date=request.start_date, - end_date=request.end_date, - is_active=True, - is_completed=False, - created_at=datetime.now(UTC), - updated_at=datetime.now(UTC), - ) +@router.patch("/{goal_id}", response_model=Goal, summary="Update goal") +async def update_goal(user_id: CurrentUserId, goal_id: UUID, request: UpdateGoalRequest, db: DBSession) -> Goal: + return await service.update_goal(db, user_id, goal_id, request) -@router.get( - "/{goal_id}", - response_model=Goal, - summary="Get goal details", -) -async def get_goal( - user_id: CurrentUserId, - goal_id: UUID, -) -> Goal: - """Return detailed information about a goal.""" - return _example_goal(goal_id) - -@router.patch( - "/{goal_id}", - response_model=Goal, - summary="Update goal", -) -async def update_goal( - user_id: CurrentUserId, - goal_id: UUID, - request: UpdateGoalRequest, -) -> Goal: - """Update goal properties.""" - goal = _example_goal(goal_id) - - if request.title is not None: - goal.title = request.title - - if request.description is not None: - goal.description = request.description - - if request.target_value is not None: - goal.target_value = request.target_value - - if request.end_date is not None: - goal.end_date = request.end_date - - if request.is_active is not None: - goal.is_active = request.is_active - - return goal - - -@router.delete( - "/{goal_id}", - status_code=status.HTTP_204_NO_CONTENT, - summary="Delete goal", -) -async def delete_goal( - user_id: CurrentUserId, - goal_id: UUID, -) -> Response: - """Delete a goal.""" +@router.delete("/{goal_id}", status_code=status.HTTP_204_NO_CONTENT, summary="Delete goal") +async def delete_goal(user_id: CurrentUserId, goal_id: UUID, db: DBSession) -> Response: + await service.delete_goal(db, user_id, goal_id) return Response(status_code=status.HTTP_204_NO_CONTENT) diff --git a/papyrus/api/routes/progress.py b/papyrus/api/routes/progress.py index a23154e..114789f 100644 --- a/papyrus/api/routes/progress.py +++ b/papyrus/api/routes/progress.py @@ -1,146 +1,42 @@ -"""Reading progress and statistics routes.""" +"""Owned activity-backed reading progress and statistics routes.""" -from datetime import UTC, date, datetime +from datetime import date from typing import Annotated -from uuid import UUID, uuid4 +from uuid import UUID -from fastapi import APIRouter, Query +from fastapi import APIRouter, Depends +from sqlalchemy.ext.asyncio import AsyncSession from papyrus.api.deps import CurrentUserId, Pagination -from papyrus.schemas.common import Pagination as PaginationSchema -from papyrus.schemas.progress import ( - BookBreakdown, - CreateReadingSessionRequest, - DailyBreakdown, - ReadingSession, - ReadingSessionList, - ReadingStatistics, - StatisticsPeriod, - StatisticsTotals, -) +from papyrus.core.database import get_db +from papyrus.schemas.progress import CreateReadingSessionRequest, ReadingSession, ReadingSessionList, ReadingStatistics +from papyrus.services import progress as service router = APIRouter() +DBSession = Annotated[AsyncSession, Depends(get_db)] -def _example_session(session_id: UUID | None = None, book_id: UUID | None = None) -> ReadingSession: - """Create an example reading session for responses.""" - return ReadingSession( - session_id=session_id or uuid4(), - book_id=book_id or uuid4(), - book_title="Example Book", - start_time=datetime.now(UTC), - end_time=datetime.now(UTC), - start_position=0.25, - end_position=0.35, - pages_read=20, - duration_minutes=30, - device_type="tablet", - device_name="iPad Pro", - created_at=datetime.now(UTC), - ) - - -@router.get( - "/sessions", - response_model=ReadingSessionList, - summary="List reading sessions", -) +@router.get("/sessions", response_model=ReadingSessionList, summary="List reading sessions") async def list_reading_sessions( user_id: CurrentUserId, pagination: Pagination, + db: DBSession, book_id: UUID | None = None, start_date: date | None = None, end_date: date | None = None, ) -> ReadingSessionList: - """Return paginated list of reading sessions.""" - return ReadingSessionList( - sessions=[_example_session()], - pagination=PaginationSchema( - page=pagination.page, - limit=pagination.limit, - total=1, - total_pages=1, - has_next=False, - has_prev=False, - ), - ) + return await service.list_sessions(db, user_id, pagination, book_id, start_date, end_date) -@router.post( - "/sessions", - response_model=ReadingSession, - summary="Record reading session", -) +@router.post("/sessions", response_model=ReadingSession, summary="Record reading session") async def create_reading_session( - user_id: CurrentUserId, - request: CreateReadingSessionRequest, + user_id: CurrentUserId, request: CreateReadingSessionRequest, db: DBSession ) -> ReadingSession: - """Record a new reading session.""" - duration = None - - if request.end_time and request.start_time: - duration = int((request.end_time - request.start_time).total_seconds() / 60) + return await service.create_session(db, user_id, request) - return ReadingSession( - session_id=uuid4(), - book_id=request.book_id, - start_time=request.start_time, - end_time=request.end_time, - start_position=request.start_position, - end_position=request.end_position, - pages_read=request.pages_read, - duration_minutes=duration, - device_type=request.device_type, - device_name=request.device_name, - created_at=datetime.now(UTC), - ) - -@router.get( - "/statistics", - response_model=ReadingStatistics, - summary="Get reading statistics", -) +@router.get("/statistics", response_model=ReadingStatistics, summary="Get reading statistics") async def get_reading_statistics( - user_id: CurrentUserId, - start_date: Annotated[date | None, Query(description="Start date for statistics period")] = None, - end_date: Annotated[date | None, Query(description="End date for statistics period")] = None, + user_id: CurrentUserId, db: DBSession, start_date: date | None = None, end_date: date | None = None ) -> ReadingStatistics: - """Return reading statistics for the specified period.""" - today = date.today() - period_start = start_date or today.replace(day=1) - period_end = end_date or today - - return ReadingStatistics( - period=StatisticsPeriod( - start_date=period_start, - end_date=period_end, - ), - totals=StatisticsTotals( - reading_time_minutes=450, - pages_read=150, - books_completed=2, - sessions_count=15, - average_session_minutes=30.0, - reading_days=10, - current_streak=5, - longest_streak=7, - ), - daily_breakdown=[ - DailyBreakdown( - date=today, - reading_time_minutes=45, - pages_read=15, - sessions_count=2, - ) - ], - books_breakdown=[ - BookBreakdown( - book_id=uuid4(), - title="Example Book", - reading_time_minutes=200, - pages_read=80, - sessions_count=7, - ) - ], - ) + return await service.statistics(db, user_id, start_date, end_date) diff --git a/papyrus/models/__init__.py b/papyrus/models/__init__.py index 843fe4f..1d8c4d2 100644 --- a/papyrus/models/__init__.py +++ b/papyrus/models/__init__.py @@ -14,9 +14,13 @@ from papyrus.models.media import MediaAsset from papyrus.models.powersync_demo import PowerSyncDemoItem from papyrus.models.sync import SyncBook +from papyrus.models.tracking import SyncGoalPeriod, SyncReadingActivity, SyncReadingGoal from papyrus.models.user import User __all__ = [ + "SyncReadingGoal", + "SyncReadingActivity", + "SyncGoalPeriod", "SyncShelf", "SyncTag", "SyncNote", diff --git a/papyrus/models/tracking.py b/papyrus/models/tracking.py new file mode 100644 index 0000000..70e6390 --- /dev/null +++ b/papyrus/models/tracking.py @@ -0,0 +1,27 @@ +"""Owned tracking records survive library entity deletion.""" + +from uuid import UUID, uuid4 + +from sqlalchemy import Uuid +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import Mapped, mapped_column + +from papyrus.core.database import Base +from papyrus.models.library import LibraryEntity + + +class TrackingRecord(LibraryEntity): + id: Mapped[UUID] = mapped_column(Uuid, primary_key=True, default=uuid4) + payload: Mapped[dict[str, object]] = mapped_column(JSONB) + + +class SyncReadingGoal(TrackingRecord, Base): + __tablename__ = "reading_goals" + + +class SyncReadingActivity(TrackingRecord, Base): + __tablename__ = "reading_activities" + + +class SyncGoalPeriod(TrackingRecord, Base): + __tablename__ = "goal_periods" diff --git a/papyrus/schemas/goal.py b/papyrus/schemas/goal.py index 5ae7e25..59d0a31 100644 --- a/papyrus/schemas/goal.py +++ b/papyrus/schemas/goal.py @@ -2,6 +2,7 @@ from datetime import date, datetime from enum import StrEnum +from typing import Literal from uuid import UUID from pydantic import BaseModel, ConfigDict, Field @@ -13,6 +14,7 @@ class GoalType(StrEnum): BOOKS_COUNT = "books_count" PAGES_COUNT = "pages_count" READING_TIME = "reading_time" + READING_DAYS = "reading_days" class TimePeriod(StrEnum): @@ -40,6 +42,12 @@ class Goal(BaseModel): time_period: TimePeriod start_date: date end_date: date + is_recurring: bool = True + is_archived: bool = False + timezone: str = "UTC" + scope: Literal["library", "book", "shelf"] = "library" + scope_id: UUID | None = None + minimum_minutes: int = 5 is_active: bool = True is_completed: bool = False completed_at: datetime | None = None @@ -56,6 +64,11 @@ class GoalList(BaseModel): class CreateGoalRequest(BaseModel): """Goal creation request.""" + is_recurring: bool = True + timezone: str = "UTC" + scope: Literal["library", "book", "shelf"] = "library" + scope_id: UUID | None = None + minimum_minutes: int = Field(default=5, ge=1, le=1440) title: str = Field(..., max_length=255) description: str | None = None goal_type: GoalType @@ -73,3 +86,4 @@ class UpdateGoalRequest(BaseModel): target_value: int | None = Field(None, ge=1) end_date: date | None = None is_active: bool | None = None + is_archived: bool | None = None diff --git a/papyrus/schemas/progress.py b/papyrus/schemas/progress.py index d6e7651..18499c2 100644 --- a/papyrus/schemas/progress.py +++ b/papyrus/schemas/progress.py @@ -35,14 +35,16 @@ class ReadingSessionList(BaseModel): class CreateReadingSessionRequest(BaseModel): - """Reading session creation request.""" + """Reading session creation request; optional id supports retry-safe uploads.""" + + session_id: UUID | None = None book_id: UUID start_time: datetime end_time: datetime | None = None start_position: float | None = Field(None, ge=0, le=1) end_position: float | None = Field(None, ge=0, le=1) - pages_read: int | None = None + pages_read: int | None = Field(default=None, ge=0) device_type: str | None = None device_name: str | None = None diff --git a/papyrus/schemas/sync.py b/papyrus/schemas/sync.py index 1f098b6..2adf30a 100644 --- a/papyrus/schemas/sync.py +++ b/papyrus/schemas/sync.py @@ -53,6 +53,9 @@ ) ENTITY_FIELDS = frozenset({"owner_user_id", "created_at", "updated_at"}) UPLOAD_FIELDS = { + "reading_goals": ENTITY_FIELDS | {"payload"}, + "reading_activities": ENTITY_FIELDS | {"payload"}, + "goal_periods": ENTITY_FIELDS | {"payload"}, "books": BOOK_UPLOAD_FIELDS, "shelves": ENTITY_FIELDS | { @@ -82,9 +85,19 @@ class PowerSyncCrudMutation(BaseModel): model_config = ConfigDict(populate_by_name=True) - table: Literal["books", "shelves", "tags", "notes", "annotations", "bookmarks", "book_shelves", "book_tags"] = ( - Field(alias="type") - ) + table: Literal[ + "books", + "shelves", + "tags", + "notes", + "annotations", + "bookmarks", + "book_shelves", + "book_tags", + "reading_goals", + "reading_activities", + "goal_periods", + ] = Field(alias="type") op: Literal["PUT", "PATCH", "DELETE", "put", "patch", "delete"] id: str op_id: int | None = Field(default=None, alias="op_id") @@ -123,5 +136,6 @@ class FileStorageSettings(BaseModel): class DataSyncSettingsResponse(BaseModel): """Public sync settings used by clients for custom server discovery.""" + tracking_schema_version: int = 1 data_sync_url: str file_storage: FileStorageSettings diff --git a/papyrus/schemas/tracking.py b/papyrus/schemas/tracking.py new file mode 100644 index 0000000..ffe8318 --- /dev/null +++ b/papyrus/schemas/tracking.py @@ -0,0 +1,151 @@ +"""Version-one tracking payloads shared by REST and PowerSync.""" + +from typing import Literal +from uuid import UUID +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError + +from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, field_validator, model_validator + +from papyrus.schemas.goal import GoalType, TimePeriod + + +class TrackingPayload(BaseModel): + model_config = ConfigDict(extra="forbid") + id: UUID + + +class GoalRule(BaseModel): + model_config = ConfigDict(extra="forbid") + at: AwareDatetime + target: int = Field(ge=1) + title: str | None = Field(default=None, max_length=255) + active: bool = True + archived: bool = False + + +class GoalDefinition(TrackingPayload): + title: str | None = Field(default=None, max_length=255) + description: str | None = None + goal_type: GoalType + target_value: int = Field(ge=1) + time_period: TimePeriod + start_date: AwareDatetime + end_date: AwareDatetime + created_at: AwareDatetime + timezone: str = "UTC" + scope: Literal["library", "book", "shelf"] = "library" + scope_id: UUID | None = None + minimum_minutes: int = Field(default=5, ge=1, le=1440) + rules: list[GoalRule] = Field(default_factory=list, max_length=10000) + is_active: bool = True + is_recurring: bool = True + is_archived: bool = False + + @field_validator("timezone") + @classmethod + def valid_timezone(cls, value: str) -> str: + try: + ZoneInfo(value) + except ZoneInfoNotFoundError as exc: + raise ValueError("timezone must be an IANA identifier") from exc + + return value + + @model_validator(mode="after") + def valid_rules(self) -> "GoalDefinition": + if self.end_date <= self.start_date: + raise ValueError("end_date must follow start_date") + + if (self.scope == "library") != (self.scope_id is None): + raise ValueError("Only book and shelf scopes require scope_id") + + if self.time_period == TimePeriod.CUSTOM and self.is_recurring: + raise ValueError("Custom deadlines cannot recur") + + dates = [rule.at for rule in self.rules] + + if dates != sorted(set(dates)): + raise ValueError("Rule revisions must have unique ascending timestamps") + + if dates and dates[0] != self.created_at: + raise ValueError("First rule must begin at goal creation") + + if self.rules: + last = self.rules[-1] + + if (last.target, last.title, last.active, last.archived) != ( + self.target_value, + self.title, + self.is_active, + self.is_archived, + ): + raise ValueError("Current settings must match the last rule") + + return self + + +class PageCoverage(BaseModel): + model_config = ConfigDict(extra="forbid", allow_inf_nan=False) + key: str = Field(min_length=1, max_length=500) + start: float = Field(ge=0, le=1) + end: float = Field(ge=0, le=1) + pages_per_unit: float = Field(gt=0, le=100000) + estimated: bool = False + + @model_validator(mode="after") + def ordered(self) -> "PageCoverage": + if self.end <= self.start: + raise ValueError("Coverage must have positive extent") + + return self + + +class Activity(TrackingPayload): + session_id: UUID | None = None + book_id: UUID + book_title: str = Field(min_length=1, max_length=500) + start_time: AwareDatetime + end_time: AwareDatetime + created_at: AwareDatetime + source: Literal["reader", "manual"] = "manual" + kind: Literal["reading", "completion", "reversal"] = "reading" + device_id: str = Field(default="manual", min_length=1, max_length=255) + shelf_ids: list[UUID] = Field(default_factory=list, max_length=1000) + pages: int = Field(default=0, ge=0, le=100000) + coverage: list[PageCoverage] = Field(default_factory=list, max_length=1000) + note: str | None = Field(default=None, max_length=10000) + correction_of: UUID | None = None + + @model_validator(mode="after") + def valid_activity(self) -> "Activity": + if self.end_time < self.start_time: + raise ValueError("end_time must not precede start_time") + + if self.end_time > self.created_at: + raise ValueError("Reading activity cannot be in the future") + + if (self.kind == "reversal") != (self.correction_of is not None): + raise ValueError("Only reversals require correction_of") + + if self.kind != "reading" and (self.pages or self.coverage or self.end_time != self.start_time): + raise ValueError("Completion/reversal records are points without pages") + + if self.source == "manual" and self.coverage: + raise ValueError("Manual pages must use pages, not reader coverage") + + if self.source == "reader" and self.pages: + raise ValueError("Reader pages require document coverage") + + return self + + +class PeriodRecord(TrackingPayload): + goal_id: UUID + definition: GoalDefinition + + @model_validator(mode="after") + def valid_snapshot(self) -> "PeriodRecord": + if self.goal_id != self.definition.id or self.definition.is_recurring: + raise ValueError("Period snapshots must identify their non-recurring definition") + + return self diff --git a/papyrus/services/goal_history.py b/papyrus/services/goal_history.py new file mode 100644 index 0000000..df9d8f3 --- /dev/null +++ b/papyrus/services/goal_history.py @@ -0,0 +1,55 @@ +"""Retain period rules when a goal definition is deleted.""" + +from datetime import UTC, datetime, timedelta +from uuid import NAMESPACE_URL, UUID, uuid5 + +from sqlalchemy.ext.asyncio import AsyncSession + +from papyrus.models import SyncGoalPeriod, SyncReadingGoal +from papyrus.schemas.tracking import GoalDefinition, GoalRule, PeriodRecord +from papyrus.services.goal_progress import calendar_period, rule_at + + +async def preserve_goal_history(session: AsyncSession, user_id: UUID, row: SyncReadingGoal) -> None: + goal = GoalDefinition.model_validate(row.payload) + now = datetime.now(UTC) + boundary = min(now, goal.rules[-1].at) if goal.is_archived and goal.rules else now + start, end = goal.start_date, goal.end_date + + while start < boundary: + stop = min(end, boundary) + at = stop - timedelta(microseconds=1) + rule = rule_at(goal, at) + since_epoch = start - datetime(1970, 1, 1, tzinfo=UTC) + microseconds = (since_epoch.days * 86400 + since_epoch.seconds) * 1000000 + since_epoch.microseconds + identifier = uuid5(NAMESPACE_URL, f"papyrus:goal-period:{goal.id}:{microseconds}") + existing = await session.get(SyncGoalPeriod, identifier) + + if existing is None: + revisions = [revision for revision in goal.rules if revision.at < stop] + archived = end > boundary + + if archived: + revisions.append(GoalRule(at=stop, target=rule.target, title=rule.title, active=False, archived=True)) + + definition = goal.model_copy( + update={ + "start_date": start, + "end_date": stop, + "target_value": rule.target, + "title": rule.title, + "is_recurring": False, + "is_active": False if archived else rule.active, + "is_archived": archived or rule.archived, + "rules": revisions, + } + ) + record = PeriodRecord(id=identifier, goal_id=goal.id, definition=definition) + + session.add(SyncGoalPeriod(id=identifier, owner_user_id=user_id, payload=record.model_dump(mode="json"))) + await session.flush() + + if not goal.is_recurring or end >= boundary: + break + + start, end = calendar_period(goal, end) diff --git a/papyrus/services/goal_progress.py b/papyrus/services/goal_progress.py new file mode 100644 index 0000000..04256b1 --- /dev/null +++ b/papyrus/services/goal_progress.py @@ -0,0 +1,167 @@ +"""Deterministic goal projections over owned activity; no shared counters.""" + +from collections import defaultdict +from datetime import UTC, datetime, timedelta +from typing import Any +from zoneinfo import ZoneInfo + +from papyrus.schemas.tracking import Activity, GoalDefinition, GoalRule + + +def calendar_period(goal: GoalDefinition, at: datetime) -> tuple[datetime, datetime]: + if goal.is_archived and goal.rules: + at = min(at, goal.rules[-1].at) + + local = at.astimezone(ZoneInfo(goal.timezone)) + day = local.replace(hour=0, minute=0, second=0, microsecond=0) + period = goal.time_period + + if period == "daily": + start, end = day, day + timedelta(days=1) + elif period == "weekly": + start = day - timedelta(days=day.weekday()) + end = start + timedelta(days=7) + elif period == "monthly": + start = day.replace(day=1) + end = start.replace(year=start.year + (start.month == 12), month=start.month % 12 + 1) + elif period == "yearly": + start = day.replace(month=1, day=1) + end = start.replace(year=start.year + 1) + else: + return goal.start_date, goal.end_date + + return start.astimezone(UTC), end.astimezone(UTC) + + +def rule_at(goal: GoalDefinition, at: datetime) -> GoalRule: + rule = GoalRule( + at=goal.created_at, target=goal.target_value, title=goal.title, active=goal.is_active, archived=goal.is_archived + ) + + for revision in goal.rules: + if revision.at > at: + break + + rule = revision + + return rule + + +def union_length(ranges: list[tuple[float, float]]) -> float: + if not ranges: + return 0 + + ranges = sorted(ranges) + start, end = ranges[0] + total = 0.0 + + for left, right in ranges[1:]: + if left <= end: + end = max(end, right) + else: + total += end - start + start, end = left, right + + return total + end - start + + +def effective_activities(ledger: list[Activity]) -> list[Activity]: + unique = {activity.id: activity for activity in ledger} + reversed_ids = {activity.correction_of for activity in unique.values() if activity.kind == "reversal"} + return [activity for activity in unique.values() if activity.kind != "reversal" and activity.id not in reversed_ids] + + +def project_goal(goal: GoalDefinition, ledger: list[Activity], now: datetime) -> dict[str, Any]: + start, end = ( + calendar_period(goal, now) if goal.is_recurring and now >= goal.start_date else (goal.start_date, goal.end_date) + ) + cutoff, limit = max(start, goal.created_at), min(end, now) + rules = [revision.at for revision in goal.rules if cutoff < revision.at < limit] + zone = ZoneInfo(goal.timezone) + times: dict[datetime, list[tuple[float, float]]] = defaultdict(list) + coverage: dict[tuple[object, ...], list[tuple[float, float]]] = defaultdict(list) + scales: dict[tuple[object, ...], float] = {} + finished = set() + manual_pages = 0 + estimated = False + counted = [] + + for activity in sorted(effective_activities(ledger), key=lambda activity: (activity.end_time, str(activity.id))): + if goal.scope == "book" and activity.book_id != goal.scope_id: + continue + + if goal.scope == "shelf" and goal.scope_id not in activity.shelf_ids: + continue + + contributed = False + point = ( + activity.end_time - timedelta(microseconds=1) + if activity.kind == "reading" and activity.end_time > activity.start_time + else activity.end_time + ) + point_rule = rule_at(goal, point) + + if cutoff <= point < end and point <= now and point_rule.active and not point_rule.archived: + if activity.kind == "completion": + finished.add(activity.book_id) + contributed = True + elif activity.kind == "reading": + manual_pages += activity.pages + contributed = bool(activity.pages or activity.coverage) + day = point.astimezone(zone).date() + + for extent in activity.coverage: + key = (day, activity.book_id, extent.key) + coverage[key].append((extent.start, extent.end)) + scales.setdefault(key, extent.pages_per_unit) + estimated |= extent.estimated + + if activity.kind == "reading": + cursor = max(activity.start_time, cutoff) + stop_at = min(activity.end_time, limit) + + while cursor < stop_at: + local = cursor.astimezone(zone) + midnight = local.replace(hour=0, minute=0, second=0, microsecond=0) + stop = min((midnight + timedelta(days=1)).astimezone(UTC), stop_at) + stop = min([stop, *(boundary for boundary in rules if cursor < boundary < stop)]) + active = rule_at(goal, cursor) + + if active.active and not active.archived: + times[midnight.astimezone(UTC)].append((cursor.timestamp(), stop.timestamp())) + contributed = True + + cursor = stop + + if contributed: + counted.append(str(activity.id)) + + daily_seconds = {day: union_length(intervals) for day, intervals in times.items()} + qualified = {day for day, seconds in daily_seconds.items() if seconds >= goal.minimum_minutes * 60} + pages = manual_pages + sum(union_length(intervals) * scales[key] for key, intervals in coverage.items()) + seconds = int(sum(daily_seconds.values())) + rule = rule_at(goal, end - timedelta(microseconds=1) if end <= now else now) + value = { + "books_count": len(finished), + "pages_count": int(pages), + "reading_time": seconds // 60, + "reading_days": len(qualified), + }[goal.goal_type] + fractional = ( + seconds / 60 if goal.goal_type == "reading_time" else pages if goal.goal_type == "pages_count" else value + ) + + return { + "current_value": value, + "progress_percentage": min(100, fractional / rule.target * 100), + "target_value": rule.target, + "title": rule.title, + "start_date": start, + "end_date": end, + "seconds": seconds, + "pages": pages, + "finished_books": len(finished), + "days": len(qualified), + "estimated": estimated, + "activity_ids": counted, + } diff --git a/papyrus/services/goals.py b/papyrus/services/goals.py new file mode 100644 index 0000000..b6e2e48 --- /dev/null +++ b/papyrus/services/goals.py @@ -0,0 +1,163 @@ +"""Owned goal definitions and derived progress exposed through REST.""" + +from datetime import UTC, datetime, time, timedelta +from uuid import UUID, uuid4 +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError + +from pydantic import ValidationError as PayloadError +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from papyrus.core.exceptions import NotFoundError, ValidationError +from papyrus.models import SyncReadingActivity, SyncReadingGoal +from papyrus.schemas.goal import CreateGoalRequest, Goal, UpdateGoalRequest +from papyrus.schemas.sync import PowerSyncCrudMutation +from papyrus.schemas.tracking import Activity, GoalDefinition, GoalRule +from papyrus.services.goal_progress import calendar_period, project_goal +from papyrus.services.library_sync import owned_row +from papyrus.services.sync import apply_powersync_upload_batch + + +async def activities(session: AsyncSession, user_id: UUID) -> list[Activity]: + result = await session.execute(select(SyncReadingActivity).where(SyncReadingActivity.owner_user_id == user_id)) + return [Activity.model_validate(row.payload) for row in result.scalars()] + + +def goal_response(definition: GoalDefinition, ledger: list[Activity]) -> Goal: + now = datetime.now(UTC) + progress = project_goal(definition, ledger, now) + zone = ZoneInfo(definition.timezone) + + return Goal( + goal_id=definition.id, + title=progress["title"] or definition.title or "Reading goal", + description=definition.description, + goal_type=definition.goal_type, + target_value=progress["target_value"], + current_value=progress["current_value"], + progress_percentage=progress["progress_percentage"], + time_period=definition.time_period, + start_date=progress["start_date"].astimezone(zone).date(), + end_date=(progress["end_date"] - timedelta(microseconds=1)).astimezone(zone).date(), + is_active=definition.is_active, + is_archived=definition.is_archived, + is_recurring=definition.is_recurring, + timezone=definition.timezone, + scope=definition.scope, + scope_id=definition.scope_id, + minimum_minutes=definition.minimum_minutes, + is_completed=progress["progress_percentage"] >= 100, + created_at=definition.created_at, + ) + + +async def list_goals(session: AsyncSession, user_id: UUID, active: bool | None) -> list[Goal]: + result = await session.execute(select(SyncReadingGoal).where(SyncReadingGoal.owner_user_id == user_id)) + definitions = [GoalDefinition.model_validate(row.payload) for row in result.scalars()] + ledger = await activities(session, user_id) + return [goal_response(goal, ledger) for goal in definitions if active is None or goal.is_active == active] + + +async def get_definition(session: AsyncSession, user_id: UUID, goal_id: UUID) -> GoalDefinition: + row = await owned_row(session, user_id, "reading_goals", goal_id) + + if row is None: + raise NotFoundError("Goal was not found") + + return GoalDefinition.model_validate(row.payload) + + +async def get_goal(session: AsyncSession, user_id: UUID, goal_id: UUID) -> Goal: + return goal_response(await get_definition(session, user_id, goal_id), await activities(session, user_id)) + + +async def create_goal(session: AsyncSession, user_id: UUID, request: CreateGoalRequest) -> Goal: + now = datetime.now(UTC) + + try: + zone = ZoneInfo(request.timezone) + definition = GoalDefinition( + id=uuid4(), + title=request.title, + description=request.description, + goal_type=request.goal_type, + target_value=request.target_value, + time_period=request.time_period, + start_date=datetime.combine(request.start_date, time.min, zone).astimezone(UTC), + end_date=datetime.combine(request.end_date + timedelta(days=1), time.min, zone).astimezone(UTC), + created_at=now, + timezone=request.timezone, + scope=request.scope, + scope_id=request.scope_id, + minimum_minutes=request.minimum_minutes, + is_recurring=request.is_recurring and request.time_period != "custom", + rules=[GoalRule(at=now, title=request.title, target=request.target_value)], + ) + except (PayloadError, ZoneInfoNotFoundError) as exc: + raise ValidationError(str(exc)) from exc + + if definition.time_period != "custom": + definition.start_date, definition.end_date = calendar_period(definition, now) + + await apply_powersync_upload_batch( + session, + user_id, + [ + PowerSyncCrudMutation( + table="reading_goals", + op="PUT", + id=str(definition.id), + op_data={"payload": definition.model_dump(mode="json")}, + ) + ], + ) + return goal_response(definition, []) + + +async def update_goal(session: AsyncSession, user_id: UUID, goal_id: UUID, request: UpdateGoalRequest) -> Goal: + definition = await get_definition(session, user_id, goal_id) + + if request.end_date is not None: + raise ValidationError("Create a replacement goal to change its deadline") + + now = max( + datetime.now(UTC), + definition.rules[-1].at + timedelta(microseconds=1) + if definition.rules + else definition.created_at + timedelta(microseconds=1), + ) + changes = request.model_dump(exclude_unset=True, exclude={"end_date"}) + if changes.get("is_archived"): + changes["is_active"] = False + updated = definition.model_copy(update=changes) + rule = GoalRule( + at=now, title=updated.title, target=updated.target_value, active=updated.is_active, archived=updated.is_archived + ) + initial = definition.rules or [ + GoalRule( + at=definition.created_at, + title=definition.title, + target=definition.target_value, + active=definition.is_active, + archived=definition.is_archived, + ) + ] + updated.rules = [*initial, rule] + + await apply_powersync_upload_batch( + session, + user_id, + [ + PowerSyncCrudMutation( + table="reading_goals", op="PATCH", id=str(goal_id), op_data={"payload": updated.model_dump(mode="json")} + ) + ], + ) + return goal_response(updated, await activities(session, user_id)) + + +async def delete_goal(session: AsyncSession, user_id: UUID, goal_id: UUID) -> None: + await get_definition(session, user_id, goal_id) + await apply_powersync_upload_batch( + session, user_id, [PowerSyncCrudMutation(table="reading_goals", op="DELETE", id=str(goal_id))] + ) diff --git a/papyrus/services/library_sync.py b/papyrus/services/library_sync.py index ef2f5dd..a3a33cb 100644 --- a/papyrus/services/library_sync.py +++ b/papyrus/services/library_sync.py @@ -15,7 +15,10 @@ SyncBookmark, SyncBookShelf, SyncBookTag, + SyncGoalPeriod, SyncNote, + SyncReadingActivity, + SyncReadingGoal, SyncShelf, SyncTag, SyncTombstone, @@ -25,6 +28,9 @@ from papyrus.services.library_validation import convert_value, normalize_book_payload, uuid_value MODELS: dict[str, Any] = { + "reading_goals": SyncReadingGoal, + "reading_activities": SyncReadingActivity, + "goal_periods": SyncGoalPeriod, "books": SyncBook, "shelves": SyncShelf, "tags": SyncTag, @@ -35,6 +41,9 @@ "book_tags": SyncBookTag, } PRIMARY_KEYS = { + "reading_goals": "id", + "reading_activities": "id", + "goal_periods": "id", "books": "book_id", "shelves": "shelf_id", "tags": "tag_id", @@ -196,6 +205,14 @@ async def apply_library_mutation( return 0, [] if mutation.op.upper() == "DELETE": + if table in {"reading_activities", "goal_periods"}: + raise ValidationError("Retain history and append an activity correction instead") + + if table == "reading_goals" and row is not None: + from papyrus.services.goal_history import preserve_goal_history + + await preserve_goal_history(session, user_id, row) + if is_membership: await validate_references(session, user_id, table, row_id, payload, row, deleting=True) @@ -207,6 +224,11 @@ async def apply_library_mutation( paths = await delete_entity(session, user_id, table, uuid_value(row_id, "id"), row) return int(row is not None), paths + if table in {"reading_goals", "reading_activities", "goal_periods"}: + from papyrus.services.tracking_validation import validate_tracking_mutation + + payload = await validate_tracking_mutation(session, user_id, table, row_id, payload, row) + values = {key: convert_value(model.__table__.columns[key], value) for key, value in payload.items()} stale_parent = await validate_references(session, user_id, table, row_id, values, row) diff --git a/papyrus/services/progress.py b/papyrus/services/progress.py new file mode 100644 index 0000000..300aff6 --- /dev/null +++ b/papyrus/services/progress.py @@ -0,0 +1,275 @@ +"""Real activity-backed sessions and cross-device statistics.""" + +from datetime import UTC, date, datetime, time, timedelta +from uuid import UUID, uuid4 + +from pydantic import ValidationError as PayloadError +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from papyrus.api.deps import PaginationParams +from papyrus.core.exceptions import NotFoundError, ValidationError +from papyrus.models import SyncBookShelf +from papyrus.schemas.common import Pagination +from papyrus.schemas.goal import GoalType, TimePeriod +from papyrus.schemas.progress import ( + BookBreakdown, + CreateReadingSessionRequest, + DailyBreakdown, + ReadingSession, + ReadingSessionList, + ReadingStatistics, + StatisticsPeriod, + StatisticsTotals, +) +from papyrus.schemas.sync import PowerSyncCrudMutation +from papyrus.schemas.tracking import Activity, GoalDefinition +from papyrus.services.goal_progress import effective_activities, project_goal, union_length +from papyrus.services.goals import activities +from papyrus.services.library_sync import owned_row +from papyrus.services.sync import apply_powersync_upload_batch + + +def session_response(activity: Activity) -> ReadingSession: + return ReadingSession( + session_id=activity.id, + book_id=activity.book_id, + book_title=activity.book_title, + start_time=activity.start_time, + end_time=activity.end_time, + pages_read=activity.pages, + duration_minutes=int((activity.end_time - activity.start_time).total_seconds()) // 60, + device_type=activity.source, + device_name=activity.device_id, + created_at=activity.created_at, + ) + + +def grouped_sessions(entries: list[Activity]) -> list[ReadingSession]: + groups: dict[tuple[UUID, UUID], list[Activity]] = {} + + for activity in entries: + groups.setdefault((activity.book_id, activity.session_id or activity.id), []).append(activity) + + sessions = [] + + for group in groups.values(): + first = min(group, key=lambda entry: entry.start_time) + end = max(entry.end_time for entry in group) + result = session_response(first) + result.end_time = end + result.duration_minutes = ( + int(union_length([(entry.start_time.timestamp(), entry.end_time.timestamp()) for entry in group])) // 60 + ) + projection = project_goal( + totals_definition(first.start_time, end + timedelta(microseconds=1)), group, end + timedelta(microseconds=1) + ) + result.pages_read = int(projection["pages"]) + sessions.append(result) + + return sorted(sessions, key=lambda entry: entry.start_time, reverse=True) + + +async def list_sessions( + session: AsyncSession, + user_id: UUID, + pagination: PaginationParams, + book_id: UUID | None, + start_date: date | None, + end_date: date | None, +) -> ReadingSessionList: + ledger = effective_activities(await activities(session, user_id)) + entries = sorted( + ( + entry + for entry in ledger + if entry.kind == "reading" + and (book_id is None or entry.book_id == book_id) + and (start_date is None or entry.end_time.date() >= start_date) + and (end_date is None or entry.start_time.date() <= end_date) + ), + key=lambda entry: entry.start_time, + reverse=True, + ) + sessions = grouped_sessions(entries) + total = len(sessions) + pages = (total + pagination.limit - 1) // pagination.limit + + return ReadingSessionList( + sessions=sessions[pagination.offset : pagination.offset + pagination.limit], + pagination=Pagination( + page=pagination.page, + limit=pagination.limit, + total=total, + total_pages=pages, + has_next=pagination.page < pages, + has_prev=pagination.page > 1, + ), + ) + + +async def create_session(session: AsyncSession, user_id: UUID, request: CreateReadingSessionRequest) -> ReadingSession: + book = await owned_row(session, user_id, "books", request.book_id) + + if book is None: + raise NotFoundError("Book was not found") + + if request.session_id is not None: + existing = await owned_row(session, user_id, "reading_activities", request.session_id) + + if existing is not None: + original = Activity.model_validate(existing.payload) + start = request.start_time.replace(tzinfo=UTC) if request.start_time.tzinfo is None else request.start_time + requested_end = request.end_time + + if requested_end is not None and requested_end.tzinfo is None: + requested_end = requested_end.replace(tzinfo=UTC) + + if ( + original.book_id != request.book_id + or original.start_time != start + or (requested_end is not None and original.end_time != requested_end) + or original.pages != (request.pages_read or 0) + or original.device_id != (request.device_name or "manual") + ): + raise ValidationError("Session id already identifies different activity") + + return session_response(original) + + result = await session.execute( + select(SyncBookShelf.shelf_id).where( + SyncBookShelf.owner_user_id == user_id, SyncBookShelf.book_id == request.book_id + ) + ) + shelves = list(result.scalars()) + + try: + start = request.start_time.replace(tzinfo=UTC) if request.start_time.tzinfo is None else request.start_time + end = request.end_time or datetime.now(UTC) + end = end.replace(tzinfo=UTC) if end.tzinfo is None else end + activity = Activity( + id=request.session_id or uuid4(), + book_id=request.book_id, + book_title=book.title, + start_time=start, + end_time=end, + created_at=datetime.now(UTC), + pages=request.pages_read or 0, + device_id=request.device_name or "manual", + shelf_ids=shelves, + ) + except PayloadError as exc: + raise ValidationError(str(exc)) from exc + + await apply_powersync_upload_batch( + session, + user_id, + [ + PowerSyncCrudMutation( + table="reading_activities", + op="PUT", + id=str(activity.id), + op_data={"payload": activity.model_dump(mode="json")}, + ) + ], + ) + return session_response(activity) + + +def totals_definition(start: datetime, end: datetime, book_id: UUID | None = None) -> GoalDefinition: + return GoalDefinition( + id=UUID(int=0), + goal_type=GoalType.READING_TIME, + target_value=1, + time_period=TimePeriod.CUSTOM, + start_date=start, + end_date=end, + created_at=start, + is_recurring=False, + scope="library" if book_id is None else "book", + scope_id=book_id, + ) + + +async def statistics( + session: AsyncSession, user_id: UUID, start_date: date | None, end_date: date | None +) -> ReadingStatistics: + today = datetime.now(UTC).date() + first, last = start_date or today.replace(day=1), end_date or today + + if last < first or (last - first).days > 3660: + raise ValidationError("Choose an ordered statistics period of at most ten years") + + start = datetime.combine(first, time.min, UTC) + end = datetime.combine(last + timedelta(days=1), time.min, UTC) + ledger = await activities(session, user_id) + now = datetime.now(UTC) + total = project_goal(totals_definition(start, end), ledger, now) + real = effective_activities(ledger) + counted = [ + entry for entry in real if entry.kind == "reading" and entry.start_time < end and entry.end_time >= start + ] + daily = [] + reading_dates = [] + longest = streak = 0 + + for index in range((last - first).days + 1): + day = start + timedelta(days=index) + next_day = day + timedelta(days=1) + values = project_goal(totals_definition(day, next_day), ledger, now) + count = len( + {entry.session_id or entry.id for entry in counted if entry.start_time < next_day and entry.end_time > day} + ) + daily.append( + DailyBreakdown( + date=day.date(), + reading_time_minutes=values["seconds"] // 60, + pages_read=int(values["pages"]), + sessions_count=count, + ) + ) + + if values["seconds"] >= 300: + reading_dates.append(day.date()) + streak += 1 + longest = max(longest, streak) + else: + streak = 0 + + current = 0 + cursor = today if today in reading_dates else today - timedelta(days=1) + + while cursor in reading_dates: + current += 1 + cursor -= timedelta(days=1) + + books = {entry.book_id: entry.book_title for entry in counted} + breakdown = [] + + for book_id, title in books.items(): + values = project_goal(totals_definition(start, end, book_id), ledger, now) + breakdown.append( + BookBreakdown( + book_id=book_id, + title=title, + reading_time_minutes=values["seconds"] // 60, + pages_read=int(values["pages"]), + sessions_count=sum(entry.book_id == book_id for entry in counted), + ) + ) + + return ReadingStatistics( + period=StatisticsPeriod(start_date=first, end_date=last), + totals=StatisticsTotals( + reading_time_minutes=total["seconds"] // 60, + pages_read=int(total["pages"]), + books_completed=total["finished_books"], + sessions_count=len({entry.session_id or entry.id for entry in counted}), + average_session_minutes=total["seconds"] / 60 / len(counted) if counted else 0, + reading_days=total["days"], + current_streak=current, + longest_streak=longest, + ), + daily_breakdown=daily, + books_breakdown=breakdown, + ) diff --git a/papyrus/services/tracking_validation.py b/papyrus/services/tracking_validation.py new file mode 100644 index 0000000..8dd9701 --- /dev/null +++ b/papyrus/services/tracking_validation.py @@ -0,0 +1,132 @@ +"""Tracking ownership, immutable activity, and rule-history validation.""" + +from datetime import UTC, datetime, timedelta +from typing import Any +from uuid import UUID + +from pydantic import ValidationError as PayloadError +from sqlalchemy.ext.asyncio import AsyncSession + +from papyrus.core.exceptions import ValidationError +from papyrus.schemas.tracking import Activity, GoalDefinition, GoalRule, PeriodRecord +from papyrus.services.library_sync import owned_row, tombstoned + + +async def validate_tracking_mutation( + session: AsyncSession, + user_id: UUID, + table: str, + row_id: UUID | str, + values: dict[str, Any], + row: Any, +) -> dict[str, Any]: + schemas: dict[str, type[GoalDefinition] | type[Activity] | type[PeriodRecord]] = { + "reading_goals": GoalDefinition, + "reading_activities": Activity, + "goal_periods": PeriodRecord, + } + schema = schemas[table] + raw = values.get("payload", row.payload if row is not None else None) + + try: + parsed = schema.model_validate(raw) + except PayloadError as exc: + raise ValidationError(str(exc)) from exc + + if parsed.id != row_id: + raise ValidationError("Payload id must match row id") + + if isinstance(parsed, GoalDefinition) and not parsed.rules: + parsed.rules = [ + GoalRule( + at=parsed.created_at, + target=parsed.target_value, + title=parsed.title, + active=parsed.is_active, + archived=parsed.is_archived, + ) + ] + + payload = parsed.model_dump(mode="json") + + if table == "reading_activities" and row is not None and schema.model_validate(row.payload) != parsed: + raise ValidationError("Activity and period history are immutable; append a correction") + + if isinstance(parsed, GoalDefinition): + if parsed.created_at > datetime.now(UTC) + timedelta(minutes=5): + raise ValidationError("Goal creation cannot be in the future") + + if row is not None: + old = GoalDefinition.model_validate(row.payload) + fixed = ( + "goal_type", + "time_period", + "scope", + "scope_id", + "timezone", + "created_at", + "start_date", + "end_date", + "is_recurring", + "minimum_minutes", + ) + + if any(getattr(old, field) != getattr(parsed, field) for field in fixed): + raise ValidationError("Create a replacement to change goal metric, schedule, or scope") + + revisions = {revision.at: revision for revision in old.rules} + + for revision in parsed.rules: + if revision.at in revisions and revisions[revision.at] != revision: + raise ValidationError("Existing rule history cannot be rewritten") + + revisions[revision.at] = revision + + parsed.rules = sorted(revisions.values(), key=lambda revision: revision.at) + latest = parsed.rules[-1] + parsed.title = latest.title + parsed.target_value = latest.target + parsed.is_active = latest.active + parsed.is_archived = latest.archived + payload = parsed.model_dump(mode="json") + + if parsed.scope_id is not None: + await check_reference(session, user_id, "books" if parsed.scope == "book" else "shelves", parsed.scope_id) + + if isinstance(parsed, Activity): + if parsed.created_at > datetime.now(UTC) + timedelta(minutes=5): + raise ValidationError("Activity creation cannot be in the future") + + await check_reference(session, user_id, "books", parsed.book_id) + + for shelf_id in parsed.shelf_ids: + await check_reference(session, user_id, "shelves", shelf_id) + + if parsed.correction_of is not None: + original = await owned_row(session, user_id, "reading_activities", parsed.correction_of) + + if ( + original is None + or original.payload["kind"] == "reversal" + or str(parsed.book_id) != original.payload["book_id"] + ): + raise ValidationError("Correction must reference an owned original activity for the same book") + + if isinstance(parsed, PeriodRecord): + if row is not None: + existing = PeriodRecord.model_validate(row.payload) + + if existing.goal_id != parsed.goal_id or existing.definition.start_date != parsed.definition.start_date: + raise ValidationError("Period identity cannot change") + + await check_reference(session, user_id, "reading_goals", parsed.goal_id) + + return {**values, "payload": payload} + + +async def check_reference(session: AsyncSession, user_id: UUID, table: str, row_id: UUID) -> None: + parent = await owned_row(session, user_id, table, row_id) + deleted = await tombstoned(session, user_id, table, row_id) + + if parent is None and not deleted: + raise ValidationError(f"Tracking reference {table} was not found") diff --git a/powersync/sync-config.yaml b/powersync/sync-config.yaml index 7b53085..0662c2a 100644 --- a/powersync/sync-config.yaml +++ b/powersync/sync-config.yaml @@ -170,3 +170,27 @@ streams: updated_at::text AS updated_at FROM powersync_demo_items WHERE owner_user_id::text = auth.user_id() + + reading_goals: + auto_subscribe: true + query: | + SELECT id, owner_user_id::text AS owner_user_id, payload::text AS payload, + created_at::text AS created_at, updated_at::text AS updated_at + FROM reading_goals + WHERE owner_user_id::text = auth.user_id() + + reading_activities: + auto_subscribe: true + query: | + SELECT id, owner_user_id::text AS owner_user_id, payload::text AS payload, + created_at::text AS created_at, updated_at::text AS updated_at + FROM reading_activities + WHERE owner_user_id::text = auth.user_id() + + goal_periods: + auto_subscribe: true + query: | + SELECT id, owner_user_id::text AS owner_user_id, payload::text AS payload, + created_at::text AS created_at, updated_at::text AS updated_at + FROM goal_periods + WHERE owner_user_id::text = auth.user_id() diff --git a/scripts/setup_local_powersync.sh b/scripts/setup_local_powersync.sh index 7d120c0..e9b84cc 100755 --- a/scripts/setup_local_powersync.sh +++ b/scripts/setup_local_powersync.sh @@ -29,15 +29,15 @@ END \$\$; GRANT USAGE ON SCHEMA public TO "${POWERSYNC_SOURCE_ROLE}"; -GRANT SELECT ON TABLE public.books, public.shelves, public.tags, public.notes, public.annotations, public.bookmarks, public.book_shelves, public.book_tags, public.powersync_demo_items TO "${POWERSYNC_SOURCE_ROLE}"; +GRANT SELECT ON TABLE public.books, public.shelves, public.tags, public.notes, public.annotations, public.bookmarks, public.book_shelves, public.book_tags, public.reading_goals, public.reading_activities, public.goal_periods, public.powersync_demo_items TO "${POWERSYNC_SOURCE_ROLE}"; ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO "${POWERSYNC_SOURCE_ROLE}"; DO \$\$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_publication WHERE pubname = 'powersync') THEN - CREATE PUBLICATION powersync FOR TABLE public.books, public.shelves, public.tags, public.notes, public.annotations, public.bookmarks, public.book_shelves, public.book_tags, public.powersync_demo_items; + CREATE PUBLICATION powersync FOR TABLE public.books, public.shelves, public.tags, public.notes, public.annotations, public.bookmarks, public.book_shelves, public.book_tags, public.reading_goals, public.reading_activities, public.goal_periods, public.powersync_demo_items; ELSE - ALTER PUBLICATION powersync SET TABLE public.books, public.shelves, public.tags, public.notes, public.annotations, public.bookmarks, public.book_shelves, public.book_tags, public.powersync_demo_items; + ALTER PUBLICATION powersync SET TABLE public.books, public.shelves, public.tags, public.notes, public.annotations, public.bookmarks, public.book_shelves, public.book_tags, public.reading_goals, public.reading_activities, public.goal_periods, public.powersync_demo_items; END IF; END \$\$; diff --git a/tests/api/routes/test_goals.py b/tests/api/routes/test_goals.py index d5cd5a5..2976116 100644 --- a/tests/api/routes/test_goals.py +++ b/tests/api/routes/test_goals.py @@ -1,18 +1,15 @@ -"""Tests for goal endpoints.""" - -from httpx import AsyncClient +"""Owned persisted goal REST contract, including new reading-day metric.""" +from datetime import UTC, datetime +from uuid import uuid4 -async def test_list_goals(client: AsyncClient, auth_headers: dict[str, str]): - """Test listing goals.""" - response = await client.get("/v1/goals", headers=auth_headers) - assert response.status_code == 200 - data = response.json() - assert "goals" in data +import pytest +from httpx import AsyncClient -async def test_create_goal(client: AsyncClient, auth_headers: dict[str, str]): - """Test creating a goal.""" +@pytest.fixture +async def goal_id(client: AsyncClient, auth_headers: dict[str, str]): + today = datetime.now(UTC).date() response = await client.post( "/v1/goals", headers=auth_headers, @@ -21,32 +18,59 @@ async def test_create_goal(client: AsyncClient, auth_headers: dict[str, str]): "goal_type": "books_count", "target_value": 12, "time_period": "yearly", - "start_date": "2024-01-01", - "end_date": "2024-12-31", + "start_date": f"{today.year}-01-01", + "end_date": f"{today.year}-12-31", }, ) assert response.status_code == 201 - data = response.json() - assert data["title"] == "Read 12 books this year" + return response.json()["goal_id"] -async def test_get_goal(client: AsyncClient, auth_headers: dict[str, str], goal_id: str): - """Test getting a goal by ID.""" +async def test_empty_goals_have_no_examples(client, auth_headers): + response = await client.get("/v1/goals", headers=auth_headers) + assert response.status_code == 200 + assert response.json()["goals"] == [] + + +async def test_get_goal(client, auth_headers, goal_id): response = await client.get(f"/v1/goals/{goal_id}", headers=auth_headers) assert response.status_code == 200 + assert response.json()["current_value"] == 0 + assert response.json()["goal_type"] == "books_count" -async def test_update_goal(client: AsyncClient, auth_headers: dict[str, str], goal_id: str): - """Test updating a goal.""" - response = await client.patch( - f"/v1/goals/{goal_id}", - headers=auth_headers, - json={"target_value": 15}, - ) +async def test_update_goal(client, auth_headers, goal_id): + response = await client.patch(f"/v1/goals/{goal_id}", headers=auth_headers, json={"target_value": 15}) assert response.status_code == 200 + assert response.json()["target_value"] == 15 + assert (await client.get(f"/v1/goals/{goal_id}", headers=auth_headers)).json()["target_value"] == 15 -async def test_delete_goal(client: AsyncClient, auth_headers: dict[str, str], goal_id: str): - """Test deleting a goal.""" - response = await client.delete(f"/v1/goals/{goal_id}", headers=auth_headers) - assert response.status_code == 204 +async def test_delete_goal(client, auth_headers, goal_id): + assert (await client.delete(f"/v1/goals/{goal_id}", headers=auth_headers)).status_code == 204 + assert (await client.get(f"/v1/goals/{goal_id}", headers=auth_headers)).status_code == 404 + + +async def test_unknown_goal_is_not_an_example(client, auth_headers): + assert (await client.get(f"/v1/goals/{uuid4()}", headers=auth_headers)).status_code == 404 + + +async def test_create_reading_days_goal(client, auth_headers): + today = datetime.now(UTC).date().isoformat() + response = await client.post( + "/v1/goals", + headers=auth_headers, + json={ + "title": "Read on five days", + "goal_type": "reading_days", + "target_value": 5, + "time_period": "weekly", + "start_date": today, + "end_date": "2027-12-31", + "timezone": "Europe/Vilnius", + "minimum_minutes": 5, + }, + ) + assert response.status_code == 201 + assert response.json()["goal_type"] == "reading_days" + assert response.json()["timezone"] == "Europe/Vilnius" diff --git a/tests/api/routes/test_progress.py b/tests/api/routes/test_progress.py index fbfa1a9..4bb6678 100644 --- a/tests/api/routes/test_progress.py +++ b/tests/api/routes/test_progress.py @@ -1,10 +1,28 @@ """Tests for reading progress and statistics endpoints.""" from datetime import UTC, datetime +from uuid import uuid4 +import pytest from httpx import AsyncClient +@pytest.fixture +async def book_id(client, auth_headers): + identifier = str(uuid4()) + result = await client.post( + "/v1/sync/powersync-upload", + headers=auth_headers, + json={ + "batch": [ + {"type": "books", "op": "PUT", "id": identifier, "data": {"title": "Reading book", "author": "Author"}} + ] + }, + ) + assert result.status_code == 200 + return identifier + + async def test_list_reading_sessions(client: AsyncClient, auth_headers: dict[str, str]): """Test listing reading sessions.""" response = await client.get("/v1/progress/sessions", headers=auth_headers) @@ -55,3 +73,34 @@ async def test_get_reading_statistics_with_date_range(client: AsyncClient, auth_ }, ) assert response.status_code == 200 + + +async def test_manual_session_retry_and_real_statistics(client, auth_headers, book_id): + from datetime import timedelta + + end = datetime.now(UTC) + payload = { + "session_id": str(uuid4()), + "book_id": book_id, + "start_time": (end - timedelta(minutes=20)).isoformat(), + "end_time": end.isoformat(), + "pages_read": 15, + } + first = await client.post("/v1/progress/sessions", headers=auth_headers, json=payload) + second = await client.post("/v1/progress/sessions", headers=auth_headers, json=payload) + assert first.status_code == second.status_code == 200 + assert first.json()["session_id"] == second.json()["session_id"] + sessions = (await client.get("/v1/progress/sessions", headers=auth_headers)).json() + assert sessions["pagination"]["total"] == 1 + stats = (await client.get("/v1/progress/statistics", headers=auth_headers)).json()["totals"] + assert stats["reading_time_minutes"] == 20 + assert stats["pages_read"] == 15 + + +async def test_missing_book_is_rejected(client, auth_headers): + response = await client.post( + "/v1/progress/sessions", + headers=auth_headers, + json={"book_id": str(uuid4()), "start_time": datetime.now(UTC).isoformat(), "pages_read": 2}, + ) + assert response.status_code == 404 diff --git a/tests/api/routes/test_sync.py b/tests/api/routes/test_sync.py index 57abac0..bc8b6c0 100644 --- a/tests/api/routes/test_sync.py +++ b/tests/api/routes/test_sync.py @@ -26,6 +26,7 @@ async def test_sync_settings_are_public_and_hide_implementation_details(client: assert response.status_code == 200 assert response.json() == { + "tracking_schema_version": 1, "data_sync_url": "https://sync.papyrus.test", "file_storage": { "supported": True, diff --git a/tests/fixtures/tracking_projections.json b/tests/fixtures/tracking_projections.json new file mode 100644 index 0000000..36d8a8d --- /dev/null +++ b/tests/fixtures/tracking_projections.json @@ -0,0 +1,562 @@ +[ + { + "name": "creation cutoff and overlapping devices", + "goal": { + "id": "00000000-0000-0000-0000-000000000001", + "goal_type": "reading_time", + "target_value": 30, + "time_period": "custom", + "start_date": "2026-10-05T00:00:00Z", + "end_date": "2026-10-06T00:00:00Z", + "created_at": "2026-10-05T10:00:00Z", + "timezone": "UTC", + "scope": "library", + "minimum_minutes": 5, + "is_recurring": false, + "is_active": true, + "is_archived": false, + "rules": [] + }, + "activities": [ + { + "id": "00000000-0000-0000-0000-00000000000a", + "book_id": "00000000-0000-0000-0000-000000000002", + "book_title": "Book", + "start_time": "2026-10-05T09:50:00Z", + "end_time": "2026-10-05T10:10:00Z", + "created_at": "2026-10-05T10:10:00Z", + "source": "manual", + "kind": "reading", + "device_id": "one", + "shelf_ids": [], + "pages": 0, + "coverage": [] + }, + { + "id": "00000000-0000-0000-0000-00000000000b", + "book_id": "00000000-0000-0000-0000-000000000002", + "book_title": "Book", + "start_time": "2026-10-05T10:05:00Z", + "end_time": "2026-10-05T10:20:00Z", + "created_at": "2026-10-05T10:20:00Z", + "source": "manual", + "kind": "reading", + "device_id": "one", + "shelf_ids": [], + "pages": 0, + "coverage": [] + }, + { + "id": "00000000-0000-0000-0000-00000000000a", + "book_id": "00000000-0000-0000-0000-000000000002", + "book_title": "Book", + "start_time": "2026-10-05T09:50:00Z", + "end_time": "2026-10-05T10:10:00Z", + "created_at": "2026-10-05T10:10:00Z", + "source": "manual", + "kind": "reading", + "device_id": "one", + "shelf_ids": [], + "pages": 0, + "coverage": [] + } + ], + "now": "2026-10-06T00:00:00Z", + "expected": { + "seconds": 1200, + "pages": 0, + "finished_books": 0, + "days": 1, + "current_value": 20 + } + }, + { + "name": "pause excludes intervals without extending period", + "goal": { + "id": "00000000-0000-0000-0000-000000000001", + "goal_type": "reading_time", + "target_value": 30, + "time_period": "custom", + "start_date": "2026-10-05T00:00:00Z", + "end_date": "2026-10-06T00:00:00Z", + "created_at": "2026-10-05T10:00:00Z", + "timezone": "UTC", + "scope": "library", + "minimum_minutes": 5, + "is_recurring": false, + "is_active": true, + "is_archived": false, + "rules": [ + { + "at": "2026-10-05T10:00:00Z", + "target": 30, + "title": null, + "active": true, + "archived": false + }, + { + "at": "2026-10-05T10:05:00Z", + "target": 30, + "title": null, + "active": false, + "archived": false + }, + { + "at": "2026-10-05T10:15:00Z", + "target": 30, + "title": null, + "active": true, + "archived": false + } + ] + }, + "activities": [ + { + "id": "00000000-0000-0000-0000-00000000000a", + "book_id": "00000000-0000-0000-0000-000000000002", + "book_title": "Book", + "start_time": "2026-10-05T09:50:00Z", + "end_time": "2026-10-05T10:10:00Z", + "created_at": "2026-10-05T10:10:00Z", + "source": "manual", + "kind": "reading", + "device_id": "one", + "shelf_ids": [], + "pages": 0, + "coverage": [] + }, + { + "id": "00000000-0000-0000-0000-00000000000b", + "book_id": "00000000-0000-0000-0000-000000000002", + "book_title": "Book", + "start_time": "2026-10-05T10:05:00Z", + "end_time": "2026-10-05T10:20:00Z", + "created_at": "2026-10-05T10:20:00Z", + "source": "manual", + "kind": "reading", + "device_id": "one", + "shelf_ids": [], + "pages": 0, + "coverage": [] + } + ], + "now": "2026-10-06T00:00:00Z", + "expected": { + "seconds": 600, + "pages": 0, + "finished_books": 0, + "days": 1, + "current_value": 10 + } + }, + { + "name": "coverage union uses stable calibration", + "goal": { + "id": "00000000-0000-0000-0000-000000000001", + "goal_type": "pages_count", + "target_value": 30, + "time_period": "custom", + "start_date": "2026-10-05T00:00:00Z", + "end_date": "2026-10-06T00:00:00Z", + "created_at": "2026-10-05T10:00:00Z", + "timezone": "UTC", + "scope": "library", + "minimum_minutes": 5, + "is_recurring": false, + "is_active": true, + "is_archived": false, + "rules": [] + }, + "activities": [ + { + "id": "00000000-0000-0000-0000-00000000000d", + "book_id": "00000000-0000-0000-0000-000000000002", + "book_title": "Book", + "start_time": "2026-10-05T10:10:00Z", + "end_time": "2026-10-05T10:20:00Z", + "created_at": "2026-10-05T10:20:00Z", + "source": "reader", + "kind": "reading", + "device_id": "one", + "shelf_ids": [], + "pages": 0, + "coverage": [ + { + "key": "epub:0:500", + "start": 0.25, + "end": 0.75, + "pages_per_unit": 40, + "estimated": true + } + ] + }, + { + "id": "00000000-0000-0000-0000-00000000000c", + "book_id": "00000000-0000-0000-0000-000000000002", + "book_title": "Book", + "start_time": "2026-10-05T10:00:00Z", + "end_time": "2026-10-05T10:10:00Z", + "created_at": "2026-10-05T10:10:00Z", + "source": "reader", + "kind": "reading", + "device_id": "one", + "shelf_ids": [], + "pages": 0, + "coverage": [ + { + "key": "epub:0:500", + "start": 0, + "end": 0.5, + "pages_per_unit": 20, + "estimated": true + } + ] + }, + { + "id": "00000000-0000-0000-0000-00000000000c", + "book_id": "00000000-0000-0000-0000-000000000002", + "book_title": "Book", + "start_time": "2026-10-05T10:00:00Z", + "end_time": "2026-10-05T10:10:00Z", + "created_at": "2026-10-05T10:10:00Z", + "source": "reader", + "kind": "reading", + "device_id": "one", + "shelf_ids": [], + "pages": 0, + "coverage": [ + { + "key": "epub:0:500", + "start": 0, + "end": 0.5, + "pages_per_unit": 20, + "estimated": true + } + ] + } + ], + "now": "2026-10-06T00:00:00Z", + "expected": { + "seconds": 1200, + "pages": 15, + "finished_books": 0, + "days": 1, + "current_value": 15 + } + }, + { + "name": "distinct books and completion undo", + "goal": { + "id": "00000000-0000-0000-0000-000000000001", + "goal_type": "books_count", + "target_value": 30, + "time_period": "custom", + "start_date": "2026-10-05T00:00:00Z", + "end_date": "2026-10-06T00:00:00Z", + "created_at": "2026-10-05T10:00:00Z", + "timezone": "UTC", + "scope": "library", + "minimum_minutes": 5, + "is_recurring": false, + "is_active": true, + "is_archived": false, + "rules": [] + }, + "activities": [ + { + "id": "00000000-0000-0000-0000-00000000000e", + "book_id": "00000000-0000-0000-0000-000000000002", + "book_title": "Book", + "start_time": "2026-10-05T10:15:00Z", + "end_time": "2026-10-05T10:15:00Z", + "created_at": "2026-10-05T10:15:00Z", + "source": "manual", + "kind": "completion", + "device_id": "one", + "shelf_ids": [], + "pages": 0, + "coverage": [] + }, + { + "id": "00000000-0000-0000-0000-00000000000f", + "book_id": "00000000-0000-0000-0000-000000000002", + "book_title": "Book", + "start_time": "2026-10-05T10:18:00Z", + "end_time": "2026-10-05T10:18:00Z", + "created_at": "2026-10-05T10:18:00Z", + "source": "manual", + "kind": "completion", + "device_id": "one", + "shelf_ids": [], + "pages": 0, + "coverage": [] + }, + { + "id": "00000000-0000-0000-0000-000000000010", + "book_id": "00000000-0000-0000-0000-000000000002", + "book_title": "Book", + "start_time": "2026-10-05T11:00:00Z", + "end_time": "2026-10-05T11:00:00Z", + "created_at": "2026-10-05T11:00:00Z", + "source": "manual", + "kind": "reversal", + "device_id": "one", + "shelf_ids": [], + "pages": 0, + "coverage": [], + "correction_of": "00000000-0000-0000-0000-00000000000e" + } + ], + "now": "2026-10-06T00:00:00Z", + "expected": { + "seconds": 0, + "pages": 0, + "finished_books": 1, + "days": 0, + "current_value": 1 + } + }, + { + "name": "undo last completion", + "goal": { + "id": "00000000-0000-0000-0000-000000000001", + "goal_type": "books_count", + "target_value": 30, + "time_period": "custom", + "start_date": "2026-10-05T00:00:00Z", + "end_date": "2026-10-06T00:00:00Z", + "created_at": "2026-10-05T10:00:00Z", + "timezone": "UTC", + "scope": "library", + "minimum_minutes": 5, + "is_recurring": false, + "is_active": true, + "is_archived": false, + "rules": [] + }, + "activities": [ + { + "id": "00000000-0000-0000-0000-00000000000e", + "book_id": "00000000-0000-0000-0000-000000000002", + "book_title": "Book", + "start_time": "2026-10-05T10:15:00Z", + "end_time": "2026-10-05T10:15:00Z", + "created_at": "2026-10-05T10:15:00Z", + "source": "manual", + "kind": "completion", + "device_id": "one", + "shelf_ids": [], + "pages": 0, + "coverage": [] + }, + { + "id": "00000000-0000-0000-0000-00000000000f", + "book_id": "00000000-0000-0000-0000-000000000002", + "book_title": "Book", + "start_time": "2026-10-05T10:18:00Z", + "end_time": "2026-10-05T10:18:00Z", + "created_at": "2026-10-05T10:18:00Z", + "source": "manual", + "kind": "completion", + "device_id": "one", + "shelf_ids": [], + "pages": 0, + "coverage": [] + }, + { + "id": "00000000-0000-0000-0000-000000000010", + "book_id": "00000000-0000-0000-0000-000000000002", + "book_title": "Book", + "start_time": "2026-10-05T11:00:00Z", + "end_time": "2026-10-05T11:00:00Z", + "created_at": "2026-10-05T11:00:00Z", + "source": "manual", + "kind": "reversal", + "device_id": "one", + "shelf_ids": [], + "pages": 0, + "coverage": [], + "correction_of": "00000000-0000-0000-0000-00000000000e" + }, + { + "id": "00000000-0000-0000-0000-000000000011", + "book_id": "00000000-0000-0000-0000-000000000002", + "book_title": "Book", + "start_time": "2026-10-05T11:00:00Z", + "end_time": "2026-10-05T11:00:00Z", + "created_at": "2026-10-05T11:00:00Z", + "source": "manual", + "kind": "reversal", + "device_id": "one", + "shelf_ids": [], + "pages": 0, + "coverage": [], + "correction_of": "00000000-0000-0000-0000-00000000000f" + } + ], + "now": "2026-10-06T00:00:00Z", + "expected": { + "seconds": 0, + "pages": 0, + "finished_books": 0, + "days": 0, + "current_value": 0 + } + }, + { + "name": "midnight checkpoint credits exposed prior-day PDF page", + "goal": { + "id": "00000000-0000-0000-0000-000000000001", + "goal_type": "pages_count", + "target_value": 30, + "time_period": "custom", + "start_date": "2026-10-05T00:00:00Z", + "end_date": "2026-10-06T00:00:00Z", + "created_at": "2026-10-05T10:00:00Z", + "timezone": "UTC", + "scope": "library", + "minimum_minutes": 5, + "is_recurring": false, + "is_active": true, + "is_archived": false, + "rules": [] + }, + "activities": [ + { + "id": "00000000-0000-0000-0000-000000000012", + "book_id": "00000000-0000-0000-0000-000000000002", + "book_title": "Book", + "start_time": "2026-10-05T23:59:50Z", + "end_time": "2026-10-06T00:00:00Z", + "created_at": "2026-10-06T00:00:00Z", + "source": "reader", + "kind": "reading", + "device_id": "one", + "shelf_ids": [], + "pages": 0, + "coverage": [ + { + "key": "pdf:9", + "start": 0, + "end": 1, + "pages_per_unit": 1, + "estimated": false + } + ] + } + ], + "now": "2026-10-06T00:00:00Z", + "expected": { + "seconds": 10, + "pages": 1, + "finished_books": 0, + "days": 0, + "current_value": 1 + } + }, + { + "name": "spring DST day has twenty-three hours", + "goal": { + "id": "00000000-0000-0000-0000-000000000001", + "goal_type": "reading_time", + "target_value": 30, + "time_period": "daily", + "start_date": "2026-03-28T22:00:00Z", + "end_date": "2026-03-29T21:00:00Z", + "created_at": "2026-03-28T22:00:00Z", + "timezone": "Europe/Vilnius", + "scope": "library", + "minimum_minutes": 5, + "is_recurring": true, + "is_active": true, + "is_archived": false, + "rules": [] + }, + "activities": [ + { + "id": "00000000-0000-0000-0000-000000000013", + "book_id": "00000000-0000-0000-0000-000000000002", + "book_title": "Book", + "start_time": "2026-03-28T22:00:00Z", + "end_time": "2026-03-29T21:00:00Z", + "created_at": "2026-03-29T21:00:00Z", + "source": "manual", + "kind": "reading", + "device_id": "one", + "shelf_ids": [], + "pages": 0, + "coverage": [] + } + ], + "now": "2026-03-29T20:59:59.999999Z", + "expected": { + "seconds": 82799, + "pages": 0, + "finished_books": 0, + "days": 1, + "current_value": 1379 + } + }, + { + "name": "captured shelf scope and reading-day threshold", + "goal": { + "id": "00000000-0000-0000-0000-000000000001", + "goal_type": "reading_days", + "target_value": 30, + "time_period": "custom", + "start_date": "2026-10-05T00:00:00Z", + "end_date": "2026-10-06T00:00:00Z", + "created_at": "2026-10-05T10:00:00Z", + "timezone": "UTC", + "scope": "shelf", + "minimum_minutes": 5, + "is_recurring": false, + "is_active": true, + "is_archived": false, + "rules": [], + "scope_id": "00000000-0000-0000-0000-000000000003" + }, + "activities": [ + { + "id": "00000000-0000-0000-0000-00000000000a", + "book_id": "00000000-0000-0000-0000-000000000002", + "book_title": "Book", + "start_time": "2026-10-05T09:50:00Z", + "end_time": "2026-10-05T10:10:00Z", + "created_at": "2026-10-05T10:10:00Z", + "source": "manual", + "kind": "reading", + "device_id": "one", + "shelf_ids": [ + "00000000-0000-0000-0000-000000000003" + ], + "pages": 0, + "coverage": [] + }, + { + "id": "00000000-0000-0000-0000-00000000000b", + "book_id": "00000000-0000-0000-0000-000000000002", + "book_title": "Book", + "start_time": "2026-10-05T10:05:00Z", + "end_time": "2026-10-05T10:20:00Z", + "created_at": "2026-10-05T10:20:00Z", + "source": "manual", + "kind": "reading", + "device_id": "one", + "shelf_ids": [ + "00000000-0000-0000-0000-000000000004" + ], + "pages": 0, + "coverage": [] + } + ], + "now": "2026-10-06T00:00:00Z", + "expected": { + "seconds": 600, + "pages": 0, + "finished_books": 0, + "days": 1, + "current_value": 1 + } + } +] diff --git a/tests/services/test_goal_progress.py b/tests/services/test_goal_progress.py new file mode 100644 index 0000000..ab51048 --- /dev/null +++ b/tests/services/test_goal_progress.py @@ -0,0 +1,22 @@ +"""Projection fixtures shared byte-for-byte with the Dart client.""" + +import json +from datetime import datetime +from pathlib import Path + +import pytest + +from papyrus.schemas.tracking import Activity, GoalDefinition +from papyrus.services.goal_progress import project_goal + +FIXTURES = json.loads((Path(__file__).parents[1] / "fixtures/tracking_projections.json").read_text()) + + +@pytest.mark.parametrize("fixture", FIXTURES, ids=lambda value: value["name"]) +def test_matching_client_projection(fixture): + goal = GoalDefinition.model_validate(fixture["goal"]) + ledger = [Activity.model_validate(value) for value in fixture["activities"]] + result = project_goal(goal, ledger, datetime.fromisoformat(fixture["now"])) + + for key, value in fixture["expected"].items(): + assert result[key] == pytest.approx(value) diff --git a/tests/services/test_tracking.py b/tests/services/test_tracking.py new file mode 100644 index 0000000..4cc2c1f --- /dev/null +++ b/tests/services/test_tracking.py @@ -0,0 +1,156 @@ +"""Owned immutable activity, concurrent rules, and retained deletion history.""" + +from datetime import UTC, datetime, timedelta +from uuid import uuid4 + +import pytest +from sqlalchemy import select + +from papyrus.core.exceptions import ForbiddenError, ValidationError +from papyrus.models import SyncGoalPeriod, SyncReadingActivity, SyncReadingGoal +from papyrus.schemas.sync import PowerSyncCrudMutation +from papyrus.schemas.tracking import Activity, GoalDefinition, GoalRule +from papyrus.services.sync import apply_powersync_upload_batch +from tests.services.test_sync import _create_book, _create_user + + +def mutation(table, payload): + return PowerSyncCrudMutation( + table=table, op="PUT", id=str(payload.id), op_data={"payload": payload.model_dump(mode="json")} + ) + + +async def setup(session): + owner = await _create_user(session, f"{uuid4()}@example.com") + book = await _create_book(session, owner) + now = datetime.now(UTC) - timedelta(minutes=30) + goal = GoalDefinition( + id=uuid4(), + goal_type="reading_time", + target_value=30, + time_period="daily", + start_date=now.replace(hour=0, minute=0, second=0, microsecond=0), + end_date=(now + timedelta(days=1)).replace(hour=0, minute=0, second=0, microsecond=0), + created_at=now, + rules=[GoalRule(at=now, target=30)], + ) + activity = Activity( + id=uuid4(), + book_id=book.book_id, + book_title=book.title, + start_time=now, + end_time=now + timedelta(minutes=10), + created_at=now + timedelta(minutes=10), + ) + await session.commit() + return owner.user_id, book.book_id, goal, activity + + +async def test_tracking_retries_are_idempotent_and_mutation_is_atomic(test_session_maker): + async with test_session_maker() as session: + owner, book_id, goal, activity = await setup(session) + batch = [mutation("reading_goals", goal), mutation("reading_activities", activity)] + await apply_powersync_upload_batch(session, owner, batch) + await apply_powersync_upload_batch(session, owner, batch) + assert len((await session.execute(select(SyncReadingActivity))).scalars().all()) == 1 + changed = activity.model_copy(update={"pages": 99}) + + with pytest.raises(ValidationError): + await apply_powersync_upload_batch( + session, + owner, + [ + PowerSyncCrudMutation( + table="books", op="PATCH", id=str(book_id), op_data={"title": "Should roll back"} + ), + mutation("reading_activities", changed), + ], + ) + + assert (await session.get(SyncReadingActivity, activity.id)).payload["pages"] == 0 + from papyrus.models import SyncBook + + assert (await session.get(SyncBook, book_id)).title == activity.book_title + + +async def test_tracking_ownership_applies_to_payload_references(test_session_maker): + async with test_session_maker() as session: + owner, book_id, goal, activity = await setup(session) + intruder = await _create_user(session, f"{uuid4()}@example.com") + intruder_id = intruder.user_id + await session.commit() + + with pytest.raises(ForbiddenError): + await apply_powersync_upload_batch(session, intruder_id, [mutation("reading_activities", activity)]) + + assert await session.get(SyncReadingActivity, activity.id) is None + await apply_powersync_upload_batch( + session, owner, [mutation("reading_goals", goal), mutation("reading_activities", activity)] + ) + + with pytest.raises(ForbiddenError): + await apply_powersync_upload_batch(session, intruder_id, [mutation("reading_goals", goal)]) + + +async def test_goal_rule_branches_merge_without_rewriting_existing_history(test_session_maker): + async with test_session_maker() as session: + owner, _, goal, _ = await setup(session) + await apply_powersync_upload_batch(session, owner, [mutation("reading_goals", goal)]) + first = goal.model_copy( + update={ + "target_value": 20, + "rules": [*goal.rules, GoalRule(at=goal.created_at + timedelta(minutes=5), target=20)], + } + ) + second = goal.model_copy( + update={ + "target_value": 40, + "rules": [*goal.rules, GoalRule(at=goal.created_at + timedelta(minutes=10), target=40)], + } + ) + await apply_powersync_upload_batch(session, owner, [mutation("reading_goals", first)]) + await apply_powersync_upload_batch(session, owner, [mutation("reading_goals", second)]) + row = await session.get(SyncReadingGoal, goal.id) + result = GoalDefinition.model_validate(row.payload) + assert [rule.target for rule in result.rules] == [30, 20, 40] + assert result.target_value == 40 + rewrite = goal.model_copy(update={"target_value": 10, "rules": [GoalRule(at=goal.created_at, target=10)]}) + + with pytest.raises(ValidationError): + await apply_powersync_upload_batch(session, owner, [mutation("reading_goals", rewrite)]) + + +async def test_book_goal_deletion_preserves_activity_and_periods(test_session_maker): + async with test_session_maker() as session: + owner, book_id, goal, activity = await setup(session) + await apply_powersync_upload_batch( + session, owner, [mutation("reading_goals", goal), mutation("reading_activities", activity)] + ) + await apply_powersync_upload_batch( + session, + owner, + [ + PowerSyncCrudMutation(table="books", op="DELETE", id=str(book_id)), + PowerSyncCrudMutation(table="reading_goals", op="DELETE", id=str(goal.id)), + ], + ) + assert await session.get(SyncReadingActivity, activity.id) is not None + periods = (await session.execute(select(SyncGoalPeriod))).scalars().all() + assert len(periods) == 1 + assert periods[0].payload["definition"]["is_archived"] + assert await session.get(SyncReadingGoal, goal.id) is None + await apply_powersync_upload_batch(session, owner, [mutation("reading_goals", goal)]) + assert await session.get(SyncReadingGoal, goal.id) is None + at = datetime.now(UTC) + reverse = Activity( + id=uuid4(), + book_id=book_id, + book_title=activity.book_title, + start_time=at, + end_time=at, + created_at=at, + kind="reversal", + correction_of=activity.id, + ) + await apply_powersync_upload_batch(session, owner, [mutation("reading_activities", reverse)]) + assert await session.get(SyncReadingActivity, reverse.id) is not None diff --git a/tests/test_tracking_migration.py b/tests/test_tracking_migration.py new file mode 100644 index 0000000..fa2b3d7 --- /dev/null +++ b/tests/test_tracking_migration.py @@ -0,0 +1,55 @@ +"""Tracking tables are additive and preserve an existing owned library.""" + +import importlib.util +from datetime import UTC, datetime +from pathlib import Path +from uuid import uuid4 + +from alembic.autogenerate import compare_metadata +from alembic.migration import MigrationContext +from alembic.operations import Operations +from sqlalchemy import inspect + +from papyrus.models import Base, SyncBook, User + + +async def test_tracking_upgrade_preserves_library_and_matches_metadata(db_session): + path = Path(__file__).parents[1] / "alembic/versions/b5c6d7e8f901_add_reading_activity_and_goals.py" + spec = importlib.util.spec_from_file_location("tracking_revision", path) + revision = importlib.util.module_from_spec(spec) + spec.loader.exec_module(revision) + + def migrate(session, fn): + with Operations.context(MigrationContext.configure(session.connection())): + fn() + + await db_session.run_sync(lambda session: migrate(session, revision.downgrade)) + owner = User( + user_id=uuid4(), + display_name="Reader", + primary_email="migration@example.com", + primary_email_verified=True, + last_login_at=datetime.now(UTC), + ) + db_session.add(owner) + await db_session.flush() + book = SyncBook( + book_id=uuid4(), + owner_user_id=owner.user_id, + title="Preserved", + author="Author", + added_at=datetime.now(UTC), + updated_at=datetime.now(UTC), + ) + identifier = book.book_id + db_session.add(book) + await db_session.flush() + await db_session.run_sync(lambda session: migrate(session, revision.upgrade)) + differences = await db_session.run_sync( + lambda session: compare_metadata(MigrationContext.configure(session.connection()), Base.metadata) + ) + assert differences == [] + assert (await db_session.get(SyncBook, identifier)).title == "Preserved" + for table in ("reading_goals", "reading_activities", "goal_periods"): + assert await db_session.run_sync(lambda session, name=table: inspect(session.connection()).has_table(name)) + await db_session.commit()