erin.dev

Back to home

📚 ShelfLife

A personalized book ranking engine that surfaces the right book at the right time

Source Code

Live Demo

Everyone has a reading list that grows faster than they can read — but existing tools are flat, unsorted backlogs that create decision paralysis. ShelfLife treats your reading list as a living system, not a static queue. A multi-signal ranking engine dynamically reorders books based on your current mood, how long each book has been waiting (exponential time decay), your stated priority, tag-based relevance, and genre diversity — then explains every recommendation in natural language via LLM-generated "why now" explanations. The system uses a hybrid AI architecture: Claude interprets emoji-based mood check-ins into structured mood vectors (energy, valence, cognitive load), then generates batch explanations referencing the user's emotional state and each book's attributes. A static fallback map ensures the app works fully offline — no API key required. Built as a full-stack monorepo with a FastAPI async backend, React + TypeScript frontend, and SQLite persistence. Features include Goodreads CSV import with fuzzy title matching, a reading streak gamification system with milestone celebrations, transparent score breakdowns per book, and user-adjustable ranking weights with live normalization. Deployed with Docker Compose, with CI/CD via GitHub Actions. 201 tests across unit, integration, pipeline, E2E, and performance benchmarks.

🛠️ Tech Stack

PythonFastAPIReactTypeScriptSQLiteClaude API (Anthropic)Pydantic v2Chakra UI v3Framer MotionRechartsDockerGitHub Actions CI

🚀 Features

  • Multi-signal ranking engine — 5 weighted scoring dimensions (mood match, recency decay, priority, tag similarity, genre diversity penalty) computed as pure functions with no side effects. Weights auto-normalize to sum to 1.0, and a subtractive diversity penalty prevents genre ruts. Scores are transparent: every book shows its raw and weighted breakdown via an interactive bar chart.

  • LLM-powered mood interpretation — Emoji grid + optional text input is sent to Claude, which returns a structured mood vector (energy, valence, cognitive_load, preferred_tags). A static fallback map of 12 curated emoji-to-mood mappings ensures full functionality without an API key. The app degrades gracefully at every LLM touchpoint.

  • Batch explanation generation with caching — Top 10 ranked books are sent to Claude with mood context. A system prompt enforces structured JSON output with sentence-boundary truncation. Explanations are cached per (book_id, mood_log_id) pair via upsert, so re-ranking is instant. A refresh endpoint allows on-demand regeneration.

  • Goodreads CSV import with fuzzy matching — Two-pass preview/execute pattern lets users see matched vs. unmatched counts before committing. difflib.SequenceMatcher with a 0.7 threshold handles subtitle variations between Goodreads and Open Library. Polite 100ms async rate limiting between API calls.

  • Reading streak gamification — Singleton-row streak tracker with a 48-hour grace period, 9 data-driven milestones, reading pace calculator, and personal leaderboard. Milestone data piggybacks on existing API responses (mood check-in, book finish) — zero additional API calls. Full-screen celebration overlays with spring physics animations.

  • Transparent, tunable scoring — Users adjust 5 ranking weights + a recency half-life via sliders with live normalization preview. Recharts bar chart shows each signal's contribution per book. The ranking engine is a pure function: books in, ranked books out, fully testable with no DB access.

  • Dual-source book search — Open Library as primary source with Google Books as automatic fallback when results are sparse (< 3 hits). Two-level duplicate detection (external key match, then case-insensitive title+author) prevents shelf pollution.

🧗‍♂️ Challenges

  • Designing a pure ranking function — The ranking engine needed to combine 5 heterogeneous signals (mood cosine similarity, exponential time decay, linear priority normalization, Jaccard tag overlap, rolling-window genre saturation) into a single comparable score. The key insight was separating orchestration (fetching mood, config, recent genres from the DB) from computation (the pure rank_shelf() function), which made the engine trivially testable and eliminated hidden state. Deferred imports solved circular dependencies between service modules without compromising type safety.

  • LLM output reliability at the boundary — Claude's JSON output isn't guaranteed to be valid, so every LLM integration point needed a validation + fallback chain: parse JSON → validate book_id against the sent set → truncate at sentence boundary if too long → fall back to empty list on any failure. The system prompt enforces "ONLY valid JSON, no markdown, no preamble" but the code trusts nothing. Mood interpretation, explanation generation, and explanation refresh each have independent failure modes that never propagate to the user.

  • Async SQLite in a concurrent web server — SQLite's default rollback journal blocks readers during writes. WAL mode + foreign key pragmas (off by default — a well-known SQLite gotcha) were essential for correct concurrent behavior under FastAPI's async event loop. The test harness monkey-patches the module-level connection with in-memory databases, giving each test full isolation while matching production pragma configuration.

  • Race conditions in search-as-you-type — A debounced search input still produces race conditions when responses arrive out of order. A cancellation flag pattern in the useEffect cleanup function ensures stale responses are discarded, while Framer Motion layout animations with stable keys provide smooth FLIP transitions when the shelf reorders after mood changes.

🧠 Learnings

  • Pure functions are the best testing strategy — By isolating the ranking engine, scoring utilities, milestone detection, and tag normalization as pure functions with no DB access, I achieved high test coverage with simple input/output assertions. The impure orchestration layer became a thin wrapper that was easy to integration-test separately. This separation also made the codebase easier to reason about: 8 pipeline tests and 1 comprehensive E2E test provided confidence that the layers composed correctly.

  • Graceful degradation should be the default architecture — Every external dependency (Claude API, Open Library, Google Books) has a fallback path that preserves core functionality. This wasn't an afterthought — it was designed into the service interfaces from the start. The app works fully without an ANTHROPIC_API_KEY: mood check-in uses the static emoji map, explanations return null, and the ranking engine functions on the remaining 4 signals.

  • The preview/execute pattern builds user trust — Showing users exactly what an import will do before committing eliminates anxiety around bulk operations. The critical invariant: preview and execute must share the same matching logic, or the preview count won't match the actual result. Shared private helpers (_match_book, _is_duplicate) enforce this at the code level.

  • Pydantic at boundaries, dataclasses internally — Using Pydantic BaseModel for API request/response validation and plain @dataclass for internal data transfer kept the right level of validation at each layer. External input gets full constraint checking (Field(ge=1, le=5)); internal data from trusted code paths avoids the overhead. This distinction made the architecture intent self-documenting.