diff --git a/.dockerignore b/.dockerignore index 4da58da..ba37fcf 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,11 +1,94 @@ -node_modules -dist -.git -.vscode -.idea -.DS_Store -.env +# Dependencies +node_modules/ npm-debug.log* -yarn-*.log* +yarn-debug.log* +yarn-error.log* pnpm-debug.log* +.bmad-core/ +.bmad-creative-writing/ +.bmad-infrastructure-devops/ +.claude/ +# Build outputs (will be rebuilt in container) +dist/ +build/ +out/ + +# Environment files (use container env vars instead) +.env +.env.* + +# Data directory (use volume mount instead) +data/ +*.json + +# Git directory +.git/ +.gitignore + +# Docker files docker-compose.yml +docker-compose.*.yml +Dockerfile +Dockerfile.* +.dockerignore + +# IDE directories +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS generated files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# Logs +logs/ +*.log + +# Coverage and test files +coverage/ +.nyc_output +test/ +tests/ +__tests__/ +*.test.js +*.test.ts +*.spec.js +*.spec.ts + +# Documentation +README.md +docs/ +*.md +!package.json + +# Cache directories +.cache/ +.parcel-cache/ +.npm/ +.eslintcache + +# Temporary files +tmp/ +temp/ + +# Claude Code specific +CLAUDE.md +**/docs/ + +# CI/CD files +.github/ +.gitlab-ci.yml +.travis.yml +.circleci/ + +# Other common files not needed in container +LICENSE +CHANGELOG.md diff --git a/.gitignore b/.gitignore index 2306d99..691e46c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,123 @@ -node_modules -dist -.env -.DS_Store -.vscode -.idea +# Dependencies +node_modules/ npm-debug.log* yarn-debug.log* yarn-error.log* pnpm-debug.log* +lerna-debug.log* + +# Build outputs +dist/ +build/ +out/ + +# Environment files +.env +.env.local +.env.development.local +.env.test.local +.env.production.local + +# Data directory (for local development) +data/ +*.json + +# Logs +logs/ +*.log +lerna-debug.log* + +# Runtime data +pids/ +*.pid +*.seed +*.pid.lock + +# Coverage directory used by tools like istanbul +coverage/ +*.lcov + +# nyc test coverage +.nyc_output + +# node-waf configuration +.lock-wscript + +# Compiled binary addons +build/Release + +# Dependency directories +jspm_packages/ + +# TypeScript v1 declaration files +typings/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# parcel-bundler cache +.cache +.parcel-cache + +# next.js build output +.next + +# nuxt.js build output +.nuxt + +# vuepress build output +.vuepress/dist + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# IDE directories +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS generated files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# Temporary files +tmp/ +temp/ + +# Claude Code specific +CLAUDE.md +**/docs/ diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md new file mode 100644 index 0000000..1e1a4cf --- /dev/null +++ b/DEPLOYMENT.md @@ -0,0 +1,174 @@ +# Docker Volume JSON Storage Deployment Guide + +The Reading Tracker app now supports persistent JSON storage using Docker volumes, enabling multi-device access and data persistence across container restarts. + +## Architecture Overview + +The application now consists of: +- **Frontend**: React SPA (served by Express in production) +- **Backend**: Express.js server with API endpoints +- **Storage**: JSON files in Docker volume (`/app/data`) +- **Fallback**: localStorage for offline/server unavailable scenarios + +## Docker Deployment + +### Quick Start with Docker Compose + +```bash +# Build and start the application +docker-compose up -d + +# The app will be available at http://localhost:8080 +``` + +### Manual Docker Commands + +```bash +# Build the image +docker build -t reading-tracker:latest . + +# Run with persistent storage +docker run -d \ + --name reading-tracker \ + -p 8080:80 \ + -v reading-data:/app/data \ + --restart unless-stopped \ + reading-tracker:latest +``` + +### For Coolify or Similar Platforms + +Use these settings: +- **Port**: 80 (internal), map to your desired external port +- **Volume Mount**: `/app/data` (for JSON storage) +- **Health Check**: `GET /api/health` +- **Environment Variables**: + - `NODE_ENV=production` + - `DATA_DIR=/app/data` (optional, defaults to `/app/data`) + - `ALLOWED_ORIGINS=https://your-domain.com` (comma-separated for production CORS) + +## API Endpoints + +- `GET /api/books` - Load all books from JSON storage +- `POST /api/books` - Save books array to JSON storage +- `GET /api/health` - Health check endpoint + +## Data Storage Details + +### File Location +- **Production**: `/app/data/books.json` (Docker volume) +- **Development**: `./data/books.json` (local directory) + +### JSON Format +```json +{ + "books": [ + { + "id": 1, + "title": "Book Title", + "author": "Author Name", + "totalPages": 300, + "currentPage": 150, + "startDate": "2025-01-01", + "targetDate": "2025-02-01", + "readingHistory": { + "2025-01-01": 10, + "2025-01-02": 25 + }, + "createdAt": "2025-01-01T00:00:00.000Z" + } + ], + "lastModified": "2025-08-17T21:46:52.274Z", + "version": "1.0" +} +``` + +### Security Features +- **Security Headers**: Helmet.js with CSP, anti-clickjacking, MIME protection +- **Rate Limiting**: 100 req/15min general, 20 req/15min for saves +- **Input Validation**: String limits, numeric bounds, date format validation +- **CORS Protection**: Configurable origins for production +- **Error Disclosure Prevention**: Generic error messages, sanitized logs +- **Container Security**: Non-root user, minimal Alpine base image + +๐Ÿ“‹ **See [SECURITY.md](./SECURITY.md) for complete security documentation** + +## Fallback Behavior + +The app gracefully handles server unavailability: + +1. **Load Priority**: Server JSON โ†’ localStorage fallback +2. **Save Priority**: Server JSON โ†’ localStorage backup +3. **Offline Mode**: Continues working with localStorage +4. **Auto-Recovery**: Syncs with server when available + +## Development + +### Local Development +```bash +# Install dependencies +npm install + +# Start both frontend and backend +npm run dev + +# Frontend: http://localhost:5173 +# Backend: http://localhost:3001 +``` + +### Backend Only +```bash +npm run server +``` + +## Volume Management + +### Backup Data +```bash +# Create backup +docker cp reading-tracker:/app/data/books.json ./backup-books.json + +# Restore backup +docker cp ./backup-books.json reading-tracker:/app/data/books.json +``` + +### View Volume Contents +```bash +# List volume contents +docker exec reading-tracker ls -la /app/data + +# View current data +docker exec reading-tracker cat /app/data/books.json +``` + +## Troubleshooting + +### Health Check +```bash +curl http://localhost:8080/api/health +``` + +### Check Logs +```bash +docker logs reading-tracker +``` + +### Volume Issues +```bash +# Verify volume mount +docker inspect reading-tracker | grep -A 10 "Mounts" + +# Recreate volume if needed +docker volume rm reading-data +docker-compose up -d +``` + +## Migration from localStorage + +When first deploying, the app will: +1. Try to load from server (empty initially) +2. Fall back to localStorage if available +3. Save localStorage data to server on first interaction +4. Continue using server storage for all subsequent operations + +No manual migration needed - the fallback system handles it automatically! \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 136a800..d558a5d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,29 +3,44 @@ ARG NODE_VERSION=20.15.0 FROM node:${NODE_VERSION}-alpine AS build WORKDIR /app -# Install deps with cache leverage +# Install all deps for build COPY package*.json ./ -RUN npm ci || npm install +RUN npm ci && npm cache clean --force -# Build +# Build frontend COPY . . ENV NODE_ENV=production ENV NODE_OPTIONS=--max-old-space-size=512 RUN npm run build -# ---- Runtime (Nginx) ---- -FROM nginx:1.27-alpine AS runtime +# ---- Runtime (Node.js server with static files) ---- +FROM node:${NODE_VERSION}-alpine AS runtime +WORKDIR /app + # Install curl for healthcheck RUN apk add --no-cache curl -# Remove default site content -RUN rm -rf /usr/share/nginx/html/* -# SPA config -COPY docker/nginx.conf /etc/nginx/conf.d/default.conf -# Static assets -COPY --from=build /app/dist /usr/share/nginx/html + +# Copy package.json and production dependencies +COPY package*.json ./ +RUN npm ci --only=production && npm cache clean --force + +# Copy server files and built frontend +COPY server ./server +COPY --from=build /app/dist ./dist + +# Create data directory for volume mount +RUN mkdir -p /app/data && chown -R node:node /app/data + +# Set data directory environment variable +ENV DATA_DIR=/app/data +ENV NODE_ENV=production +ENV PORT=80 + +# Switch to non-root user +USER node EXPOSE 80 HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ - CMD curl -fsS http://localhost/ >/dev/null || exit 1 + CMD curl -fsS http://localhost/api/health >/dev/null || exit 1 -CMD ["nginx", "-g", "daemon off;"] +CMD ["npm", "start"] diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..f8d00ce --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,213 @@ +# Security Documentation + +This document outlines the security measures implemented in the Reading Tracker application and provides recommendations for secure production deployment. + +## Implemented Security Measures + +### 1. Security Headers (Helmet.js) +- **Content Security Policy (CSP)**: Prevents XSS attacks by controlling resource loading +- **X-Frame-Options**: Prevents clickjacking attacks +- **X-Content-Type-Options**: Prevents MIME type sniffing +- **Referrer-Policy**: Controls referrer information sent to other sites +- **X-Download-Options**: Prevents file downloads in older IE versions + +### 2. Rate Limiting +- **General API Rate Limit**: 100 requests per 15 minutes per IP +- **Strict POST Rate Limit**: 20 save operations per 15 minutes per IP +- **Rate Limit Headers**: Standard headers included for client awareness +- **Custom Error Messages**: Clear feedback without revealing system details + +### 3. Input Validation & Sanitization +- **JSON Size Limit**: Reduced to 1MB to prevent DoS attacks +- **Book Data Validation**: + - String length limits (200 chars for title/author) + - Numeric bounds checking (1-100,000 pages) + - Date format validation (YYYY-MM-DD) + - Type coercion prevention +- **Reading History Sanitization**: Validates date keys and page values +- **Array/Object Type Checking**: Prevents malformed data injection + +### 4. Error Information Disclosure Prevention +- **Generic Error Messages**: No sensitive system information in responses +- **Log Message Sanitization**: Only error messages logged, not full error objects +- **Health Check Information**: Removed sensitive directory paths from health endpoint + +### 5. CORS Configuration +- **Production Origins**: Configurable allowed origins via environment variable +- **Development Mode**: Unrestricted for local development +- **No Credentials**: Disabled credential sharing for security + +### 6. Container Security +- **Non-root User**: Application runs as 'node' user, not root +- **Minimal Base Image**: Alpine Linux for reduced attack surface +- **Read-only File System**: Static files served read-only +- **Health Checks**: Automated container health monitoring + +## Security Configurations + +### Environment Variables + +For production deployment, set these environment variables: + +```bash +NODE_ENV=production +ALLOWED_ORIGINS=https://your-domain.com,https://www.your-domain.com +DATA_DIR=/app/data +PORT=80 +``` + +### Content Security Policy + +The CSP is configured to allow: +- **Self-hosted resources**: Scripts, styles, and images from same origin +- **Inline styles**: Required for Tailwind CSS (consider moving to external stylesheet for enhanced security) +- **Data URIs**: For inline images and icons +- **HTTPS images**: For external image sources + +## Recommendations for Enhanced Security + +### 1. HTTPS Enforcement (HIGH PRIORITY) +```bash +# Use a reverse proxy like Nginx or Traefik with SSL termination +# Or deploy behind a cloud load balancer with SSL certificates + +# Example Nginx configuration: +server { + listen 443 ssl http2; + server_name your-domain.com; + + ssl_certificate /path/to/cert.pem; + ssl_certificate_key /path/to/key.pem; + + location / { + proxy_pass http://reading-tracker:80; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} +``` + +### 2. Authentication System (HIGH PRIORITY) +Consider implementing user authentication for multi-user scenarios: +- JWT-based authentication +- Session management +- User isolation for data access +- Password security requirements + +### 3. Database Migration (MEDIUM PRIORITY) +For production scale, consider migrating from JSON files to a proper database: +- PostgreSQL or SQLite for relational data +- Proper connection pooling +- Query parameterization to prevent SQL injection +- Data encryption at rest + +### 4. Additional Security Headers +```javascript +app.use(helmet({ + // Add additional security headers + hsts: { + maxAge: 31536000, + includeSubDomains: true, + preload: true + } +})); +``` + +### 5. Request Validation Middleware +```javascript +// Consider adding express-validator for more robust validation +import { body, validationResult } from 'express-validator'; + +app.post('/api/books', [ + body('*.title').isLength({ min: 1, max: 200 }).trim().escape(), + body('*.author').isLength({ min: 1, max: 200 }).trim().escape(), + body('*.totalPages').isInt({ min: 1, max: 100000 }), + // ... additional validation rules +], (req, res, next) => { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ error: 'Invalid input data' }); + } + next(); +}); +``` + +### 6. Logging and Monitoring +```javascript +// Add structured logging with winston or similar +import winston from 'winston'; + +const logger = winston.createLogger({ + level: 'info', + format: winston.format.json(), + transports: [ + new winston.transports.File({ filename: 'error.log', level: 'error' }), + new winston.transports.File({ filename: 'combined.log' }) + ] +}); + +// Log security events +app.use('/api', (req, res, next) => { + logger.info('API request', { + method: req.method, + url: req.url, + ip: req.ip, + userAgent: req.get('User-Agent') + }); + next(); +}); +``` + +## Security Testing + +### Automated Security Scanning +```bash +# Install security audit tools +npm audit + +# Use snyk for vulnerability scanning +npx snyk test + +# Docker security scanning +docker scan reading-tracker:latest +``` + +### Manual Security Testing +1. **XSS Testing**: Try injecting scripts in book titles/authors +2. **CSRF Testing**: Verify CORS policies prevent unauthorized requests +3. **Rate Limit Testing**: Verify rate limits are enforced +4. **Input Validation**: Test with malformed JSON and invalid data types +5. **Path Traversal**: Ensure file access is restricted to intended directories + +## Incident Response + +### Security Event Monitoring +Monitor these events for potential security incidents: +- Rate limit violations +- Validation errors +- Unexpected server errors +- Health check failures + +### Response Procedures +1. **Immediate**: Block suspicious IP addresses at load balancer level +2. **Investigation**: Review application logs for attack patterns +3. **Recovery**: Restore from known good backup if data integrity is compromised +4. **Post-incident**: Update security measures based on lessons learned + +## Compliance Notes + +### Data Privacy +- No personally identifiable information is collected +- Reading data is stored locally per user/session +- Consider GDPR compliance if deploying in EU + +### Data Retention +- JSON files persist indefinitely +- Consider implementing data retention policies +- Provide data export/deletion capabilities + +## Security Contact + +For security vulnerabilities or concerns, create an issue in the project repository with the "security" label. Do not disclose security vulnerabilities publicly until they have been addressed. \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..72cb259 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,24 @@ +version: '3.8' + +services: + reading-tracker: + build: . + ports: + - "8080:80" + volumes: + # Mount data directory for persistent JSON storage + - reading-data:/app/data + environment: + - NODE_ENV=production + - DATA_DIR=/app/data + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost/api/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 10s + +volumes: + reading-data: + driver: local \ No newline at end of file diff --git a/package.json b/package.json index 02f05bc..ec5f989 100644 --- a/package.json +++ b/package.json @@ -4,23 +4,32 @@ "private": true, "type": "module", "scripts": { - "dev": "vite", + "dev": "concurrently \"npm run server\" \"vite\"", + "server": "node server/index.js", "build": "vite build", - "preview": "vite preview --host 0.0.0.0 --port 5173" + "preview": "vite preview --host 0.0.0.0 --port 5173", + "start": "node server/index.js" }, "dependencies": { "lucide-react": "^0.452.0", "react": "^18.2.0", - "react-dom": "^18.2.0" + "react-dom": "^18.2.0", + "express": "^4.18.2", + "cors": "^2.8.5", + "helmet": "^7.1.0", + "express-rate-limit": "^7.1.5" }, "devDependencies": { "@types/react": "^18.2.45", "@types/react-dom": "^18.2.18", "@vitejs/plugin-react": "^4.2.0", + "@types/express": "^4.17.21", + "@types/cors": "^2.8.17", "autoprefixer": "^10.4.18", "postcss": "^8.4.33", "tailwindcss": "^3.4.9", "typescript": "^5.5.4", - "vite": "^5.3.4" + "vite": "^5.3.4", + "concurrently": "^8.2.2" } } \ No newline at end of file diff --git a/server/index.js b/server/index.js new file mode 100644 index 0000000..545cf4b --- /dev/null +++ b/server/index.js @@ -0,0 +1,210 @@ +import express from 'express'; +import cors from 'cors'; +import helmet from 'helmet'; +import rateLimit from 'express-rate-limit'; +import fs from 'fs/promises'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const app = express(); +const PORT = process.env.PORT || 3001; + +// Data directory - will be mounted as Docker volume in production +const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, '..', 'data'); +const BOOKS_FILE = path.join(DATA_DIR, 'books.json'); + +// Security middleware +app.use(helmet({ + contentSecurityPolicy: { + directives: { + defaultSrc: ["'self'"], + styleSrc: ["'self'", "'unsafe-inline'"], + scriptSrc: ["'self'"], + imgSrc: ["'self'", "data:", "https:"], + connectSrc: ["'self'"], + fontSrc: ["'self'"], + objectSrc: ["'none'"], + mediaSrc: ["'self'"], + frameSrc: ["'none'"], + }, + }, + crossOriginEmbedderPolicy: false // Allow for better browser compatibility +})); + +// Rate limiting +const apiLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, // 15 minutes + max: 100, // Limit each IP to 100 requests per windowMs + message: { + error: 'Too many requests from this IP, please try again later.', + retryAfter: 15 * 60 // seconds + }, + standardHeaders: true, + legacyHeaders: false, +}); + +const strictApiLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, // 15 minutes + max: 20, // Limit POST requests to 20 per windowMs + message: { + error: 'Too many save requests from this IP, please try again later.', + retryAfter: 15 * 60 // seconds + }, + standardHeaders: true, + legacyHeaders: false, +}); + +// Apply rate limiting to API routes +app.use('/api', apiLimiter); + +// CORS configuration +const corsOptions = { + origin: true, // Allow all origins since Coolify reverse proxy handles domain security + credentials: false, + optionsSuccessStatus: 200 +}; + +app.use(cors(corsOptions)); +app.use(express.json({ + limit: '1mb', // Reduced from 10mb for security + strict: true +})); + +// Serve static files in production +if (process.env.NODE_ENV === 'production') { + const staticPath = path.join(__dirname, '..', 'dist'); + app.use(express.static(staticPath)); +} + +// Ensure data directory exists +async function ensureDataDir() { + try { + await fs.mkdir(DATA_DIR, { recursive: true }); + console.log(`๐Ÿ“ Data directory ready: ${DATA_DIR}`); + } catch (err) { + console.error('Failed to create data directory:', err.message); + } +} + +// Security helper - basic input validation +function sanitizeBooks(data) { + if (!Array.isArray(data)) return []; + + return data.map(book => { + if (!book || typeof book !== 'object') return null; + + // Basic validation and sanitization + const sanitized = { + id: Number(book.id) || 0, + title: String(book.title || '').slice(0, 200), + author: String(book.author || '').slice(0, 200), + totalPages: Math.max(1, Math.min(100000, Number(book.totalPages) || 1)), + currentPage: Math.max(0, Number(book.currentPage) || 0), + startDate: String(book.startDate || ''), + targetDate: String(book.targetDate || ''), + readingHistory: {}, + createdAt: String(book.createdAt || new Date().toISOString()) + }; + + // Validate and sanitize reading history + if (book.readingHistory && typeof book.readingHistory === 'object') { + for (const [dateKey, pages] of Object.entries(book.readingHistory)) { + // Validate YYYY-MM-DD format + if (/^\d{4}-\d{2}-\d{2}$/.test(dateKey)) { + const pageNum = Number(pages); + if (pageNum > 0 && pageNum <= sanitized.totalPages) { + sanitized.readingHistory[dateKey] = pageNum; + } + } + } + } + + // Ensure currentPage doesn't exceed totalPages + sanitized.currentPage = Math.min(sanitized.currentPage, sanitized.totalPages); + + return sanitized; + }).filter(Boolean); +} + +// API Routes + +// GET /api/books - Load books from JSON file +app.get('/api/books', async (req, res) => { + try { + let books = []; + + try { + const data = await fs.readFile(BOOKS_FILE, 'utf8'); + const parsed = JSON.parse(data); + books = sanitizeBooks(parsed.books || parsed); + } catch (err) { + // File doesn't exist or is invalid - return empty array + console.log('๐Ÿ“š No existing books file, starting fresh'); + } + + res.json(books); + } catch (err) { + console.error('Error loading books:', err.message); + res.status(500).json({ error: 'Failed to load books' }); + } +}); + +// POST /api/books - Save books to JSON file +app.post('/api/books', strictApiLimiter, async (req, res) => { + try { + const books = sanitizeBooks(req.body); + + const dataToSave = { + books, + lastModified: new Date().toISOString(), + version: '1.0' + }; + + await fs.writeFile(BOOKS_FILE, JSON.stringify(dataToSave, null, 2), 'utf8'); + console.log(`๐Ÿ’พ Saved ${books.length} books to ${BOOKS_FILE}`); + + res.json({ success: true, count: books.length }); + } catch (err) { + console.error('Error saving books:', err.message); + res.status(500).json({ error: 'Failed to save books' }); + } +}); + +// Health check endpoint +app.get('/api/health', (req, res) => { + res.json({ + status: 'ok', + timestamp: new Date().toISOString() + // Removed dataDir from health check to prevent information disclosure + }); +}); + +// Serve React app for all other routes in production +if (process.env.NODE_ENV === 'production') { + app.get('*', (req, res) => { + res.sendFile(path.join(__dirname, '..', 'dist', 'index.html')); + }); +} + +// Start server +async function startServer() { + await ensureDataDir(); + + app.listen(PORT, () => { + console.log(`๐Ÿš€ Reading Tracker server running on port ${PORT}`); + console.log(`๐Ÿ“Š API endpoints:`); + console.log(` GET /api/books - Load books`); + console.log(` POST /api/books - Save books`); + console.log(` GET /api/health - Health check`); + console.log(`๐Ÿ“ Data directory: ${DATA_DIR}`); + + if (process.env.NODE_ENV === 'production') { + console.log(`๐ŸŒ Serving static files from dist/`); + } + }); +} + +startServer().catch(console.error); \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx index 4d911ec..33c01c7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useState } from 'react'; -import { BookOpen, Plus, X, Edit2, Check, ChevronLeft, ChevronRight } from 'lucide-react'; +import { BookOpen, Plus, X, Edit2, Check, ChevronLeft, ChevronRight, TrendingUp } from 'lucide-react'; // ----------------------------------------------------------------------------- // Security + Date utilities @@ -186,6 +186,17 @@ const ReadingGoalApp = () => { const [showAddBook, setShowAddBook] = useState(false); const [selectedDate, setSelectedDate] = useState(toLocalDateKey()); // calendar month anchor const [confirmDelete, setConfirmDelete] = useState<{ id: number; title: string } | null>(null); + + // Goal change notification state + const [goalNotification, setGoalNotification] = useState<{ + bookId: number; + oldGoal: number; + newGoal: number; + explanation: string; + show: boolean; + } | null>(null); + const [showCalculationModal, setShowCalculationModal] = useState(false); + const [lastNotificationDate, setLastNotificationDate] = useState(''); // Close confirm with Escape useEffect(() => { @@ -195,19 +206,37 @@ const ReadingGoalApp = () => { return () => window.removeEventListener('keydown', onKey); }, [confirmDelete]); - // Load from LocalStorage (client-only) + // Load from server with localStorage fallback useEffect(() => { if (typeof window === 'undefined') return; - try { - const raw = window.localStorage.getItem(STORAGE_KEY); - if (raw) { - const restored = deserializeBooks(raw); - setBooks(restored); + + const loadBooksFromServer = async () => { + try { + // Try to load from server first + const response = await fetch('/api/books'); + if (response.ok) { + const serverBooks = await response.json(); + console.log('๐Ÿ“š Loaded books from server:', serverBooks.length); + setBooks(Array.isArray(serverBooks) ? serverBooks : []); + return; + } + throw new Error(`Server responded with ${response.status}`); + } catch (err) { + console.warn('Failed to load from server, falling back to localStorage:', err); + try { + const raw = window.localStorage.getItem(STORAGE_KEY); + if (raw) { + const restored = deserializeBooks(raw); + console.log('๐Ÿ“ฑ Loaded books from localStorage:', restored.length); + setBooks(restored); + } + } catch (localErr) { + console.warn('Failed to restore books from localStorage:', localErr); + } } - } catch (e) { - // eslint-disable-next-line no-console - console.warn('Failed to restore books from storage:', e); - } + }; + + loadBooksFromServer(); }, []); // Persist to LocalStorage when books change (fallback safety) @@ -225,39 +254,218 @@ const ReadingGoalApp = () => { } }, [books]); - // Centralized state + storage apply (ensures delete clears storage when empty) - const applyBooks = (next: BookItem[]) => { + // Centralized state + storage apply (server first, localStorage fallback) + const applyBooks = async (next: BookItem[]) => { setBooks(next); - if (typeof window !== 'undefined') { + + if (typeof window === 'undefined') return; + + // Try to save to server first + try { + const response = await fetch('/api/books', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(next) + }); + + if (response.ok) { + console.log('๐Ÿ’พ Saved books to server successfully'); + // Also save to localStorage as backup + try { + if (next.length === 0) { + window.localStorage.removeItem(STORAGE_KEY); + } else { + window.localStorage.setItem(STORAGE_KEY, serializeBooks(next)); + } + } catch (e) { + console.warn('LocalStorage backup failed:', e); + } + } else { + throw new Error(`Server responded with ${response.status}`); + } + } catch (err) { + console.warn('Failed to save to server, using localStorage only:', err); try { if (next.length === 0) { window.localStorage.removeItem(STORAGE_KEY); } else { window.localStorage.setItem(STORAGE_KEY, serializeBooks(next)); } - } catch (e) { - // eslint-disable-next-line no-console - console.warn('Storage update failed:', e); + console.log('๐Ÿ“ฑ Saved books to localStorage as fallback'); + } catch (localErr) { + console.warn('Storage update failed completely:', localErr); } } }; // --- Goal math - const calculateDailyGoal = (book: BookItem) => { + interface GoalCalculation { + pages: number; + daysRemaining: number; + pagesRemaining: number; + isOverdue: boolean; + targetDate: string; + recentPace: number; // last 3 days average + totalPagesRead: number; + percentageChange?: number; // vs previous goal + } + + const calculateDailyGoal = (book: BookItem): GoalCalculation => { const today = fromLocalDateKey(toLocalDateKey()); const target = fromLocalDateKey(book.targetDate); const msInDay = 1000 * 60 * 60 * 24; const daysRemaining = Math.ceil((target.getTime() - today.getTime()) / msInDay); const pagesRemaining = Math.max(0, book.totalPages - book.currentPage); - if (daysRemaining <= 0) return { pages: 0, daysRemaining: 0, pagesRemaining, isOverdue: pagesRemaining > 0 }; + + // Calculate recent pace (last 3 days) + const recentDays = 3; + const todayKey = toLocalDateKey(); + let recentPageTotal = 0; + let validDays = 0; + + for (let i = 0; i < recentDays; i++) { + const checkDate = new Date(today); + checkDate.setDate(checkDate.getDate() - i); + const dateKey = toLocalDateKey(checkDate); + + const currentPage = book.readingHistory[dateKey] || 0; + const prevDate = new Date(checkDate); + prevDate.setDate(prevDate.getDate() - 1); + const prevDateKey = toLocalDateKey(prevDate); + const prevPage = book.readingHistory[prevDateKey] || 0; + + if (currentPage > prevPage) { + recentPageTotal += (currentPage - prevPage); + validDays++; + } + } + + const recentPace = validDays > 0 ? Math.round(recentPageTotal / validDays) : 0; + const totalPagesRead = book.currentPage; + + if (daysRemaining <= 0) return { + pages: 0, + daysRemaining: 0, + pagesRemaining, + isOverdue: pagesRemaining > 0, + targetDate: book.targetDate, + recentPace, + totalPagesRead + }; + return { pages: Math.ceil(pagesRemaining / daysRemaining), daysRemaining, pagesRemaining, isOverdue: false, + targetDate: book.targetDate, + recentPace, + totalPagesRead }; }; + // Streak calculation + const calculateReadingStreak = (book: BookItem): number => { + const history = book.readingHistory; + if (Object.keys(history).length === 0) return 0; + + // Get all dates with reading progress, sorted in descending order + const dateKeys = Object.keys(history).sort().reverse(); + if (dateKeys.length === 0) return 0; + + let streak = 0; + const today = new Date(); + + // Start from today and go backwards + for (let i = 0; i < 365; i++) { // Limit to check last year + const checkDate = new Date(today); + checkDate.setDate(checkDate.getDate() - i); + const dateKey = toLocalDateKey(checkDate); + + const currentPage = history[dateKey] || 0; + if (currentPage === 0) { + // If we haven't reached any reading day yet and it's today or yesterday, continue + if (i <= 1) continue; + break; + } + + // Check if there was actual reading progress on this day + const prevDate = new Date(checkDate); + prevDate.setDate(prevDate.getDate() - 1); + const prevDateKey = toLocalDateKey(prevDate); + const prevPage = history[prevDateKey] || 0; + + if (currentPage > prevPage) { + streak++; + } else if (i > 0) { + // If no progress and not the first day, break the streak + break; + } + } + + return streak; + }; + + // Goal change detection and notification + const shouldShowNotification = (oldGoal: number, newGoal: number, bookId: number): boolean => { + const percentChange = Math.abs((newGoal - oldGoal) / oldGoal) * 100; + const todayKey = toLocalDateKey(); + const notificationKey = `goal-notification-${bookId}-${todayKey}`; + + // Don't show if already shown today or change is less than 20% + if (percentChange < 20 || lastNotificationDate === notificationKey) { + return false; + } + + return true; + }; + + const generateGoalExplanation = (book: BookItem, oldGoal: number, newGoal: number): string => { + const todayKey = toLocalDateKey(); + const yesterdayDate = new Date(); + yesterdayDate.setDate(yesterdayDate.getDate() - 1); + const yesterdayKey = toLocalDateKey(yesterdayDate); + const yesterdayPages = book.readingHistory[yesterdayKey] || 0; + const dayBeforeYesterday = new Date(); + dayBeforeYesterday.setDate(dayBeforeYesterday.getDate() - 2); + const dayBeforeKey = toLocalDateKey(dayBeforeYesterday); + const dayBeforePages = book.readingHistory[dayBeforeKey] || 0; + + const dailyProgress = yesterdayPages > dayBeforePages ? yesterdayPages - dayBeforePages : 0; + + if (newGoal > oldGoal) { + if (dailyProgress === 0) { + return `You haven't updated your progress recently. To reach your ${formatDDMMYYYY(book.targetDate)} deadline, we've increased your daily goal.`; + } else { + return `You read ${dailyProgress} pages yesterday! However, to stay on track for your ${formatDDMMYYYY(book.targetDate)} deadline, we've adjusted your daily goal.`; + } + } else { + return `Great progress! You read ${dailyProgress} pages yesterday, so we've lowered your daily goal. Keep up the good work!`; + } + }; + + const checkAndNotifyGoalChange = (book: BookItem, oldGoal: number, newGoal: number) => { + if (shouldShowNotification(oldGoal, newGoal, book.id)) { + const explanation = generateGoalExplanation(book, oldGoal, newGoal); + const todayKey = toLocalDateKey(); + + setGoalNotification({ + bookId: book.id, + oldGoal, + newGoal, + explanation, + show: true + }); + + setLastNotificationDate(`goal-notification-${book.id}-${todayKey}`); + + // Auto-dismiss after 30 seconds + setTimeout(() => { + setGoalNotification(prev => prev ? { ...prev, show: false } : null); + }, 30000); + } + }; + // --- CRUD helpers const deleteBook = (bookId: number) => { const next = books.filter(b => b.id !== bookId); @@ -271,6 +479,11 @@ const ReadingGoalApp = () => { // --- Progress updates const updateProgress = (bookId: number, dateKey: string, pagesRead: number) => { const safePages = clampInt(pagesRead, 0, MAX_PAGES, 0); + + // Calculate old goal before update + const currentBook = books.find(b => b.id === bookId); + const oldGoal = currentBook ? calculateDailyGoal(currentBook).pages : 0; + const next = books.map(book => { if (book.id !== bookId) return book; const history = { ...(book.readingHistory || Object.create(null)) } as ReadingHistory; @@ -278,6 +491,16 @@ const ReadingGoalApp = () => { const totalPagesRead = Object.values(history).reduce((s, n) => s + n, 0); return { ...book, readingHistory: history, currentPage: Math.min(totalPagesRead, book.totalPages) }; }); + + // Check for goal changes after update + const updatedBook = next.find(b => b.id === bookId); + if (updatedBook && currentBook) { + const newGoal = calculateDailyGoal(updatedBook).pages; + if (oldGoal !== newGoal) { + checkAndNotifyGoalChange(updatedBook, oldGoal, newGoal); + } + } + applyBooks(next); if (selectedBook && selectedBook.id === bookId) { const b = next.find(b => b.id === bookId)!; @@ -505,7 +728,6 @@ const ReadingGoalApp = () => { const [editingDate, setEditingDate] = useState(false); const [newTargetDate, setNewTargetDate] = useState(book.targetDate); const [editDateKey, setEditDateKey] = useState(''); - const [clickTimer, setClickTimer] = useState(null); const [editingMeta, setEditingMeta] = useState(false); const [metaTitle, setMetaTitle] = useState(book.title); const [metaAuthor, setMetaAuthor] = useState(book.author || ''); @@ -524,29 +746,31 @@ const ReadingGoalApp = () => { const calendarDays = generateCalendarDays(); - const handleDateInteraction = (dateKey: string, isDouble: boolean) => { - if (isDouble) { - if (clickTimer) { - window.clearTimeout(clickTimer); - setClickTimer(null); - } - const prevKey = getPreviousDateKey(dateKey); - setEditDateKey(prevKey); - setProgressInput(String(book.readingHistory[prevKey] || 0)); + const [touchStartTime, setTouchStartTime] = useState(0); + + const handleEditClick = (dateKey: string, event: React.MouseEvent) => { + event.stopPropagation(); + setEditDateKey(dateKey); + setProgressInput(String(book.readingHistory[dateKey] || 0)); + setEditingProgress(true); + }; + + const handleTouchStart = (dateKey: string) => { + setTouchStartTime(Date.now()); + }; + + const handleTouchEnd = (dateKey: string, event: React.TouchEvent) => { + const touchDuration = Date.now() - touchStartTime; + if (touchDuration >= 500) { // 500ms for long press + event.preventDefault(); + setEditDateKey(dateKey); + setProgressInput(String(book.readingHistory[dateKey] || 0)); setEditingProgress(true); - } else { - if (clickTimer) window.clearTimeout(clickTimer); - const timer = window.setTimeout(() => { - setEditDateKey(dateKey); - setProgressInput(String(book.readingHistory[dateKey] || 0)); - setEditingProgress(true); - }, 250); - setClickTimer(timer); } }; const handleSaveProgress = () => { - const pages = Math.max(0, parseInt(progressInput || '0', 10)); + const pages = clampInt(progressInput, 0, book.totalPages, 0); if (editDateKey) updateProgress(book.id, editDateKey, pages); setEditingProgress(false); setProgressInput(''); @@ -608,9 +832,101 @@ const ReadingGoalApp = () => {

pages per day

{goal.pagesRemaining} pages remaining โ€ข {goal.daysRemaining} days left

+ + {/* Progress Insights */} + {(goal.recentPace > 0 || calculateReadingStreak(book) > 0) && (() => { + const streak = calculateReadingStreak(book); + const isAhead = goal.recentPace >= goal.pages; + const isBehind = goal.recentPace > 0 && goal.recentPace < goal.pages * 0.8; + + const bgColor = streak >= 7 || isAhead ? 'bg-green-50' : + isBehind ? 'bg-amber-50' : 'bg-blue-50'; + const textColor = streak >= 7 || isAhead ? 'text-green-900' : + isBehind ? 'text-amber-900' : 'text-blue-900'; + const valueColor = streak >= 7 || isAhead ? 'text-green-700' : + isBehind ? 'text-amber-700' : 'text-blue-700'; + + return ( +
+

๐Ÿ“ˆ Your Progress

+
+
+
Streak
+
+ ๐Ÿ”ฅ {streak} days +
+
+
+
Recent Pace
+
{goal.recentPace} pages/day
+
+
+
Pages Read
+
{goal.totalPagesRead} pages
+
+
+
+ {calculateReadingStreak(book) >= 30 ? ( + `๐Ÿ† INCREDIBLE! ${calculateReadingStreak(book)} day streak - you're a reading champion! ๐Ÿ†` + ) : calculateReadingStreak(book) >= 14 ? ( + `โญ Outstanding! ${calculateReadingStreak(book)} days of consistent reading! You've built a great habit! โญ` + ) : calculateReadingStreak(book) >= 7 ? ( + `๐Ÿ”ฅ Amazing! You've been reading for ${calculateReadingStreak(book)} days straight! ๐Ÿ”ฅ` + ) : calculateReadingStreak(book) >= 3 ? ( + `๐Ÿ“š Great streak! ${calculateReadingStreak(book)} days of consistent reading! ๐Ÿ“š` + ) : goal.recentPace >= goal.pages ? ( + `You're reading ${Math.round(((goal.recentPace - goal.pages) / goal.pages) * 100)}% faster than your goal! Keep it up! ๐ŸŽ‰` + ) : goal.recentPace > 0 ? ( + `You're ${Math.round(((goal.pages - goal.recentPace) / goal.pages) * 100)}% behind your goal pace. You can catch up!` + ) : ( + 'Start reading to see your progress insights!' + )} +
+
+ ); + })()} + {/* Goal Change Notification */} + {goalNotification && goalNotification.bookId === book.id && goalNotification.show && ( +
+
+ +
+

Your daily goal updated!

+
+
+ Previous goal: +
{goalNotification.oldGoal} pages/day
+
+
+ New goal: +
{goalNotification.newGoal} pages/day
+
+
+

+ {goalNotification.explanation} +

+
+ + +
+
+
+
+ )} +

Target Completion Date

@@ -747,21 +1063,53 @@ const ReadingGoalApp = () => { {calendarDays.map((day, index) => { if (!day) return
; const dateKey = toLocalDateKey(day); - const pagesRead = book.readingHistory[dateKey] || 0; + const currentPage = book.readingHistory[dateKey] || 0; const isToday = dateKey === todayKey; const isPast = day < todayStart; // start-of-day compare + + // Calculate pages read on this day (difference from previous day) + const previousDate = new Date(day); + previousDate.setDate(previousDate.getDate() - 1); + const previousDateKey = toLocalDateKey(previousDate); + const previousPage = book.readingHistory[previousDateKey] || 0; + const dailyPagesRead = currentPage > previousPage ? currentPage - previousPage : 0; return ( - +
+ {day.getDate()} + +
+
+ {currentPage > 0 && ( + <> +
Page {currentPage}
+ {dailyPagesRead > 0 && ( +
+{dailyPagesRead} pages
+ )} + + )} +
+
); })}
@@ -769,23 +1117,98 @@ const ReadingGoalApp = () => { {/* Edit Progress */} {editingProgress && (
-

Edit progress for {formatDDMMYYYY(editDateKey)}

+
setProgressInput(e.target.value)} - placeholder="Pages read" + placeholder="Enter page number" min={0} + max={book.totalPages} inputMode="numeric" + autoFocus + onKeyDown={(e) => { + if (e.key === 'Enter') { + handleSaveProgress(); + } else if (e.key === 'Escape') { + setEditingProgress(false); + setProgressInput(''); + setEditDateKey(''); + } + }} /> - - + +
)}
+ + {/* Detailed Calculation Modal */} + {showCalculationModal && ( +
+
+

Goal Calculation Details

+ +
+
+ Target completion: + {formatDDMMYYYY(goal.targetDate)} +
+
+ Days remaining: + {goal.daysRemaining} days +
+
+ Pages remaining: + {goal.pagesRemaining} pages +
+
+ Your recent pace: + {goal.recentPace} pages/day (last 3 days) +
+ +
+
+
Calculation:
+
+ {goal.pagesRemaining} pages รท {goal.daysRemaining} days = {goal.pages} pages/day +
+
+
+ +
+
๐Ÿ’ก Tip
+
+ This goal adjusts automatically based on your reading progress. + Read ahead to lower your daily target! +
+
+
+ + +
+
+ )} ); };