- 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>
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 titleauthor: String (max 500 chars, optional) - Book authortotalPages: Integer - Total pages in the bookcoverUrl: String (max 1000 chars, optional) - URL to book cover image (from Open Library)deadlineDate: Date - Date by which user needs to finish the bookisPrimary: 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 addedupdatedAt: 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
ReadingLogentries (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 modellogDate: Date - Date of the reading log entrycurrentPage: Integer - Page number user reached on this datecreatedAt: DateTime - When log was createdupdatedAt: 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 trackedcurrentPage: Integer - Latest logged pagetotalPages: Integer - Total pages in bookpagesRemaining: Integer - Pages left to readdeadlineDate: Date - Finish by datedaysRemaining: Integer - Days until deadlinerequiredPace: Float - Pages/day needed to finish on timeactualPace: Float - Pages/day based on 7-day rolling averagestatus: 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
BookandReadingLogmodels - 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
- Purpose: Search for books by title, author, or ISBN to populate book metadata
- Documentation: https://openlibrary.org/dev/docs/api/search
- Base URL(s): https://openlibrary.org
- Authentication: None (public API, no API key required)
- Rate Limits: ~100 requests per 5 minutes (unofficial limit, respect fair use)
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_medianor firstnumber_of_pagesvalue 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
deadlineDateandstatusfor filtering active books - Composite unique index on
(bookId, logDate)prevents duplicate logs and optimizes queries - Index on
logDatefor 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/healthmonitored by Coolify
Database Deployment:
- Platform: Coolify-managed PostgreSQL container
- Backups: Automated daily backups via Coolify
- Migrations: Run
npx prisma migrate deployon 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: 10for logs,where: { status: 'reading' } - Use Prisma's
includeto avoid N+1 queries
- Indexes on
- 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:
- Navigate to http://localhost:5173
- Click "Add Book" button
- Search for "Name of the Wind"
- Select first result
- Set deadline to 30 days from today
- Click "Add to Reading List"
- Verify book appears in list with status "on-track" or "unknown"
- Click "Log Progress" button
- Enter page number: 50
- Click "Save"
- 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.envorimport.meta.envdirectly 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