diff --git a/AGENTS.md b/AGENTS.md
index 2cdabb4..ad8690e 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -13,6 +13,9 @@ requires the exact Expo SDK 57 documentation before writing mobile code.
- Deliver assigned work in coherent chunks, with one PR per chunk.
- Give every small, meaningful change its own commit. Commit incrementally;
do not wait until the end and put the entire task into one large commit.
+- Prefer a higher number of focused commits per PR. Separate independently
+ reviewable behavior, UI, and documentation changes instead of bundling them;
+ keep regression tests with the behavior they verify.
- Keep as many meaningful, atomic commits as the chunk naturally produces in
its PR. There is no numeric maximum or minimum. Do not split a coherent
change into broken fragments or make empty commits to inflate the count.
diff --git a/README.md b/README.md
index 4a5a9db..8df529b 100644
--- a/README.md
+++ b/README.md
@@ -34,6 +34,7 @@ link's build current automatically.
- [Architecture](docs/ARCHITECTURE.md) — how the pieces fit, selection, reminders, security
- [Roadmap](docs/ROADMAP.md) — shipped phases and what's next
- [Backend](backend/README.md) — API endpoints, auth, running locally
+- [Email templates](docs/EMAIL_TEMPLATES.md) — branded Supabase emails and installation
- [Deployment](mobile/DEPLOYMENT.md) — EAS builds, OTA updates, release runbook
## Contributing
diff --git a/backend/.env.example b/backend/.env.example
index 75f8176..a27c6ed 100644
--- a/backend/.env.example
+++ b/backend/.env.example
@@ -13,6 +13,11 @@ DATABASE_URL=postgresql://postgres.PROJECT:PASSWORD@aws-0-REGION.pooler.supabase
# Session-mode pooler (port 5432). Used for migrations and any DDL.
DIRECT_URL=postgresql://postgres.PROJECT:PASSWORD@aws-0-REGION.pooler.supabase.com:5432/postgres
+# Best-effort API connection warm-up. Zero interval disables it.
+# The timeout covers pool checkout, reconnect, and the SELECT 1 probe.
+DB_KEEPALIVE_INTERVAL_SECONDS=30
+DB_KEEPALIVE_TIMEOUT_SECONDS=5
+
# Settings → API
SUPABASE_URL=https://PROJECT.supabase.co
# Public anon key (Settings → API). Safe to expose — it's already in the mobile
@@ -34,11 +39,12 @@ GEMINI_MODEL=gemini-3.1-flash-lite
GENERATION_ENABLED=false
# The worker tops each topic up to this many published concepts.
MIN_POOL_PER_TOPIC=25
-# Hard ceiling so a retry loop cannot burn the daily quota.
+# Shared daily reservation ceiling across API prefetch and workers (Pacific day).
+# Use the same cap on all services; zero blocks new generation calls.
GENERATION_DAILY_CALL_CAP=200
-# Seconds between worker calls; the free tier allows ~10 requests a minute.
+# Seconds between scheduled worker calls; provider limits vary by model/tier.
GENERATION_PACE_SECONDS=6
-# Last-resort generation inside a request when a user's own pool is empty.
+# Background refill when a user is running low; never waits on the HTTP path.
GENERATION_ON_DEMAND=true
# --- HTTP -----------------------------------------------------------------
diff --git a/backend/README.md b/backend/README.md
index ea88e28..fd723d0 100644
--- a/backend/README.md
+++ b/backend/README.md
@@ -29,6 +29,7 @@ backend/
│ │ └── users.py profile bootstrap (safety net for the DB trigger)
│ └── api/v1/ health, topics, daily
├── migrations/ # plain SQL, applied in filename order
+├── email-templates/ # account email HTML installed manually in Supabase Auth
├── tests/ # 25 tests: token verification, selection, HTTP
├── Dockerfile # what Railway builds
└── .env.example # copy to .env — never commit the filled copy
@@ -52,7 +53,9 @@ python3 -m venv .venv && .venv/bin/pip install -r requirements-dev.txt
| GET | `/v1/topics` | yes | Active topics, concept counts, and whether you follow each. |
| GET | `/v1/daily` | yes | Today's concept. Creates the assignment on first call, idempotent after. |
| POST | `/v1/daily/complete` | yes | Mark today learned. Server sets the timestamp and the day it counts for. |
-| GET | `/v1/me/state` | yes | Everything the app renders: follows, history, likes, saves, streaks. One query. |
+| GET | `/v1/me/state?compact=true` | yes | Startup state with at most 50 learned/saved detail rows, full membership and totals, and today's lesson. |
+| GET | `/v1/me/history` | yes | Completed concepts, newest assigned day first; cursor pagination. |
+| GET | `/v1/me/saved` | yes | Saved metadata, newest save first; cursor pagination. |
| GET | `/v1/me/stats` | yes | Streaks alone, for other consumers. |
| PUT | `/v1/me/topics` | yes | Replace the followed set (whole-list semantics, so retries are safe). |
| PATCH | `/v1/me` | yes | Display name and timezone. Unknown zones are rejected. |
@@ -66,6 +69,33 @@ python3 -m venv .venv && .venv/bin/pip install -r requirements-dev.txt
been assigned every published concept — it never repeats one. Phase 6 hooks
Gemini generation in at that point.
+## Startup and collection pagination
+
+Updated mobile clients send `compact=true` on `GET /v1/me/state`,
+`PUT /v1/me/topics`, and `PATCH /v1/me`. Each response embeds at most 50 learned
+and 50 saved detail records. Full `likes` and `bookmarks` slug arrays, streaks,
+and `stats.total_learned` remain authoritative. `learned_before_window` groups
+older completions by topic name; add those counts to the recent learned rows
+for category totals, including any optimistic offline completion.
+
+Continue from `history_next_cursor` or `saved_next_cursor` using the matching
+collection endpoint. Each returns `{items, next_cursor}`; a null cursor means
+there are no more rows. Both accept `limit` (default 50, range 1–100) and an
+optional `cursor`. History uses the last assigned date; Saved uses an opaque
+save-timestamp/UUID cursor so tied timestamps and deletions do not skip rows.
+Keep cursors unchanged and URL-encode them. All queries use the verified user.
+Pages are live reads: refresh startup state to see new saves/completions made
+above an existing cursor while paging.
+
+The limit applies before per-concept like-count enrichment. Bare membership
+arrays and aggregate calculations still grow with account activity. Older
+clients that omit `compact=true` keep the full legacy detail response during
+backend/OTA rollout; their startup cost is unchanged until updated. The mobile
+History screen still shows the last ten lessons; its full-history UI is separate.
+Saved loads older metadata only when opened, preserving search/category filters,
+and caches it for offline use. Downloaded lesson bodies also supply missing
+metadata offline, even if Saved was never opened before.
+
## Authentication
The project signs tokens with **ES256**, so the API verifies them against the
@@ -77,6 +107,11 @@ confusion attacks, both of which are covered by tests.
`user_id` is taken from the verified token's `sub` claim and from nowhere else.
No endpoint accepts a user id as a parameter.
+Supabase Auth sends signup, recovery, and enabled security notifications using
+the project's configured sender. See [Email templates](../docs/EMAIL_TEMPLATES.md)
+for the three branded HTML files and manual installation steps. An app deployment
+does not publish these templates or change SMTP settings.
+
## Content generation
Gemini writes lessons. It does **not** choose subjects.
@@ -86,6 +121,8 @@ curated backlog (150 titles)
↓
worker: is a topic below MIN_POOL_PER_TOPIC published concepts?
↓
+claim a title + reserve one shared daily call, then commit
+ ↓
Gemini writes {summary, example} for one backlogged title
↓
validate — length bounds, no boilerplate opener, no code fence,
@@ -117,17 +154,60 @@ It is safe to run concurrently: backlog items are claimed with
| Control | Effect |
|---|---|
| `GENERATION_ENABLED` | Master switch. Nothing calls Gemini when false. |
-| `GENERATION_DAILY_CALL_CAP` | Hard ceiling per run, so a retry loop cannot burn the quota. |
+| `GENERATION_DAILY_CALL_CAP` | Shared daily reservation ceiling across scheduled refill, on-demand prefetch, and catalog rewriting; zero stops new calls. |
| `attempts < 3` | A title that keeps failing is retired rather than blocking the queue. |
| Validation | Malformed output leaves the item pending; it never reaches a reader. |
-| `GENERATION_ON_DEMAND` | Last-resort in-request generation when one user's pool is dry. |
+| `GENERATION_ON_DEMAND` | Allows background refill when a user has few unread lessons. |
+
+### Shared generation budget
+
+`generation_daily_usage` stores committed call reservations, one row per Pacific
+calendar day. PostgreSQL computes the day in `America/Los_Angeles`, matching
+[Gemini's midnight Pacific RPD reset](https://ai.google.dev/gemini-api/docs/rate-limits),
+including daylight saving time. User assignment/streak timezones are unchanged.
+The atomic UPSERT prevents competing API/worker processes from spending the same
+last slot. Restarts and repeated job runs retain usage; a new day gets a new row.
+
+`generate_one` claims the backlog title and reserves quota in one transaction,
+then commits before calling Gemini. An exhausted budget rolls back the title
+claim and attempt. Once committed, failed responses, rate limits, cancellation,
+or a worker crash keep the reservation; uncertain provider calls must not be
+refunded. Gemini throttling still refunds the separate **backlog retry attempt**.
+Empty backlog does not consume quota. Database/reservation failures stop generation
+before the provider call. The manual rewrite worker uses the same ledger and
+respects `GENERATION_ENABLED`; unfinished lessons retain their old prompt version
+for a later run. Daily reading remains independent of generation.
+
+Set the **same `GENERATION_DAILY_CALL_CAP` on the API and every worker** sharing
+this database. Changing it does not erase existing usage. This is an application
+budget, not a provider quota lookup: other applications using the same Gemini
+project are outside this ledger, and provider rate/token limits still apply.
+
+**Before deploying this code:** apply
+[`0010_generation_daily_usage.sql`](migrations/0010_generation_daily_usage.sql)
+using the migration procedure in [RELEASING.md](../RELEASING.md). The production
+ledger remains unchanged until actual application is verified. Earlier application
+versions do not use this counter, and calls made before deployment cannot be
+reconstructed from it. Pause old generators during rollout; enable the new code
+at the next Pacific reset, or conservatively seed today's usage while generation
+is paused, to avoid granting another allowance in the middle of the day.
+
+Inspect reservations without changing them:
+
+```sql
+select budget_day, calls_used
+from public.generation_daily_usage
+order by budget_day desc
+limit 7;
+```
### Fallback ladder in `/v1/daily`
1. An unseen concept in a followed topic.
-2. Failing that, generate one in the user's least-recently-seen followed topic.
-3. Failing that, widen to the whole catalog and flag `outside_followed_topics`.
-4. Failing that, return `409 catalog_exhausted`. A concept is never repeated.
+2. If that pool is dry, schedule a background refill and immediately widen to
+ the whole catalog, flagging `outside_followed_topics`.
+3. If nothing unseen remains, return `409 catalog_exhausted`. A concept is never
+ repeated. A low unread watermark can also schedule refill before exhaustion.
## Latency and database region
@@ -136,16 +216,48 @@ Measured against a Supabase project in `ap-northeast-1` from Europe, a single
round trip is 160–1100 ms — so the code is written to minimise the *number* of
statements rather than their complexity:
-- `/v1/me/state` is **one query**. It returns follows, history, likes, saves,
- today's assignment, and streaks together, and bootstrapping only runs when
- that query finds no profile.
+- The state aggregate is **one query**, returning follows, recent detail rows,
+ membership, assignment slug, and full streaks/totals. Bootstrapping only runs
+ when no profile exists. The `/v1/me/state` handler separately resolves today's
+ lesson, folding it into the same HTTP response.
- Follow updates are one statement (a data-modifying CTE), not one per topic.
- Connection pooling is on. Without it every request paid a fresh TCP + TLS +
auth handshake to the database region, which cost seconds.
-The remaining latency is geography. **Deploy the API in the same region as the
-database** — on Railway, pick the region closest to your Supabase project — and
-these round trips drop to single-digit milliseconds.
+Geography and connection reuse both affect latency. **Deploy the API close to
+the database**, then compare fresh, immediately reused, and post-idle requests.
+Timing a slow query alone does not establish that a new connection was opened.
+
+The API runs a best-effort `SELECT 1` probe immediately on startup and then
+every `DB_KEEPALIVE_INTERVAL_SECONDS` (default 30 seconds, measured after each
+probe finishes). It borrows from the same pool as requests and promptly returns
+the connection, rolling back the implicit transaction. The pool reuses its most
+recently returned connection, so low traffic can use a warm slot while surplus
+idle slots expire. `pool_pre_ping` remains enabled for dead connections.
+
+`DB_KEEPALIVE_TIMEOUT_SECONDS` (default 5 seconds) bounds checkout, reconnect,
+and query together. Rollback/return has a separate budget of the same duration;
+failed cleanup invalidates the connection. A failed attempt logs only the
+exception type and retries after the interval; it does not block API startup.
+Shutdown cancels the task before disposing the engine. Set the interval to `0`
+to disable probes. Each API
+process runs its own task; importing the engine in cron workers starts no task.
+Pool size and overflow limits remain 5 each. A cold startup or a burst requiring
+additional connections can still pay connection setup time.
+
+Reproduce idle expiry without production services using:
+
+```bash
+DATABASE_URL=postgresql+asyncpg://postgres:postgres@127.0.0.1:55433/postgres \
+SUPABASE_URL=http://test.invalid SUPABASE_JWKS_URL=http://test.invalid/jwks \
+GENERATION_ENABLED=false GEMINI_API_KEY= \
+ .venv/bin/python -m pytest tests/test_db_keepalive_postgres.py -q -s
+```
+
+The PostgreSQL 16 tests set short idle expiry only on their own sessions and
+compare connection counts and request timings with warming disabled/enabled.
+They also check transaction cleanup and recovery from a terminated connection.
+These timings describe the local test environment, not deployed Supavisor latency.
## Connection strings
diff --git a/backend/app/api/v1/me.py b/backend/app/api/v1/me.py
index 683fe85..b6c7e42 100644
--- a/backend/app/api/v1/me.py
+++ b/backend/app/api/v1/me.py
@@ -1,14 +1,18 @@
-from datetime import time
+from datetime import date, time
-from fastapi import APIRouter, Depends, HTTPException, status
+from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import ARRAY, Time, bindparam, text
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.session import get_db
from app.deps import CurrentUser, get_current_user
from app.schemas.daily import ConceptOut, DailyOut
-from app.schemas.me import LearnedOut, ProfileIn, SavedConceptOut, StateOut, StreakOut, TopicsIn
+from app.schemas.me import (
+ HistoryPageOut, LearnedOut, ProfileIn, SavedConceptOut, SavedPageOut, StateOut,
+ StreakOut, TopicsIn,
+)
from app.schemas.notifications import NotificationPrefs, PushTokenIn
+from app.services.collections import history_page, saved_page
from app.services.interactions import set_followed_topics
from app.services.selection import DailyResult, get_or_create_daily
from app.services.state import load_state
@@ -59,12 +63,16 @@ def _to_state_out(state) -> StateOut:
for s in state.saved
],
stats=StreakOut(**vars(state.stats)),
+ learned_before_window=state.learned_before_window,
+ history_next_cursor=state.history_next_cursor,
+ saved_next_cursor=state.saved_next_cursor,
assignment_slug=state.assignment_slug,
)
@router.get("/state", response_model=StateOut)
async def get_state(
+ compact: bool = False,
user: CurrentUser = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> StateOut:
@@ -73,11 +81,11 @@ async def get_state(
Bootstrapping only runs when the state query finds no profile, so the
common path costs a single round trip.
"""
- state = await load_state(db, user.id)
+ state = await load_state(db, user.id, compact=compact)
if state is None:
await ensure_bootstrapped(db, user.id, user.email)
await db.commit()
- state = await load_state(db, user.id)
+ state = await load_state(db, user.id, compact=compact)
out = _to_state_out(state)
# Fold today's concept in so the app needs one startup round trip (#102).
# Same create-on-first-call behaviour as GET /v1/daily.
@@ -85,6 +93,29 @@ async def get_state(
return out
+@router.get("/history", response_model=HistoryPageOut)
+async def get_history(
+ cursor: date | None = None,
+ limit: int = Query(default=50, ge=1, le=100),
+ user: CurrentUser = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+) -> HistoryPageOut:
+ return await history_page(db, user.id, cursor, limit)
+
+
+@router.get("/saved", response_model=SavedPageOut)
+async def get_saved(
+ cursor: str | None = Query(default=None, max_length=200),
+ limit: int = Query(default=50, ge=1, le=100),
+ user: CurrentUser = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+) -> SavedPageOut:
+ try:
+ return await saved_page(db, user.id, cursor, limit)
+ except ValueError as exc:
+ raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Invalid saved cursor") from exc
+
+
@router.get("/stats", response_model=StreakOut)
async def get_stats(
user: CurrentUser = Depends(get_current_user),
@@ -96,11 +127,12 @@ async def get_stats(
@router.put("/topics", response_model=StateOut)
async def put_topics(
body: TopicsIn,
+ compact: bool = False,
user: CurrentUser = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> StateOut:
await set_followed_topics(db, user.id, body.topics)
- return _to_state_out(await load_state(db, user.id))
+ return _to_state_out(await load_state(db, user.id, compact=compact))
@router.post("/push-token", status_code=status.HTTP_204_NO_CONTENT)
@@ -217,6 +249,7 @@ async def put_notifications(
@router.patch("", response_model=StateOut)
async def patch_profile(
body: ProfileIn,
+ compact: bool = False,
user: CurrentUser = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> StateOut:
@@ -244,4 +277,4 @@ async def patch_profile(
{"n": body.display_name, "uid": user.id},
)
await db.commit()
- return _to_state_out(await load_state(db, user.id))
+ return _to_state_out(await load_state(db, user.id, compact=compact))
diff --git a/backend/app/config.py b/backend/app/config.py
index 46654c3..92fd5c0 100644
--- a/backend/app/config.py
+++ b/backend/app/config.py
@@ -1,5 +1,6 @@
from functools import lru_cache
+from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
@@ -17,6 +18,10 @@ class Settings(BaseSettings):
database_url: str
direct_url: str | None = None
+ # Best-effort API pool warm-up; zero disables it. Workers do not start it.
+ db_keepalive_interval_seconds: float = Field(default=30, ge=0, allow_inf_nan=False)
+ db_keepalive_timeout_seconds: float = Field(default=5, gt=0, allow_inf_nan=False)
+
supabase_url: str
supabase_jwks_url: str
# Present for legacy HS256 projects; this project signs with ES256 via JWKS.
@@ -32,10 +37,11 @@ class Settings(BaseSettings):
gemini_model: str = "gemini-3.1-flash-lite"
generation_enabled: bool = False
min_pool_per_topic: int = 25
- generation_daily_call_cap: int = 200
+ # Shared by all generation paths; zero prevents new reservations.
+ generation_daily_call_cap: int = Field(default=200, ge=0)
# Seconds between worker calls; the free tier allows ~10 requests a minute.
generation_pace_seconds: float = 6.0
- # Last-resort generation inside a request, when a user's pool is empty.
+ # Schedule background refill when a user's unread pool is low.
generation_on_demand: bool = True
allowed_origins: str = "http://localhost:8081"
diff --git a/backend/app/db/keepalive.py b/backend/app/db/keepalive.py
new file mode 100644
index 0000000..fc5927d
--- /dev/null
+++ b/backend/app/db/keepalive.py
@@ -0,0 +1,55 @@
+"""Keep a reusable API connection warm without adding work to user requests."""
+
+import asyncio
+import logging
+from time import perf_counter
+
+from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine
+
+logger = logging.getLogger("uvicorn.error")
+
+
+async def _release(connection: AsyncConnection, timeout: float) -> None:
+ try:
+ async with asyncio.timeout(timeout):
+ await connection.close()
+ except BaseException:
+ # A failed rollback must not leave a borrowed or broken pool slot.
+ await connection.invalidate()
+ raise
+
+
+async def _probe(engine: AsyncEngine, timeout: float) -> None:
+ connection = None
+ try:
+ async with asyncio.timeout(timeout):
+ connection = await engine.connect()
+ await connection.exec_driver_sql("SELECT 1")
+ finally:
+ if connection is not None:
+ # SQLAlchemy's context exit shields close(), but cancellation can
+ # return before that close finishes. Retain and await cleanup so
+ # lifespan disposal cannot race a connection still being returned.
+ cleanup = asyncio.create_task(_release(connection, timeout))
+ try:
+ await asyncio.shield(cleanup)
+ except asyncio.CancelledError:
+ await cleanup
+ raise
+
+
+async def keep_database_warm(engine: AsyncEngine, interval: float, timeout: float) -> None:
+ """Probe immediately, then periodically; cancellation belongs to the lifespan."""
+ while True:
+ started = perf_counter()
+ try:
+ await _probe(engine, timeout)
+ logger.debug("Database warm-up completed in %.1f ms", (perf_counter() - started) * 1000)
+ except Exception as error:
+ # Cleanup errors must not turn a shutdown cancellation into a retry.
+ if asyncio.current_task().cancelling():
+ raise asyncio.CancelledError from error
+ # Outages must not stop the API or cause tight retry loops. Log only
+ # the exception type: driver messages can contain connection details.
+ logger.warning("Database warm-up failed (%s); retrying later", type(error).__name__)
+ await asyncio.sleep(interval)
diff --git a/backend/app/db/models.py b/backend/app/db/models.py
index ddde594..c83680e 100644
--- a/backend/app/db/models.py
+++ b/backend/app/db/models.py
@@ -12,6 +12,7 @@
Date,
DateTime,
ForeignKey,
+ Integer,
SmallInteger,
Text,
Time,
@@ -175,3 +176,12 @@ class ReminderLog(Base):
local_date: Mapped[date] = mapped_column(Date, primary_key=True)
slot: Mapped[time] = mapped_column(Time, primary_key=True)
sent_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
+
+
+class GenerationDailyUsage(Base):
+ """Mirror of migrations/0010_generation_daily_usage.sql; backend-only quota."""
+
+ __tablename__ = "generation_daily_usage"
+
+ budget_day: Mapped[date] = mapped_column(Date, primary_key=True)
+ calls_used: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
diff --git a/backend/app/db/session.py b/backend/app/db/session.py
index 684f2c0..78d102f 100644
--- a/backend/app/db/session.py
+++ b/backend/app/db/session.py
@@ -1,8 +1,8 @@
from collections.abc import AsyncIterator
-from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
+from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker, create_async_engine
-from app.config import get_settings
+from app.config import Settings, get_settings
settings = get_settings()
@@ -11,20 +11,24 @@
# is not only safe but necessary: without it every request pays a fresh
# TCP + TLS + auth handshake to the database region, which dominates response
# time when the database is far away.
-_connect_args: dict = {}
-if settings.uses_transaction_pooler:
- _connect_args["statement_cache_size"] = 0
-
-engine = create_async_engine(
- settings.sqlalchemy_url,
- echo=False,
- pool_size=5,
- max_overflow=5,
- pool_recycle=1800,
- pool_pre_ping=True,
- connect_args=_connect_args,
- execution_options={"compiled_cache": None} if settings.uses_transaction_pooler else {},
-)
+def create_db_engine(config: Settings) -> AsyncEngine:
+ connect_args = {"statement_cache_size": 0} if config.uses_transaction_pooler else {}
+ return create_async_engine(
+ config.sqlalchemy_url,
+ echo=False,
+ pool_size=5,
+ max_overflow=5,
+ pool_recycle=1800,
+ pool_pre_ping=True,
+ # Keep the most recently used connection hot instead of cycling through
+ # every idle slot. Other slots still reconnect safely on demand.
+ pool_use_lifo=True,
+ connect_args=connect_args,
+ execution_options={"compiled_cache": None} if config.uses_transaction_pooler else {},
+ )
+
+
+engine = create_db_engine(settings)
SessionLocal = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession)
diff --git a/backend/app/main.py b/backend/app/main.py
index 9cd6038..ecf18be 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -1,5 +1,6 @@
+import asyncio
import logging
-from contextlib import asynccontextmanager
+from contextlib import asynccontextmanager, suppress
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
@@ -9,6 +10,7 @@
from app.api.v1.router import api_router
from app.config import get_settings
from app.core.security import JwksCache
+from app.db.keepalive import keep_database_warm
from app.db.session import engine
@@ -22,8 +24,22 @@ async def lifespan(app: FastAPI):
settings.environment,
settings.uses_transaction_pooler,
)
- yield
- await engine.dispose()
+ warmup = None
+ if settings.db_keepalive_interval_seconds > 0:
+ warmup = asyncio.create_task(
+ keep_database_warm(
+ engine, settings.db_keepalive_interval_seconds, settings.db_keepalive_timeout_seconds
+ ),
+ name="database-keepalive",
+ )
+ try:
+ yield
+ finally:
+ if warmup is not None:
+ warmup.cancel()
+ with suppress(asyncio.CancelledError):
+ await warmup
+ await engine.dispose()
def create_app() -> FastAPI:
diff --git a/backend/app/schemas/me.py b/backend/app/schemas/me.py
index ecbbbbf..ab1553e 100644
--- a/backend/app/schemas/me.py
+++ b/backend/app/schemas/me.py
@@ -28,6 +28,16 @@ class LearnedOut(BaseModel):
like_count: int = 0
+class HistoryPageOut(BaseModel):
+ items: list[LearnedOut]
+ next_cursor: str | None = None
+
+
+class SavedPageOut(BaseModel):
+ items: list[SavedConceptOut]
+ next_cursor: str | None = None
+
+
class StateOut(BaseModel):
display_name: str | None = None
timezone: str
@@ -38,6 +48,11 @@ class StateOut(BaseModel):
bookmarks: list[str]
saved: list[SavedConceptOut] = Field(default_factory=list)
stats: StreakOut
+ # Present for compact clients. Counts exclude the embedded recent window,
+ # so optimistic/offline completions can still be added by the client.
+ learned_before_window: dict[str, int] | None = None
+ history_next_cursor: str | None = None
+ saved_next_cursor: str | None = None
assignment_slug: str | None = None
# Today's concept, folded in so the app needs a single round trip at startup
# (issue #102). Null when the catalog is exhausted for this user. This GET
diff --git a/backend/app/services/collections.py b/backend/app/services/collections.py
new file mode 100644
index 0000000..73fcb94
--- /dev/null
+++ b/backend/app/services/collections.py
@@ -0,0 +1,100 @@
+"""Bounded collection reads; cursor ordering survives deletions between pages."""
+
+import base64
+import binascii
+import json
+import uuid
+from datetime import date, datetime
+
+from sqlalchemy import text
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.schemas.me import HistoryPageOut, LearnedOut, SavedConceptOut, SavedPageOut
+
+STATE_WINDOW = 50
+
+
+def saved_cursor(saved_at: str, concept_id: str) -> str:
+ return base64.urlsafe_b64encode(json.dumps([saved_at, concept_id]).encode()).decode().rstrip("=")
+
+
+def parse_saved_cursor(cursor: str | None) -> tuple[datetime | None, uuid.UUID | None]:
+ if cursor is None:
+ return None, None
+ try:
+ timestamp, concept_id = json.loads(base64.b64decode(
+ cursor + "=" * (-len(cursor) % 4), altchars=b"-_", validate=True,
+ ))
+ if not isinstance(timestamp, str) or not isinstance(concept_id, str):
+ raise ValueError("Cursor fields must be strings")
+ at = datetime.fromisoformat(timestamp)
+ if at.tzinfo is None:
+ raise ValueError("Timestamp must include a timezone")
+ return at, uuid.UUID(concept_id)
+ except (ValueError, TypeError, binascii.Error) as exc:
+ raise ValueError("Invalid saved cursor") from exc
+
+
+_HISTORY_PAGE = text("""
+ with page as materialized (
+ select concept_id, assigned_for
+ from public.daily_assignments
+ where user_id = :uid and completed_at is not null
+ and (cast(:before as date) is null or assigned_for < :before)
+ order by assigned_for desc limit :take
+ )
+ select c.slug as concept_slug, p.assigned_for as learned_on,
+ c.title, t.name as topic_name,
+ (select count(*) from public.concept_interactions ci
+ where ci.concept_id = p.concept_id and ci.liked_at is not null
+ and ci.user_id <> :uid)::int as like_count
+ from page p join public.concepts c on c.id = p.concept_id
+ join public.topics t on t.id = c.topic_id
+ order by p.assigned_for desc
+""")
+
+_SAVED_PAGE = text("""
+ with page as materialized (
+ select concept_id, saved_at
+ from public.concept_interactions
+ where user_id = :uid and saved_at is not null
+ and (cast(:before as timestamptz) is null
+ or (saved_at, concept_id) < (:before, cast(:id as uuid)))
+ order by saved_at desc, concept_id desc limit :take
+ )
+ select c.slug as concept_slug, c.title, t.name as topic_name, p.saved_at, p.concept_id,
+ (select count(*) from public.concept_interactions ci
+ where ci.concept_id = p.concept_id and ci.liked_at is not null
+ and ci.user_id <> :uid)::int as like_count
+ from page p join public.concepts c on c.id = p.concept_id
+ join public.topics t on t.id = c.topic_id
+ order by p.saved_at desc, p.concept_id desc
+""")
+
+
+async def history_page(
+ db: AsyncSession, user_id: uuid.UUID, before: date | None, limit: int,
+) -> HistoryPageOut:
+ rows = (await db.execute(_HISTORY_PAGE, {
+ "uid": user_id, "before": before, "take": limit + 1,
+ })).mappings().all()
+ items = [LearnedOut(**r) for r in rows[:limit]]
+ return HistoryPageOut(
+ items=items,
+ next_cursor=items[-1].learned_on.isoformat() if len(rows) > limit else None,
+ )
+
+
+async def saved_page(
+ db: AsyncSession, user_id: uuid.UUID, cursor: str | None, limit: int,
+) -> SavedPageOut:
+ before, concept_id = parse_saved_cursor(cursor)
+ rows = (await db.execute(_SAVED_PAGE, {
+ "uid": user_id, "before": before, "id": concept_id, "take": limit + 1,
+ })).mappings().all()
+ return SavedPageOut(
+ items=[SavedConceptOut(**r) for r in rows[:limit]],
+ next_cursor=(saved_cursor(rows[limit - 1]["saved_at"].isoformat(),
+ str(rows[limit - 1]["concept_id"]))
+ if len(rows) > limit else None),
+ )
diff --git a/backend/app/services/generation_budget.py b/backend/app/services/generation_budget.py
new file mode 100644
index 0000000..cdebbad
--- /dev/null
+++ b/backend/app/services/generation_budget.py
@@ -0,0 +1,38 @@
+"""Shared daily reservations, made in the caller's short database transaction.
+
+The caller must COMMIT before contacting Gemini. A failed/uncertain provider
+request still costs its reservation; refunding it could exceed the daily cap.
+"""
+
+from sqlalchemy import text
+from sqlalchemy.ext.asyncio import AsyncSession
+
+
+class GenerationBudgetExhausted(RuntimeError):
+ """Normal stop condition: no more generation calls may start today."""
+
+
+# Gemini RPD resets at midnight Pacific, including DST. Use the database clock
+# for all workers and statement_timestamp rather than the transaction's start:
+# a session may have read its work list before the day changed.
+_RESERVE = text("""
+ insert into public.generation_daily_usage (budget_day, calls_used)
+ select (statement_timestamp() at time zone 'America/Los_Angeles')::date, 1
+ where :cap > 0
+ on conflict (budget_day) do update
+ set calls_used = generation_daily_usage.calls_used + 1
+ where generation_daily_usage.calls_used < :cap
+ returning calls_used
+""")
+
+
+async def reserve_generation_call(session: AsyncSession, call_cap: int) -> None:
+ """Atomically reserve one call; the caller commits or rolls back the claim.
+
+ The UPSERT locks the shared day's row and checks the latest committed count,
+ so concurrent processes cannot each spend the same final slot. Database
+ errors propagate: generation must never proceed without a confirmed budget.
+ """
+ used = (await session.execute(_RESERVE, {"cap": call_cap})).scalar_one_or_none()
+ if used is None:
+ raise GenerationBudgetExhausted("daily generation call cap reached")
diff --git a/backend/app/services/pool.py b/backend/app/services/pool.py
index 62631ca..9deea69 100644
--- a/backend/app/services/pool.py
+++ b/backend/app/services/pool.py
@@ -13,6 +13,8 @@
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
+from app.config import get_settings
+from app.services.generation_budget import GenerationBudgetExhausted, reserve_generation_call
from app.services.generation import GenerationError, RateLimitedError, generate_concept
log = logging.getLogger(__name__)
@@ -130,11 +132,21 @@ class TopUpResult:
async def generate_one(
- session: AsyncSession, api_key: str, model: str, topic_id: uuid.UUID | None = None
+ session: AsyncSession, api_key: str, model: str, topic_id: uuid.UUID | None = None,
+ *, call_cap: int | None = None,
) -> uuid.UUID | None:
- """Generate and publish a single backlog item. Returns the concept id."""
- claimed = (await session.execute(_CLAIM, {"topic_id": topic_id})).first()
- await session.commit()
+ """Claim a title and daily budget together, then generate outside the transaction."""
+ cap = get_settings().generation_daily_call_cap if call_cap is None else call_cap
+ try:
+ claimed = (await session.execute(_CLAIM, {"topic_id": topic_id})).first()
+ if claimed is not None:
+ await reserve_generation_call(session, cap)
+ await session.commit()
+ except BaseException:
+ # Quota denial/DB failure/cancellation must undo the claim and its attempt.
+ # Once committed, a call's quota reservation is never refunded.
+ await session.rollback()
+ raise
if claimed is None:
return None
@@ -209,7 +221,7 @@ async def top_up(
if reaped:
log.warning("reclaimed %s stale 'generating' backlog rows", len(reaped))
- generated = failed = calls = 0
+ generated = failed = 0
backoff = BACKOFF_START_SECONDS
rate_limit_streak = 0
for topic in (await session.execute(_POOL_COUNTS)).all():
@@ -218,13 +230,11 @@ async def top_up(
continue
remaining = min(deficit, topic.pending)
while remaining > 0:
- if calls >= call_cap:
- # A hard ceiling so a retry loop cannot burn the daily quota.
- log.warning("stopping: hit the daily call cap of %s", call_cap)
- return TopUpResult(generated, failed, "daily call cap reached")
- calls += 1
try:
- concept_id = await generate_one(session, api_key, model, topic.id)
+ concept_id = await generate_one(session, api_key, model, topic.id, call_cap=call_cap)
+ except GenerationBudgetExhausted:
+ log.info("stopping: shared daily call cap of %s reached", call_cap)
+ return TopUpResult(generated, failed, "daily call cap reached")
except RateLimitedError as exc:
rate_limit_streak += 1
if rate_limit_streak >= MAX_CONSECUTIVE_RATE_LIMITS:
diff --git a/backend/app/services/prefetch.py b/backend/app/services/prefetch.py
index 9b843e4..7615440 100644
--- a/backend/app/services/prefetch.py
+++ b/backend/app/services/prefetch.py
@@ -18,6 +18,7 @@
from app.config import get_settings
from app.db.session import SessionLocal
from app.services.generation import RateLimitedError
+from app.services.generation_budget import GenerationBudgetExhausted
from app.services.pool import generate_one
log = logging.getLogger(__name__)
@@ -85,8 +86,12 @@ async def _run(topic_id: uuid.UUID) -> None:
break
try:
concept_id = await generate_one(
- session, settings.gemini_api_key, settings.gemini_model, topic_id
+ session, settings.gemini_api_key, settings.gemini_model, topic_id,
+ call_cap=settings.generation_daily_call_cap,
)
+ except GenerationBudgetExhausted:
+ log.info("prefetch for topic %s stopped: daily call cap reached", topic_id)
+ break
except RateLimitedError:
log.info("prefetch for topic %s stopped: rate limited", topic_id)
break
diff --git a/backend/app/services/state.py b/backend/app/services/state.py
index 675ee0b..3a6945b 100644
--- a/backend/app/services/state.py
+++ b/backend/app/services/state.py
@@ -13,6 +13,7 @@
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
+from app.services.collections import STATE_WINDOW, saved_cursor
from app.services.streaks import StreakStats
@@ -46,6 +47,9 @@ class UserState:
# user's 20 concepts). `bookmarks` stays as bare slugs for membership counts.
saved: list[SavedConcept]
stats: StreakStats
+ learned_before_window: dict[str, int] | None = None
+ history_next_cursor: str | None = None
+ saved_next_cursor: str | None = None
assignment_slug: str | None = None
display_name: str | None = None
@@ -68,14 +72,22 @@ class UserState:
join public.topics t on t.id = c.topic_id
where a.user_id = :uid and a.completed_at is not null
),
+ recent_learned_rows as materialized (
+ select * from learned_rows order by assigned_for desc limit :window_limit
+ ),
+ older_counts as (
+ select topic_name, count(*)::int as n from learned_rows
+ where assigned_for < (select min(assigned_for) from recent_learned_rows)
+ group by topic_name
+ ),
learned as (
select coalesce(json_agg(json_build_object(
'slug', slug, 'title', title, 'topic', topic_name, 'on', assigned_for,
'likes', (select count(*) from public.concept_interactions ci
- where ci.concept_id = learned_rows.concept_id
+ where ci.concept_id = recent_learned_rows.concept_id
and ci.liked_at is not null and ci.user_id <> :uid)::int)
order by assigned_for desc), '[]'::json) as v
- from learned_rows
+ from recent_learned_rows
),
interactions as (
select
@@ -85,18 +97,22 @@ class UserState:
join public.concepts c on c.id = i.concept_id
where i.user_id = :uid
),
+ saved_rows as materialized (
+ select concept_id, saved_at from public.concept_interactions
+ where user_id = :uid and saved_at is not null
+ order by saved_at desc, concept_id desc limit :window_limit
+ ),
saved as (
select coalesce(json_agg(json_build_object(
'slug', c.slug, 'title', c.title, 'topic', t.name,
+ 'at', i.saved_at, 'id', i.concept_id,
'likes', (select count(*) from public.concept_interactions ci
where ci.concept_id = c.id
and ci.liked_at is not null and ci.user_id <> :uid)::int)
- order by i.saved_at desc) filter (where i.saved_at is not null),
- '[]'::json) as v
- from public.concept_interactions i
+ order by i.saved_at desc, i.concept_id desc), '[]'::json) as v
+ from saved_rows i
join public.concepts c on c.id = i.concept_id
join public.topics t on t.id = c.topic_id
- where i.user_id = :uid
),
assignment as (
select c.slug
@@ -114,6 +130,8 @@ class UserState:
prof.today,
followed.v as followed_topics,
learned.v as learned,
+ coalesce((select json_object_agg(topic_name, n) from older_counts),
+ '{}'::json) as learned_before_window,
interactions.likes,
interactions.saves,
saved.v as saved,
@@ -127,10 +145,12 @@ class UserState:
""")
-async def load_state(session: AsyncSession, user_id: uuid.UUID) -> UserState | None:
+async def load_state(session: AsyncSession, user_id: uuid.UUID, *, compact: bool = False) -> UserState | None:
"""Returns None when the user has no profile row yet, so the caller can
bootstrap and retry — keeping the common path to a single query."""
- row = (await session.execute(_STATE, {"uid": user_id})).first()
+ row = (await session.execute(_STATE, {
+ "uid": user_id, "window_limit": STATE_WINDOW if compact else None,
+ })).first()
if row is None:
return None
@@ -165,5 +185,10 @@ async def load_state(session: AsyncSession, user_id: uuid.UUID) -> UserState | N
longest=row.longest_streak,
total_learned=row.total_learned,
),
+ learned_before_window=dict(row.learned_before_window) if compact else None,
+ history_next_cursor=(row.learned[-1]["on"]
+ if compact and row.total_learned > len(row.learned) else None),
+ saved_next_cursor=(saved_cursor(row.saved[-1]["at"], row.saved[-1]["id"])
+ if compact and len(row.saves) > len(row.saved) else None),
assignment_slug=row.assignment_slug,
)
diff --git a/backend/app/workers/rewrite_catalog.py b/backend/app/workers/rewrite_catalog.py
index fb673fb..0309afd 100644
--- a/backend/app/workers/rewrite_catalog.py
+++ b/backend/app/workers/rewrite_catalog.py
@@ -13,6 +13,7 @@
from app.config import get_settings
from app.db.session import SessionLocal, engine
+from app.services.generation_budget import GenerationBudgetExhausted, reserve_generation_call
from app.services.generation import (
PROMPT_VERSION,
GenerationError,
@@ -45,54 +46,67 @@ async def main() -> None:
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
settings = get_settings()
- async with SessionLocal() as session:
- todo = (await session.execute(_TODO, {"pv": PROMPT_VERSION})).all()
- # Close the read transaction before the paced generation loop begins:
- # otherwise this initial SELECT's transaction stays open across every
- # Gemini call, pace sleep, and rate-limit backoff below, pinning a server
- # connection on the transaction pooler for the entire (long) run.
- await session.commit()
- log.info("%s lessons to rewrite with prompt %s", len(todo), PROMPT_VERSION)
+ try:
+ if not settings.generation_enabled:
+ log.info("generation disabled; catalog unchanged")
+ return
+ if not settings.gemini_api_key:
+ log.info("no API key configured; catalog unchanged")
+ return
+ async with SessionLocal() as session:
+ todo = (await session.execute(_TODO, {"pv": PROMPT_VERSION})).all()
+ # Close the read transaction before the paced generation loop begins:
+ # otherwise this initial SELECT's transaction stays open across every
+ # Gemini call, pace sleep, and rate-limit backoff below, pinning a server
+ # connection on the transaction pooler for the entire (long) run.
+ await session.commit()
+ log.info("%s lessons to rewrite with prompt %s", len(todo), PROMPT_VERSION)
- rewritten = failed = 0
- backoff, streak = BACKOFF_START, 0
- for row in todo:
- while True:
- try:
- result = await generate_concept(
- title=row.title, topic_name=row.topic_name, angle=None,
- api_key=settings.gemini_api_key, model=settings.gemini_model,
- )
- except RateLimitedError as exc:
- streak += 1
- if streak >= MAX_RATE_LIMIT_STREAK:
- log.warning("giving up: %s consecutive rate limits", streak)
- log.info("rewritten %s, failed %s (resume by re-running)", rewritten, failed)
- await engine.dispose()
+ rewritten = failed = 0
+ backoff, streak = BACKOFF_START, 0
+ for row in todo:
+ while True:
+ try:
+ await reserve_generation_call(session, settings.generation_daily_call_cap)
+ await session.commit()
+ result = await generate_concept(
+ title=row.title, topic_name=row.topic_name, angle=None,
+ api_key=settings.gemini_api_key, model=settings.gemini_model,
+ )
+ except GenerationBudgetExhausted:
+ await session.rollback()
+ log.info("daily call cap reached: rewritten %s, failed %s (resume on a later day)", rewritten, failed)
return
- delay = max(exc.retry_after or 0.0, backoff)
- log.warning("rate limited; retrying %s in %.0fs", row.title, delay)
- await asyncio.sleep(delay)
- backoff = min(backoff * 2, BACKOFF_MAX)
- continue
- except GenerationError as exc:
- # Leave it on the old prompt version; a later run retries it.
- log.warning("skipping %s: %s", row.title, exc)
- failed += 1
+ except RateLimitedError as exc:
+ streak += 1
+ if streak >= MAX_RATE_LIMIT_STREAK:
+ log.warning("giving up: %s consecutive rate limits", streak)
+ log.info("rewritten %s, failed %s (resume by re-running)", rewritten, failed)
+ return
+ delay = max(exc.retry_after or 0.0, backoff)
+ log.warning("rate limited; retrying %s in %.0fs", row.title, delay)
+ await asyncio.sleep(delay)
+ backoff = min(backoff * 2, BACKOFF_MAX)
+ continue
+ except GenerationError as exc:
+ # Leave it on the old prompt version; a later run retries it.
+ log.warning("skipping %s: %s", row.title, exc)
+ failed += 1
+ break
+ streak, backoff = 0, BACKOFF_START
+ await session.execute(_UPDATE, {
+ "id": row.id, "summary": result.summary, "example": result.example,
+ "model": result.model, "pv": result.prompt_version,
+ })
+ await session.commit()
+ rewritten += 1
+ log.info("rewrote %s (%s/%s)", row.title, rewritten, len(todo))
break
- streak, backoff = 0, BACKOFF_START
- await session.execute(_UPDATE, {
- "id": row.id, "summary": result.summary, "example": result.example,
- "model": result.model, "pv": result.prompt_version,
- })
- await session.commit()
- rewritten += 1
- log.info("rewrote %s (%s/%s)", row.title, rewritten, len(todo))
- break
- await asyncio.sleep(PACE_SECONDS)
+ await asyncio.sleep(PACE_SECONDS)
- log.info("done: rewritten %s, failed %s", rewritten, failed)
- await engine.dispose()
+ log.info("done: rewritten %s, failed %s", rewritten, failed)
+ finally:
+ await engine.dispose()
if __name__ == "__main__":
diff --git a/backend/email-templates/confirm-signup.html b/backend/email-templates/confirm-signup.html
new file mode 100644
index 0000000..784094f
--- /dev/null
+++ b/backend/email-templates/confirm-signup.html
@@ -0,0 +1,41 @@
+
+
+
+
+
+ Confirm your email — One Concept
+
+
+
+ One last step to finish setting up your account.
+
+
+
+
+
+ One Concept.
+ by Coding Moves
+
+
+ Confirm your email.
+ Confirm this address to finish setting up your account.
+
+ Then return to the app and sign in.
+
+
+ If you didn’t sign up, you can ignore this email.
+ Button not working? Copy this link into your browser:
+
+ {{ .ConfirmationURL }}
+
+
+
+
+
+
+
+
diff --git a/backend/email-templates/password-changed.html b/backend/email-templates/password-changed.html
new file mode 100644
index 0000000..46897df
--- /dev/null
+++ b/backend/email-templates/password-changed.html
@@ -0,0 +1,34 @@
+
+
+
+
+
+ Your password was changed — One Concept
+
+
+
+ A security update for your One Concept account.
+
+
+
+
+
+ One Concept.
+ by Coding Moves
+
+
+ Password changed.
+ Your One Concept password was changed.
+ If you made this change, no further action is needed.
+
+
+ Didn’t make this change?
+ Open One Concept and use Forgot password on the sign-in screen to reset your password.
+ Contact support immediately.
+
+
+
+
+
+
+
diff --git a/backend/email-templates/reset-password.html b/backend/email-templates/reset-password.html
new file mode 100644
index 0000000..3367efd
--- /dev/null
+++ b/backend/email-templates/reset-password.html
@@ -0,0 +1,41 @@
+
+
+
+
+
+ Reset your password — One Concept
+
+
+
+ Choose a new password for your One Concept account.
+
+
+
+
+
+ One Concept.
+ by Coding Moves
+
+
+ Reset your password.
+ A password reset was requested for your account. Choose a new password below.
+
+ Once it’s saved, return to the app and sign in.
+
+
+ If you didn’t request this, ignore this email. Your password won’t change.
+ Button not working? Copy this link into your browser:
+
+ {{ .ConfirmationURL }}
+
+
+
+
+
+
+
+
diff --git a/backend/migrations/0010_generation_daily_usage.sql b/backend/migrations/0010_generation_daily_usage.sql
new file mode 100644
index 0000000..8a1370d
--- /dev/null
+++ b/backend/migrations/0010_generation_daily_usage.sql
@@ -0,0 +1,15 @@
+-- Shared reservation ledger for every application Gemini generation path.
+-- Calls are reserved before network I/O and never refunded after commit.
+-- A new Pacific calendar day gets a new row; no reset job is needed.
+begin;
+
+create table public.generation_daily_usage (
+ budget_day date primary key,
+ calls_used integer not null default 0 check (calls_used >= 0)
+);
+
+-- Backend only. No policies means authenticated mobile users cannot access it,
+-- even on installations whose default grants expose new public-schema tables.
+alter table public.generation_daily_usage enable row level security;
+
+commit;
diff --git a/backend/migrations/applied.txt b/backend/migrations/applied.txt
index ae690ba..0ff1ffc 100644
--- a/backend/migrations/applied.txt
+++ b/backend/migrations/applied.txt
@@ -17,3 +17,4 @@
0007_reminder_log.sql
0008_backlog_claimed_at.sql
0009_like_count_index.sql
+0010_generation_daily_usage.sql
diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py
index 3f0e98c..eab4a5b 100644
--- a/backend/tests/conftest.py
+++ b/backend/tests/conftest.py
@@ -22,6 +22,7 @@
# the actual Gemini API instead of reporting exhaustion.
os.environ["GENERATION_ENABLED"] = "false"
os.environ["GEMINI_API_KEY"] = ""
+os.environ["GENERATION_DAILY_CALL_CAP"] = "200"
CONTAINER = "one-concept-test-db"
PORT = 55433
@@ -114,3 +115,16 @@ async def user(session):
)
await session.commit()
return user_id
+
+
+@pytest_asyncio.fixture
+async def empty_generation_budget(session):
+ """Generation tests share a schema, but each starts with its own daily budget."""
+ from sqlalchemy import text
+
+ await session.execute(text("delete from public.generation_daily_usage"))
+ await session.commit()
+ yield
+ await session.rollback()
+ await session.execute(text("delete from public.generation_daily_usage"))
+ await session.commit()
diff --git a/backend/tests/test_db_keepalive.py b/backend/tests/test_db_keepalive.py
new file mode 100644
index 0000000..6e7259c
--- /dev/null
+++ b/backend/tests/test_db_keepalive.py
@@ -0,0 +1,137 @@
+"""Pool warming must be bounded, release connections, and follow API lifetime."""
+
+import asyncio
+from contextlib import suppress
+from types import SimpleNamespace
+from unittest.mock import AsyncMock
+
+import pytest
+
+from app.config import Settings
+from app.db.keepalive import keep_database_warm
+
+
+async def stop(task):
+ task.cancel()
+ with suppress(asyncio.CancelledError):
+ await task
+
+
+@pytest.mark.parametrize("failure", ["query_error", "query_timeout", "checkout_timeout", "release_timeout"])
+async def test_probe_recovers_without_overlap_or_leaking_a_connection(failure, caplog):
+ entered = 0
+ active = 0
+ maximum_active = 0
+ recovered = asyncio.Event()
+
+ async def query(sql):
+ assert sql == "SELECT 1"
+ if entered == 1:
+ if failure == "query_error":
+ raise ConnectionError("private connection details must not be logged")
+ if failure != "release_timeout":
+ await asyncio.Event().wait()
+ else:
+ recovered.set()
+
+ async def connect():
+ nonlocal entered, active, maximum_active
+ entered += 1
+ if entered == 1 and failure == "checkout_timeout":
+ await asyncio.Event().wait()
+ active += 1
+ maximum_active = max(maximum_active, active)
+ released = False
+
+ async def invalidate():
+ nonlocal active, released
+ if released:
+ return
+ released = True
+ active -= 1
+
+ async def close():
+ if entered == 1 and failure == "release_timeout":
+ await asyncio.Event().wait()
+ await invalidate()
+
+ return SimpleNamespace(exec_driver_sql=query, close=close, invalidate=invalidate)
+
+ task = asyncio.create_task(keep_database_warm(SimpleNamespace(connect=connect), 0.02, 0.02))
+ try:
+ await asyncio.wait_for(recovered.wait(), 2)
+ finally:
+ await stop(task)
+ assert entered == 2
+ assert maximum_active == 1
+ assert active == 0
+ assert "Database warm-up failed" in caplog.text
+ assert "private connection details" not in caplog.text
+
+
+@pytest.mark.parametrize("fail_request", [False, True])
+@pytest.mark.parametrize("fail_cleanup", [False, True])
+async def test_lifespan_starts_without_waiting_and_cancels_probe_before_disposal(monkeypatch, fail_request, fail_cleanup):
+ from app import main
+
+ config = Settings(
+ _env_file=None, database_url="postgresql://test:test@localhost/test",
+ supabase_url="http://test.invalid", supabase_jwks_url="http://test.invalid/jwks",
+ db_keepalive_interval_seconds=30,
+ )
+ events = []
+ probing = asyncio.Event()
+
+ async def query(sql):
+ probing.set()
+ await asyncio.Event().wait()
+
+ async def close():
+ events.append("released")
+ if fail_cleanup:
+ raise ConnectionError("rollback failed during shutdown")
+
+ connection = SimpleNamespace(
+ exec_driver_sql=query,
+ close=close,
+ invalidate=AsyncMock(),
+ )
+ engine = SimpleNamespace(
+ connect=AsyncMock(return_value=connection),
+ dispose=AsyncMock(side_effect=lambda: events.append("disposed")),
+ )
+ monkeypatch.setattr(main, "engine", engine)
+ monkeypatch.setattr(main, "get_settings", lambda: config)
+
+ async def run():
+ async with main.lifespan(main.app):
+ await asyncio.wait_for(probing.wait(), 2)
+ if fail_request:
+ raise RuntimeError("lifespan body failed")
+
+ if fail_request:
+ with pytest.raises(RuntimeError, match="lifespan body failed"):
+ await run()
+ else:
+ await run()
+ assert events == ["released", "disposed"]
+ assert connection.invalidate.await_count == int(fail_cleanup)
+
+
+async def test_zero_interval_disables_warming(monkeypatch):
+ from app import main
+
+ config = Settings(
+ _env_file=None, database_url="postgresql://test:test@localhost/test",
+ supabase_url="http://test.invalid", supabase_jwks_url="http://test.invalid/jwks",
+ db_keepalive_interval_seconds=0,
+ )
+ probe = AsyncMock()
+ engine = SimpleNamespace(dispose=AsyncMock())
+ monkeypatch.setattr(main, "get_settings", lambda: config)
+ monkeypatch.setattr(main, "engine", engine)
+ monkeypatch.setattr(main, "keep_database_warm", probe)
+ async with main.lifespan(main.app):
+ await asyncio.sleep(0)
+ probe.assert_not_called()
+ engine.dispose.assert_awaited_once()
diff --git a/backend/tests/test_db_keepalive_postgres.py b/backend/tests/test_db_keepalive_postgres.py
new file mode 100644
index 0000000..5a262ea
--- /dev/null
+++ b/backend/tests/test_db_keepalive_postgres.py
@@ -0,0 +1,105 @@
+"""Reproduce idle expiry with real PostgreSQL, never a configured live database."""
+
+import asyncio
+from contextlib import suppress
+from time import perf_counter
+
+import pytest
+from sqlalchemy import event, text
+
+from app.config import Settings
+from app.db.session import create_db_engine
+
+
+@pytest.mark.parametrize("warming", [False, True])
+async def test_idle_request_reconnects_only_when_warming_is_disabled(database, monkeypatch, warming):
+ from app import main
+
+ config = Settings(
+ _env_file=None, database_url=database,
+ supabase_url="http://test.invalid", supabase_jwks_url="http://test.invalid/jwks",
+ db_keepalive_interval_seconds=0.2 if warming else 0,
+ )
+ engine = create_db_engine(config)
+ connections = 0
+
+ @event.listens_for(engine.sync_engine, "connect")
+ def connected(connection, _):
+ nonlocal connections
+ connections += 1
+ # Expire only this test's physical sessions, outside a transaction.
+ connection.run_async(lambda driver: driver.execute("SET idle_session_timeout = '800ms'"))
+
+ monkeypatch.setattr(main, "engine", engine)
+ monkeypatch.setattr(main, "get_settings", lambda: config)
+
+ async def sample():
+ started = perf_counter()
+ async with engine.connect() as connection:
+ assert await connection.scalar(text("SELECT 1")) == 1
+ return (perf_counter() - started) * 1000
+
+ # Start with two checked-in slots, as after a small concurrent traffic burst.
+ # Requests must reuse a warm slot even when the pool has more than one slot.
+ async with engine.connect(), engine.connect():
+ pass
+ try:
+ async with main.lifespan(main.app):
+ before = connections
+ cold_or_reused = []
+ for _ in range(3):
+ await asyncio.sleep(1.2)
+ # Measure outside a probe's checkout, so this is idle latency,
+ # not a request competing for a currently borrowed connection.
+ async with asyncio.timeout(5):
+ while engine.pool.checkedout():
+ await asyncio.sleep(0.01)
+ cold_or_reused.append(round(await sample(), 2))
+ assert connections - before == (0 if warming else 3)
+ print(f"warming={warming}, new_connections={connections-before}, request_ms={cold_or_reused}")
+ finally:
+ await engine.dispose()
+
+
+async def test_warmup_releases_transactions_and_recovers_from_a_dead_connection(database):
+ from app.db.keepalive import keep_database_warm
+
+ config = Settings(
+ _env_file=None, database_url=database,
+ supabase_url="http://test.invalid", supabase_jwks_url="http://test.invalid/jwks",
+ )
+ engine = create_db_engine(config)
+ completed = asyncio.Event()
+
+ @event.listens_for(engine.sync_engine, "after_cursor_execute")
+ def executed(*_):
+ completed.set()
+
+ task = asyncio.create_task(keep_database_warm(engine, 0.05, 5))
+ try:
+ await asyncio.wait_for(completed.wait(), 5)
+ # Cancel immediately after SELECT, potentially during rollback/return.
+ task.cancel()
+ with suppress(asyncio.CancelledError):
+ await task
+ assert engine.pool.checkedout() == 0
+ async with engine.connect() as connection:
+ driver = (await connection.get_raw_connection()).driver_connection
+ assert not driver.is_in_transaction()
+ await connection.scalar(text("SELECT 1"))
+ pid = await connection.scalar(text("SELECT pg_backend_pid()"))
+
+ # A pool slot can still die between probes; pre-ping must remain enabled.
+ killer = create_db_engine(config)
+ try:
+ async with killer.connect() as connection:
+ assert await connection.scalar(text("SELECT pg_terminate_backend(:pid)"), {"pid": pid})
+ finally:
+ await killer.dispose()
+ async with engine.connect() as connection:
+ assert await connection.scalar(text("SELECT 1")) == 1
+ finally:
+ task.cancel()
+ with suppress(asyncio.CancelledError):
+ await task
+ await engine.dispose()
diff --git a/backend/tests/test_generation.py b/backend/tests/test_generation.py
index 1342736..fe39ac7 100644
--- a/backend/tests/test_generation.py
+++ b/backend/tests/test_generation.py
@@ -137,7 +137,7 @@ async def test_unexpected_shape_is_rejected(patch_httpx):
# ---------------------------------------------------------------- pool
-async def test_generate_one_publishes_and_marks_the_backlog(session, patch_httpx):
+async def test_generate_one_publishes_and_marks_the_backlog(empty_generation_budget, session, patch_httpx):
patch_httpx(_stub_transport(_gemini_response(GOOD_SUMMARY, GOOD_EXAMPLE)))
before = await session.scalar(text("select count(*) from public.concepts where source = 'gemini'"))
@@ -158,7 +158,7 @@ async def test_generate_one_publishes_and_marks_the_backlog(session, patch_httpx
assert row.backlog_status == "done"
-async def test_failed_generation_leaves_the_item_retryable(session, patch_httpx):
+async def test_failed_generation_leaves_the_item_retryable(empty_generation_budget, session, patch_httpx):
patch_httpx(_stub_transport({}, status=500))
claimed_before = await session.scalar(
text("select count(*) from public.concept_backlog where status = 'pending'"))
@@ -178,7 +178,7 @@ async def test_failed_generation_leaves_the_item_retryable(session, patch_httpx)
assert after == claimed_before, "the item returned to the queue"
-async def test_repeated_failures_retire_the_item(session, patch_httpx):
+async def test_repeated_failures_retire_the_item(empty_generation_budget, session, patch_httpx):
patch_httpx(_stub_transport({}, status=500))
# An inactive topic of its own, so this cannot disturb the shared backlog
# that the other tests draw from.
@@ -202,7 +202,7 @@ async def test_repeated_failures_retire_the_item(session, patch_httpx):
assert row.attempts == 3
-async def test_rate_limit_releases_the_claim_and_refunds_the_attempt(session, patch_httpx):
+async def test_rate_limit_releases_the_claim_and_refunds_the_attempt(empty_generation_budget, session, patch_httpx):
patch_httpx(_stub_transport({}, status=429))
pending_before = await session.scalar(
text("select count(*) from public.concept_backlog where status = 'pending'"))
@@ -225,7 +225,7 @@ async def test_retry_delay_is_read_from_the_response():
assert generation._retry_after_seconds(response) == 12.0
-async def test_stale_generating_rows_are_reclaimed(session):
+async def test_stale_generating_rows_are_reclaimed(empty_generation_budget, session):
"""Issue #37: a row abandoned mid-generation by a crashed worker must not be
stuck in 'generating' forever — a later run reclaims it to 'pending'."""
topic_id = (await session.execute(text("""
@@ -259,7 +259,7 @@ async def test_stale_generating_rows_are_reclaimed(session):
assert rows["in-flight"] == "generating", "a fresh claim must be left alone"
-async def test_slug_collision_does_not_mark_the_backlog_done(session, patch_httpx):
+async def test_slug_collision_does_not_mark_the_backlog_done(empty_generation_budget, session, patch_httpx):
"""Issue #37: if the concept slug already exists the insert is a no-op, so the
backlog row must be flagged failed — not marked done, which would retire the
title having burned a Gemini call without publishing anything."""
@@ -295,7 +295,7 @@ async def test_slug_collision_does_not_mark_the_backlog_done(session, patch_http
assert generation._retry_after_seconds(httpx.Response(429, text="{}")) is None
-async def test_top_up_backs_off_and_retries_after_a_rate_limit(session, patch_httpx, monkeypatch):
+async def test_top_up_backs_off_and_retries_after_a_rate_limit(empty_generation_budget, session, patch_httpx, monkeypatch):
calls = {"n": 0}
def handler(request: httpx.Request) -> httpx.Response:
@@ -317,7 +317,7 @@ async def fake_sleep(seconds):
assert sleeps and sleeps[0] >= pool.BACKOFF_START_SECONDS
-async def test_top_up_gives_up_after_consecutive_rate_limits(session, patch_httpx, monkeypatch):
+async def test_top_up_gives_up_after_consecutive_rate_limits(empty_generation_budget, session, patch_httpx, monkeypatch):
patch_httpx(_stub_transport({}, status=429))
async def fake_sleep(seconds):
@@ -330,20 +330,20 @@ async def fake_sleep(seconds):
assert result.skipped_reason == "rate limited"
-async def test_top_up_respects_the_kill_switch(session):
+async def test_top_up_respects_the_kill_switch(empty_generation_budget, session):
result = await top_up(session, api_key="k", model="m", enabled=False,
minimum_per_topic=25, call_cap=200)
assert result.generated == 0
assert result.skipped_reason == "generation disabled"
-async def test_top_up_without_a_key_does_nothing(session):
+async def test_top_up_without_a_key_does_nothing(empty_generation_budget, session):
result = await top_up(session, api_key="", model="m", enabled=True,
minimum_per_topic=25, call_cap=200)
assert result.skipped_reason == "no API key configured"
-async def test_top_up_stops_at_the_call_cap(session, patch_httpx):
+async def test_top_up_stops_at_the_call_cap(empty_generation_budget, session, patch_httpx):
patch_httpx(_stub_transport(_gemini_response(GOOD_SUMMARY, GOOD_EXAMPLE)))
result = await top_up(session, api_key="k", model="gemini-2.0-flash", enabled=True,
minimum_per_topic=25, call_cap=3)
@@ -351,7 +351,7 @@ async def test_top_up_stops_at_the_call_cap(session, patch_httpx):
assert result.skipped_reason == "daily call cap reached"
-async def test_top_up_fills_only_topics_below_the_threshold(session, patch_httpx):
+async def test_top_up_fills_only_topics_below_the_threshold(empty_generation_budget, session, patch_httpx):
patch_httpx(_stub_transport(_gemini_response(GOOD_SUMMARY, GOOD_EXAMPLE)))
threshold = 6
@@ -381,6 +381,7 @@ async def test_top_up_fills_only_topics_below_the_threshold(session, patch_httpx
async def test_dry_followed_topic_schedules_prefetch_not_inline_generation(
+ empty_generation_budget,
session, user, monkeypatch
):
"""Issue #43: the request never calls Gemini inline. When a followed topic
diff --git a/backend/tests/test_generation_budget.py b/backend/tests/test_generation_budget.py
new file mode 100644
index 0000000..633ba08
--- /dev/null
+++ b/backend/tests/test_generation_budget.py
@@ -0,0 +1,92 @@
+"""A real PostgreSQL ledger must serialize competing workers and survive reruns."""
+
+import asyncio
+from datetime import date, datetime
+
+import pytest
+from sqlalchemy import text
+from sqlalchemy.exc import DBAPIError
+
+from app.services import generation_budget as budget
+from app.services.generation_budget import GenerationBudgetExhausted, reserve_generation_call
+
+pytestmark = pytest.mark.usefixtures("empty_generation_budget")
+
+
+async def usage(session):
+ return (await session.execute(text(
+ "select budget_day, calls_used from public.generation_daily_usage order by budget_day"
+ ))).all()
+
+
+async def test_concurrent_sessions_cannot_spend_the_same_slots(sessionmaker_for_test, session):
+ async def reserve():
+ async with sessionmaker_for_test() as worker:
+ try:
+ await reserve_generation_call(worker, 3)
+ await worker.commit()
+ return True
+ except GenerationBudgetExhausted:
+ await worker.rollback()
+ return False
+
+ results = await asyncio.gather(*(reserve() for _ in range(20)))
+ assert sum(results) == 3
+ assert [row.calls_used for row in await usage(session)] == [3]
+ # A later worker with a fresh session sees the spent budget too.
+ assert await reserve() is False
+
+
+async def test_zero_cap_and_rollback_do_not_spend_budget(session):
+ with pytest.raises(GenerationBudgetExhausted):
+ await reserve_generation_call(session, 0)
+ await session.rollback()
+ assert await usage(session) == []
+ await reserve_generation_call(session, 1)
+ await session.rollback()
+ assert await usage(session) == []
+ await reserve_generation_call(session, 1)
+ await session.commit()
+ assert [row.calls_used for row in await usage(session)] == [1]
+
+
+@pytest.mark.parametrize("before, after, first_day, second_day", [
+ ("2026-01-12T07:59:59+00:00", "2026-01-12T08:00:00+00:00", date(2026, 1, 11), date(2026, 1, 12)),
+ ("2026-07-12T06:59:59+00:00", "2026-07-12T07:00:00+00:00", date(2026, 7, 11), date(2026, 7, 12)),
+])
+async def test_budget_resets_at_pacific_midnight_in_winter_and_summer(
+ session, monkeypatch, before, after, first_day, second_day,
+):
+ # Replace only the database clock, retaining the production timezone/UPSERT.
+ query = text(str(budget._RESERVE).replace("statement_timestamp()", "cast(:now as timestamptz)"))
+ for now in [before, before, after]:
+ monkeypatch.setattr(budget, "_RESERVE", query.bindparams(now=datetime.fromisoformat(now)))
+ await reserve_generation_call(session, 2)
+ await session.commit()
+ assert await usage(session) == [(first_day, 2), (second_day, 1)]
+
+
+async def test_lowering_limit_does_not_erase_previous_usage(session):
+ for _ in range(3):
+ await reserve_generation_call(session, 3)
+ await session.commit()
+ with pytest.raises(GenerationBudgetExhausted):
+ await reserve_generation_call(session, 2)
+ await session.rollback()
+ assert [row.calls_used for row in await usage(session)] == [3]
+ await reserve_generation_call(session, 4)
+ await session.commit()
+ assert [row.calls_used for row in await usage(session)] == [4]
+
+
+async def test_mobile_role_cannot_read_or_forge_usage(session):
+ await reserve_generation_call(session, 2)
+ # Simulate Supabase's broad public-schema grants; RLS must still deny access.
+ await session.execute(text("grant select, insert, update, delete on public.generation_daily_usage to authenticated"))
+ await session.commit()
+ await session.execute(text("set local role authenticated"))
+ assert await usage(session) == []
+ with pytest.raises(DBAPIError, match="row-level security"):
+ await session.execute(text("insert into public.generation_daily_usage values ('2000-01-01', 0)"))
+ await session.rollback()
+ assert [row.calls_used for row in await usage(session)] == [1]
diff --git a/backend/tests/test_generation_limits.py b/backend/tests/test_generation_limits.py
new file mode 100644
index 0000000..2f5e65e
--- /dev/null
+++ b/backend/tests/test_generation_limits.py
@@ -0,0 +1,302 @@
+"""Shared call limits across real backlog transactions and mocked generation."""
+
+import asyncio
+import uuid
+from types import SimpleNamespace
+from unittest.mock import AsyncMock
+
+import pytest
+import pytest_asyncio
+from sqlalchemy import text
+from sqlalchemy.exc import DBAPIError
+
+from app.services import generation_budget as budget, pool, prefetch
+from app.services.generation import GeneratedConcept, GenerationError, RateLimitedError
+from app.services.generation_budget import GenerationBudgetExhausted
+from app.workers import rewrite_catalog as rewrite
+
+pytestmark = pytest.mark.usefixtures("empty_generation_budget")
+
+
+@pytest_asyncio.fixture
+async def topic(session, monkeypatch):
+ tid = uuid.uuid4()
+ await session.execute(text("""
+ insert into public.topics (id, slug, name, is_active)
+ values (:id, :slug, 'Budget fixture', false)
+ """), {"id": tid, "slug": f"budget-{tid}"})
+ await session.execute(text("""
+ insert into public.concept_backlog (topic_id, slug, title)
+ select :id, :prefix || n, 'Fixture ' || n from generate_series(1, 20) n
+ """), {"id": tid, "prefix": f"budget-{tid}-"})
+ await session.commit()
+ # Restrict only fixture selection; retain the real counts/claim/publish SQL.
+ monkeypatch.setattr(pool, "_POOL_COUNTS", text(str(pool._POOL_COUNTS).replace(
+ "where t.is_active", "where t.id = :tid"
+ )).bindparams(tid=tid))
+ yield tid
+ await session.rollback()
+ await session.execute(text("delete from public.concepts where topic_id = :id"), {"id": tid})
+ await session.execute(text("delete from public.topics where id = :id"), {"id": tid})
+ await session.commit()
+
+
+@pytest.fixture
+def generator(monkeypatch):
+ mock = AsyncMock(return_value=GeneratedConcept(summary="Fixture summary", example="Fixture example", model="test"))
+ monkeypatch.setattr(pool, "generate_concept", mock)
+ return mock
+
+
+async def calls_used(session):
+ return await session.scalar(text("select coalesce(sum(calls_used), 0) from public.generation_daily_usage"))
+
+
+async def top_up(session, cap):
+ return await pool.top_up(session, api_key="test", model="test", enabled=True,
+ minimum_per_topic=25, call_cap=cap)
+
+
+async def test_scheduled_runs_share_daily_limit(topic, generator, session):
+ first = await top_up(session, 2)
+ second = await top_up(session, 2)
+ assert (first.generated, second.generated) == (2, 0)
+ assert second.skipped_reason == "daily call cap reached"
+ assert generator.await_count == await calls_used(session) == 2
+
+
+async def test_quota_denial_rolls_back_claim_and_attempt(topic, generator, session):
+ with pytest.raises(GenerationBudgetExhausted):
+ await pool.generate_one(session, "test", "test", topic, call_cap=0)
+ assert not session.in_transaction()
+ assert await calls_used(session) == 0
+ statuses = (await session.execute(text(
+ "select distinct status, attempts, claimed_at from public.concept_backlog where topic_id = :id"
+ ), {"id": topic})).all()
+ assert statuses == [("pending", 0, None)]
+ generator.assert_not_awaited()
+
+
+async def test_no_backlog_does_not_spend_budget(topic, generator, session):
+ await session.execute(text("delete from public.concept_backlog where topic_id = :id"), {"id": topic})
+ await session.commit()
+ assert await pool.generate_one(session, "test", "test", topic, call_cap=2) is None
+ assert await calls_used(session) == 0
+ generator.assert_not_awaited()
+
+
+async def test_reservation_is_committed_before_provider_call(topic, generator, session, sessionmaker_for_test):
+ async def generate(**kwargs):
+ assert not session.in_transaction(), "no connection stays pinned during generation"
+ async with sessionmaker_for_test() as observer:
+ assert await calls_used(observer) == 1
+ return GeneratedConcept(summary="Fixture", example="Fixture", model="test")
+ generator.side_effect = generate
+ assert await pool.generate_one(session, "test", "test", topic, call_cap=1)
+
+
+@pytest.mark.parametrize("failure", [GenerationError("bad response"), RateLimitedError(), asyncio.CancelledError()])
+async def test_failed_throttled_or_cancelled_calls_keep_their_reservation(topic, generator, session, failure):
+ generator.side_effect = failure
+ if type(failure) is GenerationError:
+ assert await pool.generate_one(session, "test", "test", topic, call_cap=1) is None
+ else:
+ with pytest.raises(type(failure)):
+ await pool.generate_one(session, "test", "test", topic, call_cap=1)
+ with pytest.raises(GenerationBudgetExhausted):
+ await pool.generate_one(session, "test", "test", topic, call_cap=1)
+ assert generator.await_count == await calls_used(session) == 1
+ attempts = await session.scalar(text(
+ "select sum(attempts) from public.concept_backlog where topic_id = :id"
+ ), {"id": topic})
+ assert attempts == (0 if isinstance(failure, RateLimitedError) else 1)
+
+
+async def test_parallel_generation_keeps_claims_and_budget_consistent(topic, generator, session, sessionmaker_for_test):
+ async def generate():
+ async with sessionmaker_for_test() as worker:
+ try:
+ return await pool.generate_one(worker, "test", "test", topic, call_cap=3)
+ except GenerationBudgetExhausted:
+ return None
+ results = await asyncio.gather(*(generate() for _ in range(12)))
+ assert len({result for result in results if result}) == 3
+ assert generator.await_count == await calls_used(session) == 3
+ attempts = await session.scalar(text(
+ "select sum(attempts) from public.concept_backlog where topic_id = :id"
+ ), {"id": topic})
+ assert attempts == 3
+
+
+async def test_budget_database_error_prevents_generation_and_releases_claim(topic, generator, session, monkeypatch):
+ monkeypatch.setattr(budget, "_RESERVE", text("select 1 / 0"))
+ with pytest.raises(DBAPIError):
+ await pool.generate_one(session, "test", "test", topic, call_cap=2)
+ assert not session.in_transaction()
+ generator.assert_not_awaited()
+ assert await calls_used(session) == 0
+ assert await session.scalar(text(
+ "select sum(attempts) from public.concept_backlog where topic_id = :id"
+ ), {"id": topic}) == 0
+
+
+async def test_failed_budget_commit_never_calls_provider(topic, generator, session, monkeypatch):
+ with monkeypatch.context() as patch:
+ patch.setattr(session, "commit", AsyncMock(side_effect=RuntimeError("commit failed")))
+ with pytest.raises(RuntimeError, match="commit failed"):
+ await pool.generate_one(session, "test", "test", topic, call_cap=2)
+ generator.assert_not_awaited()
+ assert await calls_used(session) == 0
+
+
+
+def test_negative_daily_cap_is_rejected():
+ from pydantic import ValidationError
+ from app.config import Settings
+ with pytest.raises(ValidationError, match="generation_daily_call_cap"):
+ Settings(_env_file=None, database_url="postgresql://test:test@localhost/test",
+ supabase_url="http://test.invalid", supabase_jwks_url="http://test.invalid/jwks",
+ generation_daily_call_cap=-1)
+
+
+@pytest.fixture
+def prefetch_config(monkeypatch, sessionmaker_for_test):
+ config = SimpleNamespace(generation_enabled=True, generation_on_demand=True,
+ gemini_api_key="test", gemini_model="test", generation_daily_call_cap=2)
+ monkeypatch.setattr(prefetch, "get_settings", lambda: config)
+ monkeypatch.setattr(prefetch, "SessionLocal", sessionmaker_for_test)
+ return config
+
+
+async def run_prefetch(topic):
+ prefetch.request_prefetch(topic)
+ await asyncio.gather(*list(prefetch._tasks))
+ assert topic not in prefetch._inflight
+
+
+async def test_prefetch_zero_cap_makes_no_provider_call(topic, generator, session, prefetch_config, caplog):
+ prefetch_config.generation_daily_call_cap = 0
+ with caplog.at_level("INFO"):
+ await run_prefetch(topic)
+ generator.assert_not_awaited()
+ assert await calls_used(session) == 0
+ assert "daily call cap reached" in caplog.text
+ assert not any(record.levelname == "ERROR" for record in caplog.records)
+
+
+@pytest.mark.parametrize("prefetch_first", [True, False])
+async def test_scheduled_and_prefetch_share_remaining_budget(topic, generator, session, prefetch_config, prefetch_first):
+ if prefetch_first:
+ await run_prefetch(topic)
+ assert (await top_up(session, 2)).generated == 0
+ else:
+ assert (await top_up(session, 2)).generated == 2
+ await run_prefetch(topic)
+ # Re-triggering a completed background job cannot restore its allowance.
+ await run_prefetch(topic)
+ assert generator.await_count == await calls_used(session) == 2
+
+
+async def test_prefetch_and_worker_compete_for_the_same_last_slots(topic, generator, session, prefetch_config):
+ await asyncio.gather(run_prefetch(topic), top_up(session, 2))
+ assert generator.await_count == await calls_used(session) == 2
+
+
+@pytest.mark.parametrize("disabled", ["generation_enabled", "generation_on_demand", "gemini_api_key"])
+async def test_prefetch_preserves_generation_switches(topic, generator, session, prefetch_config, disabled):
+ setattr(prefetch_config, disabled, "" if disabled == "gemini_api_key" else False)
+ await run_prefetch(topic)
+ generator.assert_not_awaited()
+ assert await calls_used(session) == 0
+
+
+async def test_prefetch_rate_limit_spends_quota_but_refunds_backlog_attempt(topic, generator, session, prefetch_config):
+ generator.side_effect = RateLimitedError()
+ await run_prefetch(topic)
+ assert generator.await_count == await calls_used(session) == 1
+ assert await session.scalar(text(
+ "select sum(attempts) from public.concept_backlog where topic_id = :id"
+ ), {"id": topic}) == 0
+
+
+@pytest_asyncio.fixture
+async def rewrite_config(topic, session, generator, sessionmaker_for_test, monkeypatch):
+ config = SimpleNamespace(generation_enabled=True, gemini_api_key="test", gemini_model="test",
+ generation_daily_call_cap=2)
+ await session.execute(text("""
+ insert into public.concepts (topic_id, slug, title, summary)
+ select :tid, :prefix || n, 'Original title ' || n, 'Original summary'
+ from generate_series(1, 3) n
+ """), {"tid": topic, "prefix": f"rewrite-{topic}-"})
+ await session.commit()
+ monkeypatch.setattr(rewrite, "get_settings", lambda: config)
+ monkeypatch.setattr(rewrite, "SessionLocal", sessionmaker_for_test)
+ monkeypatch.setattr(rewrite, "generate_concept", generator)
+ monkeypatch.setattr(rewrite, "_TODO", text(str(rewrite._TODO).replace(
+ "where c.status = 'published'", "where c.status = 'published' and c.topic_id = :tid"
+ )).bindparams(tid=topic))
+ monkeypatch.setattr(rewrite, "engine", SimpleNamespace(dispose=AsyncMock()))
+ monkeypatch.setattr(rewrite, "asyncio", SimpleNamespace(sleep=AsyncMock()))
+ return config
+
+
+async def rewritten_count(session, topic):
+ return await session.scalar(text("""
+ select count(*) from public.concepts where topic_id = :tid and prompt_version = :pv
+ """), {"tid": topic, "pv": rewrite.PROMPT_VERSION})
+
+
+async def test_rewrite_reruns_and_scheduled_jobs_share_budget(topic, generator, session, rewrite_config):
+ await rewrite.main()
+ await rewrite.main()
+ assert await rewritten_count(session, topic) == 2
+ assert (await top_up(session, 2)).generated == 0
+ assert generator.await_count == await calls_used(session) == 2
+ assert rewrite.engine.dispose.await_count == 2
+
+
+async def test_scheduled_budget_denial_leaves_catalog_unchanged(topic, generator, session, rewrite_config):
+ assert (await top_up(session, 2)).generated == 2
+ generator.reset_mock()
+ await rewrite.main()
+ generator.assert_not_awaited()
+ assert await session.scalar(text("""
+ select count(*) from public.concepts where topic_id = :tid and summary = 'Original summary'
+ """), {"tid": topic}) == 3
+ rewrite.engine.dispose.assert_awaited_once()
+
+
+@pytest.mark.parametrize("disabled", ["generation_enabled", "gemini_api_key", "generation_daily_call_cap"])
+async def test_rewrite_preserves_switches_and_zero_budget(topic, generator, session, rewrite_config, disabled):
+ setattr(rewrite_config, disabled, "" if disabled == "gemini_api_key" else 0)
+ await rewrite.main()
+ generator.assert_not_awaited()
+ assert await calls_used(session) == 0
+ assert await rewritten_count(session, topic) == 0
+ rewrite.engine.dispose.assert_awaited_once()
+
+
+async def test_rewrite_throttling_retry_spends_each_call(topic, generator, session, rewrite_config):
+ generator.side_effect = [RateLimitedError(), GeneratedConcept(summary="Fixture", example="Fixture", model="test")]
+ await rewrite.main()
+ assert generator.await_count == await calls_used(session) == 2
+ assert await rewritten_count(session, topic) == 1
+ assert rewrite.asyncio.sleep.await_count >= 1
+
+
+async def test_rewrite_database_failure_never_calls_provider(topic, generator, session, rewrite_config, monkeypatch):
+ monkeypatch.setattr(budget, "_RESERVE", text("select 1 / 0"))
+ with pytest.raises(DBAPIError):
+ await rewrite.main()
+ generator.assert_not_awaited()
+ assert await calls_used(session) == 0
+ rewrite.engine.dispose.assert_awaited_once()
+
+
+async def test_rewrite_cancelled_provider_keeps_budget_and_cleans_up(topic, generator, session, rewrite_config):
+ generator.side_effect = asyncio.CancelledError()
+ with pytest.raises(asyncio.CancelledError):
+ await rewrite.main()
+ assert await calls_used(session) == 1
+ assert await rewritten_count(session, topic) == 0
+ rewrite.engine.dispose.assert_awaited_once()
diff --git a/backend/tests/test_state_pagination.py b/backend/tests/test_state_pagination.py
new file mode 100644
index 0000000..eb63eed
--- /dev/null
+++ b/backend/tests/test_state_pagination.py
@@ -0,0 +1,157 @@
+"""Large accounts retain totals and full collection access with compact startup."""
+
+import uuid
+
+import pytest_asyncio
+from httpx import ASGITransport, AsyncClient
+from sqlalchemy import text
+
+from app.db.session import get_db
+from app.deps import CurrentUser, get_current_user
+from app.main import app
+
+
+@pytest_asyncio.fixture
+async def collection_client(session, sessionmaker_for_test, user):
+ prefix = f"collection-{user}-"
+ await session.execute(text("""
+ insert into public.concepts (topic_id, slug, title, summary)
+ select (select id from public.topics where slug = 'computer-science'),
+ :prefix || n, 'Lesson ' || n, 'Fixture summary'
+ from generate_series(1, 365) n
+ """), {"prefix": prefix})
+ await session.execute(text("""
+ insert into public.daily_assignments (user_id, concept_id, assigned_for, completed_at)
+ select :uid, id, current_date - cast(substring(slug from length(:prefix) + 1) as int), now()
+ from public.concepts where starts_with(slug, :prefix)
+ """), {"prefix": prefix, "uid": user})
+ await session.execute(text("""
+ insert into public.concept_interactions (user_id, concept_id, liked_at, saved_at)
+ select :uid, id, now(), now()
+ from public.concepts where starts_with(slug, :prefix)
+ """), {"prefix": prefix, "uid": user})
+ await session.commit()
+
+ async def db():
+ async with sessionmaker_for_test() as value:
+ yield value
+
+ app.dependency_overrides[get_db] = db
+ app.dependency_overrides[get_current_user] = lambda: CurrentUser(id=user, email="fixture@example.invalid")
+ try:
+ async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
+ yield client
+ finally:
+ app.dependency_overrides.clear()
+
+
+async def test_compact_state_bounds_details_without_losing_totals(collection_client):
+ legacy = await collection_client.get("/v1/me/state")
+ compact = await collection_client.get("/v1/me/state?compact=true")
+ assert legacy.status_code == compact.status_code == 200
+ before, after = legacy.json(), compact.json()
+ print(f"state bytes: legacy={len(legacy.content)}, compact={len(compact.content)}; "
+ f"detail rows: learned={len(after['learned'])}, saved={len(after['saved'])}")
+ assert len(before["learned"]) == len(before["saved"]) == 365
+ assert len(after["learned"]) == len(after["saved"]) == 50
+ assert len(after["likes"]) == len(after["bookmarks"]) == 365
+ assert after["stats"] == before["stats"] == {"current": 365, "longest": 365, "total_learned": 365}
+ assert after["learned_before_window"] == {"Computer Science": 315}
+ assert after["history_next_cursor"] and after["saved_next_cursor"]
+ assert len(compact.content) < len(legacy.content) / 2
+
+
+async def test_pages_cover_history_and_tied_saves(collection_client):
+ state = (await collection_client.get('/v1/me/state?compact=true')).json()
+ for path, field, cursor_field in (
+ ('history', 'learned', 'history_next_cursor'),
+ ('saved', 'saved', 'saved_next_cursor'),
+ ):
+ items = state[field][:]
+ cursor = state[cursor_field]
+ while cursor:
+ response = await collection_client.get(f'/v1/me/{path}', params={'cursor': cursor, 'limit': 37})
+ assert response.status_code == 200, response.text
+ page = response.json()
+ assert 0 < len(page['items']) <= 37
+ items.extend(page['items'])
+ cursor = page['next_cursor']
+ assert len(items) == len({item['concept_slug'] for item in items}) == 365
+ assert all(item['like_count'] == 0 for item in items) # own likes excluded
+ full = (await collection_client.get('/v1/me/state')).json()[field]
+ assert items == full
+
+
+async def test_state_mutations_support_compact_clients(collection_client):
+ for response in (
+ await collection_client.put('/v1/me/topics?compact=true', json={'topics': ['computer-science']}),
+ await collection_client.patch('/v1/me?compact=true', json={'display_name': 'Updated'}),
+ ):
+ assert response.status_code == 200, response.text
+ assert len(response.json()['learned']) == len(response.json()['saved']) == 50
+ assert response.json()['learned_before_window'] == {'Computer Science': 315}
+ legacy = await collection_client.patch('/v1/me', json={'display_name': 'Legacy'})
+ assert len(legacy.json()['learned']) == len(legacy.json()['saved']) == 365
+
+
+async def test_empty_and_other_user_collections(collection_client, session, user):
+ from app.services.collections import saved_cursor
+ from datetime import datetime, timezone
+
+ # A cursor contains ordering only; it cannot choose which user's rows to read.
+ other = uuid.uuid4()
+ app.dependency_overrides[get_current_user] = lambda: CurrentUser(id=other, email='other@example.invalid')
+ for path in ('saved', 'history'):
+ result = await collection_client.get(f'/v1/me/{path}')
+ assert result.json() == {'items': [], 'next_cursor': None}
+ cursor = saved_cursor(datetime.now(timezone.utc).isoformat(), str(user))
+ assert (await collection_client.get('/v1/me/saved', params={'cursor': cursor})).json()['items'] == []
+
+
+async def test_cursor_validation_and_limits(collection_client):
+ import base64
+ import json
+
+ for cursor in ('not-a-cursor', base64.urlsafe_b64encode(json.dumps([
+ '2026-01-01T00:00:00', str(uuid.uuid4()),
+ ]).encode()).decode(), base64.urlsafe_b64encode(b'[42, 42]').decode()):
+ result = await collection_client.get('/v1/me/saved', params={'cursor': cursor})
+ assert result.status_code == 400
+ assert (await collection_client.get('/v1/me/history?cursor=bad')).status_code == 422
+ for path in ('saved', 'history'):
+ for limit in (0, 101):
+ assert (await collection_client.get(f'/v1/me/{path}?limit={limit}')).status_code == 422
+ response = await collection_client.get(f'/v1/me/{path}?limit=100')
+ assert len(response.json()['items']) == 100
+
+
+async def test_saved_deletion_between_pages_does_not_skip_rows(collection_client, session, user):
+ first = (await collection_client.get('/v1/me/saved?limit=10')).json()
+ await session.execute(text('''
+ update public.concept_interactions set saved_at = null
+ where user_id = :uid and concept_id = (
+ select id from public.concepts where slug = :slug
+ )
+ '''), {'uid': user, 'slug': first['items'][-1]['concept_slug']})
+ await session.commit()
+ second = (await collection_client.get('/v1/me/saved', params={
+ 'cursor': first['next_cursor'], 'limit': 100,
+ })).json()
+ all_remaining = (await collection_client.get('/v1/me/state')).json()['saved']
+ assert second['items'] == all_remaining[9:109]
+
+
+async def test_exact_window_has_no_cursor(collection_client, session, user):
+ await session.execute(text('''
+ delete from public.daily_assignments where user_id = :uid
+ and assigned_for < current_date - 50
+ '''), {'uid': user})
+ await session.execute(text('''
+ update public.concept_interactions set saved_at = null where user_id = :uid
+ and concept_id not in (select concept_id from public.daily_assignments where user_id = :uid)
+ '''), {'uid': user})
+ await session.commit()
+ state = (await collection_client.get('/v1/me/state?compact=true')).json()
+ assert len(state['learned']) == len(state['saved']) == 50
+ assert state['learned_before_window'] == {}
+ assert state['history_next_cursor'] is state['saved_next_cursor'] is None
diff --git a/docs/CODEBASE_MAP.md b/docs/CODEBASE_MAP.md
index d4505f8..ce4544c 100644
--- a/docs/CODEBASE_MAP.md
+++ b/docs/CODEBASE_MAP.md
@@ -18,6 +18,7 @@ learned history, streaks, likes, saved concepts, and push reminders.
| Operations | `.github/workflows/`, `backend/railway.json`, `backend/Dockerfile`, `mobile/eas.json`, `mobile/app.config.js`. |
| Documentation | Root `README.md`, `RELEASING.md`, `CONTRIBUTING.md`, `docs/ARCHITECTURE.md`, `docs/ROADMAP.md`, and the backend/mobile guides. |
| Agent guidance | Root `AGENTS.md`; `mobile/AGENTS.md` adds Expo documentation requirements and `mobile/CLAUDE.md` references it. |
+| Authentication email | `backend/email-templates/` contains branded signup, recovery, and password-changed HTML; `docs/EMAIL_TEMPLATES.md` covers manual Supabase installation and activation checks. Templates use the configured sender and are not installed by app deployment. |
## Mobile navigation and presentation
@@ -33,9 +34,9 @@ inside a root stack, with a concept-detail modal above them.
| `StatsScreen.tsx` | Streak and topic statistics. |
| `ProfileScreen.tsx` | Account, reminder preferences, theme, sign-out, and links to profile subpages. |
| `PersonalizationScreen.tsx` | Server topic catalog and follow controls through `useTopics`. |
-| `SavedScreen.tsx` | Saved concepts and detail navigation. |
-| `ConceptDetailScreen.tsx` | Full lesson fetched by slug, with bundled catalog fallback. |
-| `AuthScreen.tsx` | Sign-in, sign-up, and password recovery. |
+| `SavedScreen.tsx` | Recent/cached saved concepts, older metadata pagination, search/category filters, and detail navigation. |
+| `ConceptDetailScreen.tsx` | Cached full lesson first, then online refresh by slug; bundled catalog fallback. |
+| `AuthScreen.tsx` | Sign-in, sign-up, password recovery, and accessible show/hide password controls that reset on mode changes or submission. |
| `AboutScreen.tsx` | Branding and app information. |
All screens live in `mobile/src/screens/`. Reusable presentation in
@@ -61,26 +62,46 @@ typography, shadows, and scaling; `ThemeContext` persists light/dark preference.
`api/fetchWithTimeout.ts` bounds API and auth fetches to 15 seconds.
- `ProgressContext.tsx` is the shared UI state owner. It loads cached state
before revalidation, applies optimistic actions, serializes mutation requests,
- and flushes queued work on foreground/connectivity events.
+ and flushes queued work on the same mutation chain. `services/syncLoop.ts`
+ retries while offline or actions remain, using 5–30 second backoff, including
+ when only some requests succeed. Daily refreshes preserve pending actions;
+ account/source changes invalidate them and clear the displayed state. Foreground
+ and browser reconnect events wake an idle loop immediately; backgrounding
+ pauses timers.
+ This remains compatible with the current APK and has no closed-app worker.
Screen retries use its serialized `refresh`; topic and detail screens have
their own retry paths. Failed loads do not substitute demo lessons or totals
for an authenticated account.
- `services/progressRepository.ts` defines the persistence interface.
`remoteProgressRepository.ts` implements API state, account caching, optimistic
- offline fallbacks, and replay. `localProgressRepository.ts` and `storage.ts`
+ offline fallbacks, and replay. It opts into compact startup metadata; Stats
+ uses full server totals plus older-topic counts through `progressTotals.ts`.
+ `pendingProgress.ts` retains unacknowledged
+ likes, saves, and same-day completions during server reconciliation.
+ `localProgressRepository.ts` and `storage.ts`
retain local/demo support; this is not a separate visible guest navigation flow.
-- `mutationQueue.ts` stores the latest intent per like/save/topic/completion key
- in AsyncStorage. Replay discards stale-day completions, retains retryable
- failures, and reconciles state. It does not backdate server completion.
+- `mutationQueue.ts` wires AsyncStorage to `mutationOutbox.ts`, which serializes
+ disk writes and stores the latest intent per like/save/topic/completion key.
+ Replay discards stale-day completions, retains retryable failures, and
+ reconciles state. It does not backdate server completion.
- `accountCaches.ts` centralizes account cache cleanup. The remote repository's
- epoch guards prevent some late results from being persisted after a wipe.
+ epoch guards reject late mutation callbacks after a wipe; the API invalidates
+ requests still waiting for an old account's token during cleanup.
Device theme/demo state is separate from account data.
- `dailyApi.ts` maps server concepts to UI types and clears an old daily cache;
- current daily data arrives in `/v1/me/state`. `conceptApi.ts` fetches full
- concepts by slug. UI concept IDs are slugs, while the database also has UUIDs.
-- `hooks/useTopics.ts` and `services/topicsApi.ts` load the dynamic server catalog
- and replace follow sets. This path is separate from the progress repository's
- queued topic mutations; inspect the caller before assuming offline support.
+ current daily data arrives in `/v1/me/state`. `conceptApi.ts` persists full
+ lessons by slug, including each cached daily lesson and missing saved lessons
+ downloaded with three workers. Offline reading requires a completed download.
+ `offlineCache.ts` provides per-entry storage and fences late writes on sign-out.
+ UI concept IDs are slugs, while the database also has UUIDs.
+- `hooks/useSavedConcepts.ts` loads older Saved metadata in 50-record pages on
+ that screen, retaining full search/filter access. `services/savedApi.ts` owns
+ its account-keyed disk cache; `accountCaches.ts` clears it and invalidates late
+ writes. Missing offline metadata can be recovered from downloaded lesson bodies.
+- `hooks/useTopics.ts`, `services/topicsApi.ts`, and `topicStore.ts` share the
+ cached dynamic topic catalog. Follow changes enter the same durable outbox as
+ other actions; queued choices override stale server responses until replay.
+ Both the catalog and full-concept cache participate in account cleanup.
- `services/notifications.ts` handles permissions, Android channel setup, Expo
tokens, timezone sync, preference caching, and deregistration before sign-out.
- `data/concepts.ts`, `services/dailyConcept.ts`, `dates.ts`, `streak.ts`, and
@@ -88,14 +109,20 @@ typography, shadows, and scaling; `ThemeContext` persists light/dark preference.
- `data/whatsNew.ts`, `hooks/useWhatsNew.ts`, and `services/whatsNewStore.ts` control
version announcements. Every release includes a matching one-time card focused
on new features and user-visible improvements, per `RELEASING.md`.
+ `components/WhatsNewCard.tsx` scrolls long highlight lists independently of the
+ heading/dismissal controls so small screens can reach every item.
- `src/types/index.ts` defines shared concept, progress, daily, history, and
streak types. API payloads also have types near their service consumers.
## Backend request and service flow
`main.py` configures CORS, routes, production documentation visibility, a shared
-JWKS cache, and engine cleanup. `config.py` loads settings and normalizes pooler
-URLs; `db/session.py` creates the async engine/session dependency.
+JWKS cache, and engine cleanup. Its lifespan owns `db/keepalive.py`'s configurable
+database probes; checkout/query and connection return are bounded, failures retry,
+and cancellation awaits cleanup before engine disposal. `config.py` loads settings
+and normalizes pooler URLs; `db/session.py` creates the async engine/session
+dependency and reuses the most recently returned connection to keep a hot slot.
+The existing pre-ping, transaction pooler mode, and pool limits remain in place.
`deps.py` obtains identity from bearer tokens verified by `core/security.py`
(ES256, issuer, audience, expiry, and subject). `core/errors.py` formats auth errors.
@@ -104,7 +131,8 @@ URLs; `db/session.py` creates the async engine/session dependency.
| `health.py`: `GET /health` | Liveness plus a database query. |
| `topics.py`: `GET /v1/topics` | Active topics, published counts, follow state. |
| `daily.py`: `GET /v1/daily`, `POST /v1/daily/complete` | Selection, completion, server-derived date and streaks. Exhaustion returns 409 with `catalog_exhausted`. |
-| `me.py`: `GET /v1/me/state`, `/stats` | Aggregate app state, with today's lesson folded into the state response. |
+| `me.py`: `GET /v1/me/state`, `/stats` | Optional compact state, exact totals, today's lesson. |
+| `me.py`: `GET /v1/me/history`, `/saved` | Cursor pages through `services/collections.py`; default 50, maximum 100 items. |
| `me.py`: `PUT /v1/me/topics`, `PATCH /v1/me` | Whole-set follows, profile name, PostgreSQL-validated timezone. |
| `me.py`: `GET/PUT /v1/me/notifications`, `POST/DELETE /v1/me/push-token` | Reminder preferences and scoped device registration/removal. |
| `concepts.py`: `GET /v1/concepts/{slug}`, `PUT/DELETE .../like`, `.../save` | Published lesson detail and independent interaction writes. |
@@ -120,7 +148,10 @@ models live in `schemas/daily.py`, `me.py`, `notifications.py`, and `topics.py`.
- `services/state.py` aggregates profile, follows, learned/saved metadata, likes,
assignment slug, and derived streaks in one SQL statement. The `/me/state`
handler then calls selection separately to add `daily`; one HTTP request does
- not mean one database statement for the entire endpoint.
+ not mean one database statement for the entire endpoint. Compact clients get
+ at most 50 enriched learned/saved rows, older-topic counts and continuation
+ cursors; bare membership and aggregate streak/totals remain complete. Legacy
+ clients keep the full detail lists until upgraded.
- `services/interactions.py` implements likes/saves, full-set follows, and
idempotent completion of the most recent assignment from today or yesterday.
Yesterday's grace applies when there is no newer assignment; older days cannot
@@ -131,23 +162,30 @@ models live in `schemas/daily.py`, `me.py`, `notifications.py`, and `topics.py`.
- `services/generation.py` builds versioned prompts, calls Gemini through httpx,
validates output, and exposes rate-limit errors. `pool.py` claims backlog work
with `FOR UPDATE SKIP LOCKED`, publishes validated rows, refunds throttled
- attempts, reclaims stale work, and applies pacing/retry/call limits.
+ attempts and reclaims stale work. Claims and daily call reservations commit
+ together before contacting the provider; quota denial rolls back the claim.
+- `services/generation_budget.py` atomically reserves from the shared
+ `generation_daily_usage` ledger using PostgreSQL's Pacific calendar day. All
+ API prefetch, scheduled refill, and manual rewrite calls share this budget.
+ Failed/uncertain calls retain their reservation; restarts do not reset it.
- `services/prefetch.py` schedules bounded background top-ups, with a low unread
watermark, a published-count target, and per-process in-flight topic tracking.
+ Exhausted shared budget is a normal stop condition.
- `services/reminders.py` claims due user/day/time slots before sending Expo push
batches, handles timezone and midnight windows, suppresses completed days, and
drops unregistered device tokens. A claimed but failed send can miss a reminder.
- `workers/pool_topup.py` and `workers/reminders.py` are cron entry points.
`workers/rewrite_catalog.py` is a maintenance command that rewrites existing
- lessons through Gemini; do not run it merely to inspect the project.
+ lessons through Gemini with the shared budget and generation kill switch;
+ do not run it merely to inspect the project.
## Schema and migrations
`db/models.py` mirrors the SQL schema; migrations are the schema authority.
-The ten tables cover profiles, topics, concepts, user topics, daily assignments,
+The eleven tables cover profiles, topics, concepts, user topics, daily assignments,
concept interactions, notification preferences, device tokens, the concept
-backlog, and reminder logs. Unique constraints enforce one daily assignment and
-no concept repeats per user. RLS adds isolation behind backend identity checks.
+backlog, reminder logs, and shared daily generation usage. Unique constraints
+enforce one daily assignment and no concept repeats per user. RLS adds isolation behind backend identity checks.
| Migration | Purpose |
| --- | --- |
@@ -158,24 +196,29 @@ no concept repeats per user. RLS adds isolation behind backend identity checks.
| `0007_reminder_log.sql` | Unique reminder claims. |
| `0008_backlog_claimed_at.sql` | Timestamp for reclaiming abandoned generation. |
| `0009_like_count_index.sql` | Index for public like counts. |
+| `0010_generation_daily_usage.sql` | Backend-only daily Gemini call reservations shared by all generation paths. |
-All nine filenames are recorded in `migrations/applied.txt` in this checkout.
-That is repository evidence, not an independent check of production. Application
-connections use the transaction pooler; migration DDL uses `DIRECT_URL` and the
-session pooler. Applied migrations must not be rewritten.
+All ten filenames are recorded in `migrations/applied.txt`; migration 0010 was
+applied and independently verified in production during the 1.8.0 release
+follow-up. The ledger is repository evidence, not a live check of production.
+Application connections use the transaction pooler; migration DDL uses `DIRECT_URL`
+and the session pooler. Applied migrations must not be rewritten.
## Builds, checks, and releases
- Mobile dependencies/scripts are in `mobile/package.json` and `package-lock.json`;
use npm. `npm run typecheck` runs `tsc --noEmit`; `npm test` uses Node 24's
- built-in runner for session recovery, auth messages, and request timeouts.
+ built-in runner for session recovery, auth messages, request timeouts, offline
+ cache cleanup, outbox ordering, topic persistence, and sync scheduling.
Expo provides Android/iOS/web development commands. Native project folders are
not tracked. EAS profiles separate development, preview, production, and production APKs.
- Backend dependencies are pinned in `requirements.txt`/`requirements-dev.txt`.
From `backend/`, run `.venv/bin/python -m uvicorn app.main:app --reload --port 8000`
for development and `.venv/bin/python -m pytest` for tests after configuration.
-- Seven test modules cover HTTP contracts, token validation, daily selection,
- writes/streaks, generation, reminders, and notification preferences.
+- Nine test modules cover HTTP contracts, token validation, daily selection,
+ writes/streaks, generation, reminders, notification preferences, and connection
+ warm-up/cleanup. The pool integration checks compare real PostgreSQL idle expiry
+ with warming disabled/enabled and print timing plus physical-connection counts.
`tests/conftest.py` supplies a disposable PostgreSQL 16 database through Podman
on port 55433, applies every migration, and disables live generation. HTTP calls
to Gemini/Expo are mocked. Database-dependent tests skip if Podman cannot start.
@@ -191,7 +234,7 @@ session pooler. Applied migrations must not be rewritten.
findings as issues. `cleanup.yml` manages stale issues; Dependabot schedules
dependency updates with Expo-managed version restrictions. The checked-in
workflows do not include a general PR pytest job.
-- `mobile/app.config.js` currently has app version `1.7.1` and native runtime
+- `mobile/app.config.js` currently has app version `1.8.0` and native runtime
`1.3.0`; `package.json`'s `1.0.0` is not the release-version authority.
## Documentation drift to remember
@@ -207,8 +250,8 @@ the implementation or older documentation:
only. Both are used by the authenticated application today.
- `mobile/DEPLOYMENT.md` describes an older OTA trigger and fewer workflows.
Use current workflow YAML plus `RELEASING.md` to trace release behavior.
-- The roadmap still lists offline reading as future work although cached state
- and queued progress writes exist. This does not establish complete offline
- coverage for every screen (topic personalization has its own request path).
+- The roadmap's older offline milestones predate the current full-lesson cache,
+ cached personalization, and foreground queue synchronization. Closed-app OS
+ background scheduling remains outside the current APK's capabilities.
- The backend README's test-count/phase notes are historical. See
[WORK_LOG.md](WORK_LOG.md) for the actual local validation baseline.
diff --git a/docs/EMAIL_TEMPLATES.md b/docs/EMAIL_TEMPLATES.md
new file mode 100644
index 0000000..4e429d9
--- /dev/null
+++ b/docs/EMAIL_TEMPLATES.md
@@ -0,0 +1,68 @@
+# One Concept email templates
+
+These three templates customize emails sent by Supabase Auth. They work with
+the project's configured sender; installing the HTML does not require Resend,
+a purchased domain, or an app update. Provider setup remains separate in
+[draft PR #187](https://github.com/Coding-Moves/one-concept/pull/187).
+
+**Merging or releasing the app does not install these files in Supabase.**
+Paste the templates into the dashboard to change future emails. The repository
+stores their reviewed source; it does not synchronize production settings.
+
+## Install
+
+1. Open the correct Supabase project, then **Authentication → Emails → Templates**.
+2. Open each template listed below and set its subject exactly as shown.
+3. Replace the body with the entire raw HTML file, starting with ``.
+ Copy the file's source, not its rendered browser preview, and save.
+4. Under **Security → Password changed**, also enable the notification and save.
+
+| Supabase template | Subject | HTML source |
+| --- | --- | --- |
+| Confirm sign up | `Confirm your email — One Concept` | [confirm-signup.html](../backend/email-templates/confirm-signup.html) |
+| Reset password | `Reset your password — One Concept` | [reset-password.html](../backend/email-templates/reset-password.html) |
+| Password changed (Security) | `Your password was changed — One Concept` | [password-changed.html](../backend/email-templates/password-changed.html) |
+
+Keep every `{{ .ConfirmationURL }}` placeholder in signup and recovery. Supabase
+fills in the appropriate verification URL; the button and copyable fallback
+must both pass through that verification. Password changed reports a completed
+change and intentionally has no token or reset-link placeholder. It directs
+unrecognized changes to Forgot password in the app and the existing support email.
+
+Preserve the project's existing working redirect configuration. Signup uses the
+configured Site URL for `/confirmed`; recovery requests `/reset-password` on the
+app's API base URL. The corresponding HTTPS destinations must be allowed in
+Supabase's URL Configuration. Do not replace verification links with those
+landing-page URLs. See [Supabase email templates](https://supabase.com/docs/guides/auth/auth-email-templates).
+
+## Sending and activation
+
+Template customization and sending capacity are separate. Supabase's default
+sender is for testing: it currently sends only to project-team addresses and
+allows two emails per hour. Other recipients require custom SMTP, which can be
+the owner's existing Gmail setup once configured and tested. These templates
+do not remove sender limits or establish delivery. See
+[Supabase SMTP documentation](https://supabase.com/docs/guides/auth/auth-smtp)
+(checked 2026-09-12).
+
+Before announcing the new emails to users, test with accounts and inboxes you
+control under the configured sender's limits:
+
+- Confirm signup, follow the email's button, and successfully sign in to the app.
+- Request Forgot password, set a new password through the recovery email, and
+ sign in with it. Verify the separate Password changed notification arrives.
+- Check the design in a real inbox on phone and desktop, the full fallback links,
+ and the Coding Moves organization and support destinations. Use a second inbox
+ provider and a non-team recipient when custom SMTP is available.
+
+Record the results before treating production activation as complete. Local
+browser previews verify layout, not Gmail/Outlook rendering or live delivery.
+No production settings or messages are changed by the template PR itself.
+
+## Design
+
+The dark masthead uses the owner's selected One Concept text branding and links
+Coding Moves once to its GitHub organization. The app's current image assets are
+Expo starter icons, so the emails do not use them as a logo. Inline styles,
+presentation tables, system fonts, and text links need no external assets.
+Subjects are separate dashboard fields; the HTML comments do not configure them.
diff --git a/docs/WORK_LOG.md b/docs/WORK_LOG.md
index 2594d07..74175b7 100644
--- a/docs/WORK_LOG.md
+++ b/docs/WORK_LOG.md
@@ -7,20 +7,417 @@ claims as completed work.
## Current status
-- Assigned scope: add the explicitly requested one-time What's New card for
- `1.7.1` to the open release PR.
-- Branch: `codex/1-7-1-whats-new`, based on `develop` at `0596f6d`.
-- Version preparation [#178](https://github.com/Coding-Moves/one-concept/pull/178)
- is merged. Release [#179](https://github.com/Coding-Moves/one-concept/pull/179)
- is open; its migration check and version-preparation preview OTA passed.
-- Card and standing-policy follow-up:
- [#180](https://github.com/Coding-Moves/one-concept/pull/180), targeting `develop`
- so it is included in release #179. Its PR records the final merge/check status.
-- [PR #177](https://github.com/Coding-Moves/one-concept/pull/177) is merged; its
- [preview OTA](https://github.com/Coding-Moves/one-concept/actions/runs/34677521802)
- passed. Its completed browser checks also covered retry after reconnecting,
- dark/reduced-motion mode, and friendly offline sign-in errors.
-- Production merge/publication remains the owner's next step after release review.
+- [Release PR #191](https://github.com/Coding-Moves/one-concept/pull/191) is open
+ from **develop → main** for **1.8.0**, with the six-benefit one-time card and
+ runtime **1.3.0**. Feature/fix PRs #183–#186, #188, and #189 are included;
+ preparation #190 and release bookkeeping #192 are merged.
+- The owner explicitly approved applying migration 0010 to production after
+ the earlier automatic-approval rejections. The exact migration is now applied
+ and its committed schema was independently verified. The ledger entry was
+ added only afterward, in `01a532e`.
+- [Fix PR #193](https://github.com/Coding-Moves/one-concept/pull/193) brings the
+ verified ledger and handoff into `develop`, updating release #191. Confirm its
+ merge and the release's latest required check on GitHub before production merge.
+ The unchanged migration workflow passes locally for all ten migrations.
+- No production app release was merged or deployed in this follow-up. The shared
+ generation-budget first-day rollout and consistent API/worker caps still need
+ the release-time handling documented in `backend/README.md`.
+- Resend setup remains deferred in [draft PR #187](https://github.com/Coding-Moves/one-concept/pull/187).
+ The owner reports installing all three merged email templates and enabling the
+ password-change notification in Supabase; actual inbox delivery is unverified.
+
+## Release migration check follow-up — 2026-09-13
+
+- **Problem:** both failed runs on #191 reported only
+ `0010_generation_daily_usage.sql`; the latest preview OTA passed. Read-only
+ production inspection confirmed the table was absent, so adding an unverified
+ ledger entry or weakening CI would not fix the deployment prerequisite.
+- **Authorization:** automatic review initially rejected the production mutation.
+ The owner then explicitly approved the exact migration, verification, and PR
+ update. Previous blocked attempts did not run SQL.
+- **Application:** executed the unchanged migration against the configured One
+ Concept production Supabase database using its validated session connection.
+ It creates only `public.generation_daily_usage` and enables RLS; existing tables
+ and rows are unchanged. The initial helper's post-commit assertion failed;
+ no retry of the migration was performed. A separate read-only connection
+ verified the committed schema with explicit text casts for constraint kinds.
+- **Verification:** `budget_day` is a nonnullable date primary key; `calls_used`
+ is a nonnullable integer defaulting to zero with a nonnegative check. RLS is
+ enabled and no client policies exist. Recorded the migration only after all
+ these assertions passed. No test data was inserted into production.
+- **Commits:** `df377b0` records scope; `402f520` records the previous authorization
+ blocker; `01a532e` records verified production application in the ledger;
+ `docs: record verified release migration and handoff` records the result and
+ updates the codebase map. Preserve all commits in #193.
+- **Checks:** the unchanged CI shell check passes locally; all ten SQL filenames
+ are recorded with no unknown ledger entries. Migration SQL and workflow are
+ unchanged. Reviewed staged changes and checked whitespace/local documentation
+ paths. No application tests were rerun for this ledger/documentation follow-up;
+ the release's prior 145 backend tests used this same SQL on disposable
+ PostgreSQL 16 and passed without skips.
+- **Handoff:** merge #193 into `develop`, confirm the required migration check on
+ #191, and update that release's checklist with the verified application. GitHub
+ records the resulting merge/check state. Release merging remains with the owner.
+
+## Version 1.8.0 preparation — 2026-09-13
+
+- **Scope:** wind up merged work and open the release PR with its required
+ version-matched, one-time What's New card. Keep original focused commits and
+ leave production release merging to the owner.
+- **Highlights:** downloaded saved lessons/examples offline; automatic action
+ synchronization on reconnect/reopen; persistent offline topic choices; lighter
+ startup and older Saved search/filter access; password visibility; clearer
+ account emails. The deferred SMTP-provider draft is excluded.
+- **Card:** allow the highlight list to scroll while the heading and Got it stay
+ visible. Keep the existing device/version dismissal key and sign-in gating.
+ The browser regression checks the actual exported version, both themes at
+ 320×568, 390×844, and 844×390, the last highlight, and offline restart after
+ dismissal. No native dependency or runtime change is needed.
+- **Commits:** `d087182` records scope; `e846760` makes long release cards readable
+ with regression coverage; `1e92332` sets version 1.8.0 and its six highlights.
+ `docs: record 1.8.0 release validation and migration prerequisite` records the
+ test instructions, map, and this handoff.
+- **Validation:** all **145 backend tests passed, no skips**, using disposable
+ PostgreSQL 16 and dummy Auth configuration with generation disabled. TypeScript
+ and all **33 Node 24 tests passed, no skips**. Android and web production exports
+ passed with dummy public configuration. The new card browser scenario passed
+ all six viewport/theme combinations and dismissal across offline restart;
+ screenshots were inspected. An initial web export retained the old Expo config
+ version in Metro's cache; rebuilding with `--clear` resolved it. No application
+ code workaround was needed. Artifacts remain under `/tmp/one-concept-1-8-*`.
+- **Limits:** physical-device/native accessibility checks and actual inbox
+ delivery were not exercised. This release reran backend/unit/card checks;
+ #189's documented password and full offline-collection acceptance checks remain
+ the coverage for those unchanged flows.
+- **Database prerequisite at preparation:** production initially lacked the
+ usage table and automatic approval blocked applying it. Resolved after the
+ owner's explicit approval in the migration-check follow-up above; #193 records
+ verified production application.
+- **Deployment handoff:** the shared generation-budget rollout in
+ [backend/README.md](../backend/README.md#shared-generation-budget) still
+ requires consistent caps across API/workers and pausing old generators during
+ rollout. Enable the new generators at the next Pacific reset, or seed today's
+ usage conservatively while paused. No live worker settings were changed.
+- **PRs:** preparation [#190](https://github.com/Coding-Moves/one-concept/pull/190)
+ merged into `develop` as `fa2b7a7`, preserving all five commits. Release
+ [#191](https://github.com/Coding-Moves/one-concept/pull/191) is open from
+ `develop` to `main` at the owner's explicit request to open it now. The release
+ description initially marked migration 0010 and generation rollout as pending.
+ `docs: record 1.8.0 release PR handoff` records this outcome.
+- **Next step:** require the latest migration check to pass after #193 lands
+ before production merge. Opening #191 did not itself apply the migration or
+ publish a production release.
+
+## Saved reading offline and password visibility (#182) — 2026-09-12
+
+- Read the exact Expo SDK 57 and React Native 0.86 input/Pressable documentation.
+ Compared fresh `develop` with production `main` and inspected both issue images.
+ Production still fetches full lessons only from the API; `develop` already
+ persists/downloads saved bodies through the earlier #133/#150 changes.
+- Reproduced the missing eye button in an unchanged web export: the new browser
+ test failed at Show password. The existing 365-item offline scenario passed
+ before editing, so no duplicate cache implementation or new native storage
+ dependency was needed. Lessons must download online once before offline use.
+- Added an eye button with changing Show/Hide password accessibility labels and
+ a 48px minimum target. It preserves typed values/autofill hints, disables
+ correction, and masks again on sign-in/signup submission and mode changes.
+ The field/control disable during requests. Visibility is not persisted.
+- **Validation:** TypeScript and all 33 Node 24 tests passed, with no skips.
+ Web and Android production exports succeeded with dummy configuration.
+ The password browser regression passed both themes, keyboard activation,
+ value/submission preservation, busy state, signup confirmation, remasking,
+ and 320/390/960px targets/overflow. An initial harness assertion expected an
+ explicit HTML text type; corrected it to check the input's effective type.
+- Both final offline browser scenarios passed: large collections and timer-only
+ reconnect with a replay error/sign-out in flight. Verified all 365 explanations
+ and examples on disk after restart, unopened older/read saved bodies in the UI,
+ search/filter, retry, pending unsaves, offline save, and cache cleanup. No browser
+ runtime errors. Inspected light/dark auth and offline lesson screenshots.
+- Artifacts stay under `/tmp/one-concept-182-*`. No live account, email, backend,
+ or production service was used. Native keyboard/autofill, screen readers, and
+ on-device storage were not exercised; preview checks remain in
+ `mobile/tests/README.md`. Backend tests were not run for this mobile-only change.
+- Commits: `b76fecd` records scope; `2e41289` adds the UI and browser regression;
+ `18fb94b` strengthens offline acceptance; `eef0ea4` records the map, test
+ instructions, and handoff. `docs: link password and saved reading PR` records
+ [PR #189](https://github.com/Coding-Moves/one-concept/pull/189), non-draft into
+ `develop`, with five focused commits. Version/runtime are unchanged; this
+ chunk does not open or merge a release PR.
+
+## Separate branded email templates — 2026-09-12
+
+- Owner requested the three templates in a separate non-draft PR, allowing
+ branding to merge while domain/provider setup stays deferred. Rechecked
+ Supabase documentation: customizing templates does not remove the built-in
+ sender's team-only/two-emails-per-hour restrictions. Existing configured
+ Gmail SMTP can be used separately once authenticated and tested.
+- Preserved nine original template commits from #187, in order, covering signup,
+ recovery, copy/spacing improvements, the dark text masthead, the Coding Moves
+ organization link, and the password-changed notification. Sources are byte for
+ byte identical to the previously validated versions. The owner selected text
+ branding; no custom app logo was available.
+- Added `docs/EMAIL_TEMPLATES.md` with exact subjects, manual installation,
+ confirmation placeholders, the security toggle, existing redirect contract,
+ and activation checks. The guide does not require domain purchase or Resend.
+- **Validation:** the unchanged HTML previously passed 60 Chromium scenarios:
+ 48 for signup/recovery and 12 for password changed, across four widths and
+ normal/doubled/stripped styles, plus normal/long verification URLs. Those checks
+ covered links, fallback URLs, styled overflow, spacing, action sizes, and no
+ external resources; mobile/desktop/enlarged previews were inspected. For this
+ split, verified byte equality with `b0695ca`, inspected app redirect/support
+ source, checked local Markdown paths/anchors, and ran `git diff --check`.
+ Browser scenarios were not repeated for identical HTML. Backend/mobile and
+ live-email tests were not run for this HTML/documentation-only change.
+- Commits: `245b7f6` records scope; `d88f432` through `50d065d` preserve the nine
+ template changes; `4ccba83` adds installation/navigation docs; this handoff is
+ `docs: record independent email template PR and validation`.
+- **Handoff:** merge #188 independently of #187. Then install/test templates in
+ Supabase before announcing the changed emails. Merging/releasing the app does
+ not synchronize templates or enable security notifications. Delivery issues
+ #152/#171 stay open for the remaining operational work; no release PR opened.
+
+## Shared generation budget (#151) — 2026-09-12
+
+- Confirmed both gaps before implementation against the same generation source
+ now on `develop`: with a zero configured cap, actual prefetch/pool control flow
+ reached the mocked generator five times; two scheduled runs capped at two each
+ made four calls total. Database/provider boundaries were mocked, with no live
+ services or keys. The existing counter resets per run; prefetch never reads it.
+- Use one database reservation per attempted provider call, committed before the
+ network request. Budget denial must roll back the backlog claim without burning
+ an attempt. Once reserved, failed/throttled/uncertain calls still consume budget;
+ preserve the separate backlog retry refund for rate limits.
+- Align the budget day with Gemini's documented midnight Pacific reset, computed
+ by PostgreSQL in `America/Los_Angeles`, independently of user progress timezones.
+ [Provider documentation](https://ai.google.dev/gemini-api/docs/rate-limits).
+- Inspection also found the manual catalog rewriter calls the same provider;
+ include it in the shared budget rather than leaving another bypass. No catalog
+ rewrite will actually be run against production.
+- Intended commits: scope/reproduction; counter migration/service/concurrency
+ tests; backlog/scheduled enforcement/tests; prefetch integration/tests; rewrite
+ enforcement/tests; operational documentation/validation; PR bookkeeping.
+
+- Added migration `0010_generation_daily_usage.sql`, an ORM mirror, and a backend-
+ only ledger with an atomic UPSERT. A new Pacific budget date gets a new row;
+ no reset job or process memory is involved. All services must use the same cap.
+- Before pool integration, the new PostgreSQL regression reproduced two scheduled
+ runs each spending two calls under a cap of two (expected second run: zero).
+ After integration it passes. The prefetch zero-cap test now makes zero calls,
+ and simultaneous scheduled/prefetch runs share their remaining slots.
+- Quota reservation and backlog claim commit together before the provider call,
+ releasing the connection. Denial or a failed commit rolls the claim back. Empty
+ backlog spends nothing; committed failed/throttled/cancelled attempts retain
+ quota. Separate backlog rate-limit refunds and retry retirement remain intact.
+- Catalog rewrites use the same reservation gate, honor generation/key switches,
+ retain old content when stopped, and dispose their engine on every exit. The
+ maintenance command was exercised only with a disposable DB and mocked provider.
+- **Validation:** all 145 backend tests passed against PostgreSQL 16 (no skips),
+ including 33 new cases across the counter and generation-path suites. Ruff
+ (`F,E9`), whitespace, and documentation link checks passed. Twenty concurrent
+ reservations with cap three admit exactly three; twelve concurrent backlog
+ generations also admit three unique publications without extra spent attempts.
+ Coverage includes independent sessions/reruns, Pacific winter/summer midnight,
+ cap changes/zero/negative values, RLS denial for client roles, empty backlog,
+ committed-before-provider checks, DB/commit failures, provider failures and
+ cancellation, switches, rate-limit refunds, and cross-path competition.
+- **Deployment limits:** production DDL, migration-ledger updates, paid calls,
+ live provider quota checks, and deployment were not performed. Mobile tests
+ were not run because no mobile code changed. The ledger starts empty and cannot
+ reconstruct old calls: pause old generators during rollout, then enable at the
+ next budget reset or seed today's usage conservatively. Other applications'
+ Gemini usage is outside this application ledger; provider limits still apply.
+- Commits: `92d537f` scope/reproduction; `91f70ba` ledger/migration/tests;
+ `b8eabdc` claim/scheduled enforcement/tests; `2b199da` prefetch integration/tests;
+ `e39df10` rewrite enforcement/tests; `2baec95` operational documentation and
+ validation.
+- **PR:** [#186](https://github.com/Coding-Moves/one-concept/pull/186), open into
+ `develop` with individual commits and owner authorship. Publication bookkeeping:
+ `docs: link shared generation budget pull request`. Ready for review; merging,
+ production migration application, and deployment were not performed.
+
+## Bounded startup state (#150) — 2026-09-12
+
+- Read the exact Expo SDK 57 documentation before mobile edits.
+- Confirmed before implementation with disposable PostgreSQL 16 and 365 completed
+ and saved concepts: both the existing request and `?compact=true` returned all
+ 365 detail rows in each list, about 151 KB. The new regression failed at the
+ expected 50-row limit. No production service was used.
+- Keep legacy state responses for older clients during backend/OTA rollout. The
+ updated client will request compact metadata, retain exact aggregate totals,
+ and load older Saved metadata in pages when needed. Full History UI is separate.
+
+- Delivered opt-in compact state on GET state, PUT topics, and PATCH profile;
+ updated mobile requests use it. Both cursor endpoints default to 50 and allow
+ 1–100 items. Like counts are enriched after limiting rows; full membership and
+ streak/aggregate work still grow with activity. Saved timestamp/UUID ordering
+ handles ties and deletion between pages, with all queries scoped to the JWT user.
+- Stats combines older-topic aggregates with recent/optimistic learned rows.
+ Saved paints recent/cached rows, then loads older metadata in bounded pages;
+ previously downloaded bodies provide older titles offline even before the first
+ Saved visit. The new account-keyed cache clears at sign-out and fences late pages.
+- Final review reproduced a manual Retry that sent no request after connectivity
+ returned. Fixed it in a follow-up commit and retained the failing-before/passing-
+ after browser scenario. Equivalent state refreshes do not spin failed page loads.
+- **Validation:** all 112 backend tests passed against disposable PostgreSQL 16,
+ with no skips; 33 Node 24 tests, TypeScript, Ruff (`F,E9`), and whitespace checks
+ passed. Android/web production exports succeeded using dummy configuration.
+ The first restricted export stalled; it was stopped and completed with local
+ worker communication enabled. Documentation links/new source paths were checked.
+- The 365-record PostgreSQL fixture returned 151,007 bytes through the legacy
+ contract and 55,676 bytes through compact state, about 63% smaller, with exactly
+ 50 learned and 50 saved details. Pagination recovered all 365 without duplicates.
+ Tests cover limits/invalid cursors, user isolation, exact window boundaries,
+ mutations, tied saves, deletion between pages, own-like exclusion, and totals.
+- Browser coverage passed for the 365-item account: full Stats/category totals,
+ older Saved search/category filters and reading, offline restart before Saved
+ was ever opened, page failure/retry without a request loop, manual reconnect
+ retry, offline completion/unsave, and sign-out during a pending successful page.
+ Inspected the mobile-sized search screenshot. Existing timer-only sync, 503
+ replay recovery, saved-body/topic persistence, midnight save, and partial-
+ connectivity recovery scenarios also passed without browser runtime errors.
+- **Limits:** no physical-device run or production benchmark/deployment. Android
+ validation is a JS export, not a new APK. Old clients keep the unbounded legacy
+ response until OTA adoption; membership arrays remain complete. Saved still
+ downloads all older metadata when opened to preserve full search, and offline
+ full text requires its completed body download. Pagination is a live view;
+ refresh startup state for new rows above an existing cursor.
+- Commits: `64afb6d` scope/reproduction; `09f4b22` future commit preference;
+ `ce38299` backend contract/endpoints/tests; `32cc70a` Stats compatibility/tests;
+ `b62ab19` Saved paging/cache/browser checks; `50b7f88` compact request activation;
+ `2d83b7a` manual reconnect retry. Documentation handoff:
+ `docs: document compact state rollout and validation` (`730a28d`).
+- **PR:** [#185](https://github.com/Coding-Moves/one-concept/pull/185), open into
+ `develop` with individual commits and owner authorship. Publication bookkeeping:
+ `docs: link bounded startup pull request`. Ready for owner review; merging and
+ deployment were not performed. Disposable browser/database checks finished.
+
+## Database connections after idle (#149) — 2026-09-12
+
+- Reproduced before implementation using the unchanged app engine/session and
+ a disposable PostgreSQL 16 container on loopback port 55434. Set the test
+ server's idle-session timeout to 1.5 seconds, then waited two seconds between
+ requests. SQLAlchemy connection events confirm all three post-idle requests
+ created a new physical connection: 260.23, 221.45, and 220.35 ms. Immediate
+ reuse took 10.91, 10.24, and 9.40 ms with zero new connections.
+- This reproduces the request-time reconnect mechanism under controlled idle
+ expiry, not Supavisor's deployed timeout or the issue's production 600 ms figure.
+- Intended fix: configurable, bounded probes in the FastAPI lifespan, reusing
+ the most recently returned connection. Keep `pool_pre_ping`, transaction-mode
+ configuration, and current connection limits. Cancel probes before pool disposal.
+- Planned commits: reproduction/scope; warm connection lifecycle with regression
+ tests; measured validation, operational instructions, and PR handoff.
+- Implemented an immediate then periodic API lifespan probe (30-second default,
+ zero to disable), reusing the most recently returned pool connection. Checkout/
+ query and rollback/return each have a five-second default budget. Failures retry
+ after the interval; logs omit raw connection details. Shutdown waits for cleanup,
+ including when cleanup itself fails. The task does not start in cron workers.
+- A real-PostgreSQL test caught cancellation returning before SQLAlchemy finished
+ connection cleanup. Fixed it before handoff and added failure/cancellation coverage.
+- **Validation:** all 105 backend tests passed, with real PostgreSQL 16 integration
+ coverage and no skips. Ruff (`F,E9`) and whitespace checks passed. The final
+ controlled comparison (800 ms test-session idle timeout, 1.2-second gaps) was:
+
+ | Warm-up | Three request times (ms) | New physical connections |
+ | --- | --- | --- |
+ | Disabled | 223.98, 209.61, 234.05 | 3 |
+ | Enabled, 200 ms test interval | 7.93, 8.18, 8.29 | 0 |
+
+ Both cases use the same engine factory, query, and disposable database. Timings
+ are reported rather than asserted; connection counts are the regression check.
+ Covered query/checkout/cleanup timeouts, recovery, cancellation, transaction
+ release, terminated connections, disabled probes, and lifespan failures.
+- **Limits:** no live Supabase/Railway measurement, production deployment, mobile
+ test, or native build. The issue's deployed timeout/600 ms figure remains
+ unverified. Cold startup and additional connections during bursts can still pay
+ setup cost. Each API process adds a periodic probe with the configured interval.
+- Commits: `50d73ab` — reproduction and scope; `6bf0399` — implementation/tests;
+ `679163a` — validation and operational documentation.
+- **PR:** [#184](https://github.com/Coding-Moves/one-concept/pull/184), open into
+ `develop` with all individual commits and the owner's configured identity.
+ Publication bookkeeping: `docs: link database warm-up pull request`.
+ Disposable reproduction/test containers were removed. Ready for owner review;
+ merging and deployment were not performed.
+
+## PR #183 review fixes — 2026-09-12
+
+- Owner requested both findings fixed in the existing PR against `develop`.
+ Planned and delivered one fix/test commit per finding, followed by this
+ documentation handoff. Read the exact Expo SDK 57 documentation before edits.
+- Reproduced both problems before editing: a save waiting behind a like across
+ midnight never persisted (the unchanged `develop` control succeeded); successful
+ state requests followed by failed topics requests caused rapid repeated fetches.
+ The new committed browser scenarios fail on the previous PR export.
+- `d9a07f5` — `fix: preserve pending actions across midnight`: split account
+ invalidation from daily refresh, preserve pending counts, and prevent cached
+ previews from overwriting pending optimistic actions. Browser coverage checks
+ queued save persistence through date change, offline restart, and replay.
+- `409b1af` — `fix: retain backoff after partial sync failures`: failed attempts
+ retain backoff even when their own requests report connectivity changes.
+ Tests cover the full 5/10/20/30-second progression, immediate reconnect while
+ waiting, coalesced successful wakeups, and automatic recovery in the browser.
+- **Validation:** 31 Node 24 tests, TypeScript, Android/web production exports,
+ and whitespace checks passed. Both new browser scenarios and the full offline
+ flow passed, including timer-only reconnect, 503 recovery, offline save restart,
+ and sign-out with an in-flight request. No browser runtime errors. No backend
+ changes; backend tests, live services, and physical-device checks were not run.
+- **Handoff:** both fixes stay in [PR #183](https://github.com/Coding-Moves/one-concept/pull/183)
+ on `codex/133-offline-reading-sync`. Native runtime, version, and dependencies
+ are unchanged. Documentation commit: `docs: record PR 183 review fixes`.
+
+## Offline reading and synchronization (#133) — 2026-09-12
+
+- Before editing, exported the unchanged web app and exercised it in Chromium
+ with dummy authentication and intercepted API requests. No live account used.
+- Confirmed: a custom saved concept opened online loses its full text after an
+ offline restart; a warmed topic catalog is unavailable after offline restart;
+ offline topic changes do not enter the persistent queue; restoring connectivity
+ alone leaves an offline like queued without sending a request.
+- Passing controls: saved detail online, cached Today offline, and durable offline
+ like queuing. Preserve those existing behaviors and the offline banner.
+- Planned atomic commits: persistent full-concept reading with account cleanup
+ and tests; cached topic catalog and queued follows with tests; automatic sync
+ triggers with tests; validation, codebase map, and PR handoff.
+- Owner chose to keep the current APK: automatically retry while the app is open
+ or reopened. No OS background worker, native dependency, or version/runtime bump.
+- Read the exact Expo SDK 57 documentation before mobile edits. Other issues,
+ including #182's password-visibility request, remain outside this PR.
+- Additional replay checks reproduced two related failures before their fixes:
+ a 503 reverted a queued unlike in the UI; a request failing after sign-out
+ recreated the old action queue. Preserve pending choices during reconciliation
+ and fence late request callbacks/token resolution after account cleanup.
+- Implemented full per-lesson storage and missing saved-lesson downloads;
+ shared cached topics with durable follows; serialized outbox writes; automatic
+ foreground retry with 5–30 second backoff, immediate browser/foreground wakeup,
+ and no idle polling once reachable with an empty queue. Saved reading requires
+ the lesson to have finished downloading during an online session.
+- **Validation:** all 28 Node 24 regression tests, TypeScript, Android/web Expo
+ production exports, and whitespace checks passed. The mocked Chromium flow
+ passed offline restart, unopened saved-lesson downloads, cached sharing, follows,
+ likes/saves, timer-only and browser-event reconnect, 503 retention/recovery,
+ and sign-out with a request in flight. Inspected the offline detail screenshot.
+ No live backend, physical phone, native share sheet, or production deployment
+ was tested; no backend code changed and backend tests were not run.
+- **Repeatability:** `mobile/tests/offline.browser.cjs` and its README retain the
+ before/after reproduction flow, dummy public configuration, and optional race
+ checks. New focused Node tests cover storage, outbox, topics, reconciliation,
+ and scheduler behavior without adding dependencies.
+
+| Change | Commit |
+| --- | --- |
+| Baseline reproduction and scope | `0e82f03` |
+| Full offline lesson storage and saved downloads | `0085285` |
+| Cached topics and queued follow choices | `41da1ab` |
+| Serialized durable outbox | `f8d1ca6` |
+| Automatic foreground synchronization | `1a82c2e` |
+| Preserve pending choices during reconciliation | `cfa24ea` |
+| Sign-out request fence and browser regression | `d19e1fe` |
+| Validation and navigation guide | `docs: record issue 133 validation and handoff` |
+
+- **PR:** [#183](https://github.com/Coding-Moves/one-concept/pull/183), opened
+ into `develop` with all individual commits and the owner's configured identity.
+ Publication bookkeeping: `docs: link issue 133 pull request`.
+- **Handoff:** ready for owner review. The PR remains open; merging and release
+ preparation are the owner's next steps, outside this task.
## Working agreement — 2026-09-11
diff --git a/mobile/app.config.js b/mobile/app.config.js
index 254e010..ba62a98 100644
--- a/mobile/app.config.js
+++ b/mobile/app.config.js
@@ -18,7 +18,7 @@ module.exports = {
name: 'One Concept',
slug: 'one-concept',
owner: 'coding-moves',
- version: '1.7.1',
+ version: '1.8.0',
orientation: 'portrait',
icon: './assets/icon.png',
userInterfaceStyle: 'automatic',
diff --git a/mobile/src/api/client.ts b/mobile/src/api/client.ts
index c90439e..8da63cd 100644
--- a/mobile/src/api/client.ts
+++ b/mobile/src/api/client.ts
@@ -26,6 +26,12 @@ export class ApiError extends Error {
type TokenProvider = () => Promise;
let getAccessToken: TokenProvider = async () => null;
+let accountEpoch = 0;
+
+/** Cancel requests still waiting for a token when the account is cleared. */
+export function invalidateAccountRequests(): void {
+ accountEpoch += 1;
+}
/** Registered once by the auth layer in Phase 3. */
export function setTokenProvider(provider: TokenProvider): void {
@@ -77,7 +83,9 @@ export async function apiRequest(path: string, options: RequestOptions = {}):
throw new ApiError(0, 'EXPO_PUBLIC_API_BASE_URL is not set');
}
+ const epoch = accountEpoch;
const token = await getAccessToken();
+ if (epoch !== accountEpoch) throw new ApiError(401, 'Account changed');
const headers: Record = { Accept: 'application/json' };
if (options.body !== undefined) headers['Content-Type'] = 'application/json';
if (token) headers.Authorization = `Bearer ${token}`;
diff --git a/mobile/src/components/WhatsNewCard.tsx b/mobile/src/components/WhatsNewCard.tsx
index fd12a82..19a804c 100644
--- a/mobile/src/components/WhatsNewCard.tsx
+++ b/mobile/src/components/WhatsNewCard.tsx
@@ -5,6 +5,7 @@ import {
Easing,
Modal,
Pressable,
+ ScrollView,
StyleSheet,
Text,
View,
@@ -71,7 +72,11 @@ export function WhatsNewCard({ entry, onDismiss }: Props) {
What's new
Version {entry.version}
-
+
{entry.highlights.map((line, i) => (
- {line}
+ {line}
))}
-
+
cardWrap: {
width: '100%',
maxWidth: 420,
+ maxHeight: '100%',
},
card: {
+ flexShrink: 1,
backgroundColor: colors.surface,
borderRadius: radius.xl,
borderWidth: 1,
@@ -149,10 +156,11 @@ const createStyles = (colors: ThemeColors) =>
marginTop: 2,
marginBottom: spacing.lg,
},
- list: {
- gap: spacing.md,
+ highlights: {
+ flexShrink: 1,
marginBottom: spacing.lg,
},
+ list: { gap: spacing.md },
item: {
flexDirection: 'row',
alignItems: 'flex-start',
diff --git a/mobile/src/context/ProgressContext.tsx b/mobile/src/context/ProgressContext.tsx
index 8b806e9..783f2ce 100644
--- a/mobile/src/context/ProgressContext.tsx
+++ b/mobile/src/context/ProgressContext.tsx
@@ -8,8 +8,8 @@ import {
useRef,
useState,
} from 'react';
-import { AppState } from 'react-native';
-import { subscribeConnectivity } from '../api/client';
+import { AppState, Platform } from 'react-native';
+import { getConnectivity, isApiConfigured, setConnectivity, subscribeConnectivity } from '../api/client';
import { CONCEPTS } from '../data/concepts';
import { Category, Concept, DailyOutcome, ProgressState } from '../types';
import { selectDailyConcept } from '../services/dailyConcept';
@@ -17,6 +17,9 @@ import { todayKey } from '../services/dates';
import { localProgressRepository } from '../services/localProgressRepository';
import { ProgressRepository } from '../services/progressRepository';
import { remoteProgressRepository } from '../services/remoteProgressRepository';
+import { pending as queuedMutations, subscribeQueue } from '../services/mutationQueue';
+import { createSyncLoop } from '../services/syncLoop';
+import { fetchTopics } from '../services/topicsApi';
import { useAuth } from './AuthContext';
import { EMPTY_PROGRESS } from '../services/storage';
import { computeStreaks, StreakStats } from '../services/streak';
@@ -73,22 +76,33 @@ export function ProgressProvider({ children, repository: override }: Props) {
override ?? (session ? remoteProgressRepository : localProgressRepository);
const today = todayKey();
+ const userId = session?.user.id;
+ const chain = useRef>(Promise.resolve());
+ const pending = useRef(0);
+ const accountEpoch = useRef(0);
+ const confirmed = useRef(null);
+ // Only an account/source change invalidates queued actions. A new calendar
+ // day refreshes the assignment without cancelling taps still in flight.
useEffect(() => {
- let cancelled = false;
- (async () => {
- setLoading(true);
- // The repository just swapped (sign-in or sign-out). The old account's
- // state must not stay on screen while the new source loads — wiping the
- // caches below is not enough when the leak lives in React state.
- setProgress(EMPTY_PROGRESS);
+ accountEpoch.current += 1;
+ pending.current = 0;
+ confirmed.current = null;
+ setLoading(true);
+ setProgress(EMPTY_PROGRESS);
+ return () => { accountEpoch.current += 1; };
+ }, [repository, userId]);
+ useEffect(() => {
+ let cancelled = false;
+ chain.current = chain.current.then(async () => {
+ if (cancelled) return;
// Paint from the last known state immediately — on a slow connection
// the difference between this and waiting on the network is the whole
// perceived speed of the app. The fresh load replaces it silently.
const cached = await repository.loadCached?.();
if (cached && !cancelled) {
- setProgress(cached);
+ if (pending.current === 0) setProgress(cached);
setLoading(false);
}
@@ -101,48 +115,13 @@ export function ProgressProvider({ children, repository: override }: Props) {
const next = picked ? await repository.setAssignment(picked.id, today) : stored;
if (cancelled) return;
- setProgress(next);
+ confirmed.current = next;
+ if (pending.current === 0) setProgress(next);
setLoading(false);
- })();
- return () => {
- cancelled = true;
- };
- }, [repository, today]);
-
- // Drain the offline mutation queue when connectivity returns or the app comes
- // back to the foreground, then apply the server-reconciled state (issue #133).
- // Serialised via `flushing` so overlapping triggers don't double-replay.
- useEffect(() => {
- if (!repository.flushQueue) return;
- let active = true;
- let flushing = false;
- const flush = async () => {
- if (flushing || !active) return;
- flushing = true;
- try {
- const next = await repository.flushQueue?.();
- if (active && next) setProgress(next);
- } catch {
- // A flush failure just leaves items queued for the next trigger.
- } finally {
- flushing = false;
- }
- };
-
- const unsubscribe = subscribeConnectivity((online) => {
- if (online) flush();
- });
- const appState = AppState.addEventListener('change', (s) => {
- if (s === 'active') flush();
- });
- flush(); // catch up on anything left from a previous session
-
- return () => {
- active = false;
- unsubscribe();
- appState.remove();
- };
- }, [repository]);
+ if (userId && getConnectivity()) void fetchTopics().catch(() => {});
+ }).catch(() => { if (!cancelled) setLoading(false); });
+ return () => { cancelled = true; };
+ }, [repository, today, userId]);
// The day's assignment is pinned once made, even if the concept's topic is
// unfollowed later that day — topic changes apply from the next assignment.
@@ -177,22 +156,25 @@ export function ProgressProvider({ children, repository: override }: Props) {
// momentarily wipe a later tap's optimistic change (a flicker). On failure we
// undo just this change functionally, on top of the latest state, so a
// concurrent change is never lost (the clobber #39 originally fixed).
- const chain = useRef>(Promise.resolve());
- const pending = useRef(0);
const apply = useCallback(
(
optimistic: ((prev: ProgressState) => ProgressState) | null,
- run: () => Promise,
+ run: () => Promise,
undo?: (prev: ProgressState) => ProgressState
) => {
+ const epoch = accountEpoch.current;
if (optimistic) setProgress(optimistic);
pending.current += 1;
chain.current = chain.current.then(async () => {
+ if (epoch !== accountEpoch.current) return;
try {
const next = await run();
+ if (epoch !== accountEpoch.current) return;
pending.current -= 1;
- if (pending.current === 0) setProgress(next);
+ if (next) confirmed.current = next;
+ if (pending.current === 0 && confirmed.current) setProgress(confirmed.current);
} catch {
+ if (epoch !== accountEpoch.current) return;
pending.current -= 1;
if (undo) setProgress(undo);
}
@@ -202,6 +184,57 @@ export function ProgressProvider({ children, repository: override }: Props) {
[]
);
+ // Queue replay shares the same chain as taps and initial loading. A later
+ // optimistic action cannot be overwritten by a reconnect's server snapshot.
+ useEffect(() => {
+ if (!repository.flushQueue || !isApiConfigured()) return;
+ let active = true;
+ const loop = createSyncLoop(async () => {
+ let retry = true;
+ await apply(null, async () => {
+ if (!active) return null;
+ const entries = await queuedMutations();
+ let next: ProgressState | null = null;
+ if (entries.length) next = await repository.flushQueue!();
+ else if (!getConnectivity()) next = await repository.load();
+ if (!active) return null;
+ if (next && getConnectivity()) await fetchTopics().catch(() => {});
+ retry = !getConnectivity() || (await queuedMutations()).length > 0;
+ return next;
+ });
+ return retry;
+ }, AppState.currentState !== 'background' && AppState.currentState !== 'inactive');
+ const unsubscribe = subscribeConnectivity(online => {
+ if (online) loop.wake();
+ else loop.retry();
+ });
+ const unsubscribeQueue = subscribeQueue(() => {
+ if (getConnectivity()) loop.wake();
+ else loop.retry();
+ });
+ const appState = AppState.addEventListener('change', state => loop.setActive(state === 'active'));
+ // Browsers have an immediate reconnect event. Native JS retries pending
+ // work with backoff because the current APK has no connectivity module.
+ const reconnect = () => loop.wake();
+ const disconnect = () => setConnectivity(false);
+ if (Platform.OS === 'web') {
+ window.addEventListener('online', reconnect);
+ window.addEventListener('offline', disconnect);
+ }
+ loop.wake();
+ return () => {
+ active = false;
+ loop.stop();
+ unsubscribe();
+ unsubscribeQueue();
+ appState.remove();
+ if (Platform.OS === 'web') {
+ window.removeEventListener('online', reconnect);
+ window.removeEventListener('offline', disconnect);
+ }
+ };
+ }, [apply, repository, userId]);
+
// Retry shares the mutation chain, so a refresh cannot overwrite a later tap.
const refresh = useCallback(() => apply(null, () => repository.load()), [apply, repository]);
diff --git a/mobile/src/data/whatsNew.ts b/mobile/src/data/whatsNew.ts
index b32e169..4be8f70 100644
--- a/mobile/src/data/whatsNew.ts
+++ b/mobile/src/data/whatsNew.ts
@@ -21,6 +21,17 @@ export interface WhatsNewEntry {
}
export const WHATS_NEW: WhatsNewEntry[] = [
+ {
+ version: '1.8.0',
+ highlights: [
+ 'Read saved lessons and examples offline after they download to your device.',
+ 'Likes, saves, and progress sync automatically when you reconnect or reopen the app.',
+ 'Choose topics offline and keep your selections between visits.',
+ 'Enjoy a lighter startup and find older saved lessons with search and topic filters.',
+ 'Show or hide your password when signing in or creating an account.',
+ 'Refreshed account emails make confirmation, password resets, and security updates clearer.',
+ ],
+ },
{
version: '1.7.1',
highlights: [
diff --git a/mobile/src/hooks/useSavedConcepts.ts b/mobile/src/hooks/useSavedConcepts.ts
new file mode 100644
index 0000000..45506d1
--- /dev/null
+++ b/mobile/src/hooks/useSavedConcepts.ts
@@ -0,0 +1,95 @@
+import { useCallback, useEffect, useMemo, useState } from 'react';
+import { getConnectivity } from '../api/client';
+import { useAuth } from '../context/AuthContext';
+import { conceptCache } from '../services/conceptApi';
+import { fetchSavedPage, savedCollectionCache } from '../services/savedApi';
+import { ProgressState, SavedConcept } from '../types';
+
+/** Fetch older metadata only while Saved is open. Recent rows paint immediately;
+ * cached pages and already-downloaded lesson bodies preserve offline search. */
+export function useSavedConcepts(progress: ProgressState) {
+ const { session } = useAuth();
+ const userId = session?.user.id ?? null;
+ const [attempt, setAttempt] = useState(0);
+ const retry = useCallback(() => setAttempt(n => n + 1), []);
+ const [loaded, setLoaded] = useState<{
+ owner: string | null; items: SavedConcept[]; loading: boolean; failed: boolean;
+ }>({ owner: null, items: [], loading: false, failed: false });
+ // Equivalent state refreshes must not restart a failed page fetch in a loop.
+ const key = JSON.stringify([progress.bookmarks, progress.savedConcepts, progress.savedNextCursor]);
+ const snapshot = useMemo(() => ({
+ bookmarks: progress.bookmarks, recent: progress.savedConcepts ?? [], cursor: progress.savedNextCursor,
+ }), [key]);
+
+ useEffect(() => {
+ if (!userId) return;
+ let cancelled = false;
+ const epoch = savedCollectionCache.epoch;
+ const contentEpoch = conceptCache.epoch;
+ const active = () => !cancelled && epoch === savedCollectionCache.epoch;
+ const membership = new Set(snapshot.bookmarks);
+ const rows = new Map();
+ const recent = new Map(snapshot.recent.map(s => [s.conceptId, s]));
+ const items = () => [...new Map([...recent, ...[...rows].map(([id, row]) =>
+ [id, recent.get(id) ?? row] as const)]).values()].filter(s => membership.has(s.conceptId));
+ const publish = (loading: boolean, failed = false) => {
+ if (active()) setLoaded({ owner: userId, items: items(), loading, failed });
+ };
+ const persist = () => active()
+ ? savedCollectionCache.set(userId, items(), epoch).catch(() => {}) : Promise.resolve();
+
+ publish(true);
+ void (async () => {
+ const cached = await savedCollectionCache.get(userId, epoch);
+ if (!active()) return;
+ cached?.forEach(s => rows.set(s.conceptId, s));
+ publish(!!snapshot.cursor);
+ let failed = false;
+ let cursor = snapshot.cursor;
+ const seen = new Set();
+ try {
+ // An explicit retry probes the network even if the last request was
+ // offline; automatic loads can keep using downloaded metadata.
+ while (cursor && active() && (getConnectivity() || attempt > 0)) {
+ if (seen.has(cursor)) throw new Error('Repeated saved cursor');
+ seen.add(cursor);
+ const page = await fetchSavedPage(cursor);
+ if (!active()) return;
+ page.items.forEach(s => rows.set(s.conceptId, s));
+ cursor = page.nextCursor;
+ publish(!!cursor);
+ await persist();
+ }
+ } catch {
+ failed = true;
+ }
+ if (!active()) return;
+ // Offline users may have downloaded all saved bodies without ever opening
+ // this screen. Recover their metadata even if the list cache is absent.
+ const missing = snapshot.bookmarks.filter(id => !rows.has(id) && !recent.has(id));
+ for (let offset = 0; offset < missing.length && active(); offset += 20) {
+ const concepts = await Promise.all(missing.slice(offset, offset + 20)
+ .map(id => conceptCache.get(id, contentEpoch)));
+ if (!active()) return;
+ concepts.forEach(c => {
+ if (c) rows.set(c.id, { conceptId: c.id, title: c.title, topicName: c.category, likeCount: c.likeCount });
+ });
+ }
+ publish(false, failed || items().length < membership.size);
+ await persist();
+ })();
+ return () => { cancelled = true; };
+ }, [userId, snapshot, attempt]);
+
+ const membership = new Set(progress.bookmarks);
+ const rows = new Map((loaded.owner === userId ? loaded.items : []).map(s => [s.conceptId, s]));
+ // Apply optimistic saves/unsaves immediately, before any in-flight page ends.
+ progress.savedConcepts?.forEach(s => rows.set(s.conceptId, s));
+ return {
+ savedConcepts: progress.savedConcepts === undefined ? undefined
+ : [...rows.values()].filter(s => membership.has(s.conceptId)),
+ loading: !!userId && loaded.owner === userId && loaded.loading,
+ failed: !!userId && loaded.owner === userId && loaded.failed,
+ retry,
+ };
+}
diff --git a/mobile/src/hooks/useTopics.ts b/mobile/src/hooks/useTopics.ts
index cc8d28f..3d5986c 100644
--- a/mobile/src/hooks/useTopics.ts
+++ b/mobile/src/hooks/useTopics.ts
@@ -1,6 +1,6 @@
-import { useCallback, useEffect, useState } from 'react';
+import { useCallback, useEffect, useState, useSyncExternalStore } from 'react';
import { useAuth } from '../context/AuthContext';
-import { fetchTopics, ServerTopic, setFollowedTopics } from '../services/topicsApi';
+import { fetchTopics, ServerTopic, topicStore } from '../services/topicsApi';
export interface Topics {
loading: boolean;
@@ -17,7 +17,7 @@ export function useTopics(): Topics {
// Key on the user id, not the session object: supabase hands a fresh object
// on every token refresh, which would otherwise refetch the list hourly.
const userId = session?.user?.id ?? null;
- const [topics, setTopics] = useState([]);
+ const topics = useSyncExternalStore(topicStore.subscribe, topicStore.getSnapshot);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
const [attempt, setAttempt] = useState(0);
@@ -25,7 +25,6 @@ export function useTopics(): Topics {
useEffect(() => {
if (!userId) {
- setTopics([]);
setLoading(false);
return;
}
@@ -33,9 +32,6 @@ export function useTopics(): Topics {
setLoading(true);
setError(false);
fetchTopics()
- .then((list) => {
- if (!cancelled) setTopics(list);
- })
.catch(() => {
if (!cancelled) setError(true);
})
@@ -49,25 +45,10 @@ export function useTopics(): Topics {
const toggle = useCallback(
(slug: string) => {
- // Compute the next list synchronously from current state — never read a
- // value assigned inside a setState updater, which React may not have run
- // yet (that would PUT an empty list and unfollow everything).
- const next = topics.map((t) =>
- t.slug === slug ? { ...t, following: !t.following } : t
- );
- setTopics(next);
-
- const followed = next.filter((t) => t.following).map((t) => t.slug);
- // Fire-and-forget; on failure reload the server's truth so the pill can
- // never lie about what was actually saved.
- setFollowedTopics(followed).catch(() => {
- fetchTopics()
- .then(setTopics)
- .catch(() => {});
- });
+ topicStore.toggle(slug).catch(() => setError(true));
},
- [topics]
+ []
);
- return { loading, error, retry, topics, toggle };
+ return { loading: loading && topics.length === 0, error, retry, topics, toggle };
}
diff --git a/mobile/src/screens/AuthScreen.tsx b/mobile/src/screens/AuthScreen.tsx
index 55088de..02799a6 100644
--- a/mobile/src/screens/AuthScreen.tsx
+++ b/mobile/src/screens/AuthScreen.tsx
@@ -29,6 +29,7 @@ export function AuthScreen() {
const [mode, setMode] = useState('signIn');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
+ const [passwordVisible, setPasswordVisible] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState(null);
// A banner with a bold header + body. The reset variant carries the email so
@@ -40,6 +41,7 @@ export function AuthScreen() {
const canSubmit = email.trim().length > 3 && password.length >= 6 && !busy;
const submit = async () => {
+ setPasswordVisible(false);
setError(null);
setNotice(null);
setBusy(true);
@@ -143,17 +145,35 @@ export function AuthScreen() {
Password
-
+
+
+ setPasswordVisible(visible => !visible)}
+ disabled={busy}
+ accessibilityRole="button"
+ accessibilityLabel={passwordVisible ? 'Hide password' : 'Show password'}
+ accessibilityState={{ disabled: busy }}
+ style={({ pressed }) => [styles.passwordToggle, pressed && { opacity: 0.6 }]}
+ >
+
+
+
{mode === 'signIn' ? (
{
setMode(mode === 'signIn' ? 'signUp' : 'signIn');
+ setPasswordVisible(false);
setError(null);
setNotice(null);
}}
@@ -269,6 +290,18 @@ const createStyles = (colors: ThemeColors) =>
color: colors.text,
...shadows.card,
},
+ passwordInput: { paddingRight: scaleIcon(20) + spacing.md * 2 + spacing.sm },
+ passwordToggle: {
+ position: 'absolute',
+ right: spacing.xs,
+ top: 0,
+ bottom: 0,
+ minWidth: 48,
+ minHeight: 48,
+ paddingHorizontal: spacing.sm,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
banner: {
flexDirection: 'row',
alignItems: 'flex-start',
diff --git a/mobile/src/screens/ConceptDetailScreen.tsx b/mobile/src/screens/ConceptDetailScreen.tsx
index 1704044..352e461 100644
--- a/mobile/src/screens/ConceptDetailScreen.tsx
+++ b/mobile/src/screens/ConceptDetailScreen.tsx
@@ -31,7 +31,12 @@ export function ConceptDetailScreen() {
useEffect(() => {
let active = true;
setStatus('loading');
- fetchConcept(conceptId)
+ fetchConcept(conceptId, (cached) => {
+ if (active) {
+ setConcept(cached);
+ setStatus('ready');
+ }
+ })
.then((c) => {
if (active) {
setConcept(c);
diff --git a/mobile/src/screens/SavedScreen.tsx b/mobile/src/screens/SavedScreen.tsx
index fcbf9ce..086feb0 100644
--- a/mobile/src/screens/SavedScreen.tsx
+++ b/mobile/src/screens/SavedScreen.tsx
@@ -2,12 +2,13 @@ import { Ionicons } from '@expo/vector-icons';
import { CompositeNavigationProp, useNavigation } from '@react-navigation/native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { useMemo, useState } from 'react';
-import { FlatList, Pressable, StyleSheet, Text, TextInput, View } from 'react-native';
+import { ActivityIndicator, FlatList, Pressable, StyleSheet, Text, TextInput, View } from 'react-native';
import { CategoryChip } from '../components/CategoryChip';
import { LikeCount } from '../components/LikeCount';
import { UnavailableState } from '../components/UnavailableState';
import { useOnline } from '../context/ConnectivityContext';
import { useProgress } from '../context/ProgressContext';
+import { useSavedConcepts } from '../hooks/useSavedConcepts';
import { useTheme } from '../context/ThemeContext';
import { CONCEPTS_BY_ID } from '../data/concepts';
import { RootStackParamList } from '../navigation';
@@ -34,6 +35,7 @@ export function SavedScreen() {
const navigation = useNavigation();
const { progress, refresh } = useProgress();
const online = useOnline();
+ const { savedConcepts, loading, failed, retry } = useSavedConcepts(progress);
const { colors } = useTheme();
const styles = useMemo(() => createStyles(colors), [colors]);
@@ -45,8 +47,8 @@ export function SavedScreen() {
// Server state carries titles/topics; the signed-out demo resolves bookmark
// ids against the bundled catalog (issue #90).
const saved: SavedItem[] = useMemo(() => {
- if (progress.savedConcepts) {
- return progress.savedConcepts.map((s) => ({
+ if (savedConcepts) {
+ return savedConcepts.map((s) => ({
id: s.conceptId,
title: s.title,
topicName: s.topicName,
@@ -62,7 +64,7 @@ export function SavedScreen() {
topicName: c.category as string,
likes: likedIds.has(c.id) ? 1 : 0,
}));
- }, [progress.savedConcepts, progress.bookmarks, likedIds]);
+ }, [savedConcepts, progress.bookmarks, likedIds]);
const categories = useMemo(() => {
const set = new Set();
@@ -99,7 +101,21 @@ export function SavedScreen() {
- {saved.length === 0 && !online && !progress.stats ? (
+ {loading ? (
+
+
+ Loading more saved concepts…
+
+ ) : failed ? (
+
+
+ {online ? 'Some saved concepts could not be refreshed. Tap to retry.'
+ : 'Showing downloaded concepts. Connect and tap to load more.'}
+
+
+ ) : null}
+
+ {saved.length === 0 && (loading || failed) ? null : saved.length === 0 && !online && !progress.stats ? (
) : saved.length === 0 ? (
@@ -164,7 +180,9 @@ export function SavedScreen() {
ItemSeparatorComponent={() => }
keyboardShouldPersistTaps="handled"
ListEmptyComponent={
- No saved concepts match your search.
+
+ {loading || failed ? 'No matches in the concepts loaded so far.' : 'No saved concepts match your search.'}
+
}
renderItem={({ item }) => (
},
iconButton: { width: 40, height: 40, alignItems: 'center', justifyContent: 'center' },
title: { ...typography.title, fontSize: scaleFont(20), color: colors.text },
+ loadStatus: { padding: spacing.md, gap: spacing.sm, alignItems: 'center' },
searchBox: {
flexDirection: 'row',
alignItems: 'center',
diff --git a/mobile/src/screens/StatsScreen.tsx b/mobile/src/screens/StatsScreen.tsx
index 96fc70a..6154ebf 100644
--- a/mobile/src/screens/StatsScreen.tsx
+++ b/mobile/src/screens/StatsScreen.tsx
@@ -12,6 +12,7 @@ import { CONCEPTS } from '../data/concepts';
import { ServerTopic } from '../services/topicsApi';
import { scaleFont, radius, spacing, ThemeColors, typography } from '../theme';
import { LearnedRecord } from '../types';
+import { learnedTopicCounts } from '../services/progressTotals';
interface CategoryProgress {
label: string;
@@ -40,14 +41,10 @@ function demoCategoryProgress(learnedIds: Set): CategoryProgress[] {
* bars reflect the 125+ real catalog, not the demo's 20 (issue #35). */
function serverCategoryProgress(
topics: ServerTopic[],
- learned: LearnedRecord[]
+ learned: LearnedRecord[],
+ beforeWindow?: Record,
): CategoryProgress[] {
- const learnedByTopic = new Map();
- for (const record of learned) {
- if (record.topicName) {
- learnedByTopic.set(record.topicName, (learnedByTopic.get(record.topicName) ?? 0) + 1);
- }
- }
+ const learnedByTopic = learnedTopicCounts(learned, beforeWindow);
return topics.map((t) => ({
label: t.name,
total: t.conceptCount,
@@ -80,13 +77,13 @@ export function StatsScreen() {
const categories = useMemo(
() =>
serverMode
- ? serverCategoryProgress(topics, progress.learned)
+ ? serverCategoryProgress(topics, progress.learned, progress.learnedBeforeWindow)
: demoCategoryProgress(new Set(progress.learned.map((r) => r.conceptId))),
- [serverMode, topics, progress.learned]
+ [serverMode, topics, progress.learned, progress.learnedBeforeWindow]
);
const overall = serverMode
? {
- learned: progress.learned.length,
+ learned: progress.stats?.totalLearned ?? progress.learned.length,
total: topics.reduce((sum, t) => sum + t.conceptCount, 0),
}
: {
diff --git a/mobile/src/services/accountCaches.ts b/mobile/src/services/accountCaches.ts
index 04dc6a4..48ba5c1 100644
--- a/mobile/src/services/accountCaches.ts
+++ b/mobile/src/services/accountCaches.ts
@@ -7,14 +7,22 @@
* progress) deliberately stay out.
*/
+import { invalidateAccountRequests } from '../api/client';
import { clearDailyCache } from './dailyApi';
+import { conceptCache } from './conceptApi';
import { clearNotificationPrefsCache } from './notifications';
import { clearServerStateCache } from './remoteProgressRepository';
+import { savedCollectionCache } from './savedApi';
+import { clearTopicsCache } from './topicsApi';
export async function clearAccountCaches(): Promise {
+ invalidateAccountRequests();
await Promise.all([
clearServerStateCache(),
clearDailyCache(),
+ conceptCache.clear(),
+ savedCollectionCache.clear(),
+ clearTopicsCache(),
clearNotificationPrefsCache(),
]);
}
diff --git a/mobile/src/services/conceptApi.ts b/mobile/src/services/conceptApi.ts
index fdae75a..76186c3 100644
--- a/mobile/src/services/conceptApi.ts
+++ b/mobile/src/services/conceptApi.ts
@@ -1,5 +1,9 @@
-import { apiRequest } from '../api/client';
+import AsyncStorage from '@react-native-async-storage/async-storage';
+import { apiRequest, getConnectivity } from '../api/client';
import { Category, Concept } from '../types';
+import { OfflineCache } from './offlineCache';
+
+export const conceptCache = new OfflineCache(AsyncStorage, 'one-concept/concepts/v1/');
/** Server shape from GET /v1/concepts/{slug} (matches the daily ConceptOut). */
interface ConceptResponse {
@@ -18,7 +22,7 @@ interface ConceptResponse {
* concept's local id (see dailyApi.toConcept), so History/Saved conceptIds pass
* straight through here.
*/
-export async function fetchConcept(slug: string): Promise {
+async function downloadConcept(slug: string): Promise {
const c = await apiRequest(`/v1/concepts/${encodeURIComponent(slug)}`);
return {
id: c.slug,
@@ -29,3 +33,44 @@ export async function fetchConcept(slug: string): Promise {
likeCount: c.like_count ?? 0,
};
}
+
+/** Paint cached text immediately, then refresh counts/content when reachable. */
+export async function fetchConcept(
+ slug: string,
+ onCached?: (concept: Concept) => void,
+): Promise {
+ const epoch = conceptCache.epoch;
+ const cached = await conceptCache.get(slug, epoch);
+ if (cached) {
+ onCached?.(cached);
+ if (!getConnectivity()) return cached;
+ }
+ try {
+ const concept = await downloadConcept(slug);
+ await conceptCache.set(slug, concept, epoch).catch(() => {});
+ return concept;
+ } catch (error) {
+ if (cached) return cached;
+ throw error;
+ }
+}
+
+/** Download every missing saved lesson with bounded concurrency. Individual
+ * entries avoid one large AsyncStorage row; already-downloaded lessons stay. */
+export async function cacheSavedConcepts(slugs: string[], epoch = conceptCache.epoch): Promise {
+ const remaining = [...new Set(slugs)];
+ let cursor = 0;
+ const worker = async () => {
+ while (cursor < remaining.length && epoch === conceptCache.epoch && getConnectivity()) {
+ const slug = remaining[cursor++];
+ if (await conceptCache.get(slug, epoch)) continue;
+ if (epoch !== conceptCache.epoch) return;
+ try {
+ await conceptCache.set(slug, await downloadConcept(slug), epoch);
+ } catch {
+ // Missing downloads retry on the next successful state load/reconnect.
+ }
+ }
+ };
+ await Promise.all(Array.from({ length: Math.min(3, remaining.length) }, worker));
+}
diff --git a/mobile/src/services/mutationOutbox.ts b/mobile/src/services/mutationOutbox.ts
new file mode 100644
index 0000000..4e952b9
--- /dev/null
+++ b/mobile/src/services/mutationOutbox.ts
@@ -0,0 +1,90 @@
+interface Storage {
+ getItem(key: string): Promise;
+ setItem(key: string, value: string): Promise;
+ removeItem(key: string): Promise;
+}
+
+export type QueuedMutation =
+ | { kind: 'like'; slug: string; desired: boolean }
+ | { kind: 'save'; slug: string; desired: boolean }
+ | { kind: 'topics'; slugs: string[] }
+ // The date it was completed: /v1/daily/complete only completes "today", so a
+ // 'learn' queued on a previous day must be dropped, not replayed (#133).
+ | { kind: 'learn'; date: string };
+
+/** Stable coalescing key — one pending intent per (kind, target). */
+export function keyOf(m: QueuedMutation): string {
+ switch (m.kind) {
+ case 'like':
+ return `like:${m.slug}`;
+ case 'save':
+ return `save:${m.slug}`;
+ case 'topics':
+ return 'topics';
+ case 'learn':
+ return 'learn';
+ }
+}
+
+/** Serialize disk operations so replay acknowledgements cannot race a new
+ * intent or resurrect data after sign-out. Keeps the existing disk format. */
+export class MutationOutbox {
+ private storage: Storage;
+ private key: string;
+ private epoch = 0;
+ private tail: Promise = Promise.resolve();
+ private listeners = new Set<() => void>();
+
+ constructor(storage: Storage, key: string) { this.storage = storage; this.key = key; }
+
+ subscribe = (listener: () => void): (() => void) => {
+ this.listeners.add(listener);
+ return () => { this.listeners.delete(listener); };
+ };
+
+ private async read(): Promise> {
+ const raw = await this.storage.getItem(this.key);
+ try { return raw ? JSON.parse(raw) : {}; } catch { return {}; }
+ }
+
+ private serial(operation: () => Promise): Promise {
+ const next = this.tail.then(operation);
+ this.tail = next.catch(() => {});
+ return next;
+ }
+
+ enqueue(mutation: QueuedMutation): Promise {
+ const epoch = this.epoch;
+ return this.serial(async () => {
+ const map = await this.read();
+ if (epoch !== this.epoch) return;
+ map[keyOf(mutation)] = mutation;
+ await this.storage.setItem(this.key, JSON.stringify(map));
+ if (epoch === this.epoch) this.listeners.forEach(listener => listener());
+ });
+ }
+
+ dequeue(key: string, expected?: QueuedMutation): Promise {
+ const epoch = this.epoch;
+ return this.serial(async () => {
+ const map = await this.read();
+ if (epoch !== this.epoch || !(key in map)) return;
+ if (expected && JSON.stringify(map[key]) !== JSON.stringify(expected)) return;
+ delete map[key];
+ await this.storage.setItem(this.key, JSON.stringify(map));
+ });
+ }
+
+ pending(): Promise {
+ const epoch = this.epoch;
+ return this.serial(async () => {
+ const map = await this.read();
+ return epoch === this.epoch ? Object.values(map) : [];
+ });
+ }
+
+ clear(): Promise {
+ this.epoch += 1;
+ return this.serial(() => this.storage.removeItem(this.key));
+ }
+}
diff --git a/mobile/src/services/mutationQueue.ts b/mobile/src/services/mutationQueue.ts
index d2a1c8d..ac7004b 100644
--- a/mobile/src/services/mutationQueue.ts
+++ b/mobile/src/services/mutationQueue.ts
@@ -1,84 +1,12 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
-
-/**
- * Durable outbox for mutations made while offline (issue #133).
- *
- * Entries are COALESCED by a stable key, keeping only the latest intent — the
- * server operations are all idempotent or whole-list (like/save via PUT/DELETE,
- * topics via a whole-list PUT, daily-complete is idempotent), so replaying the
- * final desired state is correct and order across different keys doesn't matter.
- * A like→unlike→like offline collapses to a single "like"; two topic edits keep
- * only the final set.
- */
-const QUEUE_KEY = 'one-concept/mutation-queue/v1';
-
-export type QueuedMutation =
- | { kind: 'like'; slug: string; desired: boolean }
- | { kind: 'save'; slug: string; desired: boolean }
- | { kind: 'topics'; slugs: string[] }
- // The date it was completed: /v1/daily/complete only completes "today", so a
- // 'learn' queued on a previous day must be dropped, not replayed (#133).
- | { kind: 'learn'; date: string };
-
-/** Stable coalescing key — one pending intent per (kind, target). */
-export function keyOf(m: QueuedMutation): string {
- switch (m.kind) {
- case 'like':
- return `like:${m.slug}`;
- case 'save':
- return `save:${m.slug}`;
- case 'topics':
- return 'topics';
- case 'learn':
- return 'learn';
- }
-}
-
-// In-memory mirror of the persisted map, lazily loaded once.
-let map: Record | null = null;
-
-async function ensureLoaded(): Promise> {
- if (map) return map;
- try {
- const raw = await AsyncStorage.getItem(QUEUE_KEY);
- map = raw ? (JSON.parse(raw) as Record) : {};
- } catch {
- map = {};
- }
- return map;
-}
-
-async function persist(): Promise {
- if (map) await AsyncStorage.setItem(QUEUE_KEY, JSON.stringify(map)).catch(() => {});
-}
-
-/** Add or replace the intent for its key (latest wins). */
-export async function enqueue(m: QueuedMutation): Promise {
- const q = await ensureLoaded();
- q[keyOf(m)] = m;
- await persist();
-}
-
-/**
- * Remove the intent at a key. If `expected` is given, only remove it when the
- * stored intent still equals it — so a flush that replayed an old intent can't
- * clobber a newer one enqueued for the same key mid-flush (#133).
- */
-export async function dequeue(key: string, expected?: QueuedMutation): Promise {
- const q = await ensureLoaded();
- if (!(key in q)) return;
- if (expected && JSON.stringify(q[key]) !== JSON.stringify(expected)) return;
- delete q[key];
- await persist();
-}
-
-/** All pending intents (order across keys is not significant). */
-export async function pending(): Promise {
- return Object.values(await ensureLoaded());
-}
-
-/** Drop everything — used on sign-out so one account's queue can't leak. */
-export async function clearQueue(): Promise {
- map = {};
- await AsyncStorage.removeItem(QUEUE_KEY).catch(() => {});
-}
+import { MutationOutbox, QueuedMutation } from './mutationOutbox';
+export { keyOf } from './mutationOutbox';
+export type { QueuedMutation } from './mutationOutbox';
+
+// Preserve existing queues when an installed app receives the update.
+const queue = new MutationOutbox(AsyncStorage, 'one-concept/mutation-queue/v1');
+export const subscribeQueue = queue.subscribe;
+export const enqueue = (mutation: QueuedMutation) => queue.enqueue(mutation);
+export const dequeue = (key: string, expected?: QueuedMutation) => queue.dequeue(key, expected);
+export const pending = () => queue.pending();
+export const clearQueue = () => queue.clear();
diff --git a/mobile/src/services/notifications.ts b/mobile/src/services/notifications.ts
index 9d22a9a..4c6a5d0 100644
--- a/mobile/src/services/notifications.ts
+++ b/mobile/src/services/notifications.ts
@@ -37,7 +37,7 @@ Notifications.setNotificationHandler({
export async function syncTimezone(): Promise {
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
if (!timezone) return;
- await apiRequest('/v1/me', { method: 'PATCH', body: { timezone } });
+ await apiRequest('/v1/me?compact=true', { method: 'PATCH', body: { timezone } });
}
/** Ask permission (first run only) and register this handset's token. */
diff --git a/mobile/src/services/offlineCache.ts b/mobile/src/services/offlineCache.ts
new file mode 100644
index 0000000..3269950
--- /dev/null
+++ b/mobile/src/services/offlineCache.ts
@@ -0,0 +1,55 @@
+export interface CacheStorage {
+ getItem(key: string): Promise;
+ setItem(key: string, value: string): Promise;
+ getAllKeys(): Promise;
+ multiRemove(keys: string[]): Promise;
+}
+
+/** Per-entry disk storage. Serial writes and a generation fence make sign-out
+ * win over pending reads, writes, and downloads from the previous account. */
+export class OfflineCache {
+ private storage: CacheStorage;
+ private prefix: string;
+ private writes: Promise = Promise.resolve();
+ private generation = 0;
+
+ constructor(storage: CacheStorage, prefix: string) {
+ this.storage = storage;
+ this.prefix = prefix;
+ }
+
+ get epoch(): number { return this.generation; }
+
+ async get(id: string, epoch = this.epoch): Promise {
+ await this.writes;
+ if (epoch !== this.epoch) return null;
+ try {
+ const raw = await this.storage.getItem(this.prefix + id);
+ return raw && epoch === this.epoch ? JSON.parse(raw) as T : null;
+ } catch {
+ return null;
+ }
+ }
+
+ set(id: string, value: T, epoch = this.epoch): Promise {
+ const raw = JSON.stringify(value);
+ return this.write(async () => {
+ if (epoch === this.epoch) await this.storage.setItem(this.prefix + id, raw);
+ });
+ }
+
+ clear(): Promise {
+ this.generation += 1;
+ return this.write(async () => {
+ const keys = (await this.storage.getAllKeys()).filter(key => key.startsWith(this.prefix));
+ if (keys.length) await this.storage.multiRemove(keys);
+ });
+ }
+
+ private write(operation: () => Promise): Promise {
+ const next = this.writes.then(operation);
+ // A disk failure must not prevent later writes or account cleanup.
+ this.writes = next.catch(() => {});
+ return next;
+ }
+}
diff --git a/mobile/src/services/pendingProgress.ts b/mobile/src/services/pendingProgress.ts
new file mode 100644
index 0000000..63617d4
--- /dev/null
+++ b/mobile/src/services/pendingProgress.ts
@@ -0,0 +1,32 @@
+import type { ProgressState } from '../types';
+import type { QueuedMutation } from './mutationOutbox';
+
+/** A server refresh must not erase actions still waiting for acknowledgement. */
+export function withPendingProgress(
+ server: ProgressState,
+ previous: ProgressState,
+ mutations: QueuedMutation[],
+): ProgressState {
+ let state = server;
+ for (const mutation of mutations) {
+ if (mutation.kind === 'like' || mutation.kind === 'save') {
+ const key = mutation.kind === 'like' ? 'likes' : 'bookmarks';
+ const ids = state[key].filter(id => id !== mutation.slug);
+ if (mutation.desired) ids.push(mutation.slug);
+ state = { ...state, [key]: ids };
+ if (mutation.kind === 'save') {
+ const saved = (state.savedConcepts ?? []).filter(row => row.conceptId !== mutation.slug);
+ const row = previous.savedConcepts?.find(row => row.conceptId === mutation.slug)
+ ?? server.savedConcepts?.find(row => row.conceptId === mutation.slug);
+ if (mutation.desired && row) saved.push(row);
+ state = { ...state, savedConcepts: saved };
+ }
+ } else if (mutation.kind === 'learn' && mutation.date === state.assignment?.date) {
+ const record = previous.learned.find(row => row.date === mutation.date);
+ if (record && !state.learned.some(row => row.date === mutation.date)) {
+ state = { ...state, learned: [...state.learned, record], stats: previous.stats ?? state.stats };
+ }
+ }
+ }
+ return state;
+}
diff --git a/mobile/src/services/progressTotals.ts b/mobile/src/services/progressTotals.ts
new file mode 100644
index 0000000..18ecd4a
--- /dev/null
+++ b/mobile/src/services/progressTotals.ts
@@ -0,0 +1,15 @@
+import type { LearnedRecord } from '../types';
+
+/** Older aggregate counts plus the recent records (including offline completions). */
+export function learnedTopicCounts(
+ learned: LearnedRecord[],
+ beforeWindow: Record = {},
+): Map {
+ const counts = new Map(Object.entries(beforeWindow));
+ for (const record of learned) {
+ if (record.topicName) {
+ counts.set(record.topicName, (counts.get(record.topicName) ?? 0) + 1);
+ }
+ }
+ return counts;
+}
diff --git a/mobile/src/services/remoteProgressRepository.ts b/mobile/src/services/remoteProgressRepository.ts
index f5340ae..1566d58 100644
--- a/mobile/src/services/remoteProgressRepository.ts
+++ b/mobile/src/services/remoteProgressRepository.ts
@@ -2,6 +2,9 @@ import AsyncStorage from '@react-native-async-storage/async-storage';
import { ApiError, apiRequest } from '../api/client';
import { Category, DailyPayload, ProgressState } from '../types';
import { todayKey } from './dates';
+import { cacheSavedConcepts, conceptCache } from './conceptApi';
+import { toConcept } from './dailyApi';
+import { withPendingProgress } from './pendingProgress';
import { clearQueue, dequeue, enqueue, keyOf, pending, QueuedMutation } from './mutationQueue';
import { ProgressRepository } from './progressRepository';
import { EMPTY_PROGRESS } from './storage';
@@ -29,6 +32,8 @@ interface StatePayload {
likes: string[];
bookmarks: string[];
saved?: { concept_slug: string; title?: string; topic_name?: string; like_count?: number }[];
+ learned_before_window?: Record | null;
+ saved_next_cursor?: string | null;
stats: { current: number; longest: number; total_learned: number };
assignment_slug: string | null;
daily?: DailyPayload | null;
@@ -57,6 +62,8 @@ function toProgressState(payload: StatePayload): ProgressState {
topicName: s.topic_name || '',
likeCount: s.like_count ?? 0,
})),
+ learnedBeforeWindow: payload.learned_before_window ?? undefined,
+ savedNextCursor: payload.saved_next_cursor,
// Server-computed, so the day boundary comes from the user's stored
// timezone rather than whatever the device clock happens to say.
stats: {
@@ -94,23 +101,42 @@ export class RemoteProgressRepository implements ProgressRepository {
}
private async fromState(payload: StatePayload, epoch: number): Promise {
- return this.remember(toProgressState(payload), epoch);
+ if (epoch !== this.epoch) return EMPTY_PROGRESS;
+ const state = withPendingProgress(toProgressState(payload), this.cache, await pending());
+ if (epoch !== this.epoch) return EMPTY_PROGRESS;
+ // Keep each day's full text for later History/Saved reading, and download
+ // saved bodies without holding up the initial screen.
+ const contentEpoch = conceptCache.epoch;
+ if (payload.daily) {
+ const concept = toConcept(payload.daily);
+ await conceptCache.set(concept.id, concept, contentEpoch).catch(() => {});
+ }
+ if (epoch !== this.epoch) return EMPTY_PROGRESS;
+ void cacheSavedConcepts(state.bookmarks, contentEpoch);
+ return this.remember(state, epoch);
}
/** Drop the in-memory state; the module singleton outlives a sign-out. The
* offline queue is account data too, so it goes with it. */
- forget(): void {
+ forget(): Promise {
this.epoch += 1;
this.cache = EMPTY_PROGRESS;
- clearQueue().catch(() => {});
+ return clearQueue();
}
async loadCached(): Promise {
const epoch = this.epoch;
+ const contentEpoch = conceptCache.epoch;
const raw = await AsyncStorage.getItem(CACHE_KEY).catch(() => null);
if (!raw || epoch !== this.epoch) return null;
try {
this.cache = JSON.parse(raw) as ProgressState;
+ // Also upgrade an existing installation's cached Today while offline.
+ if (this.cache.serverDaily?.status === 'ok') {
+ const concept = toConcept(this.cache.serverDaily.payload);
+ await conceptCache.set(concept.id, concept, contentEpoch).catch(() => {});
+ }
+ if (epoch !== this.epoch) return null;
return this.cache;
} catch {
return null;
@@ -120,7 +146,7 @@ export class RemoteProgressRepository implements ProgressRepository {
async load(): Promise {
const epoch = this.epoch;
try {
- return await this.fromState(await apiRequest('/v1/me/state'), epoch);
+ return await this.fromState(await apiRequest('/v1/me/state?compact=true'), epoch);
} catch {
const raw = await AsyncStorage.getItem(CACHE_KEY).catch(() => null);
if (raw && epoch === this.epoch) {
@@ -160,6 +186,7 @@ export class RemoteProgressRepository implements ProgressRepository {
try {
done = await apiRequest('/v1/daily/complete', { method: 'POST' });
} catch (err) {
+ if (epoch !== this.epoch) return EMPTY_PROGRESS;
if (isOffline(err)) {
// Queue the completion (with the date — it can only be replayed today)
// and persist the optimistic record + streak bump so History AND the
@@ -181,6 +208,7 @@ export class RemoteProgressRepository implements ProgressRepository {
}
throw err;
}
+ if (epoch !== this.epoch) return EMPTY_PROGRESS;
await dequeue('learn');
// Reload the full state so History shows the true server record — the actual
@@ -188,7 +216,7 @@ export class RemoteProgressRepository implements ProgressRepository {
// guess (the caller's concept id, no title). Without this the History tab
// only caught up on a full reload, i.e. an app restart (issue #91).
try {
- return await this.fromState(await apiRequest('/v1/me/state'), epoch);
+ return await this.fromState(await apiRequest('/v1/me/state?compact=true'), epoch);
} catch {
// The completion already persisted; a failed reload must not roll it back.
// Patch in place using the caller's concept id (the cached assignment can
@@ -219,13 +247,15 @@ export class RemoteProgressRepository implements ProgressRepository {
// Whole-list semantics: PUT replaces the set, so a retry is harmless.
try {
- const payload = await apiRequest('/v1/me/topics', {
+ const payload = await apiRequest('/v1/me/topics?compact=true', {
method: 'PUT',
body: { topics: slugs },
});
+ if (epoch !== this.epoch) return EMPTY_PROGRESS;
await dequeue('topics');
return this.fromState(payload, epoch);
} catch (err) {
+ if (epoch !== this.epoch) return EMPTY_PROGRESS;
if (isOffline(err)) {
await enqueue({ kind: 'topics', slugs });
return this.remember({ ...this.cache, followedTopics: next }, epoch);
@@ -258,9 +288,11 @@ export class RemoteProgressRepository implements ProgressRepository {
};
try {
await this.toggle(conceptId, 'like', currently);
+ if (epoch !== this.epoch) return EMPTY_PROGRESS;
await dequeue(`like:${conceptId}`);
return this.remember(next, epoch);
} catch (err) {
+ if (epoch !== this.epoch) return EMPTY_PROGRESS;
if (isOffline(err)) {
await enqueue({ kind: 'like', slug: conceptId, desired });
return this.remember(next, epoch);
@@ -303,12 +335,14 @@ export class RemoteProgressRepository implements ProgressRepository {
try {
await this.toggle(conceptId, 'save', currently);
} catch (err) {
+ if (epoch !== this.epoch) return EMPTY_PROGRESS;
if (isOffline(err)) {
await enqueue({ kind: 'save', slug: conceptId, desired });
return this.remember(patched(), epoch);
}
throw err;
}
+ if (epoch !== this.epoch) return EMPTY_PROGRESS;
await dequeue(`save:${conceptId}`);
// The save/unsave has already persisted. Refresh the full state so the saved
// list (which needs each concept's title/topic) reflects it — but if that
@@ -316,7 +350,7 @@ export class RemoteProgressRepository implements ProgressRepository {
// by the UI. Fall back to patching in place; the saved list catches up on
// the next successful load.
try {
- return await this.fromState(await apiRequest('/v1/me/state'), epoch);
+ return await this.fromState(await apiRequest('/v1/me/state?compact=true'), epoch);
} catch {
return this.remember(patched(), epoch);
}
@@ -348,8 +382,10 @@ export class RemoteProgressRepository implements ProgressRepository {
try {
await this.replay(m);
+ if (epoch !== this.epoch) return null;
await dequeue(keyOf(m), m); // guarded: don't clobber a newer same-key intent
} catch (err) {
+ if (epoch !== this.epoch) return null;
if (isOffline(err)) return null; // still offline — keep the rest queued
if (err instanceof ApiError && err.status >= 500) continue; // transient — retry next time
await dequeue(keyOf(m), m); // 4xx: unfixable, drop so it can't block forever
@@ -358,7 +394,7 @@ export class RemoteProgressRepository implements ProgressRepository {
if (epoch !== this.epoch) return null;
try {
- return await this.fromState(await apiRequest('/v1/me/state'), epoch);
+ return await this.fromState(await apiRequest('/v1/me/state?compact=true'), epoch);
} catch {
return null;
}
@@ -377,7 +413,7 @@ export class RemoteProgressRepository implements ProgressRepository {
});
return;
case 'topics':
- await apiRequest('/v1/me/topics', { method: 'PUT', body: { topics: m.slugs } });
+ await apiRequest('/v1/me/topics?compact=true', { method: 'PUT', body: { topics: m.slugs } });
return;
case 'learn':
await apiRequest('/v1/daily/complete', { method: 'POST' });
@@ -391,6 +427,8 @@ export const remoteProgressRepository = new RemoteProgressRepository();
/** Forget everything: the disk cache AND the singleton's in-memory copy.
* Called on sign-out so the next account can never see this one's data. */
export async function clearServerStateCache(): Promise {
- remoteProgressRepository.forget();
- await AsyncStorage.removeItem(CACHE_KEY).catch(() => {});
+ await Promise.all([
+ remoteProgressRepository.forget(),
+ AsyncStorage.removeItem(CACHE_KEY),
+ ]);
}
diff --git a/mobile/src/services/savedApi.ts b/mobile/src/services/savedApi.ts
new file mode 100644
index 0000000..ea140cb
--- /dev/null
+++ b/mobile/src/services/savedApi.ts
@@ -0,0 +1,21 @@
+import AsyncStorage from '@react-native-async-storage/async-storage';
+import { apiRequest } from '../api/client';
+import { SavedConcept } from '../types';
+import { OfflineCache } from './offlineCache';
+
+export const savedCollectionCache = new OfflineCache(AsyncStorage, 'one-concept/saved-list/v1/');
+
+interface SavedPage {
+ items: { concept_slug: string; title: string; topic_name: string; like_count: number }[];
+ next_cursor: string | null;
+}
+
+export async function fetchSavedPage(cursor: string) {
+ const page = await apiRequest(`/v1/me/saved?limit=50&cursor=${encodeURIComponent(cursor)}`);
+ return {
+ items: page.items.map(s => ({
+ conceptId: s.concept_slug, title: s.title, topicName: s.topic_name, likeCount: s.like_count,
+ })),
+ nextCursor: page.next_cursor,
+ };
+}
diff --git a/mobile/src/services/syncLoop.ts b/mobile/src/services/syncLoop.ts
new file mode 100644
index 0000000..e8079f7
--- /dev/null
+++ b/mobile/src/services/syncLoop.ts
@@ -0,0 +1,55 @@
+/** Retry only while work remains or the API is unreachable. No native module:
+ * timers pause in the background and resume immediately on foregrounding. */
+export function createSyncLoop(run: () => Promise, initiallyActive = true) {
+ let active = initiallyActive;
+ let stopped = false;
+ let running = false;
+ let wakeAgain = false;
+ let delay = 5000;
+ let timer: ReturnType | undefined;
+
+ const cancel = () => {
+ if (timer !== undefined) clearTimeout(timer);
+ timer = undefined;
+ };
+ const schedule = (ms: number) => {
+ if (!active || stopped) return;
+ cancel();
+ timer = setTimeout(async () => {
+ timer = undefined;
+ running = true;
+ let retry = true;
+ try { retry = await run(); } catch { /* retry after transient failure */ }
+ finally {
+ running = false;
+ const repeat = wakeAgain;
+ wakeAgain = false;
+ // A successful request can report online before a later request in
+ // this run fails. Such wakeups must not bypass or reset retry backoff.
+ if (retry) {
+ schedule(delay);
+ delay = Math.min(delay * 2, 30000);
+ } else {
+ delay = 5000;
+ if (repeat) schedule(0);
+ }
+ }
+ }, ms);
+ };
+ const wake = () => {
+ if (!active || stopped) return;
+ if (running) wakeAgain = true;
+ else { delay = 5000; schedule(0); }
+ };
+
+ return {
+ wake,
+ retry: () => { if (!running && timer === undefined) schedule(delay); },
+ setActive: (next: boolean) => {
+ active = next;
+ if (active) wake();
+ else { cancel(); wakeAgain = false; }
+ },
+ stop: () => { stopped = true; cancel(); wakeAgain = false; },
+ };
+}
diff --git a/mobile/src/services/topicStore.ts b/mobile/src/services/topicStore.ts
new file mode 100644
index 0000000..e8557e5
--- /dev/null
+++ b/mobile/src/services/topicStore.ts
@@ -0,0 +1,87 @@
+export interface ServerTopic {
+ slug: string;
+ name: string;
+ conceptCount: number;
+ following: boolean;
+}
+
+interface Dependencies {
+ read: () => Promise;
+ write: (topics: ServerTopic[]) => Promise;
+ fetch: () => Promise;
+ pending: () => Promise;
+ enqueue: (slugs: string[]) => Promise;
+}
+
+/** Shared catalog and optimistic follow state for Personalization and Stats. */
+export class TopicStore {
+ private deps: Dependencies;
+ private topics: ServerTopic[] = [];
+ private revision = 0;
+ private epoch = 0;
+ private writes: Promise = Promise.resolve();
+ private listeners = new Set<() => void>();
+
+ constructor(deps: Dependencies) { this.deps = deps; }
+
+ getSnapshot = (): ServerTopic[] => this.topics;
+ subscribe = (listener: () => void): (() => void) => {
+ this.listeners.add(listener);
+ return () => { this.listeners.delete(listener); };
+ };
+
+ private publish(topics: ServerTopic[]): void {
+ this.topics = topics;
+ this.listeners.forEach(listener => listener());
+ }
+
+ async load(): Promise {
+ const revision = ++this.revision;
+ const cached = await this.deps.read();
+ const overlay = (rows: ServerTopic[], slugs?: string[]) => slugs
+ ? rows.map(topic => ({ ...topic, following: slugs.includes(topic.slug) }))
+ : rows;
+ const queued = await this.deps.pending();
+ if (revision !== this.revision) return this.topics;
+ if (cached && this.topics.length === 0) this.publish(overlay(cached, queued));
+ try {
+ const rows = await this.deps.fetch();
+ const pending = await this.deps.pending();
+ if (revision !== this.revision) return this.topics;
+ this.publish(overlay(rows, pending));
+ await this.deps.write(this.topics);
+ return this.topics;
+ } catch (error) {
+ if (revision !== this.revision || this.topics.length) return this.topics;
+ throw error;
+ }
+ }
+
+ toggle(slug: string): Promise {
+ if (!this.topics.some(topic => topic.slug === slug)) return Promise.resolve();
+ const before = this.topics;
+ const next = before.map(topic => topic.slug === slug
+ ? { ...topic, following: !topic.following } : topic);
+ const revision = ++this.revision;
+ const epoch = this.epoch;
+ this.publish(next);
+ const write = this.writes.then(async () => {
+ if (epoch !== this.epoch) return;
+ // All follows use the durable queue, even online. A single replay path
+ // preserves order when connectivity changes during rapid taps.
+ await this.deps.enqueue(next.filter(topic => topic.following).map(topic => topic.slug));
+ if (epoch === this.epoch) await this.deps.write(next);
+ });
+ this.writes = write.catch(() => {});
+ return write.catch(error => {
+ if (revision === this.revision) this.publish(before);
+ throw error;
+ });
+ }
+
+ reset(): void {
+ this.epoch += 1;
+ this.revision += 1;
+ this.publish([]);
+ }
+}
diff --git a/mobile/src/services/topicsApi.ts b/mobile/src/services/topicsApi.ts
index 07c5f36..796d8b1 100644
--- a/mobile/src/services/topicsApi.ts
+++ b/mobile/src/services/topicsApi.ts
@@ -5,14 +5,14 @@
* slug space so a topic the app has never heard of is never dropped.
*/
+import AsyncStorage from '@react-native-async-storage/async-storage';
import { apiRequest } from '../api/client';
+import { enqueue, pending } from './mutationQueue';
+import { OfflineCache } from './offlineCache';
+import { TopicStore, ServerTopic } from './topicStore';
-export interface ServerTopic {
- slug: string;
- name: string;
- conceptCount: number;
- following: boolean;
-}
+export type { ServerTopic } from './topicStore';
+const cache = new OfflineCache(AsyncStorage, 'one-concept/topics/v1/');
interface TopicPayload {
slug: string;
@@ -21,19 +21,22 @@ interface TopicPayload {
following: boolean;
}
-export async function fetchTopics(): Promise {
- const rows = await apiRequest('/v1/topics');
- return rows.map((r) => ({
- slug: r.slug,
- name: r.name,
- conceptCount: r.concept_count,
- following: r.following,
- }));
-}
+export const topicStore = new TopicStore({
+ read: () => cache.get('catalog'),
+ write: topics => cache.set('catalog', topics),
+ fetch: async () => {
+ const rows = await apiRequest('/v1/topics');
+ return rows.map(r => ({
+ slug: r.slug, name: r.name, conceptCount: r.concept_count, following: r.following,
+ }));
+ },
+ pending: async () => (await pending()).find(m => m.kind === 'topics')?.slugs,
+ enqueue: slugs => enqueue({ kind: 'topics', slugs }),
+});
+
+export const fetchTopics = () => topicStore.load();
-/** Replace the followed set. Whole-list semantics: the caller sends every
- * slug it wants followed, so nothing it omits by accident survives — which
- * is exactly why the caller must pass the full server list, not a subset. */
-export async function setFollowedTopics(slugs: string[]): Promise {
- await apiRequest('/v1/me/topics', { method: 'PUT', body: { topics: slugs } });
+export async function clearTopicsCache(): Promise {
+ topicStore.reset();
+ await cache.clear();
}
diff --git a/mobile/src/types/index.ts b/mobile/src/types/index.ts
index b51c2d1..f1ddeef 100644
--- a/mobile/src/types/index.ts
+++ b/mobile/src/types/index.ts
@@ -103,6 +103,10 @@ export interface ProgressState {
* which resolves saved items against the bundled catalog instead.
*/
savedConcepts?: SavedConcept[];
+ /** Counts older than the recent learned window; absent on legacy responses. */
+ learnedBeforeWindow?: Record;
+ /** Continuation after the embedded saved window; absent on legacy responses. */
+ savedNextCursor?: string | null;
/**
* Streaks as computed by the server, when the state came from the server.
* Absent for purely local state, where the client derives them instead.
diff --git a/mobile/tests/README.md b/mobile/tests/README.md
index 2b18d4e..57b44fd 100644
--- a/mobile/tests/README.md
+++ b/mobile/tests/README.md
@@ -12,10 +12,96 @@ For UI checks, use a test account and exercise these flows on preview:
- Explicitly sign out, then reopen: the previous account's content must be gone.
- Open without cached app data while offline: Today, History, Saved, Stats, and
Personalization should explain unavailable content. Try again after reconnecting.
-- Open a history/saved concept unavailable offline, then retry online in place.
+- While online, open a concept and let saved lessons download. Restart offline:
+ previously viewed lessons and saved lessons should retain full text/examples.
+ A lesson never downloaded should show the unavailable state and retry online.
+- Open Personalization offline after an online session. Follow/unfollow topics,
+ including server-added topics, restart offline, and confirm the choices persist.
+- Like/save and change topics offline, then restore connectivity while staying
+ on the same screen. The queue should drain automatically (native retry delay
+ grows from 5 to at most 30 seconds). Repeat by reopening/foregrounding the app.
+ Background timers stop; closed-app OS synchronization is outside this scope.
+- Interrupt connectivity during replay and try rapid repeated toggles. The last
+ choice should survive, including across a restart. Sign out with pending work
+ and confirm the next account sees no old lessons, catalog, or queued actions.
- Try signing in, signing up, and resetting a password offline: show connection
advice without raw Java/JavaScript diagnostics. Invalid credentials remain clear.
+- In sign-in and signup, reveal/hide a typed password with the eye button. The
+ value should stay intact, including with the native keyboard and autofill.
+ Switching modes or submitting hides it again; the button is disabled during
+ requests. Check screen-reader labels, large text, and light/dark themes.
- Check light/dark themes and large text. The offline cloud should float gently,
remain still with reduced motion, and stop while its screen/app is inactive.
-These targeted tests do not cover the separate mutation-queue work in issue #157.
+The Node tests cover storage ordering, account cleanup races, cached topics,
+latest-intent coalescing, and retry scheduling. End-to-end API replay and native
+lifecycle behavior still require the UI checks above; they are not simulated by
+the storage unit tests.
+
+## Mocked browser regression
+
+`offline.browser.cjs` exercises the actual exported app with dummy authentication
+and intercepted API calls. It uses an existing Playwright installation; set
+`PLAYWRIGHT_TEST_MODULE` to its `playwright/test` module if it is outside this
+project, and optionally set `PLAYWRIGHT_CHROMIUM_PATH` to a Chromium executable.
+No live account or backend is used. From `mobile/`, export and run:
+
+```sh
+CI=1 EXPO_NO_DOTENV=1 EXPO_NO_TELEMETRY=1 EXPO_OFFLINE=1 \
+ EXPO_PUBLIC_API_BASE_URL=http://127.0.0.1:4781/api \
+ EXPO_PUBLIC_SUPABASE_URL=http://127.0.0.1:4781 \
+ EXPO_PUBLIC_SUPABASE_ANON_KEY=test-public-key \
+ npx expo export --platform web --clear --output-dir /tmp/one-concept-offline
+node tests/offline.browser.cjs /tmp/one-concept-offline \
+ --no-event --retry-error --signout-inflight
+node tests/offline.browser.cjs /tmp/one-concept-offline --midnight
+node tests/offline.browser.cjs /tmp/one-concept-offline --partial-connectivity
+node tests/offline.browser.cjs /tmp/one-concept-offline --large-collections --require-compact
+node tests/offline.browser.cjs /tmp/one-concept-offline --whats-new
+node tests/password.browser.cjs /tmp/one-concept-offline
+```
+
+`--clear` ensures a changed app version reaches the export instead of reusing
+stale Expo configuration from Metro.
+
+The flags exercise timer-only reconnection, a 503 during replay, and a request
+that fails after sign-out. Omit `--no-event` to test the browser online event.
+The separate `--midnight` scenario holds a like request across the date change,
+queues Save behind it, and verifies persistence through offline restart and replay.
+`--partial-connectivity` serves progress but fails topics, checks that requests
+back off, and restores topics to verify automatic recovery without a browser event.
+The scheduler unit tests check the full 5–30 second delay progression.
+`--large-collections` serves 50 recent records from a 365-item account. It checks
+full Stats totals, older Saved search/category filters, page failures/retry,
+manual retry immediately after connectivity returns,
+all 365 saved explanations/examples on disk after an offline restart, unopened
+downloaded lessons in the UI, offline completion/unsave, and sign-out
+while a page is in flight. `--require-compact` checks state-bearing requests opt
+into the compact API contract; it can be added to the other scenarios too.
+`COLLECTION_SCREENSHOT_PATH` optionally captures the older-item search result.
+For the unchanged pre-fix export, `--baseline` asserts the original #133 failures.
+The test uses port 4781 and closes its server/browser afterward. Optional
+`OFFLINE_SCREENSHOT_PATH` saves the offline detail view for visual inspection.
+
+`--whats-new` checks the current version's card in light/dark themes at small
+phone, standard phone, and landscape sizes. The title and Got it must stay fully
+visible, the final highlight must scroll into view, and dismissal must persist
+through an offline restart. Set `WHATS_NEW_SCREENSHOT_DIR` to save all six previews.
+Use a version with a nonempty What's New entry; the test intentionally fails if
+that required release content is missing or the export has a stale version.
+
+`password.browser.cjs` uses the same export, Playwright settings, and port 4781;
+run the browser scripts sequentially. It starts signed out and intercepts
+authentication with dummy responses. It checks visibility and keyboard control
+in both auth modes, unchanged submitted passwords, masked input and disabled
+controls during requests, signup confirmation, remasking on mode changes and reload,
+and 44px+ targets at 320/390/960px in both themes. Set `PASSWORD_SCREENSHOT_DIR`
+to save light/dark previews with empty fields. No real emails or accounts are used.
+
+For #182, full saved-body caching is already implemented on `develop`. Let the
+app finish downloading lessons while online before testing offline; a device
+cannot read a body it has never downloaded. App upgrades preserve existing
+downloads, while explicit sign-out clears account caches. The mocked browser
+checks cover web storage and app behavior; native keyboard/autofill and device
+storage still require the preview checks above. A production release is needed
+to deliver the changes to installations still running the older `main` build.
diff --git a/mobile/tests/mutationOutbox.test.mjs b/mobile/tests/mutationOutbox.test.mjs
new file mode 100644
index 0000000..6ade89f
--- /dev/null
+++ b/mobile/tests/mutationOutbox.test.mjs
@@ -0,0 +1,45 @@
+import assert from 'node:assert/strict';
+import { test } from 'node:test';
+import { MutationOutbox } from '../src/services/mutationOutbox.ts';
+
+function disk() {
+ let raw=null;
+ return {getItem:async()=>raw,setItem:async(_k,v)=>{raw=v;},removeItem:async()=>{raw=null;}};
+}
+const like={kind:'like',slug:'saved',desired:true};
+const topics={kind:'topics',slugs:['new-server-topic']};
+test('concurrent offline actions survive restart and keep only the latest intent for each key', async () => {
+ const storage=disk(), queue=new MutationOutbox(storage,'queue');
+ await Promise.all([queue.enqueue(like),queue.enqueue(topics),queue.enqueue({...like,desired:false})]);
+ assert.deepEqual(await new MutationOutbox(storage,'queue').pending(),[{...like,desired:false},topics]);
+});
+test('an old acknowledgement cannot remove a newer same-key offline action', async () => {
+ const queue=new MutationOutbox(disk(),'queue');
+ await queue.enqueue(like);
+ const [sent]=await queue.pending();
+ await Promise.all([queue.enqueue({...like,desired:false}),queue.dequeue('like:saved',sent)]);
+ assert.deepEqual(await queue.pending(),[{...like,desired:false}]);
+});
+test('sign-out clears a disk write already in flight before another account can read the queue', async () => {
+ const storage=disk(), put=storage.setItem;
+ let release, started;
+ const writing=new Promise(r=>{started=r;});
+ const finish=new Promise(r=>{release=r;});
+ storage.setItem=async(k,v)=>{started();await finish;await put(k,v);};
+ const queue=new MutationOutbox(storage,'queue');
+ const write=queue.enqueue(like);
+ await writing;
+ const clear=queue.clear();
+ release();
+ await Promise.all([write,clear]);
+ assert.deepEqual(await new MutationOutbox(storage,'queue').pending(),[]);
+});
+test('storage failures are reported and do not prevent a later retry', async () => {
+ const storage=disk(), put=storage.setItem;
+ storage.setItem=async()=>{throw new Error('disk full');};
+ const queue=new MutationOutbox(storage,'queue');
+ await assert.rejects(queue.enqueue(like),/disk full/);
+ storage.setItem=put;
+ await queue.enqueue(like);
+ assert.deepEqual(await queue.pending(),[like]);
+});
diff --git a/mobile/tests/offline.browser.cjs b/mobile/tests/offline.browser.cjs
new file mode 100644
index 0000000..c59c799
--- /dev/null
+++ b/mobile/tests/offline.browser.cjs
@@ -0,0 +1,365 @@
+const { chromium, expect } = require(process.env.PLAYWRIGHT_TEST_MODULE || 'playwright/test');
+const http = require('node:http');
+const fs = require('node:fs');
+const path = require('node:path');
+const assert = require('node:assert/strict');
+const appVersion = require('../app.config.js').expo.version;
+const root = process.argv[2];
+if (!root || !fs.existsSync(path.join(root, 'index.html'))) throw new Error('Pass an Expo web export directory as the first argument.');
+const baseline = process.argv.includes('--baseline');
+const midnight = process.argv.includes('--midnight');
+const largeCollections = process.argv.includes('--large-collections');
+const whatsNew = process.argv.includes('--whats-new');
+const date = new Date().toISOString().slice(0, 10);
+const concept = (slug, title) => ({ id: slug, slug, title, summary: `Full explanation of ${title}.`, example: `A concrete example of ${title}.`, topic_slug: 'computer-science', topic_name: 'Computer Science', like_count: 2 });
+const daily = concept('fixture-daily', 'Daily fixture');
+const saved = concept('fixture-saved', 'Saved fixture');
+const unread = concept('fixture-unread', 'Unread saved fixture');
+const state = { display_name: 'Fixture', timezone: 'UTC', today: date, followed_topics: ['computer-science'], learned: [], likes: [], bookmarks: [saved.slug, unread.slug], saved: [saved, unread].map(c => ({concept_slug:c.slug, title:c.title, topic_name:c.topic_name})), stats: {current:0,longest:0,total_learned:0}, assignment_slug: daily.slug, daily: { assigned_for: date, assigned_at: new Date().toISOString(), completed_at: null, learned:false, outside_followed_topics:false, concept:daily } };
+const collection = [saved, unread, ...Array.from({length:363}, (_, i) => concept(`older-${i}`, `Older lesson ${i}`))];
+collection[364] = {...collection[364], title:'Older mathematics fixture', topic_name:'Mathematics'};
+if (largeCollections) {
+ state.bookmarks = collection.map(c=>c.slug);
+ state.saved = collection.slice(0,50).map(c=>({concept_slug:c.slug,title:c.title,topic_name:c.topic_name,like_count:c.like_count}));
+ state.saved_next_cursor = '50';
+ state.learned = state.saved.map((c,i)=>({...c,learned_on:new Date(Date.now()-(i+1)*86400000).toISOString().slice(0,10)}));
+ state.learned_before_window = {'Computer Science':314,Mathematics:1};
+ state.stats = {current:365,longest:365,total_learned:365};
+}
+let savedRequests = 0, failSaved = false, holdSaved = false, releaseSaved;
+let online = true;
+let failLikes = false;
+let failTopics = false;
+let stateRequests = 0, topicRequests = 0;
+let holdLike = false, releaseLike;
+const writes = [];
+const errors = [];
+const session = { access_token:'fixture', refresh_token:'fixture-refresh', token_type:'bearer', expires_in:864000, expires_at:Math.floor(Date.now()/1000)+864000, user:{ id:'11111111-1111-1111-1111-111111111111', email:'fixture@example.invalid', aud:'authenticated', role:'authenticated', app_metadata:{}, user_metadata:{}, created_at:'2026-01-01T00:00:00Z' } };
+const server = http.createServer((req,res) => {
+ const relative = decodeURIComponent(new URL(req.url, 'http://localhost').pathname);
+ const file = path.join(root, relative === '/' ? 'index.html' : relative);
+ try { const body = fs.readFileSync(file); res.setHeader('Content-Type', ({'.html':'text/html','.js':'application/javascript','.ttf':'font/ttf','.png':'image/png'})[path.extname(file)] || 'application/octet-stream'); res.end(body); }
+ catch { res.statusCode=404; res.end(); }
+});
+(async () => {
+ await new Promise(r => server.listen(4781, '127.0.0.1', r));
+ const browser = await chromium.launch({ executablePath:process.env.PLAYWRIGHT_CHROMIUM_PATH, headless:true, args:['--no-sandbox'] });
+ try {
+ const context = await browser.newContext({viewport:{width:390,height:844},timezoneId:'UTC'});
+ await context.addInitScript(({session, appVersion, whatsNew}) => {
+ Object.defineProperty(navigator, 'share', {configurable:true,value:async data=>{window.fixtureShared=data;}});
+ if (!localStorage.getItem('fixture-seeded')) {
+ localStorage.setItem('sb-127-auth-token', JSON.stringify(session));
+ localStorage.setItem('one-concept/last-seen-version/v1',whatsNew ? 'previous-version' : appVersion);
+ localStorage.setItem('fixture-seeded','yes');
+ }
+ }, {session, appVersion, whatsNew});
+ await context.route('**/api/**', async route => {
+ if (!online) return route.abort('internetdisconnected');
+ const req = route.request(); const endpoint = new URL(req.url()).pathname.replace('/api','');
+ const method = req.method();
+ if (endpoint === '/v1/me/state') {
+ stateRequests++;
+ }
+ if (process.argv.includes('--require-compact') && ['/v1/me/state','/v1/me/topics','/v1/me'].includes(endpoint)) {
+ assert.equal(new URL(req.url()).searchParams.get('compact'),'true');
+ }
+ if (endpoint === '/v1/me/saved') {
+ savedRequests++;
+ if (holdSaved) await new Promise(r=>{releaseSaved=r;});
+ if (failSaved) return route.fulfill({status:503,contentType:'application/json',body:'{}'});
+ const url=new URL(req.url()); const offset=Number(url.searchParams.get('cursor'));
+ assert.equal(url.searchParams.get('limit'),'50');
+ return route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({
+ items:collection.slice(offset,offset+50).map(c=>({concept_slug:c.slug,title:c.title,topic_name:c.topic_name,like_count:c.like_count})),
+ next_cursor:offset+50{releaseLike=r;}); return route.abort('internetdisconnected'); }
+ if (method !== 'GET') writes.push({endpoint,method,body:req.postDataJSON()});
+ if (failLikes && endpoint.endsWith('/like')) return route.fulfill({status:503,contentType:'application/json',body:'{}'});
+ let body = {};
+ if (endpoint === '/v1/me/topics' && method === 'PUT') state.followed_topics = req.postDataJSON().topics;
+ const interaction = endpoint.match(/^\/v1\/concepts\/([^/]+)\/(like|save)$/);
+ if (interaction) {
+ const key=interaction[2]==='like'?'likes':'bookmarks'; const slug=interaction[1];
+ state[key] = state[key].filter(id=>id!==slug);
+ if(method==='PUT') state[key].push(slug);
+ }
+ if (endpoint === '/v1/me/state' || endpoint === '/v1/me/topics') body=state;
+ else if (endpoint === '/v1/topics' && largeCollections) body=[
+ {slug:'computer-science',name:'Computer Science',concept_count:400,following:true},
+ {slug:'mathematics',name:'Mathematics',concept_count:25,following:false},
+ ];
+ else if (endpoint === '/v1/topics') body=[{slug:'computer-science',name:'Computer Science',concept_count:25},{slug:'new-topic',name:'New topic',concept_count:12}].map(t=>({...t,following:state.followed_topics.includes(t.slug)}));
+ else if (endpoint.startsWith('/v1/concepts/') && method === 'GET') body=[daily,...(largeCollections ? collection : [saved,unread])].find(c=>endpoint.endsWith(c.slug));
+ else if (endpoint === '/v1/me/notifications') body={enabled:false,reminder_times:[]};
+ await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify(body)});
+ });
+ await context.route('**/auth/v1/**', route => route.abort('internetdisconnected'));
+ const page=await context.newPage(); page.setDefaultTimeout(12000);
+ page.on('pageerror',e=>errors.push(e.message));
+ const profile=async()=>page.getByText('Profile',{exact:true}).last().click();
+ const close=async()=>page.getByRole('button',{name:'Close',exact:true}).last().click();
+ const queue=async()=>page.evaluate(()=>JSON.parse(localStorage.getItem('one-concept/mutation-queue/v1')||'{}'));
+ if (midnight) await page.clock.setFixedTime(new Date(date+'T23:59:00Z'));
+ await page.goto('http://127.0.0.1:4781');
+ if (whatsNew) {
+ const heading = page.getByText("What's new", {exact:true});
+ const gotIt = page.getByRole('button', {name:'Got it', exact:true});
+ await expect(heading).toBeVisible();
+ await expect(page.getByText('Version ' + appVersion, {exact:true})).toBeVisible();
+ for (const colorScheme of ['light', 'dark']) {
+ await page.emulateMedia({colorScheme});
+ for (const viewport of [{width:320,height:568}, {width:390,height:844}, {width:844,height:390}]) {
+ await page.setViewportSize(viewport);
+ await expect(heading).toBeInViewport({ratio:1});
+ await expect(gotIt).toBeInViewport({ratio:1});
+ const last = page.getByTestId('release-highlight').last();
+ await last.scrollIntoViewIfNeeded();
+ await expect(last).toBeInViewport({ratio:1});
+ await expect(gotIt).toBeInViewport({ratio:1});
+ if (process.env.WHATS_NEW_SCREENSHOT_DIR) {
+ fs.mkdirSync(process.env.WHATS_NEW_SCREENSHOT_DIR, {recursive:true});
+ await page.screenshot({path:path.join(process.env.WHATS_NEW_SCREENSHOT_DIR, `${colorScheme}-${viewport.width}x${viewport.height}.png`)});
+ }
+ }
+ }
+ await gotIt.click();
+ await expect(heading).toHaveCount(0);
+ await expect.poll(() => page.evaluate(() => localStorage.getItem('one-concept/last-seen-version/v1'))).toBe(appVersion);
+ online=false;
+ await page.reload();
+ await expect(page.getByText(daily.summary, {exact:true})).toBeVisible();
+ await expect(heading).toHaveCount(0);
+ assert.deepEqual(errors, []);
+ console.log('PASS: release card fits phone/landscape layouts in both themes, all highlights scroll into view, and dismissal survives offline restart');
+ return;
+ }
+ await expect(page.getByText(daily.summary,{exact:true})).toBeVisible();
+ if (largeCollections) {
+ assert.equal(savedRequests,0,'Older Saved metadata must not load on startup');
+ await page.getByText('Stats',{exact:true}).last().click();
+ await expect(page.getByText('365 / 425',{exact:true})).toBeVisible();
+ await expect(page.getByText('364 / 400',{exact:true})).toBeVisible();
+ await expect(page.getByText('1 / 25',{exact:true})).toBeVisible();
+ console.log('PASS: compact startup retains all-time and per-topic totals');
+ await expect.poll(async()=>page.evaluate(()=>Object.keys(localStorage).filter(k=>k.startsWith('one-concept/concepts/')).length),{timeout:30000}).toBe(366);
+ // No Saved screen/cache yet: downloaded lesson bodies must supply older titles offline.
+ online=false; await page.reload();
+ await expect(page.getByText(daily.summary,{exact:true})).toBeVisible();
+ const storedLessons = await page.evaluate(slugs => slugs.map(slug => {
+ const raw = localStorage.getItem('one-concept/concepts/v1/' + slug);
+ const lesson = raw ? JSON.parse(raw) : null;
+ return lesson && {id:lesson.id, summary:lesson.summary, example:lesson.example};
+ }), collection.map(c => c.slug));
+ assert.deepEqual(storedLessons, collection.map(c => ({id:c.slug, summary:c.summary, example:c.example})));
+ console.log('PASS: all 365 saved explanations and examples persist across offline restart');
+ await profile(); await page.getByText('Saved concepts',{exact:true}).click();
+ await page.getByPlaceholder('Search saved concepts').fill('Older mathematics');
+ await page.getByRole('button',{name:'Open Older mathematics fixture',exact:true}).click();
+ await expect(page.getByText(collection[364].summary,{exact:true})).toBeVisible();
+ await expect(page.getByText(collection[364].example,{exact:true})).toBeVisible();
+ await close();
+ console.log('PASS: unopened older saved lesson remains searchable and readable offline');
+ await page.getByRole('button',{name:'Back',exact:true}).click();
+ // Force page loading to prove that search/filter do not merely rely on cached bodies.
+ await page.evaluate(()=>{for(const k of Object.keys(localStorage)) if(k.startsWith('one-concept/concepts/') || k.startsWith('one-concept/saved-list/')) localStorage.removeItem(k);});
+ await page.getByText('Saved concepts',{exact:true}).click();
+ const offlineRetry = page.getByText('Showing downloaded concepts. Connect and tap to load more.',{exact:true});
+ await expect(offlineRetry).toBeVisible();
+ const beforeManualRetry=savedRequests;
+ online=true; await offlineRetry.click();
+ await expect.poll(()=>savedRequests-beforeManualRetry,{timeout:2000}).toBe(7);
+ await page.getByRole('button',{name:'Back',exact:true}).click();
+ console.log('PASS: manual retry probes restored connectivity without waiting for background sync');
+ online=true; failSaved=true;
+ await page.reload(); await expect(page.getByText(daily.summary,{exact:true})).toBeVisible();
+ await profile(); await page.getByText('Saved concepts',{exact:true}).click();
+ await expect(page.getByText('Some saved concepts could not be refreshed. Tap to retry.',{exact:true})).toBeVisible();
+ const beforeRetry=savedRequests;
+ await page.waitForTimeout(1500); assert.equal(savedRequests,beforeRetry,'Failed pages must not spin');
+ failSaved=false;
+ await page.getByText('Some saved concepts could not be refreshed. Tap to retry.',{exact:true}).click();
+ await expect.poll(()=>savedRequests-beforeRetry).toBe(7);
+ await expect(page.getByText('Loading more saved concepts…',{exact:true})).toHaveCount(0);
+ await page.getByRole('button',{name:'Mathematics',exact:true}).click();
+ await page.getByPlaceholder('Search saved concepts').fill('Older mathematics');
+ await page.getByRole('button',{name:'Open Older mathematics fixture',exact:true}).click();
+ await expect(page.getByText(collection[364].summary,{exact:true})).toBeVisible();
+ await close();
+ if(process.env.COLLECTION_SCREENSHOT_PATH) await page.screenshot({path:process.env.COLLECTION_SCREENSHOT_PATH});
+ console.log('PASS: bounded pages, retry, older title search, and category filter');
+ online=false; await page.reload(); await expect(page.getByText(daily.summary,{exact:true})).toBeVisible();
+ await page.getByRole('button',{name:'Mark as learned',exact:true}).click();
+ await expect.poll(async()=>(await queue()).learn?.date).toBe(date);
+ await page.getByText('Stats',{exact:true}).last().click();
+ await expect(page.getByText('366 / 425',{exact:true})).toBeVisible();
+ await expect(page.getByText('365 / 400',{exact:true})).toBeVisible();
+ console.log('PASS: offline completion increments aggregate and category totals');
+ await profile(); await page.getByText('Saved concepts',{exact:true}).click();
+ await page.getByPlaceholder('Search saved concepts').fill('Older mathematics');
+ await page.getByRole('button',{name:'Open Older mathematics fixture',exact:true}).click();
+ await page.getByRole('button',{name:'Remove from saved',exact:true}).last().click();
+ await expect.poll(async()=>(await queue())['save:older-362']?.desired).toBe(false);
+ await close();
+ await expect(page.getByRole('button',{name:'Open Older mathematics fixture',exact:true})).toHaveCount(0);
+ console.log('PASS: pending unsave overrides cached page membership');
+ // Drop only this test's pending actions to isolate a late successful page response.
+ await page.evaluate(()=>localStorage.removeItem('one-concept/mutation-queue/v1'));
+ online=true; holdSaved=true;
+ await page.reload(); await expect(page.getByText(daily.summary,{exact:true})).toBeVisible();
+ await profile(); await page.getByText('Saved concepts',{exact:true}).click();
+ await expect.poll(()=>Boolean(releaseSaved)).toBe(true);
+ await page.getByRole('button',{name:'Back',exact:true}).click();
+ await page.getByText('Sign out',{exact:true}).click();
+ await expect(page.getByText('Welcome back — sign in to pick up your streak.',{exact:true})).toBeVisible();
+ holdSaved=false; releaseSaved(); await page.waitForTimeout(500);
+ await expect.poll(async()=>page.evaluate(()=>Object.keys(localStorage).filter(k=>k.startsWith('one-concept/saved-list/') || k.startsWith('one-concept/concepts/')).length)).toBe(0);
+ assert.deepEqual(errors,[]);
+ console.log('PASS: sign-out fences a late Saved page and removes collection caches');
+ return;
+ }
+ if (process.argv.includes('--partial-connectivity')) {
+ await expect.poll(()=>topicRequests).toBeGreaterThan(0);
+ await page.waitForTimeout(300);
+ const beforeState=stateRequests, beforeTopics=topicRequests;
+ failTopics=true;
+ await page.evaluate(()=>{window.dispatchEvent(new Event('offline'));window.dispatchEvent(new Event('online'));});
+ await expect.poll(()=>topicRequests-beforeTopics).toBeGreaterThan(0);
+ await page.waitForTimeout(1000);
+ assert.equal(stateRequests-beforeState,1);
+ assert.equal(topicRequests-beforeTopics,1);
+ await expect.poll(()=>topicRequests-beforeTopics,{timeout:7000}).toBe(2);
+ await page.waitForTimeout(1000);
+ assert.equal(stateRequests-beforeState,2);
+ assert.equal(topicRequests-beforeTopics,2);
+ // Recover without an online event: the backed-off timer must still retry.
+ failTopics=false;
+ await expect.poll(()=>topicRequests-beforeTopics,{timeout:12000}).toBe(3);
+ await expect(page.getByText('Offline — changes will sync when you reconnect',{exact:true})).toHaveCount(0);
+ await page.waitForTimeout(1000);
+ assert.equal(stateRequests-beforeState,3);
+ assert.equal(topicRequests-beforeTopics,3);
+ assert.deepEqual(errors,[]);
+ console.log('PASS: partial connectivity backs off, recovers automatically, and stops polling');
+ return;
+ }
+ if (midnight) {
+ holdLike=true;
+ await page.getByRole('button',{name:'Like',exact:true}).click();
+ await expect.poll(()=>Boolean(releaseLike)).toBe(true);
+ await page.clock.setFixedTime(new Date(new Date(date+'T23:59:00Z').getTime()+120000));
+ await page.getByRole('button',{name:'Save for later',exact:true}).click();
+ // Let the date-change effect run while Save is waiting behind Like.
+ await page.waitForTimeout(300);
+ online=false; holdLike=false; releaseLike(); releaseLike=undefined;
+ await expect.poll(async()=>(await queue())['save:fixture-daily']?.desired).toBe(true);
+ await expect(page.getByRole('button',{name:'Remove from saved',exact:true})).toBeVisible();
+ await page.reload();
+ await expect(page.getByRole('button',{name:'Remove from saved',exact:true})).toBeVisible();
+ online=true; await page.evaluate(()=>window.dispatchEvent(new Event('online')));
+ await expect.poll(async()=>Object.keys(await queue()).length).toBe(0);
+ assert(state.bookmarks.includes(daily.slug));
+ assert(writes.some(w=>w.endpoint==='/v1/concepts/fixture-daily/save' && w.method==='PUT'));
+ assert.deepEqual(errors,[]);
+ console.log('PASS: save queued across midnight survives restart and syncs');
+ return;
+ }
+ await profile(); await page.getByText('Saved concepts',{exact:true}).click();
+ await page.getByRole('button',{name:'Open Saved fixture',exact:true}).click();
+ await expect(page.getByText(saved.summary,{exact:true})).toBeVisible();
+ console.log('CONTROL: saved detail loads online');
+ online=false; await page.reload();
+ await expect(page.getByText(daily.summary,{exact:true})).toBeVisible();
+ await profile(); await page.getByText('Saved concepts',{exact:true}).click();
+ await page.getByRole('button',{name:'Open Saved fixture',exact:true}).click();
+ if (baseline) await expect(page.getByText('This concept couldn’t be loaded. Check your connection and try again.',{exact:true})).toBeVisible();
+ else {
+ await expect(page.getByText(saved.summary,{exact:true})).toBeVisible();
+ await expect(page.getByText(saved.example,{exact:true})).toBeVisible();
+ }
+ console.log(baseline?'CONFIRMED: previously viewed saved body unavailable offline':'PASS: viewed saved body survives offline restart');
+ if (process.env.OFFLINE_SCREENSHOT_PATH) await page.screenshot({path:process.env.OFFLINE_SCREENSHOT_PATH});
+ if (!baseline) {
+ await page.getByRole('button',{name:'Share',exact:true}).last().click();
+ await expect.poll(()=>page.evaluate(()=>window.fixtureShared?.text)).toContain(saved.summary);
+ console.log('PASS: sharing cached text works offline');
+ }
+ await close();
+ if (!baseline) {
+ await page.getByRole('button',{name:'Open Unread saved fixture',exact:true}).click();
+ await expect(page.getByText(unread.summary,{exact:true})).toBeVisible();
+ await expect(page.getByText(unread.example,{exact:true})).toBeVisible();
+ console.log('PASS: saved body downloaded without opening its detail'); await close();
+ }
+ online=true; await page.reload();
+ await expect(page.getByText(daily.summary,{exact:true})).toBeVisible();
+ await profile(); await page.getByText('Personalize your feed',{exact:true}).click();
+ await expect(page.getByText('New topic',{exact:true})).toBeVisible();
+ online=false; await page.getByText('Follow',{exact:true}).click();
+ await expect(page.getByText('Following',{exact:true})).toHaveCount(2);
+ await page.waitForTimeout(500);
+ if (baseline) assert.equal((await queue()).topics, undefined);
+ else assert.deepEqual((await queue()).topics.slugs,['computer-science','new-topic']);
+ console.log(baseline?'CONFIRMED: offline topic change is not in persistent queue':'PASS: offline topic change is queued');
+ await page.reload(); await expect(page.getByText(daily.summary,{exact:true})).toBeVisible();
+ await profile(); await page.getByText('Personalize your feed',{exact:true}).click();
+ if(baseline) await expect(page.getByText('Connect to load your topics and choose what to learn next.',{exact:true})).toBeVisible();
+ else { await expect(page.getByText('New topic',{exact:true})).toBeVisible(); await expect(page.getByText('Following',{exact:true})).toHaveCount(2); }
+ console.log(baseline?'CONFIRMED: warm topic catalog lost on offline restart':'PASS: catalog and followed choices survive offline restart');
+ await close(); await page.getByText('Today',{exact:true}).last().click();
+ await page.getByRole('button',{name:'Like',exact:true}).click();
+ await expect.poll(async()=>(await queue())['like:fixture-daily']?.desired).toBe(true);
+ console.log('CONTROL: offline like is persistently queued');
+ const before=writes.length; online=true;
+ if (!process.argv.includes('--no-event')) await page.evaluate(()=>window.dispatchEvent(new Event('online')));
+ if(baseline) { await page.waitForTimeout(2500); assert.equal(writes.length,before); assert.equal((await queue())['like:fixture-daily'].desired,true); }
+ else { await expect.poll(async()=>Object.keys(await queue()).length,{timeout:35000}).toBe(0); assert(state.likes.includes('fixture-daily')); assert(state.followed_topics.includes('new-topic')); }
+ console.log(baseline?'CONFIRMED: connectivity restore alone does not flush queue':'PASS: reconnection automatically flushes likes and topics');
+ if (!baseline) {
+ await profile(); await page.getByText('Personalize your feed',{exact:true}).click();
+ await expect(page.getByText('Following',{exact:true})).toHaveCount(2);
+ await close(); await page.getByText('Today',{exact:true}).last().click();
+ if (process.argv.includes('--retry-error')) {
+ online=false; await page.getByRole('button',{name:'Unlike',exact:true}).click();
+ await expect.poll(async()=>(await queue())['like:fixture-daily']?.desired).toBe(false);
+ failLikes=true; online=true;
+ await page.evaluate(()=>window.dispatchEvent(new Event('online')));
+ await page.waitForTimeout(1500);
+ await expect(page.getByRole('button',{name:'Like',exact:true})).toBeVisible();
+ assert.equal((await queue())['like:fixture-daily'].desired,false);
+ console.log('PASS: transient replay error preserves pending unlike in the UI');
+ failLikes=false; await page.evaluate(()=>window.dispatchEvent(new Event('online')));
+ await expect.poll(async()=>Object.keys(await queue()).length).toBe(0);
+ }
+ online=false;
+ await page.getByRole('button',{name:'Save for later',exact:true}).click();
+ await expect.poll(async()=>(await queue())['save:fixture-daily']?.desired).toBe(true);
+ await page.reload(); await expect(page.getByText(daily.summary,{exact:true})).toBeVisible();
+ await expect(page.getByRole('button',{name:'Remove from saved',exact:true})).toBeVisible();
+ await profile(); await page.getByText('Saved concepts',{exact:true}).click();
+ await page.getByRole('button',{name:'Open Daily fixture',exact:true}).click();
+ await expect(page.getByText(daily.summary,{exact:true}).last()).toBeVisible();
+ await close(); await page.getByRole('button',{name:'Back',exact:true}).click();
+ console.log('PASS: offline save survives restart and its full text opens from Saved');
+ if (process.argv.includes('--signout-inflight')) {
+ online=true; holdLike=true;
+ await page.getByText('Today',{exact:true}).last().click();
+ await page.getByRole('button',{name:/^(Like|Unlike)$/}).click();
+ await expect.poll(()=>Boolean(releaseLike)).toBe(true);
+ await profile();
+ }
+ await page.getByText('Sign out',{exact:true}).click();
+ await expect(page.getByText('Welcome back — sign in to pick up your streak.',{exact:true})).toBeVisible();
+ if (releaseLike) { releaseLike(); await page.waitForTimeout(500); }
+ await expect.poll(async()=>page.evaluate(()=>Object.keys(localStorage).filter(key=>key.startsWith('one-concept/concepts/') || key.startsWith('one-concept/topics/') || key.startsWith('one-concept/saved-list/') || key==='one-concept/mutation-queue/v1').length)).toBe(0);
+ console.log('PASS: sign-out removes full lessons, topics, and queued actions');
+ }
+ assert.deepEqual(errors,[]); console.log('No browser runtime errors');
+ } finally { await browser.close(); await new Promise(r=>server.close(r)); }
+})().catch(e=>{console.error(e);process.exitCode=1;server.close();});
diff --git a/mobile/tests/offlineCache.test.mjs b/mobile/tests/offlineCache.test.mjs
new file mode 100644
index 0000000..1447745
--- /dev/null
+++ b/mobile/tests/offlineCache.test.mjs
@@ -0,0 +1,69 @@
+import assert from 'node:assert/strict';
+import { test } from 'node:test';
+import { OfflineCache } from '../src/services/offlineCache.ts';
+
+function disk() {
+ const rows = new Map();
+ return { rows, getItem: async k => rows.get(k) ?? null,
+ setItem: async (k, v) => { rows.set(k, v); },
+ getAllKeys: async () => [...rows.keys()],
+ multiRemove: async keys => { keys.forEach(k => rows.delete(k)); } };
+}
+const tick = () => new Promise(resolve => setImmediate(resolve));
+function deferred() {
+ let resolve;
+ return { promise: new Promise(r => { resolve = r; }), resolve: (...args) => resolve(...args) };
+}
+
+test('full lesson text survives a new cache instance and corrupt entries do not block other lessons', async () => {
+ const storage = disk();
+ const cache = new OfflineCache(storage, 'lessons/');
+ const lesson = { id: 'saved', summary: 'Full explanation', example: 'Concrete example' };
+ await cache.set(lesson.id, lesson);
+ storage.rows.set('lessons/corrupt', '{');
+ const reopened = new OfflineCache(storage, 'lessons/');
+ assert.deepEqual(await reopened.get('saved'), lesson);
+ assert.equal(await reopened.get('corrupt'), null);
+});
+
+test('sign-out removes pending disk writes and rejects late downloads without deleting device preferences', async () => {
+ const storage = disk();
+ const started = deferred(), finish = deferred();
+ storage.setItem = async (k, v) => { started.resolve(); await finish.promise; storage.rows.set(k, v); };
+ storage.rows.set('theme', 'dark');
+ const cache = new OfflineCache(storage, 'lessons/');
+ const oldAccount = cache.epoch;
+ const write = cache.set('saved', { summary:'old account' }, oldAccount);
+ await started.promise;
+ const clear = cache.clear();
+ const lateDownload = cache.set('late', { summary:'late old account' }, oldAccount);
+ finish.resolve();
+ await Promise.all([write, clear, lateDownload]);
+ assert.deepEqual([...storage.rows], [['theme', 'dark']]);
+ await cache.set('new', { summary:'new account' });
+ assert.deepEqual(await cache.get('new'), { summary:'new account' });
+});
+
+test('sign-out suppresses a disk read already in flight', async () => {
+ const storage = disk(), response = deferred();
+ storage.getItem = () => response.promise;
+ const cache = new OfflineCache(storage, 'lessons/');
+ const read = cache.get('old');
+ await tick();
+ await cache.clear();
+ response.resolve(JSON.stringify({summary:'old account'}));
+ assert.equal(await read, null);
+});
+
+test('failed storage writes do not poison subsequent downloads or cleanup', async () => {
+ const storage = disk();
+ const put = storage.setItem;
+ storage.setItem = async () => { throw new Error('disk full'); };
+ const cache = new OfflineCache(storage, 'lessons/');
+ await assert.rejects(cache.set('one', {summary:'one'}));
+ storage.setItem = put;
+ await cache.set('two', {summary:'two'});
+ assert.deepEqual(await cache.get('two'), {summary:'two'});
+ await cache.clear();
+ assert.equal(await cache.get('two'), null);
+});
diff --git a/mobile/tests/password.browser.cjs b/mobile/tests/password.browser.cjs
new file mode 100644
index 0000000..944c218
--- /dev/null
+++ b/mobile/tests/password.browser.cjs
@@ -0,0 +1,115 @@
+const { chromium, expect } = require(process.env.PLAYWRIGHT_TEST_MODULE || 'playwright/test');
+const http = require('node:http');
+const fs = require('node:fs');
+const path = require('node:path');
+const assert = require('node:assert/strict');
+const root = process.argv[2];
+if (!root || !fs.existsSync(path.join(root, 'index.html'))) throw new Error('Pass an Expo web export directory.');
+const server = http.createServer((req, res) => {
+ const pathname = new URL(req.url, 'http://localhost').pathname;
+ const file = path.join(root, pathname === '/' ? 'index.html' : pathname);
+ try {
+ res.setHeader('Content-Type', ({'.html':'text/html', '.js':'application/javascript', '.ttf':'font/ttf', '.png':'image/png'})[path.extname(file)] || 'application/octet-stream');
+ res.end(fs.readFileSync(file));
+ } catch { res.statusCode = 404; res.end(); }
+});
+
+(async () => {
+ await new Promise(resolve => server.listen(4781, '127.0.0.1', resolve));
+ const browser = await chromium.launch({executablePath:process.env.PLAYWRIGHT_CHROMIUM_PATH, args:['--no-sandbox']});
+ try {
+ for (const colorScheme of ['light', 'dark']) {
+ const context = await browser.newContext({viewport:{width:390, height:844}, colorScheme});
+ const errors = [], requests = [];
+ let release;
+ await context.route('**/auth/v1/**', async route => {
+ const endpoint = new URL(route.request().url()).pathname;
+ const body = route.request().postDataJSON();
+ requests.push({endpoint, body});
+ await new Promise(resolve => { release = resolve; });
+ const signup = endpoint.endsWith('/signup');
+ await route.fulfill({status:signup ? 200 : 400, contentType:'application/json', body:JSON.stringify(signup
+ ? {user:{id:'fixture', email:body.email, identities:[]}, session:null}
+ : {code:'invalid_credentials', message:'Invalid login credentials'})});
+ });
+ const page = await context.newPage();
+ page.on('pageerror', error => errors.push(error.message));
+ await page.goto('http://127.0.0.1:4781');
+ await expect(page.getByText('Welcome back — sign in to pick up your streak.', {exact:true})).toBeVisible();
+ const password = page.getByPlaceholder('At least 6 characters');
+ const show = page.getByRole('button', {name:'Show password', exact:true});
+ const hide = page.getByRole('button', {name:'Hide password', exact:true});
+ await expect(password).toHaveJSProperty('type', 'password');
+ // Also fails against the original UI, which has no visibility control.
+ await expect(show).toBeVisible({timeout:2000});
+ const value = 'Fixture-Password-182!';
+ await page.getByPlaceholder('you@example.com').fill('fixture@example.invalid');
+ await password.fill(value);
+ const toggle = async () => {
+ await show.click();
+ await expect(password).toHaveJSProperty('type', 'text');
+ await expect(password).toHaveValue(value);
+ await hide.focus();
+ await page.keyboard.press('Enter');
+ await expect(password).toHaveJSProperty('type', 'password');
+ await expect(password).toHaveValue(value);
+ };
+ await toggle();
+ await show.click();
+ await page.getByRole('button', {name:'New here? Create an account', exact:true}).click();
+ await expect(password).toHaveJSProperty('type', 'password');
+ await expect(password).toHaveValue(value);
+ await expect(password).toHaveAttribute('autocomplete', 'new-password');
+ await toggle();
+ await show.click();
+ await page.getByRole('button', {name:'Already have an account? Sign in', exact:true}).click();
+ await expect(password).toHaveJSProperty('type', 'password');
+ await expect(password).toHaveAttribute('autocomplete', 'current-password');
+ console.log(`PASS (${colorScheme}): sign-in/signup toggle, keyboard control, retained value, and mode remasking`);
+
+ await show.click();
+ await page.getByRole('button', {name:'Sign in', exact:true}).click();
+ await expect.poll(() => requests.length).toBe(1);
+ await expect(password).toHaveJSProperty('type', 'password');
+ await expect(show).toBeDisabled();
+ await expect(password).not.toBeEditable();
+ assert.equal(requests[0].body.password, value);
+ release();
+ await expect(show).toBeEnabled();
+ await expect(password).toHaveValue(value);
+ await expect(password).toHaveJSProperty('type', 'password');
+ await page.getByRole('button', {name:'New here? Create an account', exact:true}).click();
+ await show.click();
+ await page.getByRole('button', {name:'Create account', exact:true}).click();
+ await expect.poll(() => requests.length).toBe(2);
+ await expect(show).toBeDisabled();
+ assert.equal(requests[1].body.password, value);
+ release();
+ await expect(page.getByText('Check your email', {exact:true})).toBeVisible();
+ await expect(page.getByText('Welcome back — sign in to pick up your streak.', {exact:true})).toBeVisible();
+ await expect(password).toHaveJSProperty('type', 'password');
+ console.log(`PASS (${colorScheme}): in-flight requests mask/disable the field and signup returns masked`);
+
+ for (const width of [320, 390, 960]) {
+ await page.setViewportSize({width, height:844});
+ const box = await show.boundingBox();
+ assert(box.width >= 44 && box.height >= 44, 'Visibility control must remain tappable');
+ assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), 'No horizontal overflow');
+ }
+ await page.setViewportSize({width:390, height:844});
+ await password.fill('');
+ await page.getByPlaceholder('you@example.com').fill('');
+ if (process.env.PASSWORD_SCREENSHOT_DIR) {
+ fs.mkdirSync(process.env.PASSWORD_SCREENSHOT_DIR, {recursive:true});
+ await page.screenshot({path:path.join(process.env.PASSWORD_SCREENSHOT_DIR, `password-${colorScheme}.png`)});
+ }
+ await show.click();
+ await page.reload();
+ await expect(password).toHaveJSProperty('type', 'password');
+ await expect(password).toHaveValue('');
+ assert.deepEqual(errors, []);
+ console.log(`PASS (${colorScheme}): responsive touch targets and reload starts masked with no persisted password`);
+ await context.close();
+ }
+ } finally { await browser.close(); await new Promise(resolve => server.close(resolve)); }
+})().catch(error => {console.error(error); process.exitCode = 1; server.close();});
diff --git a/mobile/tests/pendingProgress.test.mjs b/mobile/tests/pendingProgress.test.mjs
new file mode 100644
index 0000000..842697c
--- /dev/null
+++ b/mobile/tests/pendingProgress.test.mjs
@@ -0,0 +1,32 @@
+import assert from 'node:assert/strict';
+import { test } from 'node:test';
+import { withPendingProgress } from '../src/services/pendingProgress.ts';
+
+const server={learned:[],assignment:{conceptId:'today',date:'2026-09-12'},followedTopics:[],likes:['liked'],bookmarks:[],savedConcepts:[],stats:{current:0,longest:2,totalLearned:2}};
+test('a server refresh after a retryable error preserves pending unlike and saved metadata', () => {
+ const saved={conceptId:'today',title:'Today',topicName:'Computer Science'};
+ const previous={...server,likes:[],bookmarks:['today'],savedConcepts:[saved]};
+ const next=withPendingProgress(server,previous,[{kind:'like',slug:'liked',desired:false},{kind:'save',slug:'today',desired:true}]);
+ assert.deepEqual(next.likes,[]);
+ assert.deepEqual(next.bookmarks,['today']);
+ assert.deepEqual(next.savedConcepts,[saved]);
+ assert.deepEqual(server.likes,['liked']);
+});
+test('unacknowledged unsave hides its server row and acknowledged changes use fresh state', () => {
+ const saved={conceptId:'saved',title:'Saved',topicName:'Computer Science'};
+ const state={...server,bookmarks:['saved'],savedConcepts:[saved]};
+ const next=withPendingProgress(state,state,[{kind:'save',slug:'saved',desired:false}]);
+ assert.deepEqual(next.savedConcepts,[]);
+ assert.deepEqual(next.bookmarks,[]);
+ assert.equal(withPendingProgress(state,next,[]),state);
+});
+test('preserves same-day offline completion without duplicating or backdating it', () => {
+ const record={conceptId:'today',date:'2026-09-12'};
+ const previous={...server,learned:[record],stats:{current:1,longest:2,totalLearned:3}};
+ const queued=[{kind:'learn',date:'2026-09-12'}];
+ const next=withPendingProgress(server,previous,queued);
+ assert.deepEqual(next.learned,[record]);
+ assert.equal(next.stats.totalLearned,3);
+ assert.deepEqual(withPendingProgress(next,previous,queued).learned,[record]);
+ assert.deepEqual(withPendingProgress({...server,assignment:{conceptId:'next',date:'2026-09-13'}},previous,queued).learned,[]);
+});
diff --git a/mobile/tests/progressTotals.test.mjs b/mobile/tests/progressTotals.test.mjs
new file mode 100644
index 0000000..e9f9933
--- /dev/null
+++ b/mobile/tests/progressTotals.test.mjs
@@ -0,0 +1,28 @@
+import assert from 'node:assert/strict';
+import { test } from 'node:test';
+import { learnedTopicCounts } from '../src/services/progressTotals.ts';
+import { withPendingProgress } from '../src/services/pendingProgress.ts';
+
+test('compact totals include older topics and an offline completion exactly once', () => {
+ const record = {conceptId:'today', date:'2026-09-12', topicName:'Computer Science'};
+ const server = {
+ learned:[{conceptId:'yesterday', date:'2026-09-11', topicName:'Computer Science'}],
+ learnedBeforeWindow:{'Computer Science':314, Mathematics:50},
+ assignment:{conceptId:'today',date:'2026-09-12'}, likes:[],bookmarks:[],followedTopics:[],
+ stats:{current:1,longest:1,totalLearned:365},
+ };
+ const previous = {...server, learned:[...server.learned,record],
+ stats:{...server.stats,totalLearned:366}};
+ const pending = [{kind:'learn',date:'2026-09-12'}];
+ const once = withPendingProgress(server, previous, pending);
+ const twice = withPendingProgress(once, previous, pending);
+ assert.deepEqual(learnedTopicCounts(twice.learned,twice.learnedBeforeWindow),
+ new Map([['Computer Science',316],['Mathematics',50]]));
+ assert.equal(twice.stats.totalLearned,366);
+ assert.deepEqual(server.learnedBeforeWindow,{'Computer Science':314,Mathematics:50});
+});
+
+test('legacy state still derives categories from its complete learned list', () => {
+ assert.deepEqual(learnedTopicCounts([{topicName:'Mathematics'},{topicName:'Mathematics'},{}]),
+ new Map([['Mathematics',2]]));
+});
diff --git a/mobile/tests/syncLoop.test.mjs b/mobile/tests/syncLoop.test.mjs
new file mode 100644
index 0000000..f784d1f
--- /dev/null
+++ b/mobile/tests/syncLoop.test.mjs
@@ -0,0 +1,91 @@
+import assert from 'node:assert/strict';
+import { test } from 'node:test';
+import { createSyncLoop } from '../src/services/syncLoop.ts';
+
+const settle = async () => { for (let i=0;i<5;i++) await Promise.resolve(); };
+test('retries without navigation, backs off, and stops polling after the queue drains', async t => {
+ t.mock.timers.enable({apis:['setTimeout']});
+ let calls=0, pending=true;
+ const loop=createSyncLoop(async()=>{ calls++; return pending; });
+ t.after(loop.stop);
+ loop.wake(); t.mock.timers.tick(0); await settle();
+ assert.equal(calls,1);
+ t.mock.timers.tick(4999); await settle(); assert.equal(calls,1);
+ t.mock.timers.tick(1); await settle(); assert.equal(calls,2);
+ t.mock.timers.tick(9999); await settle(); assert.equal(calls,2);
+ pending=false; t.mock.timers.tick(1); await settle(); assert.equal(calls,3);
+ t.mock.timers.tick(60000); await settle(); assert.equal(calls,3);
+});
+
+test('background pauses timers and foreground triggers immediate synchronization', async t => {
+ t.mock.timers.enable({apis:['setTimeout']});
+ let calls=0;
+ const loop=createSyncLoop(async()=>{ calls++; return true; });
+ t.after(loop.stop);
+ loop.wake(); t.mock.timers.tick(0); await settle();
+ loop.setActive(false); t.mock.timers.tick(60000); await settle(); assert.equal(calls,1);
+ loop.setActive(true); t.mock.timers.tick(0); await settle(); assert.equal(calls,2);
+});
+
+test('partial connectivity wakeups retain exponential backoff until synchronization succeeds', async t => {
+ t.mock.timers.enable({apis:['setTimeout']});
+ let calls=0, failing=true;
+ const loop=createSyncLoop(async()=>{
+ calls++;
+ if (failing) {
+ // A state response reports online; the subsequent topics fetch fails.
+ loop.wake();
+ loop.retry();
+ }
+ return failing;
+ });
+ t.after(loop.stop);
+ loop.wake(); t.mock.timers.tick(0); await settle();
+ for (const delay of [5000,10000,20000,30000,30000]) {
+ const before=calls;
+ t.mock.timers.tick(delay-1); await settle(); assert.equal(calls,before);
+ t.mock.timers.tick(1); await settle(); assert.equal(calls,before+1);
+ }
+ failing=false;
+ t.mock.timers.tick(30000); await settle(); assert.equal(calls,7);
+ t.mock.timers.tick(60000); await settle(); assert.equal(calls,7);
+});
+
+test('a reconnect while waiting for retry triggers an immediate attempt', async t => {
+ t.mock.timers.enable({apis:['setTimeout']});
+ let calls=0, failing=true;
+ const loop=createSyncLoop(async()=>{ calls++; return failing; });
+ t.after(loop.stop);
+ loop.wake(); t.mock.timers.tick(0); await settle();
+ t.mock.timers.tick(1000); await settle(); assert.equal(calls,1);
+ failing=false; loop.wake(); t.mock.timers.tick(0); await settle();
+ assert.equal(calls,2);
+ t.mock.timers.tick(60000); await settle(); assert.equal(calls,2);
+});
+
+test('wakeups during successful synchronization coalesce into one additional attempt', async t => {
+ t.mock.timers.enable({apis:['setTimeout']});
+ let calls=0, resolve;
+ const loop=createSyncLoop(()=>{
+ calls++;
+ return calls===1 ? new Promise(r=>{resolve=r;}) : Promise.resolve(false);
+ });
+ t.after(loop.stop);
+ loop.wake(); t.mock.timers.tick(0); await settle();
+ loop.wake(); loop.wake();
+ t.mock.timers.tick(1000); await settle(); assert.equal(calls,1);
+ resolve(false); await settle();
+ t.mock.timers.tick(0); await settle(); assert.equal(calls,2);
+ t.mock.timers.tick(60000); await settle(); assert.equal(calls,2);
+});
+
+test('overlapping wakeups never run concurrent flushes and stop prevents late rescheduling', async t => {
+ t.mock.timers.enable({apis:['setTimeout']});
+ let calls=0, resolve;
+ const loop=createSyncLoop(()=>{ calls++; return new Promise(r=>{resolve=r;}); });
+ loop.wake(); t.mock.timers.tick(0); await settle();
+ loop.wake(); loop.wake(); loop.retry(); t.mock.timers.tick(10000); await settle();
+ assert.equal(calls,1);
+ loop.stop(); resolve(true); await settle();
+ t.mock.timers.tick(60000); await settle(); assert.equal(calls,1);
+});
diff --git a/mobile/tests/topicStore.test.mjs b/mobile/tests/topicStore.test.mjs
new file mode 100644
index 0000000..4ae543a
--- /dev/null
+++ b/mobile/tests/topicStore.test.mjs
@@ -0,0 +1,77 @@
+import assert from 'node:assert/strict';
+import { test } from 'node:test';
+import { TopicStore } from '../src/services/topicStore.ts';
+
+const topics = [
+ {slug:'computer-science',name:'Computer Science',conceptCount:25,following:true},
+ {slug:'new-server-topic',name:'New server topic',conceptCount:12,following:false},
+];
+function setup() {
+ let cached = null, queued;
+ const calls = [];
+ const deps = { read: async () => cached, write: async rows => { cached=rows; },
+ fetch: async () => topics, pending: async () => queued,
+ enqueue: async slugs => { queued=slugs; calls.push(slugs); } };
+ return { deps, calls, store: new TopicStore(deps) };
+}
+function deferred() {
+ let resolve;
+ return { promise: new Promise(r => { resolve=r; }), resolve: (...args) => resolve(...args) };
+}
+const tick = () => new Promise(resolve => setImmediate(resolve));
+
+test('offline dynamic topic follows survive restart and override stale server state until synced', async () => {
+ const {store, deps, calls} = setup();
+ await store.load();
+ deps.fetch = async () => { throw new Error('offline'); };
+ await store.toggle('new-server-topic');
+ assert.deepEqual(calls, [['computer-science','new-server-topic']]);
+ const reopened = new TopicStore(deps);
+ await reopened.load();
+ assert.equal(reopened.getSnapshot()[1].following, true);
+ deps.fetch = async () => topics;
+ await reopened.load();
+ assert.equal(reopened.getSnapshot()[1].following, true);
+});
+
+test('rapid toggles preserve the complete followed set and stale catalog loads cannot undo them', async () => {
+ const {store, deps, calls} = setup();
+ await store.load();
+ const response = deferred();
+ deps.fetch = () => response.promise;
+ const loading = store.load();
+ await tick();
+ await Promise.all([store.toggle('new-server-topic'),store.toggle('computer-science')]);
+ response.resolve(topics);
+ await loading;
+ assert.deepEqual(calls.at(-1), ['new-server-topic']);
+ assert.deepEqual(store.getSnapshot().map(t=>t.following), [false,true]);
+});
+
+test('cached catalog paints before a stalled fetch completes', async () => {
+ const {store, deps} = setup();
+ await store.load();
+ const response = deferred();
+ deps.fetch = () => response.promise;
+ const reopened = new TopicStore(deps);
+ const loading = reopened.load();
+ await tick();
+ assert.deepEqual(reopened.getSnapshot(), topics);
+ response.resolve(topics);
+ await loading;
+});
+
+test('account reset suppresses late topic fetches and queued follow writes', async () => {
+ const {store, deps, calls} = setup();
+ await store.load();
+ const response = deferred();
+ deps.fetch = () => response.promise;
+ const loading = store.load();
+ await tick();
+ const write = store.toggle('new-server-topic');
+ store.reset();
+ response.resolve(topics);
+ await Promise.all([loading,write]);
+ assert.deepEqual(store.getSnapshot(), []);
+ assert.deepEqual(calls, []);
+});