books/docs/architecture.md
Greg fa8acef423 Epic 1, Story 1.1: Project Initialization & Repository Setup
- Initialize Git repository with main branch
- Create comprehensive .gitignore for Node.js, React, and environment files
- Set up directory structure (frontend/, backend/, docs/)
- Create detailed README.md with project overview and setup instructions
- Add .env.example with all required environment variables
- Configure Prettier for consistent code formatting

All acceptance criteria met:
 Git repository initialized with appropriate .gitignore
 Directory structure matches Technical Assumptions
 README.md created with project overview and setup docs
 .env.example file with all required environment variables
 Prettier config files added for code formatting consistency

🤖 Generated with Claude Code (https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-01 15:12:30 +01:00

78 KiB

Book Reading Tracker - Fullstack Architecture Document

Project: Book Reading Tracker Author: Winston (Architect) 🏗️ Created: 2025-12-01 Status: v1.0 Related Documents:

  • PRD: docs/prd/ (sharded)
  • Project Brief: docs/brief.md

Introduction

This document outlines the complete fullstack architecture for Book Reading Tracker, including backend systems, frontend implementation, and their integration. It serves as the single source of truth for AI-driven development, ensuring consistency across the entire technology stack.

This unified approach combines what would traditionally be separate backend and frontend architecture documents, streamlining the development process for modern fullstack applications where these concerns are increasingly intertwined.

Starter Template or Existing Project

N/A - Greenfield project

This is a new project built from scratch without relying on any starter templates. The architecture is custom-designed to meet the specific requirements outlined in the PRD.

Change Log

Date Version Description Author
2025-12-01 1.0 Initial architecture document created Winston (Architect)

High Level Architecture

Technical Summary

The Book Reading Tracker is a Progressive Web Application (PWA) built using a monolithic architecture with separate frontend and backend containers. The frontend is a React 18+ PWA using Vite for build tooling and Tailwind CSS for styling, deployed as static files. The backend is a Node.js Express REST API using Prisma ORM for PostgreSQL 15+ database access. The system integrates with the Open Library API for book metadata search. The entire stack is deployed to Coolify as Docker containers with automated SSL via Let's Encrypt. This architecture achieves zero ongoing operational costs while providing a mobile-first, offline-capable reading tracking experience that helps users meet book club deadlines through actionable pace calculations.

Platform and Infrastructure Choice

After evaluating multiple deployment options, the self-hosted Coolify platform was selected based on PRD requirements for zero ongoing costs and full control.

Platform: Self-Hosted Coolify

Key Services:

  • Web Server: Nginx/Caddy (via Coolify reverse proxy)
  • Application Runtime: Docker containers for frontend and backend
  • Database: PostgreSQL 15+ (managed by Coolify)
  • SSL/TLS: Automatic Let's Encrypt certificates (via Coolify)
  • Backups: Automated PostgreSQL backups (via Coolify)
  • Monitoring: Health check endpoints for uptime monitoring

Deployment Host and Regions:

  • Host: User's existing Coolify instance
  • Regions: Single region (user-specified)
  • Infrastructure: Containerized deployment with Docker Compose

Rationale:

  • Zero ongoing costs (leverages existing infrastructure)
  • Full privacy and control (no third-party cloud dependencies)
  • Aligns with PRD constraint: "As cheap as possible (already have Coolify)"
  • Automatic SSL, backups, and deployment management
  • No vendor lock-in

Alternatives Considered:

  • Vercel + Supabase: Rejected due to ongoing costs and vendor lock-in
  • AWS Full Stack: Rejected due to complexity and costs
  • Self-hosted on VPS: Considered but Coolify provides better DX with same infrastructure

Repository Structure

Structure: Monorepo

Monorepo Tool: N/A - Simple directory-based monorepo (no Nx/Turborepo needed for this scale)

Package Organization:

  • frontend/: React PWA application
  • backend/: Node.js Express API
  • docs/: Project documentation (PRD, architecture, deployment guides)
  • docker-compose.yml: Development orchestration
  • No shared packages initially - Can add if needed for shared TypeScript types in future

Rationale:

  • Simple monorepo structure sufficient for small project
  • Avoids complexity of monorepo tools (Nx, Turborepo) for minimal benefit
  • All code in single repository for easy AI-assisted development
  • Frontend and backend can share repository while maintaining clear boundaries

High Level Architecture Diagram

graph TB
    User[User - Mobile Browser]
    CDN[Coolify Reverse Proxy<br/>Nginx/Caddy + SSL]
    FE[Frontend Container<br/>React PWA<br/>Vite Static Build]
    BE[Backend Container<br/>Node.js + Express<br/>REST API]
    DB[(PostgreSQL 15+<br/>Database)]
    OL[Open Library API<br/>External Service]

    User -->|HTTPS| CDN
    CDN -->|Static Files| FE
    CDN -->|API Requests /api/*| BE
    FE -->|REST API Calls| BE
    BE -->|Prisma ORM| DB
    BE -->|Book Search| OL

    style User fill:#e1f5ff
    style FE fill:#90EE90
    style BE fill:#FFB366
    style DB fill:#FF6B6B
    style OL fill:#FFD93D

Architectural Patterns

  • Progressive Web App (PWA): Frontend served as installable PWA with offline capabilities - Rationale: Mobile-first requirement, no app store hassle, works across platforms

  • Monolithic Backend: Single Express API server handling all business logic - Rationale: Simplicity for MVP, easy to deploy and debug, sufficient for single-user scale

  • Repository Pattern: Abstract database access through Prisma ORM - Rationale: Type safety, migration management, testability, future flexibility

  • Component-Based UI: React functional components with hooks - Rationale: Reusability, maintainability, aligns with modern React best practices

  • REST API: HTTP-based RESTful endpoints with JSON payloads - Rationale: Simple, well-understood, no GraphQL complexity needed for straightforward CRUD

  • Stateless API: Backend does not maintain session state - Rationale: Scalability, simplicity (no auth in MVP means no sessions)

  • Cache-First Offline Strategy: Service worker caches assets and API responses - Rationale: Enables offline logging, improves perceived performance

  • Mobile-First Design: UI designed for small screens, enhanced for desktop - Rationale: Primary use case is mobile logging, desktop is secondary


Tech Stack

This is the DEFINITIVE technology selection for the entire project. All development must use these exact versions.

Technology Stack Table

Category Technology Version Purpose Rationale
Frontend Language JavaScript (ES6+) Latest Frontend development language Simpler than TypeScript for MVP, faster development, can add TS later
Frontend Framework React 18+ UI framework Component-based, excellent LLM support, large ecosystem
UI Component Library None (Custom + Tailwind) N/A UI styling Tailwind sufficient, no component library needed for simple UI
State Management React Context API Built-in Global state Sufficient for MVP, no Redux complexity needed
Backend Language Node.js 20 LTS Backend runtime JavaScript consistency with frontend, excellent async I/O
Backend Framework Express 4.x REST API framework Minimal, flexible, well-documented, fast development
API Style REST N/A API architecture Simple, standard, no GraphQL complexity needed
Database PostgreSQL 15+ Persistent data storage Robust, ACID compliant, excellent Prisma support
ORM Prisma Latest Database access layer Type-safe, great DX, migrations, excellent PostgreSQL support
Cache In-Memory (Node.js Map) N/A Book metadata cache Simple, sufficient for single-user, no Redis needed
File Storage N/A N/A File storage No file uploads in MVP (book covers from Open Library)
Authentication None (Single-User) N/A User authentication Not needed for single-user MVP, add in v1.1 for multi-user
Frontend Testing Vitest + React Testing Library Latest Component and unit tests Fast, Vite-native, modern alternative to Jest
Backend Testing Jest Latest API and unit tests Industry standard, great Node.js support
E2E Testing Manual N/A End-to-end testing Manual testing sufficient for MVP, can add Playwright/Cypress later
Build Tool Vite Latest Frontend build tool Fast, modern, excellent DX, PWA support via plugin
Bundler Vite (Rollup) Latest JavaScript bundler Built into Vite, optimized bundles
IaC Tool Docker Compose Latest Local dev orchestration Simple, sufficient for Coolify deployment
CI/CD Manual / Coolify N/A Deployment automation Coolify handles deployment, no CI/CD pipeline needed initially
Monitoring Coolify Built-in N/A Uptime monitoring Health checks via Coolify, add dedicated monitoring post-MVP
Logging Console (Dev) / Winston (Prod) Latest Application logging Structured logging in production for debugging
CSS Framework Tailwind CSS 3.x Styling framework Utility-first, mobile-friendly, fast prototyping
HTTP Client Fetch API (Native) Built-in API requests Modern, built-in, no Axios dependency needed
Date Library date-fns Latest Date manipulation Lightweight, modular, better than Moment.js
PWA Tool vite-plugin-pwa Latest PWA manifest & service worker Easy Vite integration, handles PWA boilerplate
Validation express-validator Latest Input validation Express-specific, declarative, comprehensive
Security helmet Latest Security headers Essential HTTP security headers for Express
CORS cors Latest Cross-origin requests Configure frontend-backend communication

Data Models

Book Model

Purpose: Represents a book that the user is tracking for reading progress and deadline management.

Key Attributes:

  • id: Integer - Unique identifier (auto-increment)
  • title: String (max 500 chars) - Book title
  • author: String (max 500 chars, optional) - Book author
  • totalPages: Integer - Total pages in the book
  • coverUrl: String (max 1000 chars, optional) - URL to book cover image (from Open Library)
  • deadlineDate: Date - Date by which user needs to finish the book
  • isPrimary: Boolean (default: false) - Whether this is the user's primary focus book (for v1.1 multi-book support)
  • status: String (default: "reading") - Book status: "reading", "finished", "paused"
  • createdAt: DateTime - When book was added
  • updatedAt: DateTime - Last update timestamp

TypeScript Interface

interface Book {
  id: number;
  title: string;
  author: string | null;
  totalPages: number;
  coverUrl: string | null;
  deadlineDate: Date;
  isPrimary: boolean;
  status: 'reading' | 'finished' | 'paused';
  createdAt: Date;
  updatedAt: Date;
}

Relationships

  • Has many ReadingLog entries (one-to-many)
  • Cascade delete: When a book is deleted, all associated reading logs are deleted

ReadingLog Model

Purpose: Tracks daily reading progress by recording the current page number for a specific book on a specific date.

Key Attributes:

  • id: Integer - Unique identifier (auto-increment)
  • bookId: Integer - Foreign key to Book model
  • logDate: Date - Date of the reading log entry
  • currentPage: Integer - Page number user reached on this date
  • createdAt: DateTime - When log was created
  • updatedAt: DateTime - Last update timestamp

TypeScript Interface

interface ReadingLog {
  id: number;
  bookId: number;
  logDate: Date;
  currentPage: number;
  createdAt: Date;
  updatedAt: Date;
}

Relationships

  • Belongs to one Book (many-to-one)
  • Unique constraint: (bookId, logDate) - Only one log per book per day

ProgressCalculation Model (Derived)

Purpose: Not stored in database - computed on-demand from Book and ReadingLog data to show user's reading pace and status.

Key Attributes:

  • bookId: Integer - Book being tracked
  • currentPage: Integer - Latest logged page
  • totalPages: Integer - Total pages in book
  • pagesRemaining: Integer - Pages left to read
  • deadlineDate: Date - Finish by date
  • daysRemaining: Integer - Days until deadline
  • requiredPace: Float - Pages/day needed to finish on time
  • actualPace: Float - Pages/day based on 7-day rolling average
  • status: String - "on-track", "slightly-behind", "behind"
  • lastLoggedDate: Date - Most recent log date

TypeScript Interface

interface ProgressCalculation {
  bookId: number;
  currentPage: number;
  totalPages: number;
  pagesRemaining: number;
  deadlineDate: Date;
  daysRemaining: number;
  requiredPace: number;  // pages/day
  actualPace: number | null;  // pages/day (null if insufficient data)
  status: 'on-track' | 'slightly-behind' | 'behind';
  lastLoggedDate: Date | null;
}

Relationships

  • Derived from Book and ReadingLog models
  • Calculated server-side and returned in API responses

API Specification

REST API Specification

openapi: 3.0.0
info:
  title: Book Reading Tracker API
  version: 1.0.0
  description: REST API for tracking book reading progress and calculating reading pace to meet deadlines

servers:
  - url: http://localhost:3000/api
    description: Local development server
  - url: https://books.yourdomain.com/api
    description: Production server (Coolify deployment)

paths:
  /health:
    get:
      summary: Health check endpoint
      description: Returns API status and timestamp for monitoring
      responses:
        '200':
          description: API is healthy
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    example: "ok"
                  timestamp:
                    type: string
                    format: date-time

  /books/search:
    get:
      summary: Search for books
      description: Search Open Library API for books by title, author, or ISBN
      parameters:
        - name: q
          in: query
          required: true
          schema:
            type: string
            maxLength: 200
          description: Search query (title, author, or ISBN)
      responses:
        '200':
          description: Search results
          content:
            application/json:
              schema:
                type: object
                properties:
                  results:
                    type: array
                    items:
                      type: object
                      properties:
                        olid:
                          type: string
                        title:
                          type: string
                        author:
                          type: string
                        publishYear:
                          type: integer
                        coverUrl:
                          type: string
                        totalPages:
                          type: integer
        '400':
          $ref: '#/components/responses/BadRequest'
        '500':
          $ref: '#/components/responses/ServerError'

  /books:
    get:
      summary: Get all active books
      description: Retrieve all books with status='reading' including progress calculations
      responses:
        '200':
          description: List of active books with progress
          content:
            application/json:
              schema:
                type: object
                properties:
                  books:
                    type: array
                    items:
                      allOf:
                        - $ref: '#/components/schemas/Book'
                        - type: object
                          properties:
                            currentPage:
                              type: integer
                            pagesRemaining:
                              type: integer
                            daysRemaining:
                              type: integer
                            requiredPace:
                              type: number
                            actualPace:
                              type: number
                              nullable: true
                            status:
                              type: string
                              enum: [on-track, slightly-behind, behind]
        '500':
          $ref: '#/components/responses/ServerError'

    post:
      summary: Add a new book
      description: Add a book to the user's reading list with a deadline
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - title
                - totalPages
                - deadlineDate
              properties:
                title:
                  type: string
                  maxLength: 500
                author:
                  type: string
                  maxLength: 500
                  nullable: true
                totalPages:
                  type: integer
                  minimum: 1
                coverUrl:
                  type: string
                  maxLength: 1000
                  nullable: true
                deadlineDate:
                  type: string
                  format: date
      responses:
        '201':
          description: Book created successfully
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Book'
        '400':
          $ref: '#/components/responses/BadRequest'
        '500':
          $ref: '#/components/responses/ServerError'

  /books/{bookId}:
    get:
      summary: Get book by ID
      description: Retrieve a specific book with full details
      parameters:
        - $ref: '#/components/parameters/BookId'
      responses:
        '200':
          description: Book details
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Book'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/ServerError'

    delete:
      summary: Delete a book
      description: Remove a book and all associated reading logs
      parameters:
        - $ref: '#/components/parameters/BookId'
      responses:
        '204':
          description: Book deleted successfully
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/ServerError'

  /books/{bookId}/progress:
    get:
      summary: Get book progress
      description: Calculate and return reading progress with pace metrics
      parameters:
        - $ref: '#/components/parameters/BookId'
      responses:
        '200':
          description: Progress calculations
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProgressCalculation'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/ServerError'

  /books/{bookId}/logs:
    get:
      summary: Get reading logs for a book
      description: Retrieve all reading logs for a specific book
      parameters:
        - $ref: '#/components/parameters/BookId'
      responses:
        '200':
          description: Reading logs
          content:
            application/json:
              schema:
                type: object
                properties:
                  logs:
                    type: array
                    items:
                      $ref: '#/components/schemas/ReadingLog'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/ServerError'

    post:
      summary: Log reading progress
      description: Create or update a reading log entry for a specific date
      parameters:
        - $ref: '#/components/parameters/BookId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - currentPage
              properties:
                currentPage:
                  type: integer
                  minimum: 1
                logDate:
                  type: string
                  format: date
                  description: Date of the log (defaults to today if not provided)
      responses:
        '201':
          description: Log created successfully
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ReadingLog'
        '200':
          description: Log updated successfully (if log already existed for this date)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ReadingLog'
        '400':
          $ref: '#/components/responses/BadRequest'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/ServerError'

components:
  schemas:
    Book:
      type: object
      properties:
        id:
          type: integer
        title:
          type: string
        author:
          type: string
          nullable: true
        totalPages:
          type: integer
        coverUrl:
          type: string
          nullable: true
        deadlineDate:
          type: string
          format: date
        isPrimary:
          type: boolean
        status:
          type: string
          enum: [reading, finished, paused]
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time

    ReadingLog:
      type: object
      properties:
        id:
          type: integer
        bookId:
          type: integer
        logDate:
          type: string
          format: date
        currentPage:
          type: integer
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time

    ProgressCalculation:
      type: object
      properties:
        bookId:
          type: integer
        currentPage:
          type: integer
        totalPages:
          type: integer
        pagesRemaining:
          type: integer
        deadlineDate:
          type: string
          format: date
        daysRemaining:
          type: integer
        requiredPace:
          type: number
          format: float
        actualPace:
          type: number
          format: float
          nullable: true
        status:
          type: string
          enum: [on-track, slightly-behind, behind]
        lastLoggedDate:
          type: string
          format: date
          nullable: true

    Error:
      type: object
      properties:
        error:
          type: string
          description: Error message
        details:
          type: object
          description: Additional error details
          nullable: true

  parameters:
    BookId:
      name: bookId
      in: path
      required: true
      schema:
        type: integer
      description: Unique book identifier

  responses:
    BadRequest:
      description: Invalid request
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error: "Validation failed"
            details:
              field: "deadlineDate"
              message: "Deadline must be in the future"

    NotFound:
      description: Resource not found
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error: "Book not found"

    ServerError:
      description: Internal server error
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error: "Internal server error"
            details: null

Components

Frontend Components

App Shell

Responsibility: Root application component managing routing, global state, and PWA features

Key Interfaces:

  • React Router for navigation
  • React Context providers for global state
  • PWA service worker registration

Dependencies:

  • React Router
  • PWA service worker (vite-plugin-pwa)
  • API service layer

Technology Stack: React 18, React Router 6, React Context API

Book List Screen

Responsibility: Display all active books with progress metrics and status indicators

Key Interfaces:

  • GET /api/books endpoint
  • Navigation to Add Book and Book Detail screens
  • Log Progress modal trigger

Dependencies:

  • BooksService (API client)
  • BookCard component
  • LogProgressModal component

Technology Stack: React, Tailwind CSS, date-fns

Add Book Screen

Responsibility: Search for books via Open Library and add them to reading list with deadline

Key Interfaces:

  • GET /api/books/search endpoint
  • POST /api/books endpoint
  • Navigation back to Book List

Dependencies:

  • BooksService (API client)
  • BookSearchResults component
  • AddBookForm component

Technology Stack: React, Tailwind CSS

Book Detail Screen

Responsibility: Show detailed progress metrics, calendar view, and book management options

Key Interfaces:

  • GET /api/books/:id/progress endpoint
  • GET /api/books/:id/logs endpoint
  • DELETE /api/books/:id endpoint

Dependencies:

  • BooksService (API client)
  • Calendar component
  • LogProgressModal component

Technology Stack: React, Tailwind CSS, date-fns

Calendar Component

Responsibility: Visualize reading logs and deadline dates in month view

Key Interfaces:

  • Accepts logs array and deadline date as props
  • Emits date selection events (for future backfill feature)

Dependencies:

  • date-fns for date manipulation
  • None (can use react-calendar or custom implementation)

Technology Stack: React, Tailwind CSS, date-fns

Log Progress Modal

Responsibility: Quick 3-tap workflow to log current page number

Key Interfaces:

  • POST /api/books/:id/logs endpoint
  • Accepts book context as props
  • Emits success/cancel events to parent

Dependencies:

  • BooksService (API client)
  • Form validation

Technology Stack: React, Tailwind CSS

Backend Components

Express Application

Responsibility: Main HTTP server handling REST API requests

Key Interfaces:

  • Exposes REST endpoints at /api/*
  • Middleware: CORS, Helmet, JSON parser, error handler
  • Health check at /api/health

Dependencies:

  • Express framework
  • Route controllers
  • Middleware stack

Technology Stack: Node.js 20, Express 4.x

Books Controller

Responsibility: Handle all book-related API endpoints

Key Interfaces:

  • GET /api/books (list active books)
  • POST /api/books (add book)
  • GET /api/books/:id (get book)
  • DELETE /api/books/:id (delete book)
  • GET /api/books/search (search Open Library)
  • GET /api/books/:id/progress (get progress)

Dependencies:

  • Prisma database client
  • Open Library service
  • Pace calculation service

Technology Stack: Express, Prisma, express-validator

Reading Logs Controller

Responsibility: Handle reading log API endpoints

Key Interfaces:

  • GET /api/books/:id/logs (get logs for book)
  • POST /api/books/:id/logs (create/update log)

Dependencies:

  • Prisma database client
  • Input validation

Technology Stack: Express, Prisma, express-validator

Open Library Service

Responsibility: Integrate with Open Library API for book search

Key Interfaces:

  • searchBooks(query): Promise<BookSearchResult[]>
  • Caches results in-memory (1 hour TTL)

Dependencies:

  • Native Fetch API
  • In-memory cache (Map)

Technology Stack: Node.js native Fetch, Map

Pace Calculation Service

Responsibility: Calculate reading pace metrics from book and log data

Key Interfaces:

  • calculateRequiredPace(totalPages, currentPage, deadlineDate): number
  • calculateActualPace(bookId, days): Promise<number | null>
  • calculateStatus(requiredPace, actualPace): string

Dependencies:

  • Prisma database client
  • date-fns

Technology Stack: date-fns, Prisma

Database Client

Responsibility: Prisma ORM client for PostgreSQL access

Key Interfaces:

  • Book model CRUD operations
  • ReadingLog model CRUD operations
  • Migrations management

Dependencies:

  • PostgreSQL database
  • Prisma schema

Technology Stack: Prisma, PostgreSQL 15+

Component Diagrams

graph TB
    subgraph Frontend["Frontend - React PWA"]
        App[App Shell<br/>Router + Context]
        BookList[Book List Screen]
        AddBook[Add Book Screen]
        BookDetail[Book Detail Screen]
        Calendar[Calendar Component]
        LogModal[Log Progress Modal]
        BooksAPI[Books Service<br/>API Client]
    end

    subgraph Backend["Backend - Express API"]
        ExpressApp[Express Application]
        BooksCtrl[Books Controller]
        LogsCtrl[Reading Logs Controller]
        PaceService[Pace Calculation Service]
        OLService[Open Library Service]
        PrismaClient[Prisma ORM Client]
    end

    subgraph External["External Services"]
        DB[(PostgreSQL Database)]
        OpenLib[Open Library API]
    end

    App --> BookList
    App --> AddBook
    App --> BookDetail
    BookDetail --> Calendar
    BookList --> LogModal
    BookDetail --> LogModal

    BookList --> BooksAPI
    AddBook --> BooksAPI
    BookDetail --> BooksAPI
    LogModal --> BooksAPI

    BooksAPI -->|HTTP| ExpressApp
    ExpressApp --> BooksCtrl
    ExpressApp --> LogsCtrl

    BooksCtrl --> PaceService
    BooksCtrl --> OLService
    BooksCtrl --> PrismaClient
    LogsCtrl --> PrismaClient
    PaceService --> PrismaClient

    OLService -->|HTTP| OpenLib
    PrismaClient -->|SQL| DB

    style Frontend fill:#e1f5ff
    style Backend fill:#ffe1cc
    style External fill:#fff4cc

External APIs

Open Library API

Key Endpoints Used:

  • GET /search.json?q={query} - Search for books by keyword, returns title, author, pages, cover IDs

Cover Images:

  • Base URL: https://covers.openlibrary.org/b/id/{cover_id}-{size}.jpg
  • Sizes: S (small), M (medium), L (large)
  • Example: https://covers.openlibrary.org/b/id/8233808-M.jpg

Integration Notes:

  • Cache search results for 1 hour to reduce API calls
  • Implement exponential backoff if rate limited (429 response)
  • Fallback to manual entry if API unavailable
  • Extract number_of_pages_median or first number_of_pages value for total pages
  • Some books may not have cover images (use placeholder)
  • Consider Google Books API as backup if Open Library quality is insufficient

Core Workflows

Add Book Workflow

sequenceDiagram
    actor User
    participant UI as Add Book Screen
    participant API as Express API
    participant OL as Open Library API
    participant DB as PostgreSQL

    User->>UI: Enter search query
    UI->>API: GET /api/books/search?q=query
    API->>OL: GET /search.json?q=query
    OL-->>API: Book search results (JSON)
    API-->>UI: Formatted search results
    UI->>User: Display book results

    User->>UI: Select book
    User->>UI: Set deadline date
    UI->>API: POST /api/books {title, author, pages, deadline}
    API->>API: Validate input
    API->>DB: INSERT INTO books
    DB-->>API: Created book with ID
    API-->>UI: 201 Created {book}
    UI->>User: Navigate to Book List
    UI->>User: Show success message

Log Progress Workflow

sequenceDiagram
    actor User
    participant UI as Log Progress Modal
    participant API as Express API
    participant DB as PostgreSQL

    User->>UI: Tap book card
    UI->>User: Open modal
    User->>UI: Enter page number
    UI->>API: POST /api/books/:id/logs {currentPage, logDate}
    API->>API: Validate input (page > 0, <= totalPages, >= lastPage)
    API->>DB: INSERT or UPDATE reading_logs
    DB-->>API: Created/updated log
    API-->>UI: 201/200 {log}
    UI->>UI: Close modal
    UI->>API: GET /api/books (refresh list)
    API->>DB: SELECT books with progress calculations
    DB-->>API: Books with latest logs
    API-->>UI: Books with updated progress
    UI->>User: Show updated status (green/yellow/red)

Calculate Progress Workflow

sequenceDiagram
    participant API as Books Controller
    participant PS as Pace Service
    participant DB as Prisma Client

    API->>DB: Get book by ID
    DB-->>API: Book {totalPages, deadlineDate}
    API->>DB: Get latest log for book
    DB-->>API: ReadingLog {currentPage, logDate}

    API->>PS: calculateRequiredPace(totalPages, currentPage, deadlineDate)
    PS->>PS: pagesRemaining = totalPages - currentPage
    PS->>PS: daysRemaining = deadlineDate - today
    PS->>PS: requiredPace = pagesRemaining / daysRemaining
    PS-->>API: requiredPace (number)

    API->>PS: calculateActualPace(bookId, 7 days)
    PS->>DB: Get logs from last 7 days
    DB-->>PS: ReadingLog[] (last 7 days)
    PS->>PS: actualPace = (latestPage - page7DaysAgo) / 7
    PS-->>API: actualPace (number or null)

    API->>PS: calculateStatus(requiredPace, actualPace)
    PS->>PS: if actualPace >= requiredPace: "on-track"
    PS->>PS: else if actualPace >= requiredPace * 0.9: "slightly-behind"
    PS->>PS: else: "behind"
    PS-->>API: status (string)

    API-->>Client: ProgressCalculation {pagesRemaining, daysRemaining, requiredPace, actualPace, status}

Database Schema

Prisma Schema Definition

// prisma/schema.prisma

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

model Book {
  id           Int          @id @default(autoincrement())
  title        String       @db.VarChar(500)
  author       String?      @db.VarChar(500)
  totalPages   Int
  coverUrl     String?      @db.VarChar(1000)
  deadlineDate DateTime     @db.Date
  isPrimary    Boolean      @default(false)
  status       String       @default("reading") @db.VarChar(50)
  createdAt    DateTime     @default(now())
  updatedAt    DateTime     @updatedAt

  readingLogs  ReadingLog[]

  @@index([deadlineDate])
  @@index([status])
}

model ReadingLog {
  id          Int      @id @default(autoincrement())
  bookId      Int
  logDate     DateTime @db.Date
  currentPage Int
  createdAt   DateTime @default(now())
  updatedAt   DateTime @updatedAt

  book        Book     @relation(fields: [bookId], references: [id], onDelete: Cascade)

  @@unique([bookId, logDate])
  @@index([bookId])
  @@index([logDate])
}

SQL Schema (Generated)

-- Books table
CREATE TABLE "Book" (
  "id" SERIAL PRIMARY KEY,
  "title" VARCHAR(500) NOT NULL,
  "author" VARCHAR(500),
  "totalPages" INTEGER NOT NULL,
  "coverUrl" VARCHAR(1000),
  "deadlineDate" DATE NOT NULL,
  "isPrimary" BOOLEAN NOT NULL DEFAULT false,
  "status" VARCHAR(50) NOT NULL DEFAULT 'reading',
  "createdAt" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  "updatedAt" TIMESTAMP NOT NULL
);

-- Indexes for Books
CREATE INDEX "Book_deadlineDate_idx" ON "Book"("deadlineDate");
CREATE INDEX "Book_status_idx" ON "Book"("status");

-- Reading Logs table
CREATE TABLE "ReadingLog" (
  "id" SERIAL PRIMARY KEY,
  "bookId" INTEGER NOT NULL,
  "logDate" DATE NOT NULL,
  "currentPage" INTEGER NOT NULL,
  "createdAt" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  "updatedAt" TIMESTAMP NOT NULL,
  CONSTRAINT "ReadingLog_book_fkey" FOREIGN KEY ("bookId") REFERENCES "Book"("id") ON DELETE CASCADE
);

-- Unique constraint: one log per book per day
CREATE UNIQUE INDEX "ReadingLog_bookId_logDate_key" ON "ReadingLog"("bookId", "logDate");

-- Indexes for ReadingLog
CREATE INDEX "ReadingLog_bookId_idx" ON "ReadingLog"("bookId");
CREATE INDEX "ReadingLog_logDate_idx" ON "ReadingLog"("logDate");

Performance Considerations:

  • Indexes on deadlineDate and status for filtering active books
  • Composite unique index on (bookId, logDate) prevents duplicate logs and optimizes queries
  • Index on logDate for date range queries (7-day rolling average)
  • Cascade delete ensures orphaned logs don't persist when book is deleted

Frontend Architecture

Component Architecture

Component Organization

frontend/src/
├── components/
│   ├── layout/
│   │   ├── Header.jsx
│   │   ├── Footer.jsx
│   │   └── Layout.jsx
│   ├── books/
│   │   ├── BookCard.jsx
│   │   ├── BookList.jsx
│   │   ├── BookSearchResults.jsx
│   │   └── AddBookForm.jsx
│   ├── progress/
│   │   ├── LogProgressModal.jsx
│   │   ├── ProgressDisplay.jsx
│   │   └── StatusIndicator.jsx
│   ├── calendar/
│   │   ├── Calendar.jsx
│   │   ├── CalendarDay.jsx
│   │   └── CalendarLegend.jsx
│   └── common/
│       ├── Button.jsx
│       ├── Input.jsx
│       ├── Modal.jsx
│       ├── Loading.jsx
│       └── ErrorMessage.jsx
├── pages/
│   ├── Home.jsx
│   ├── AddBook.jsx
│   ├── BookDetail.jsx
│   └── CalendarView.jsx
├── hooks/
│   ├── useBooks.js
│   ├── useProgress.js
│   └── useLogs.js
├── services/
│   ├── api.js (API client setup)
│   ├── booksService.js
│   └── logsService.js
├── context/
│   └── AppContext.jsx
├── utils/
│   ├── dateUtils.js
│   ├── paceUtils.js
│   └── validation.js
├── styles/
│   └── index.css (Tailwind directives)
└── App.jsx

Component Template

// Example: BookCard.jsx
import React from 'react';
import { useNavigate } from 'react-router-dom';
import StatusIndicator from '../progress/StatusIndicator';
import { formatDate } from '../../utils/dateUtils';

/**
 * BookCard - Displays a single book with progress metrics
 * @param {Object} book - Book object with progress data
 * @param {Function} onLogProgress - Handler for log progress action
 */
function BookCard({ book, onLogProgress }) {
  const navigate = useNavigate();

  const handleCardClick = () => {
    navigate(`/books/${book.id}`);
  };

  const handleLogClick = (e) => {
    e.stopPropagation(); // Prevent navigation when clicking log button
    onLogProgress(book);
  };

  return (
    <div
      onClick={handleCardClick}
      className="bg-white rounded-lg shadow-md p-4 cursor-pointer hover:shadow-lg transition-shadow"
    >
      {/* Book cover and title */}
      <div className="flex gap-4">
        {book.coverUrl ? (
          <img
            src={book.coverUrl}
            alt={book.title}
            className="w-16 h-24 object-cover rounded"
          />
        ) : (
          <div className="w-16 h-24 bg-gray-200 rounded flex items-center justify-center">
            <span className="text-gray-400 text-xs">No cover</span>
          </div>
        )}

        <div className="flex-1">
          <h3 className="font-bold text-lg">{book.title}</h3>
          {book.author && <p className="text-gray-600 text-sm">{book.author}</p>}
          <p className="text-gray-500 text-xs mt-1">
            Due: {formatDate(book.deadlineDate)}
          </p>
        </div>
      </div>

      {/* Progress metrics */}
      <div className="mt-4 space-y-2">
        <StatusIndicator status={book.status} />
        <div className="text-sm">
          <p><strong>Target:</strong> {book.requiredPace.toFixed(1)} pages/day</p>
          <p>
            <strong>Your pace:</strong> {' '}
            {book.actualPace ? `${book.actualPace.toFixed(1)} pages/day` : 'No data yet'}
          </p>
          <p className="text-gray-600">
            {book.pagesRemaining} pages left, {book.daysRemaining} days
          </p>
        </div>
      </div>

      {/* Log progress button */}
      <button
        onClick={handleLogClick}
        className="mt-4 w-full bg-blue-500 text-white py-2 px-4 rounded hover:bg-blue-600 transition-colors"
      >
        Log Progress
      </button>
    </div>
  );
}

export default BookCard;

State Management Architecture

State Structure

// context/AppContext.jsx
import React, { createContext, useContext, useState, useEffect } from 'react';
import { booksService } from '../services/booksService';

const AppContext = createContext();

export function AppProvider({ children }) {
  const [books, setBooks] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  // Fetch books on mount
  useEffect(() => {
    loadBooks();
  }, []);

  const loadBooks = async () => {
    setLoading(true);
    setError(null);
    try {
      const data = await booksService.getActiveBooks();
      setBooks(data.books);
    } catch (err) {
      setError(err.message);
    } finally {
      setLoading(false);
    }
  };

  const addBook = async (bookData) => {
    const newBook = await booksService.addBook(bookData);
    setBooks(prev => [...prev, newBook]);
    return newBook;
  };

  const deleteBook = async (bookId) => {
    await booksService.deleteBook(bookId);
    setBooks(prev => prev.filter(b => b.id !== bookId));
  };

  const logProgress = async (bookId, currentPage, logDate) => {
    await booksService.logProgress(bookId, currentPage, logDate);
    // Refresh books to get updated progress
    await loadBooks();
  };

  const value = {
    books,
    loading,
    error,
    loadBooks,
    addBook,
    deleteBook,
    logProgress,
  };

  return <AppContext.Provider value={value}>{children}</AppContext.Provider>;
}

export function useApp() {
  const context = useContext(AppContext);
  if (!context) {
    throw new Error('useApp must be used within AppProvider');
  }
  return context;
}

State Management Patterns

  • Global State: Books list, loading states, errors (via React Context)
  • Local Component State: Form inputs, modal open/closed, UI-only state (via useState)
  • Server State: Fetched data cached in Context, refetch on mutations
  • No External State Library: Context API sufficient for MVP (books list + UI state)
  • State Updates: Optimistic UI updates followed by data refetch for consistency

Routing Architecture

Route Organization

Routes:
/                    → Home (Book List Screen)
/add-book            → Add Book Screen
/books/:bookId       → Book Detail Screen
/calendar            → Calendar View Screen (optional, may be in Book Detail)
/*                   → 404 Not Found

Protected Route Pattern

// App.jsx with React Router
import React from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { AppProvider } from './context/AppContext';
import Layout from './components/layout/Layout';
import Home from './pages/Home';
import AddBook from './pages/AddBook';
import BookDetail from './pages/BookDetail';
import CalendarView from './pages/CalendarView';
import NotFound from './pages/NotFound';

function App() {
  return (
    <BrowserRouter>
      <AppProvider>
        <Layout>
          <Routes>
            <Route path="/" element={<Home />} />
            <Route path="/add-book" element={<AddBook />} />
            <Route path="/books/:bookId" element={<BookDetail />} />
            <Route path="/calendar" element={<CalendarView />} />
            <Route path="*" element={<NotFound />} />
          </Routes>
        </Layout>
      </AppProvider>
    </BrowserRouter>
  );
}

export default App;

Note: No protected routes needed for MVP (single-user, no authentication). Add in v1.1 if multi-user support is needed.

Frontend Services Layer

API Client Setup

// services/api.js
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000/api';

class ApiClient {
  async request(endpoint, options = {}) {
    const url = `${API_BASE_URL}${endpoint}`;
    const config = {
      headers: {
        'Content-Type': 'application/json',
        ...options.headers,
      },
      ...options,
    };

    try {
      const response = await fetch(url, config);

      // Handle non-JSON responses (like 204 No Content)
      if (response.status === 204) {
        return null;
      }

      const data = await response.json();

      if (!response.ok) {
        throw new Error(data.error || 'Request failed');
      }

      return data;
    } catch (error) {
      console.error('API request failed:', error);
      throw error;
    }
  }

  get(endpoint) {
    return this.request(endpoint, { method: 'GET' });
  }

  post(endpoint, body) {
    return this.request(endpoint, {
      method: 'POST',
      body: JSON.stringify(body),
    });
  }

  put(endpoint, body) {
    return this.request(endpoint, {
      method: 'PUT',
      body: JSON.stringify(body),
    });
  }

  delete(endpoint) {
    return this.request(endpoint, { method: 'DELETE' });
  }
}

export const apiClient = new ApiClient();

Service Example

// services/booksService.js
import { apiClient } from './api';

export const booksService = {
  // Search books via Open Library
  async searchBooks(query) {
    return apiClient.get(`/books/search?q=${encodeURIComponent(query)}`);
  },

  // Get all active books with progress
  async getActiveBooks() {
    return apiClient.get('/books');
  },

  // Add a new book
  async addBook(bookData) {
    return apiClient.post('/books', bookData);
  },

  // Get single book
  async getBook(bookId) {
    return apiClient.get(`/books/${bookId}`);
  },

  // Delete book
  async deleteBook(bookId) {
    return apiClient.delete(`/books/${bookId}`);
  },

  // Get book progress
  async getProgress(bookId) {
    return apiClient.get(`/books/${bookId}/progress`);
  },

  // Get reading logs
  async getLogs(bookId) {
    return apiClient.get(`/books/${bookId}/logs`);
  },

  // Log progress
  async logProgress(bookId, currentPage, logDate = null) {
    return apiClient.post(`/books/${bookId}/logs`, {
      currentPage,
      ...(logDate && { logDate }),
    });
  },
};

Backend Architecture

Service Architecture (Traditional Server)

Controller/Route Organization

backend/src/
├── routes/
│   ├── index.js (main router)
│   ├── books.js
│   ├── logs.js
│   └── health.js
├── controllers/
│   ├── booksController.js
│   └── logsController.js
├── services/
│   ├── openLibraryService.js
│   └── paceCalculationService.js
├── middleware/
│   ├── errorHandler.js
│   ├── validateRequest.js
│   └── cors.js
├── utils/
│   ├── logger.js
│   └── validation.js
├── prisma/
│   └── client.js (Prisma client singleton)
└── server.js

Controller Template

// controllers/booksController.js
const { PrismaClient } = require('@prisma/client');
const { body, query, validationResult } = require('express-validator');
const openLibraryService = require('../services/openLibraryService');
const paceService = require('../services/paceCalculationService');

const prisma = new PrismaClient();

// Search books via Open Library
exports.searchBooks = [
  query('q')
    .trim()
    .notEmpty().withMessage('Query is required')
    .isLength({ max: 200 }).withMessage('Query too long'),

  async (req, res, next) => {
    const errors = validationResult(req);
    if (!errors.isEmpty()) {
      return res.status(400).json({ error: 'Validation failed', details: errors.array() });
    }

    try {
      const results = await openLibraryService.searchBooks(req.query.q);
      res.json({ results });
    } catch (error) {
      next(error);
    }
  }
];

// Get all active books with progress
exports.getActiveBooks = async (req, res, next) => {
  try {
    const books = await prisma.book.findMany({
      where: { status: 'reading' },
      include: {
        readingLogs: {
          orderBy: { logDate: 'desc' },
          take: 1, // Latest log
        },
      },
      orderBy: { deadlineDate: 'asc' },
    });

    // Enrich with progress calculations
    const booksWithProgress = await Promise.all(
      books.map(async (book) => {
        const currentPage = book.readingLogs[0]?.currentPage || 0;
        const progress = await paceService.calculateProgress(book, currentPage);
        return {
          ...book,
          currentPage,
          ...progress,
        };
      })
    );

    res.json({ books: booksWithProgress });
  } catch (error) {
    next(error);
  }
};

// Add a new book
exports.addBook = [
  body('title').trim().notEmpty().isLength({ max: 500 }),
  body('author').optional().trim().isLength({ max: 500 }),
  body('totalPages').isInt({ min: 1 }),
  body('coverUrl').optional().isURL().isLength({ max: 1000 }),
  body('deadlineDate').isISO8601().toDate(),

  async (req, res, next) => {
    const errors = validationResult(req);
    if (!errors.isEmpty()) {
      return res.status(400).json({ error: 'Validation failed', details: errors.array() });
    }

    // Validate deadline is in the future
    if (req.body.deadlineDate <= new Date()) {
      return res.status(400).json({ error: 'Deadline must be in the future' });
    }

    try {
      const book = await prisma.book.create({
        data: req.body,
      });
      res.status(201).json(book);
    } catch (error) {
      next(error);
    }
  }
];

// Get book by ID
exports.getBook = async (req, res, next) => {
  try {
    const book = await prisma.book.findUnique({
      where: { id: parseInt(req.params.bookId) },
    });

    if (!book) {
      return res.status(404).json({ error: 'Book not found' });
    }

    res.json(book);
  } catch (error) {
    next(error);
  }
};

// Delete book
exports.deleteBook = async (req, res, next) => {
  try {
    await prisma.book.delete({
      where: { id: parseInt(req.params.bookId) },
    });
    res.status(204).send();
  } catch (error) {
    if (error.code === 'P2025') {
      return res.status(404).json({ error: 'Book not found' });
    }
    next(error);
  }
};

// Get book progress
exports.getProgress = async (req, res, next) => {
  try {
    const book = await prisma.book.findUnique({
      where: { id: parseInt(req.params.bookId) },
      include: {
        readingLogs: {
          orderBy: { logDate: 'desc' },
          take: 1,
        },
      },
    });

    if (!book) {
      return res.status(404).json({ error: 'Book not found' });
    }

    const currentPage = book.readingLogs[0]?.currentPage || 0;
    const progress = await paceService.calculateProgress(book, currentPage);

    res.json({
      bookId: book.id,
      currentPage,
      totalPages: book.totalPages,
      ...progress,
    });
  } catch (error) {
    next(error);
  }
};

Database Architecture

Schema Design

See Database Schema section above for complete Prisma schema.

Data Access Layer

// services/paceCalculationService.js
const { PrismaClient } = require('@prisma/client');
const { differenceInDays, subDays } = require('date-fns');

const prisma = new PrismaClient();

/**
 * Calculate required pages per day to finish on time
 */
function calculateRequiredPace(totalPages, currentPage, deadlineDate) {
  const pagesRemaining = totalPages - currentPage;
  const daysRemaining = differenceInDays(new Date(deadlineDate), new Date());

  if (daysRemaining <= 0) {
    return pagesRemaining; // All pages must be read today/overdue
  }

  if (pagesRemaining <= 0) {
    return 0; // Book finished
  }

  return pagesRemaining / daysRemaining;
}

/**
 * Calculate actual reading pace from last N days of logs
 */
async function calculateActualPace(bookId, days = 7) {
  const startDate = subDays(new Date(), days);

  const logs = await prisma.readingLog.findMany({
    where: {
      bookId,
      logDate: {
        gte: startDate,
      },
    },
    orderBy: { logDate: 'asc' },
  });

  if (logs.length < 2) {
    return null; // Insufficient data
  }

  const firstLog = logs[0];
  const lastLog = logs[logs.length - 1];
  const pagesRead = lastLog.currentPage - firstLog.currentPage;
  const daysElapsed = differenceInDays(new Date(lastLog.logDate), new Date(firstLog.logDate));

  if (daysElapsed === 0) {
    return null; // Same day logs, can't calculate pace
  }

  return pagesRead / daysElapsed;
}

/**
 * Determine status based on required vs actual pace
 */
function calculateStatus(requiredPace, actualPace) {
  if (actualPace === null) {
    return 'unknown'; // Not enough data
  }

  if (actualPace >= requiredPace) {
    return 'on-track';
  }

  if (actualPace >= requiredPace * 0.9) {
    return 'slightly-behind'; // Within 10%
  }

  return 'behind';
}

/**
 * Calculate complete progress object for a book
 */
async function calculateProgress(book, currentPage) {
  const requiredPace = calculateRequiredPace(book.totalPages, currentPage, book.deadlineDate);
  const actualPace = await calculateActualPace(book.id, 7);
  const status = calculateStatus(requiredPace, actualPace);

  const pagesRemaining = book.totalPages - currentPage;
  const daysRemaining = differenceInDays(new Date(book.deadlineDate), new Date());

  const lastLog = await prisma.readingLog.findFirst({
    where: { bookId: book.id },
    orderBy: { logDate: 'desc' },
  });

  return {
    pagesRemaining,
    daysRemaining,
    requiredPace,
    actualPace,
    status,
    lastLoggedDate: lastLog?.logDate || null,
  };
}

module.exports = {
  calculateRequiredPace,
  calculateActualPace,
  calculateStatus,
  calculateProgress,
};

Authentication and Authorization

MVP: No authentication required (single-user deployment).

Future (v1.1 Multi-User):

  • Add JWT-based authentication
  • Use Passport.js or custom middleware
  • Store JWT in httpOnly cookie or localStorage
  • Protect API routes with auth middleware
  • Add user_id foreign key to Book model

Auth Flow (Future):

sequenceDiagram
    actor User
    participant FE as Frontend
    participant API as Backend API
    participant DB as Database

    User->>FE: Enter credentials
    FE->>API: POST /api/auth/login
    API->>DB: Verify credentials
    DB-->>API: User found
    API->>API: Generate JWT token
    API-->>FE: JWT token (httpOnly cookie)
    FE->>API: GET /api/books (with cookie)
    API->>API: Verify JWT token
    API->>DB: Query books for user_id
    DB-->>API: Books
    API-->>FE: Books data

Unified Project Structure

books/
├── .github/                    # CI/CD workflows (future)
│   └── workflows/
│       └── deploy.yaml
├── frontend/                   # React PWA application
│   ├── src/
│   │   ├── components/         # Reusable UI components
│   │   │   ├── layout/
│   │   │   ├── books/
│   │   │   ├── progress/
│   │   │   ├── calendar/
│   │   │   └── common/
│   │   ├── pages/              # Page/route components
│   │   │   ├── Home.jsx
│   │   │   ├── AddBook.jsx
│   │   │   ├── BookDetail.jsx
│   │   │   └── CalendarView.jsx
│   │   ├── hooks/              # Custom React hooks
│   │   │   ├── useBooks.js
│   │   │   ├── useProgress.js
│   │   │   └── useLogs.js
│   │   ├── services/           # API client services
│   │   │   ├── api.js
│   │   │   ├── booksService.js
│   │   │   └── logsService.js
│   │   ├── context/            # React Context providers
│   │   │   └── AppContext.jsx
│   │   ├── utils/              # Frontend utilities
│   │   │   ├── dateUtils.js
│   │   │   ├── paceUtils.js
│   │   │   └── validation.js
│   │   ├── styles/             # Global styles
│   │   │   └── index.css
│   │   └── App.jsx             # Root component
│   ├── public/                 # Static assets
│   │   ├── manifest.json       # PWA manifest
│   │   ├── icons/              # PWA icons (192, 512)
│   │   └── favicon.ico
│   ├── tests/                  # Frontend tests
│   │   ├── components/
│   │   └── utils/
│   ├── index.html
│   ├── vite.config.js          # Vite + PWA config
│   ├── tailwind.config.js      # Tailwind CSS config
│   ├── postcss.config.js
│   ├── package.json
│   ├── .env.example
│   └── Dockerfile              # Frontend container
├── backend/                    # Node.js Express API
│   ├── src/
│   │   ├── routes/             # API route definitions
│   │   │   ├── index.js
│   │   │   ├── books.js
│   │   │   ├── logs.js
│   │   │   └── health.js
│   │   ├── controllers/        # Request handlers
│   │   │   ├── booksController.js
│   │   │   └── logsController.js
│   │   ├── services/           # Business logic
│   │   │   ├── openLibraryService.js
│   │   │   └── paceCalculationService.js
│   │   ├── middleware/         # Express middleware
│   │   │   ├── errorHandler.js
│   │   │   ├── validateRequest.js
│   │   │   └── cors.js
│   │   ├── utils/              # Backend utilities
│   │   │   ├── logger.js
│   │   │   └── validation.js
│   │   ├── prisma/             # Prisma client
│   │   │   └── client.js
│   │   └── server.js           # Express app entry
│   ├── prisma/                 # Prisma schema and migrations
│   │   ├── schema.prisma
│   │   └── migrations/
│   ├── tests/                  # Backend tests
│   │   ├── controllers/
│   │   └── services/
│   ├── package.json
│   ├── .env.example
│   └── Dockerfile              # Backend container
├── docs/                       # Project documentation
│   ├── prd/                    # Sharded PRD
│   ├── architecture/           # Sharded architecture (this doc)
│   ├── brief.md
│   ├── brainstorming-session-results.md
│   └── deployment.md
├── .env.example                # Environment variables template
├── .gitignore
├── docker-compose.yml          # Local development orchestration
├── README.md                   # Project overview and setup
└── package.json                # Root package.json (optional)

Development Workflow

Local Development Setup

Prerequisites

# Install Node.js 20 LTS
# Check: node --version (should be 20.x)

# Install Docker and Docker Compose
# Check: docker --version && docker-compose --version

# Install global tools
npm install -g @kayvan/markdown-tree-parser  # For doc sharding (optional)

Initial Setup

# Clone repository
git clone <repo-url>
cd books

# Install frontend dependencies
cd frontend
npm install
cd ..

# Install backend dependencies
cd backend
npm install
cd ..

# Copy environment variables
cp .env.example .env
cp frontend/.env.example frontend/.env.local
cp backend/.env.example backend/.env

# Edit .env files with your database credentials

# Start database with Docker
docker-compose up -d postgres

# Run Prisma migrations
cd backend
npx prisma migrate dev
cd ..

Development Commands

# Start all services (frontend + backend + database)
docker-compose up

# OR start services individually:

# Start database only
docker-compose up postgres

# Start frontend only (dev server with HMR)
cd frontend
npm run dev
# Runs on http://localhost:5173

# Start backend only (nodemon for auto-reload)
cd backend
npm run dev
# Runs on http://localhost:3000

# Run tests
cd frontend
npm test

cd backend
npm test

# Build for production
cd frontend
npm run build

cd backend
# No build needed (Node.js runs directly)

Environment Configuration

Required Environment Variables

# Backend (.env in backend/)
DATABASE_URL="postgresql://user:password@localhost:5432/books"
API_PORT=3000
NODE_ENV=development
OPEN_LIBRARY_API_URL=https://openlibrary.org
CORS_ORIGIN=http://localhost:5173

# Frontend (.env.local in frontend/)
VITE_API_URL=http://localhost:3000/api

# docker-compose.yml uses shared .env at root
POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres
POSTGRES_DB=books

Deployment Architecture

Deployment Strategy

Frontend Deployment:

  • Platform: Coolify (self-hosted)
  • Build Command: npm run build (in frontend/)
  • Output Directory: frontend/dist
  • Serving: Static files served by Nginx/Caddy via Coolify reverse proxy
  • CDN/Edge: No CDN (self-hosted), Coolify handles SSL and caching headers

Backend Deployment:

  • Platform: Coolify (self-hosted)
  • Build Command: None (Node.js runs directly)
  • Deployment Method: Docker container with npm start
  • Process Manager: Docker handles restarts, no PM2 needed
  • Health Checks: GET /api/health monitored by Coolify

Database Deployment:

  • Platform: Coolify-managed PostgreSQL container
  • Backups: Automated daily backups via Coolify
  • Migrations: Run npx prisma migrate deploy on deployment

CI/CD Pipeline

MVP: Manual deployment via Coolify UI (no CI/CD pipeline initially).

Future CI/CD (GitHub Actions example):

# .github/workflows/deploy.yaml
name: Deploy to Coolify

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3

      - name: Setup Node.js
        uses: actions/setup-node@v3
        with:
          node-version: '20'

      - name: Install dependencies
        run: |
          cd frontend && npm ci
          cd ../backend && npm ci

      - name: Run tests
        run: |
          cd frontend && npm test
          cd ../backend && npm test

      - name: Build frontend
        run: cd frontend && npm run build

      - name: Deploy to Coolify
        uses: coolify/action@v1
        with:
          api-key: ${{ secrets.COOLIFY_API_KEY }}
          project-id: ${{ secrets.COOLIFY_PROJECT_ID }}

Environments

Environment Frontend URL Backend URL Purpose
Development http://localhost:5173 http://localhost:3000/api Local development with hot reload
Production https://books.yourdomain.com https://books.yourdomain.com/api Live environment on Coolify

Note: No staging environment for MVP (single-user, can test locally). Add staging in v1.1 if needed.


Security and Performance

Security Requirements

Frontend Security:

  • CSP Headers: Set via Coolify/Nginx:
    Content-Security-Policy: default-src 'self';
      script-src 'self';
      style-src 'self' 'unsafe-inline';
      img-src 'self' https://covers.openlibrary.org;
      connect-src 'self' https://openlibrary.org;
    
  • XSS Prevention: React escapes by default, validate all user inputs
  • Secure Storage: No sensitive data stored in localStorage (no auth in MVP)

Backend Security:

  • Input Validation: express-validator on all POST/PUT endpoints
  • Rate Limiting: Add express-rate-limit if abuse detected (not in MVP)
    const rateLimit = require('express-rate-limit');
    const limiter = rateLimit({
      windowMs: 15 * 60 * 1000, // 15 minutes
      max: 100, // 100 requests per window
    });
    app.use('/api/', limiter);
    
  • CORS Policy:
    app.use(cors({
      origin: process.env.CORS_ORIGIN || 'http://localhost:5173',
      credentials: true,
    }));
    
  • Helmet Security Headers:
    app.use(helmet({
      contentSecurityPolicy: false, // Handled by Nginx
      hsts: { maxAge: 31536000 },
    }));
    

Authentication Security (Future v1.1):

  • Token Storage: httpOnly cookies (not localStorage)
  • Session Management: JWT with 1-hour expiration, refresh tokens
  • Password Policy: bcrypt hashing, min 8 characters, complexity requirements

Performance Optimization

Frontend Performance:

  • Bundle Size Target: <500KB gzipped
    • Vite automatically code-splits and tree-shakes
    • Lazy load non-critical routes: const BookDetail = lazy(() => import('./pages/BookDetail'))
  • Loading Strategy:
    • Critical path: App shell → Book list
    • Lazy load: BookDetail, Calendar, AddBook
    • Prefetch on hover for instant navigation
  • Caching Strategy:
    • Service worker caches static assets (cache-first)
    • API responses cached for 5 minutes (stale-while-revalidate)
    • Book search results cached in-memory (1 hour)

Backend Performance:

  • Response Time Target: <200ms for CRUD, <500ms for calculations
  • Database Optimization:
    • Indexes on deadlineDate, bookId, logDate
    • Limit queries: take: 10 for logs, where: { status: 'reading' }
    • Use Prisma's include to avoid N+1 queries
  • Caching Strategy:
    • Book metadata from Open Library cached in-memory (1 hour Map)
    • No Redis needed for single-user MVP
    • Consider Redis if adding multi-user in v1.1

Lighthouse Targets:

  • Performance: >90
  • Accessibility: >90
  • Best Practices: >95
  • SEO: >90 (PWA)

Testing Strategy

Testing Pyramid

        E2E Tests (Manual)
       /                  \
   Integration Tests (Backend)
  /                            \
Frontend Unit Tests    Backend Unit Tests

Coverage Targets:

  • Frontend: >70% for utils, services, business logic
  • Backend: >80% for controllers, services, business logic
  • Manual E2E: All critical user flows tested before deployment

Test Organization

Frontend Tests

frontend/tests/
├── components/
│   ├── BookCard.test.jsx
│   ├── LogProgressModal.test.jsx
│   └── StatusIndicator.test.jsx
├── utils/
│   ├── dateUtils.test.js
│   ├── paceUtils.test.js
│   └── validation.test.js
└── services/
    └── booksService.test.js

Backend Tests

backend/tests/
├── controllers/
│   ├── booksController.test.js
│   └── logsController.test.js
├── services/
│   ├── openLibraryService.test.js
│   └── paceCalculationService.test.js
└── integration/
    └── api.test.js

E2E Tests (Manual Checklist)

Manual E2E Test Checklist:
- [ ] Add book: Search → Select → Set deadline → Verify in list
- [ ] Log progress: Tap book → Enter page → Verify status updates
- [ ] View calendar: Navigate to book detail → See logged days
- [ ] Install PWA: "Add to Home Screen" → Launch from home screen
- [ ] Offline mode: Disable network → Log entry → Re-enable → Verify sync
- [ ] Mobile responsiveness: Test on iPhone and Android

Test Examples

Frontend Component Test

// tests/components/StatusIndicator.test.jsx
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import StatusIndicator from '../../src/components/progress/StatusIndicator';

describe('StatusIndicator', () => {
  it('displays green indicator for on-track status', () => {
    render(<StatusIndicator status="on-track" />);
    const indicator = screen.getByText(/on track/i);
    expect(indicator).toBeInTheDocument();
    expect(indicator).toHaveClass('text-green-600'); // Tailwind class
  });

  it('displays yellow indicator for slightly-behind status', () => {
    render(<StatusIndicator status="slightly-behind" />);
    const indicator = screen.getByText(/slightly behind/i);
    expect(indicator).toBeInTheDocument();
    expect(indicator).toHaveClass('text-yellow-600');
  });

  it('displays red indicator for behind status', () => {
    render(<StatusIndicator status="behind" />);
    const indicator = screen.getByText(/behind/i);
    expect(indicator).toBeInTheDocument();
    expect(indicator).toHaveClass('text-red-600');
  });
});

Backend API Test

// tests/controllers/booksController.test.js
const request = require('supertest');
const app = require('../../src/server');
const { PrismaClient } = require('@prisma/client');

const prisma = new PrismaClient();

describe('Books API', () => {
  beforeAll(async () => {
    // Setup test database
    await prisma.book.deleteMany();
  });

  afterAll(async () => {
    await prisma.$disconnect();
  });

  describe('POST /api/books', () => {
    it('creates a new book with valid data', async () => {
      const bookData = {
        title: 'The Name of the Wind',
        author: 'Patrick Rothfuss',
        totalPages: 662,
        deadlineDate: '2025-12-31',
      };

      const response = await request(app)
        .post('/api/books')
        .send(bookData)
        .expect(201);

      expect(response.body).toMatchObject({
        title: bookData.title,
        author: bookData.author,
        totalPages: bookData.totalPages,
        status: 'reading',
      });
      expect(response.body.id).toBeDefined();
    });

    it('returns 400 for invalid deadline (past date)', async () => {
      const bookData = {
        title: 'Test Book',
        totalPages: 300,
        deadlineDate: '2020-01-01', // Past date
      };

      const response = await request(app)
        .post('/api/books')
        .send(bookData)
        .expect(400);

      expect(response.body.error).toBe('Deadline must be in the future');
    });
  });

  describe('GET /api/books', () => {
    it('returns all active books with progress', async () => {
      // Create test book
      await prisma.book.create({
        data: {
          title: 'Test Book',
          totalPages: 300,
          deadlineDate: new Date('2025-12-31'),
        },
      });

      const response = await request(app)
        .get('/api/books')
        .expect(200);

      expect(response.body.books).toBeInstanceOf(Array);
      expect(response.body.books.length).toBeGreaterThan(0);
      expect(response.body.books[0]).toHaveProperty('requiredPace');
      expect(response.body.books[0]).toHaveProperty('status');
    });
  });
});

E2E Test (Manual)

Test Case: Add Book and Log Progress

Steps:

  1. Navigate to http://localhost:5173
  2. Click "Add Book" button
  3. Search for "Name of the Wind"
  4. Select first result
  5. Set deadline to 30 days from today
  6. Click "Add to Reading List"
  7. Verify book appears in list with status "on-track" or "unknown"
  8. Click "Log Progress" button
  9. Enter page number: 50
  10. Click "Save"
  11. Verify status updates to show required pace and actual pace

Expected Result:

  • Book added successfully
  • Progress logged successfully
  • Status indicator shows appropriate color
  • Pace calculations display correctly

Coding Standards

Critical Fullstack Rules

  • API Error Handling: All API routes must use the centralized error handler middleware. Never send raw errors to clients.

  • Input Validation: All POST/PUT endpoints must validate inputs using express-validator. Frontend should also validate before sending.

  • Database Access: Always use Prisma ORM for database operations. Never write raw SQL queries (except for complex queries Prisma can't handle).

  • Environment Variables: Access environment variables only through centralized config. Never use process.env or import.meta.env directly in components/controllers.

  • Date Handling: Use date-fns for all date manipulation. Never use vanilla JavaScript Date methods for calculations.

  • Component Structure: React components must be functional with hooks. No class components.

  • State Updates: Never mutate state directly. Use setState/setters or Context dispatch actions.

  • API Calls: Never make direct fetch calls in components. Always use the service layer (booksService, logsService).

  • Error Boundaries: Critical routes must have React Error Boundaries to catch and display errors gracefully.

  • Prisma Schema Changes: Never modify database directly. Always create Prisma migrations for schema changes.

Naming Conventions

Element Frontend Backend Example
Components PascalCase - BookCard.jsx, LogProgressModal.jsx
Hooks camelCase with 'use' prefix - useBooks.js, useProgress.js
Services camelCase with 'Service' suffix camelCase with 'Service' suffix booksService.js, openLibraryService.js
Controllers - camelCase with 'Controller' suffix booksController.js
API Routes - kebab-case /api/books, /api/books/:id/logs
Database Tables - PascalCase (Prisma convention) Book, ReadingLog
Files kebab-case kebab-case book-card.jsx, pace-calculation-service.js
CSS Classes kebab-case (Tailwind) - text-blue-500, book-card-container
Functions camelCase camelCase calculateRequiredPace(), handleLogClick()
Constants SCREAMING_SNAKE_CASE SCREAMING_SNAKE_CASE API_BASE_URL, MAX_QUERY_LENGTH

Error Handling Strategy

Error Flow

sequenceDiagram
    participant User
    participant FE as Frontend
    participant API as Backend API
    participant DB as Database

    User->>FE: Submit invalid form
    FE->>FE: Validate input
    FE->>User: Show validation error (inline)

    User->>FE: Submit valid form
    FE->>API: POST request
    API->>API: Validate input (express-validator)
    alt Validation fails
        API-->>FE: 400 Bad Request {error, details}
        FE->>User: Display error message
    else Validation passes
        API->>DB: Query/mutation
        alt Database error
            DB-->>API: Error
            API->>API: Log error
            API-->>FE: 500 Server Error {error}
            FE->>User: Display generic error
        else Success
            DB-->>API: Data
            API-->>FE: 200/201 Success {data}
            FE->>User: Display success message
        end
    end

Error Response Format

// Consistent error format across all API endpoints
interface ApiError {
  error: string;              // Human-readable error message
  details?: Record<string, any>; // Optional validation details
  timestamp?: string;         // ISO timestamp
  requestId?: string;         // For debugging (optional)
}

// Examples:
// Validation error
{
  "error": "Validation failed",
  "details": {
    "field": "deadlineDate",
    "message": "Deadline must be in the future"
  }
}

// Generic server error
{
  "error": "Internal server error"
}

// Not found error
{
  "error": "Book not found"
}

Frontend Error Handling

// services/api.js - Global error handler
async request(endpoint, options = {}) {
  try {
    const response = await fetch(`${API_BASE_URL}${endpoint}`, options);

    if (!response.ok) {
      const errorData = await response.json();
      throw new ApiError(errorData.error, errorData.details, response.status);
    }

    return await response.json();
  } catch (error) {
    if (error instanceof ApiError) {
      throw error; // Re-throw API errors
    }
    // Network errors or other fetch failures
    throw new ApiError('Network error. Please check your connection.');
  }
}

// Custom error class
class ApiError extends Error {
  constructor(message, details, statusCode) {
    super(message);
    this.name = 'ApiError';
    this.details = details;
    this.statusCode = statusCode;
  }
}

// Component error handling
try {
  await booksService.addBook(bookData);
  showSuccessMessage('Book added successfully!');
} catch (error) {
  if (error instanceof ApiError) {
    if (error.details) {
      // Show field-specific errors
      setFieldError(error.details.field, error.details.message);
    } else {
      // Show generic error
      showErrorMessage(error.message);
    }
  } else {
    showErrorMessage('An unexpected error occurred');
  }
}

Backend Error Handling

// middleware/errorHandler.js - Centralized error handler
module.exports = (err, req, res, next) => {
  // Log error for debugging
  console.error('Error:', err);

  // Prisma errors
  if (err.code && err.code.startsWith('P')) {
    if (err.code === 'P2025') {
      return res.status(404).json({ error: 'Resource not found' });
    }
    if (err.code === 'P2002') {
      return res.status(400).json({
        error: 'Duplicate entry',
        details: { field: err.meta?.target?.[0] }
      });
    }
    return res.status(500).json({ error: 'Database error' });
  }

  // Validation errors (from express-validator)
  if (err.errors && Array.isArray(err.errors)) {
    return res.status(400).json({
      error: 'Validation failed',
      details: err.errors,
    });
  }

  // Default: Internal server error
  res.status(err.statusCode || 500).json({
    error: err.message || 'Internal server error',
  });
};

// Usage in server.js
app.use(errorHandler);

Monitoring and Observability

Monitoring Stack

  • Frontend Monitoring: Browser console errors (dev), future: Sentry for production error tracking
  • Backend Monitoring: Winston structured logging, health check endpoint for uptime
  • Error Tracking: Console logs (dev), future: Sentry or LogRocket
  • Performance Monitoring: Lighthouse audits (manual), future: Web Vitals reporting

Key Metrics

Frontend Metrics:

  • Core Web Vitals:
    • LCP (Largest Contentful Paint): <2.5s
    • FID (First Input Delay): <100ms
    • CLS (Cumulative Layout Shift): <0.1
  • JavaScript errors: Track with Error Boundaries
  • API response times: Log slow requests (>1s)
  • User interactions: Button clicks, form submissions

Backend Metrics:

  • Request rate: Requests per second
  • Error rate: 4xx/5xx responses per minute
  • Response time: P50, P95, P99 latency
  • Database query performance: Slow queries (>100ms)
  • Open Library API: Success rate, response times

Health Check Endpoint:

// GET /api/health
app.get('/api/health', async (req, res) => {
  try {
    // Check database connection
    await prisma.$queryRaw`SELECT 1`;

    res.json({
      status: 'ok',
      timestamp: new Date().toISOString(),
      uptime: process.uptime(),
      database: 'connected',
    });
  } catch (error) {
    res.status(503).json({
      status: 'error',
      timestamp: new Date().toISOString(),
      database: 'disconnected',
    });
  }
});

Logging (Winston):

// utils/logger.js
const winston = require('winston');

const logger = winston.createLogger({
  level: process.env.LOG_LEVEL || 'info',
  format: winston.format.combine(
    winston.format.timestamp(),
    winston.format.json()
  ),
  transports: [
    new winston.transports.Console({
      format: winston.format.simple(),
    }),
    // Add file transport for production
    // new winston.transports.File({ filename: 'error.log', level: 'error' }),
  ],
});

module.exports = logger;

// Usage:
logger.info('Book created', { bookId: 123, title: 'Test Book' });
logger.error('API error', { error: err.message, stack: err.stack });

Checklist Results Report

(To be populated after running architect checklist validation)


Architecture document created by Winston (Architect) 🏗️ Based on PRD (docs/prd/) and Project Brief (docs/brief.md) Document version: 1.0 | Date: 2025-12-01