- 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>
8.2 KiB
8.2 KiB
Backend Architecture
Service Architecture (Traditional Server)
Controller/Route Organization
backend/src/
├── routes/
│ ├── index.js (main router)
│ ├── books.js
│ ├── logs.js
│ └── health.js
├── controllers/
│ ├── booksController.js
│ └── logsController.js
├── services/
│ ├── openLibraryService.js
│ └── paceCalculationService.js
├── middleware/
│ ├── errorHandler.js
│ ├── validateRequest.js
│ └── cors.js
├── utils/
│ ├── logger.js
│ └── validation.js
├── prisma/
│ └── client.js (Prisma client singleton)
└── server.js
Controller Template
// controllers/booksController.js
const { PrismaClient } = require('@prisma/client');
const { body, query, validationResult } = require('express-validator');
const openLibraryService = require('../services/openLibraryService');
const paceService = require('../services/paceCalculationService');
const prisma = new PrismaClient();
// Search books via Open Library
exports.searchBooks = [
query('q')
.trim()
.notEmpty().withMessage('Query is required')
.isLength({ max: 200 }).withMessage('Query too long'),
async (req, res, next) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ error: 'Validation failed', details: errors.array() });
}
try {
const results = await openLibraryService.searchBooks(req.query.q);
res.json({ results });
} catch (error) {
next(error);
}
}
];
// Get all active books with progress
exports.getActiveBooks = async (req, res, next) => {
try {
const books = await prisma.book.findMany({
where: { status: 'reading' },
include: {
readingLogs: {
orderBy: { logDate: 'desc' },
take: 1, // Latest log
},
},
orderBy: { deadlineDate: 'asc' },
});
// Enrich with progress calculations
const booksWithProgress = await Promise.all(
books.map(async (book) => {
const currentPage = book.readingLogs[0]?.currentPage || 0;
const progress = await paceService.calculateProgress(book, currentPage);
return {
...book,
currentPage,
...progress,
};
})
);
res.json({ books: booksWithProgress });
} catch (error) {
next(error);
}
};
// Add a new book
exports.addBook = [
body('title').trim().notEmpty().isLength({ max: 500 }),
body('author').optional().trim().isLength({ max: 500 }),
body('totalPages').isInt({ min: 1 }),
body('coverUrl').optional().isURL().isLength({ max: 1000 }),
body('deadlineDate').isISO8601().toDate(),
async (req, res, next) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ error: 'Validation failed', details: errors.array() });
}
// Validate deadline is in the future
if (req.body.deadlineDate <= new Date()) {
return res.status(400).json({ error: 'Deadline must be in the future' });
}
try {
const book = await prisma.book.create({
data: req.body,
});
res.status(201).json(book);
} catch (error) {
next(error);
}
}
];
// Get book by ID
exports.getBook = async (req, res, next) => {
try {
const book = await prisma.book.findUnique({
where: { id: parseInt(req.params.bookId) },
});
if (!book) {
return res.status(404).json({ error: 'Book not found' });
}
res.json(book);
} catch (error) {
next(error);
}
};
// Delete book
exports.deleteBook = async (req, res, next) => {
try {
await prisma.book.delete({
where: { id: parseInt(req.params.bookId) },
});
res.status(204).send();
} catch (error) {
if (error.code === 'P2025') {
return res.status(404).json({ error: 'Book not found' });
}
next(error);
}
};
// Get book progress
exports.getProgress = async (req, res, next) => {
try {
const book = await prisma.book.findUnique({
where: { id: parseInt(req.params.bookId) },
include: {
readingLogs: {
orderBy: { logDate: 'desc' },
take: 1,
},
},
});
if (!book) {
return res.status(404).json({ error: 'Book not found' });
}
const currentPage = book.readingLogs[0]?.currentPage || 0;
const progress = await paceService.calculateProgress(book, currentPage);
res.json({
bookId: book.id,
currentPage,
totalPages: book.totalPages,
...progress,
});
} catch (error) {
next(error);
}
};
Database Architecture
Schema Design
See Database Schema section above for complete Prisma schema.
Data Access Layer
// services/paceCalculationService.js
const { PrismaClient } = require('@prisma/client');
const { differenceInDays, subDays } = require('date-fns');
const prisma = new PrismaClient();
/**
* Calculate required pages per day to finish on time
*/
function calculateRequiredPace(totalPages, currentPage, deadlineDate) {
const pagesRemaining = totalPages - currentPage;
const daysRemaining = differenceInDays(new Date(deadlineDate), new Date());
if (daysRemaining <= 0) {
return pagesRemaining; // All pages must be read today/overdue
}
if (pagesRemaining <= 0) {
return 0; // Book finished
}
return pagesRemaining / daysRemaining;
}
/**
* Calculate actual reading pace from last N days of logs
*/
async function calculateActualPace(bookId, days = 7) {
const startDate = subDays(new Date(), days);
const logs = await prisma.readingLog.findMany({
where: {
bookId,
logDate: {
gte: startDate,
},
},
orderBy: { logDate: 'asc' },
});
if (logs.length < 2) {
return null; // Insufficient data
}
const firstLog = logs[0];
const lastLog = logs[logs.length - 1];
const pagesRead = lastLog.currentPage - firstLog.currentPage;
const daysElapsed = differenceInDays(new Date(lastLog.logDate), new Date(firstLog.logDate));
if (daysElapsed === 0) {
return null; // Same day logs, can't calculate pace
}
return pagesRead / daysElapsed;
}
/**
* Determine status based on required vs actual pace
*/
function calculateStatus(requiredPace, actualPace) {
if (actualPace === null) {
return 'unknown'; // Not enough data
}
if (actualPace >= requiredPace) {
return 'on-track';
}
if (actualPace >= requiredPace * 0.9) {
return 'slightly-behind'; // Within 10%
}
return 'behind';
}
/**
* Calculate complete progress object for a book
*/
async function calculateProgress(book, currentPage) {
const requiredPace = calculateRequiredPace(book.totalPages, currentPage, book.deadlineDate);
const actualPace = await calculateActualPace(book.id, 7);
const status = calculateStatus(requiredPace, actualPace);
const pagesRemaining = book.totalPages - currentPage;
const daysRemaining = differenceInDays(new Date(book.deadlineDate), new Date());
const lastLog = await prisma.readingLog.findFirst({
where: { bookId: book.id },
orderBy: { logDate: 'desc' },
});
return {
pagesRemaining,
daysRemaining,
requiredPace,
actualPace,
status,
lastLoggedDate: lastLog?.logDate || null,
};
}
module.exports = {
calculateRequiredPace,
calculateActualPace,
calculateStatus,
calculateProgress,
};
Authentication and Authorization
MVP: No authentication required (single-user deployment).
Future (v1.1 Multi-User):
- Add JWT-based authentication
- Use Passport.js or custom middleware
- Store JWT in httpOnly cookie or localStorage
- Protect API routes with auth middleware
- Add user_id foreign key to Book model
Auth Flow (Future):
sequenceDiagram
actor User
participant FE as Frontend
participant API as Backend API
participant DB as Database
User->>FE: Enter credentials
FE->>API: POST /api/auth/login
API->>DB: Verify credentials
DB-->>API: User found
API->>API: Generate JWT token
API-->>FE: JWT token (httpOnly cookie)
FE->>API: GET /api/books (with cookie)
API->>API: Verify JWT token
API->>DB: Query books for user_id
DB-->>API: Books
API-->>FE: Books data