books/docs/architecture/frontend-architecture.md
Greg fa8acef423 Epic 1, Story 1.1: Project Initialization & Repository Setup
- 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>
2025-12-01 15:12:30 +01:00

9.8 KiB

Frontend Architecture

Component Architecture

Component Organization

frontend/src/
├── components/
│   ├── layout/
│   │   ├── Header.jsx
│   │   ├── Footer.jsx
│   │   └── Layout.jsx
│   ├── books/
│   │   ├── BookCard.jsx
│   │   ├── BookList.jsx
│   │   ├── BookSearchResults.jsx
│   │   └── AddBookForm.jsx
│   ├── progress/
│   │   ├── LogProgressModal.jsx
│   │   ├── ProgressDisplay.jsx
│   │   └── StatusIndicator.jsx
│   ├── calendar/
│   │   ├── Calendar.jsx
│   │   ├── CalendarDay.jsx
│   │   └── CalendarLegend.jsx
│   └── common/
│       ├── Button.jsx
│       ├── Input.jsx
│       ├── Modal.jsx
│       ├── Loading.jsx
│       └── ErrorMessage.jsx
├── pages/
│   ├── Home.jsx
│   ├── AddBook.jsx
│   ├── BookDetail.jsx
│   └── CalendarView.jsx
├── hooks/
│   ├── useBooks.js
│   ├── useProgress.js
│   └── useLogs.js
├── services/
│   ├── api.js (API client setup)
│   ├── booksService.js
│   └── logsService.js
├── context/
│   └── AppContext.jsx
├── utils/
│   ├── dateUtils.js
│   ├── paceUtils.js
│   └── validation.js
├── styles/
│   └── index.css (Tailwind directives)
└── App.jsx

Component Template

// Example: BookCard.jsx
import React from 'react';
import { useNavigate } from 'react-router-dom';
import StatusIndicator from '../progress/StatusIndicator';
import { formatDate } from '../../utils/dateUtils';

/**
 * BookCard - Displays a single book with progress metrics
 * @param {Object} book - Book object with progress data
 * @param {Function} onLogProgress - Handler for log progress action
 */
function BookCard({ book, onLogProgress }) {
  const navigate = useNavigate();

  const handleCardClick = () => {
    navigate(`/books/${book.id}`);
  };

  const handleLogClick = (e) => {
    e.stopPropagation(); // Prevent navigation when clicking log button
    onLogProgress(book);
  };

  return (
    <div
      onClick={handleCardClick}
      className="bg-white rounded-lg shadow-md p-4 cursor-pointer hover:shadow-lg transition-shadow"
    >
      {/* Book cover and title */}
      <div className="flex gap-4">
        {book.coverUrl ? (
          <img
            src={book.coverUrl}
            alt={book.title}
            className="w-16 h-24 object-cover rounded"
          />
        ) : (
          <div className="w-16 h-24 bg-gray-200 rounded flex items-center justify-center">
            <span className="text-gray-400 text-xs">No cover</span>
          </div>
        )}

        <div className="flex-1">
          <h3 className="font-bold text-lg">{book.title}</h3>
          {book.author && <p className="text-gray-600 text-sm">{book.author}</p>}
          <p className="text-gray-500 text-xs mt-1">
            Due: {formatDate(book.deadlineDate)}
          </p>
        </div>
      </div>

      {/* Progress metrics */}
      <div className="mt-4 space-y-2">
        <StatusIndicator status={book.status} />
        <div className="text-sm">
          <p><strong>Target:</strong> {book.requiredPace.toFixed(1)} pages/day</p>
          <p>
            <strong>Your pace:</strong> {' '}
            {book.actualPace ? `${book.actualPace.toFixed(1)} pages/day` : 'No data yet'}
          </p>
          <p className="text-gray-600">
            {book.pagesRemaining} pages left, {book.daysRemaining} days
          </p>
        </div>
      </div>

      {/* Log progress button */}
      <button
        onClick={handleLogClick}
        className="mt-4 w-full bg-blue-500 text-white py-2 px-4 rounded hover:bg-blue-600 transition-colors"
      >
        Log Progress
      </button>
    </div>
  );
}

export default BookCard;

State Management Architecture

State Structure

// context/AppContext.jsx
import React, { createContext, useContext, useState, useEffect } from 'react';
import { booksService } from '../services/booksService';

const AppContext = createContext();

export function AppProvider({ children }) {
  const [books, setBooks] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  // Fetch books on mount
  useEffect(() => {
    loadBooks();
  }, []);

  const loadBooks = async () => {
    setLoading(true);
    setError(null);
    try {
      const data = await booksService.getActiveBooks();
      setBooks(data.books);
    } catch (err) {
      setError(err.message);
    } finally {
      setLoading(false);
    }
  };

  const addBook = async (bookData) => {
    const newBook = await booksService.addBook(bookData);
    setBooks(prev => [...prev, newBook]);
    return newBook;
  };

  const deleteBook = async (bookId) => {
    await booksService.deleteBook(bookId);
    setBooks(prev => prev.filter(b => b.id !== bookId));
  };

  const logProgress = async (bookId, currentPage, logDate) => {
    await booksService.logProgress(bookId, currentPage, logDate);
    // Refresh books to get updated progress
    await loadBooks();
  };

  const value = {
    books,
    loading,
    error,
    loadBooks,
    addBook,
    deleteBook,
    logProgress,
  };

  return <AppContext.Provider value={value}>{children}</AppContext.Provider>;
}

export function useApp() {
  const context = useContext(AppContext);
  if (!context) {
    throw new Error('useApp must be used within AppProvider');
  }
  return context;
}

State Management Patterns

  • Global State: Books list, loading states, errors (via React Context)
  • Local Component State: Form inputs, modal open/closed, UI-only state (via useState)
  • Server State: Fetched data cached in Context, refetch on mutations
  • No External State Library: Context API sufficient for MVP (books list + UI state)
  • State Updates: Optimistic UI updates followed by data refetch for consistency

Routing Architecture

Route Organization

Routes:
/                    → Home (Book List Screen)
/add-book            → Add Book Screen
/books/:bookId       → Book Detail Screen
/calendar            → Calendar View Screen (optional, may be in Book Detail)
/*                   → 404 Not Found

Protected Route Pattern

// App.jsx with React Router
import React from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { AppProvider } from './context/AppContext';
import Layout from './components/layout/Layout';
import Home from './pages/Home';
import AddBook from './pages/AddBook';
import BookDetail from './pages/BookDetail';
import CalendarView from './pages/CalendarView';
import NotFound from './pages/NotFound';

function App() {
  return (
    <BrowserRouter>
      <AppProvider>
        <Layout>
          <Routes>
            <Route path="/" element={<Home />} />
            <Route path="/add-book" element={<AddBook />} />
            <Route path="/books/:bookId" element={<BookDetail />} />
            <Route path="/calendar" element={<CalendarView />} />
            <Route path="*" element={<NotFound />} />
          </Routes>
        </Layout>
      </AppProvider>
    </BrowserRouter>
  );
}

export default App;

Note: No protected routes needed for MVP (single-user, no authentication). Add in v1.1 if multi-user support is needed.

Frontend Services Layer

API Client Setup

// services/api.js
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000/api';

class ApiClient {
  async request(endpoint, options = {}) {
    const url = `${API_BASE_URL}${endpoint}`;
    const config = {
      headers: {
        'Content-Type': 'application/json',
        ...options.headers,
      },
      ...options,
    };

    try {
      const response = await fetch(url, config);

      // Handle non-JSON responses (like 204 No Content)
      if (response.status === 204) {
        return null;
      }

      const data = await response.json();

      if (!response.ok) {
        throw new Error(data.error || 'Request failed');
      }

      return data;
    } catch (error) {
      console.error('API request failed:', error);
      throw error;
    }
  }

  get(endpoint) {
    return this.request(endpoint, { method: 'GET' });
  }

  post(endpoint, body) {
    return this.request(endpoint, {
      method: 'POST',
      body: JSON.stringify(body),
    });
  }

  put(endpoint, body) {
    return this.request(endpoint, {
      method: 'PUT',
      body: JSON.stringify(body),
    });
  }

  delete(endpoint) {
    return this.request(endpoint, { method: 'DELETE' });
  }
}

export const apiClient = new ApiClient();

Service Example

// services/booksService.js
import { apiClient } from './api';

export const booksService = {
  // Search books via Open Library
  async searchBooks(query) {
    return apiClient.get(`/books/search?q=${encodeURIComponent(query)}`);
  },

  // Get all active books with progress
  async getActiveBooks() {
    return apiClient.get('/books');
  },

  // Add a new book
  async addBook(bookData) {
    return apiClient.post('/books', bookData);
  },

  // Get single book
  async getBook(bookId) {
    return apiClient.get(`/books/${bookId}`);
  },

  // Delete book
  async deleteBook(bookId) {
    return apiClient.delete(`/books/${bookId}`);
  },

  // Get book progress
  async getProgress(bookId) {
    return apiClient.get(`/books/${bookId}/progress`);
  },

  // Get reading logs
  async getLogs(bookId) {
    return apiClient.get(`/books/${bookId}/logs`);
  },

  // Log progress
  async logProgress(bookId, currentPage, logDate = null) {
    return apiClient.post(`/books/${bookId}/logs`, {
      currentPage,
      ...(logDate && { logDate }),
    });
  },
};