# 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
Nginx/Caddy + SSL] FE[Frontend Container
React PWA
Vite Static Build] BE[Backend Container
Node.js + Express
REST API] DB[(PostgreSQL 15+
Database)] OL[Open Library API
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 - 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 - 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
Router + Context] BookList[Book List Screen] AddBook[Add Book Screen] BookDetail[Book Detail Screen] Calendar[Calendar Component] LogModal[Log Progress Modal] BooksAPI[Books Service
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 (
{/* Book cover and title */}
{book.coverUrl ? ( {book.title} ) : (
No cover
)}

{book.title}

{book.author &&

{book.author}

}

Due: {formatDate(book.deadlineDate)}

{/* Progress metrics */}

Target: {book.requiredPace.toFixed(1)} pages/day

Your pace: {' '} {book.actualPace ? `${book.actualPace.toFixed(1)} pages/day` : 'No data yet'}

{book.pagesRemaining} pages left, {book.daysRemaining} days

{/* Log progress button */}
); } 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 {children}; } 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 ( } /> } /> } /> } /> } /> ); } 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 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(); 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(); const indicator = screen.getByText(/slightly behind/i); expect(indicator).toBeInTheDocument(); expect(indicator).toHaveClass('text-yellow-600'); }); it('displays red indicator for behind status', () => { render(); 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; // 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*