- 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>
2756 lines
78 KiB
Markdown
2756 lines
78 KiB
Markdown
# 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
|
|
|
|
```mermaid
|
|
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
|
|
|
|
```typescript
|
|
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
|
|
|
|
```typescript
|
|
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
|
|
|
|
```typescript
|
|
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
|
|
|
|
```yaml
|
|
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
|
|
|
|
```mermaid
|
|
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_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
|
|
|
|
```mermaid
|
|
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
|
|
|
|
```mermaid
|
|
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
|
|
|
|
```mermaid
|
|
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
|
|
// 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)
|
|
|
|
```sql
|
|
-- 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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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):**
|
|
|
|
```mermaid
|
|
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
|
|
|
|
```bash
|
|
# 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
|
|
|
|
```bash
|
|
# 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
|
|
|
|
```bash
|
|
# 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
|
|
|
|
```bash
|
|
# 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):**
|
|
|
|
```yaml
|
|
# .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)
|
|
```javascript
|
|
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:**
|
|
```javascript
|
|
app.use(cors({
|
|
origin: process.env.CORS_ORIGIN || 'http://localhost:5173',
|
|
credentials: true,
|
|
}));
|
|
```
|
|
- **Helmet Security Headers:**
|
|
```javascript
|
|
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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```mermaid
|
|
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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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:**
|
|
```javascript
|
|
// 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):**
|
|
```javascript
|
|
// 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*
|