import React, { useEffect, useState } from 'react'; import { BookOpen, Plus, X, Edit2, Check, ChevronLeft, ChevronRight } from 'lucide-react'; // ----------------------------------------------------------------------------- // Security + Date utilities // - Keep DD/MM/YYYY everywhere, Monday week start // - Add strong input validation/sanitization and hardened deserialization // ----------------------------------------------------------------------------- // ---- Security helpers ---- const MAX_TITLE_LEN = 200; const MAX_AUTHOR_LEN = 200; const MAX_PAGES = 100000; /** * Minimal, dependency-free text sanitizer for UI strings. * - Removes control chars and angle brackets to defang HTML tags. * - Normalizes whitespace and clamps length. * NOTE: React escapes by default; this is defense-in-depth and protects * future changes or accidental innerHTML usage. */ const sanitizeText = (s: unknown, maxLen: number): string => { if (typeof s !== 'string') return ''; let out = s .replace(/[\u0000-\u001f\u007f]/g, '') // control chars .replace(/[<>]/g, '') // strip tag brackets .replace(/\s+/g, ' ') // collapse whitespace .trim(); if (out.length > maxLen) out = out.slice(0, maxLen); return out; }; const clampInt = (n: unknown, min: number, max: number, fallback: number): number => { const v = typeof n === 'number' ? n : parseInt(String(n ?? ''), 10); if (!Number.isFinite(v)) return fallback; return Math.min(max, Math.max(min, v)); }; const isValidDateKey = (key: unknown): key is string => { if (typeof key !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(key)) return false; const [y, m, d] = key.split('-').map(Number); const dt = new Date(y, m - 1, d); return dt.getFullYear() === y && dt.getMonth() === m - 1 && dt.getDate() === d; }; const coerceDateKey = (key: unknown, fallback: string): string => (isValidDateKey(key) ? String(key) : fallback); // ---- Date helpers (DD/MM/YYYY + Monday start) ---- const toLocalDateKey = (d: Date = new Date()): string => { const year = d.getFullYear(); const month = String(d.getMonth() + 1).padStart(2, '0'); const day = String(d.getDate()).padStart(2, '0'); return `${year}-${month}-${day}`; }; const fromLocalDateKey = (key: string): Date => { const safeKey = coerceDateKey(key, toLocalDateKey()); const [y, m, d] = safeKey.split('-').map(Number); return new Date(y, m - 1, d, 0, 0, 0, 0); }; const formatDDMMYYYY = (keyOrDate: string | Date): string => { const d = typeof keyOrDate === 'string' ? fromLocalDateKey(keyOrDate) : keyOrDate; const dd = String(d.getDate()).padStart(2, '0'); const mm = String(d.getMonth() + 1).padStart(2, '0'); const yyyy = d.getFullYear(); return `${dd}/${mm}/${yyyy}`; }; const formatDateLong = (key: string): string => { const date = fromLocalDateKey(key); const dayName = date.toLocaleDateString('en-GB', { weekday: 'long' }); return `${dayName}, ${formatDDMMYYYY(date)}`; }; const formatMonthYear = (key: string): string => { const date = fromLocalDateKey(key); const month = date.toLocaleDateString('en-GB', { month: 'long' }); return `${month} ${date.getFullYear()}`; }; // Monday-start helpers (Sunday=0, Monday=1 in JS) const getMondayStartPadding = (firstDay: Date): number => { const js = firstDay.getDay(); // 0..6 (Sun..Sat) const pad = js - 1; // shift so Mon=0 return pad === -1 ? 6 : pad; // if Sunday }; const getDaysInMonth = (year: number, monthIndex0: number): number => { return new Date(year, monthIndex0 + 1, 0).getDate(); }; // Pure util for tests and calendar grid const computeMonthGridDays = (year: number, monthIndex0: number): (Date | null)[] => { const firstDay = new Date(year, monthIndex0, 1); const lastDate = getDaysInMonth(year, monthIndex0); const padding = getMondayStartPadding(firstDay); const days: (Date | null)[] = []; for (let i = 0; i < padding; i++) days.push(null); for (let d = 1; d <= lastDate; d++) days.push(new Date(year, monthIndex0, d)); return days; }; // ----------------------------------------------------------------------------- // Persistence helpers (LocalStorage) — hardened against polluted/invalid data // ----------------------------------------------------------------------------- const STORAGE_KEY = 'reading-goal-app.v1'; interface ReadingHistory { [dateKey: string]: number; // YYYY-MM-DD (local) } interface BookItem { id: number; title: string; author?: string; totalPages: number; currentPage: number; startDate: string; // YYYY-MM-DD local targetDate: string; // YYYY-MM-DD local readingHistory: ReadingHistory; createdAt: string; // ISO timestamp } const serializeBooks = (arr: BookItem[]): string => JSON.stringify(arr); const deserializeBooks = (raw: string): BookItem[] => { let parsed: unknown; try { parsed = JSON.parse(raw); } catch { return []; } if (!Array.isArray(parsed)) return []; return parsed .filter((b) => b && typeof b === 'object' && !Array.isArray(b)) .map((b: any): BookItem => { // readingHistory using a null-prototype object to avoid prototype pollution const readingHistory: ReadingHistory = Object.create(null); if (b && typeof b.readingHistory === 'object' && b.readingHistory && !Array.isArray(b.readingHistory)) { for (const k of Object.keys(b.readingHistory)) { if (!Object.prototype.hasOwnProperty.call(b.readingHistory, k)) continue; if (!isValidDateKey(k)) continue; const v = clampInt(b.readingHistory[k], 0, MAX_PAGES, 0); if (v > 0) (readingHistory as any)[k] = v; } } const totalPages = clampInt(b?.totalPages, 1, MAX_PAGES, 1); const currentPageRaw = clampInt(b?.currentPage, 0, MAX_PAGES, 0); const currentPage = Math.min(currentPageRaw, totalPages); const startDate = coerceDateKey(b?.startDate, toLocalDateKey()); const targetDate = coerceDateKey(b?.targetDate, toLocalDateKey()); const title = sanitizeText(b?.title ?? 'Untitled', MAX_TITLE_LEN) || 'Untitled'; const author = sanitizeText(b?.author ?? 'Unknown Author', MAX_AUTHOR_LEN) || 'Unknown Author'; const idCandidate = Number(b?.id); const id = Number.isSafeInteger(idCandidate) ? idCandidate : Math.floor(Date.now() + Math.random() * 1000); const createdAt = typeof b?.createdAt === 'string' ? b.createdAt : new Date().toISOString(); return { id, title, author, totalPages, currentPage, startDate, targetDate, readingHistory, createdAt, } as BookItem; }); }; // ----------------------------------------------------------------------------- // App component // ----------------------------------------------------------------------------- const ReadingGoalApp = () => { const [books, setBooks] = useState([]); const [activeView, setActiveView] = useState<'dashboard' | 'detail'>('dashboard'); const [selectedBook, setSelectedBook] = useState(null); const [showAddBook, setShowAddBook] = useState(false); const [selectedDate, setSelectedDate] = useState(toLocalDateKey()); // calendar month anchor const [confirmDelete, setConfirmDelete] = useState<{ id: number; title: string } | null>(null); // Close confirm with Escape useEffect(() => { if (!confirmDelete) return; const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setConfirmDelete(null); }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [confirmDelete]); // Load from LocalStorage (client-only) useEffect(() => { if (typeof window === 'undefined') return; try { const raw = window.localStorage.getItem(STORAGE_KEY); if (raw) { const restored = deserializeBooks(raw); setBooks(restored); } } catch (e) { // eslint-disable-next-line no-console console.warn('Failed to restore books from storage:', e); } }, []); // Persist to LocalStorage when books change (fallback safety) useEffect(() => { if (typeof window === 'undefined') return; try { if (books.length === 0) { window.localStorage.removeItem(STORAGE_KEY); } else { window.localStorage.setItem(STORAGE_KEY, serializeBooks(books)); } } catch (e) { // eslint-disable-next-line no-console console.warn('Failed to persist books to storage:', e); } }, [books]); // Centralized state + storage apply (ensures delete clears storage when empty) const applyBooks = (next: BookItem[]) => { setBooks(next); if (typeof window !== 'undefined') { 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); } } }; // --- Goal math const calculateDailyGoal = (book: BookItem) => { 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 }; return { pages: Math.ceil(pagesRemaining / daysRemaining), daysRemaining, pagesRemaining, isOverdue: false, }; }; // --- CRUD helpers const deleteBook = (bookId: number) => { const next = books.filter(b => b.id !== bookId); applyBooks(next); if (selectedBook && selectedBook.id === bookId) { setSelectedBook(null); setActiveView('dashboard'); } }; // --- Progress updates const updateProgress = (bookId: number, dateKey: string, pagesRead: number) => { const safePages = clampInt(pagesRead, 0, MAX_PAGES, 0); const next = books.map(book => { if (book.id !== bookId) return book; const history = { ...(book.readingHistory || Object.create(null)) } as ReadingHistory; if (safePages > 0) (history as any)[dateKey] = safePages; else delete (history as any)[dateKey]; const totalPagesRead = Object.values(history).reduce((s, n) => s + n, 0); return { ...book, readingHistory: history, currentPage: Math.min(totalPagesRead, book.totalPages) }; }); applyBooks(next); if (selectedBook && selectedBook.id === bookId) { const b = next.find(b => b.id === bookId)!; setSelectedBook(b); } }; const updateTargetDate = (bookId: number, newDateKey: string) => { const key = coerceDateKey(newDateKey, toLocalDateKey()); const next = books.map(b => (b.id === bookId ? { ...b, targetDate: key } : b)); applyBooks(next); }; const updateBookMeta = (bookId: number, data: { title?: string; author?: string; totalPages?: number }) => { const next = books.map(book => { if (book.id !== bookId) return book; const newTotalPages = data.totalPages !== undefined ? clampInt(data.totalPages, 1, MAX_PAGES, book.totalPages) : book.totalPages; const totalPagesRead = Object.values(book.readingHistory).reduce((s, n) => s + n, 0); const nextBook: BookItem = { ...book, title: data.title !== undefined ? (sanitizeText(data.title, MAX_TITLE_LEN) || book.title) : book.title, author: data.author !== undefined ? (sanitizeText(data.author, MAX_AUTHOR_LEN) || 'Unknown Author') : (book.author || 'Unknown Author'), totalPages: newTotalPages, currentPage: Math.min(totalPagesRead, newTotalPages), }; return nextBook; }); applyBooks(next); if (selectedBook && data) { const b = next.find(b => b.id === selectedBook.id); if (b) setSelectedBook(b); } }; const getPreviousDateKey = (dateKey: string): string => { const d = fromLocalDateKey(dateKey); d.setDate(d.getDate() - 1); return toLocalDateKey(d); }; // --------------------------------------------------------------------------- // Add Book Form // --------------------------------------------------------------------------- const AddBookForm = () => { const [title, setTitle] = useState(''); const [author, setAuthor] = useState(''); const [totalPages, setTotalPages] = useState(''); const [targetDate, setTargetDate] = useState(''); // YYYY-MM-DD const minDateKey = (() => { const d = new Date(); d.setDate(d.getDate() + 1); return toLocalDateKey(d); })(); const canSubmit = title.trim() && Number(totalPages) > 0 && !!targetDate; return (

Add New Book

setTitle(e.target.value)} placeholder="Enter book title" autoComplete="off" />
setAuthor(e.target.value)} placeholder="Enter author name" autoComplete="off" />
setTotalPages(e.target.value)} placeholder="Enter total pages" inputMode="numeric" />
setTargetDate(e.target.value)} />

Shown and stored in local time; week starts on Monday.

); }; // --------------------------------------------------------------------------- // Book Card // --------------------------------------------------------------------------- const BookCard = ({ book, onClick, onDelete }: { book: BookItem; onClick: () => void; onDelete: () => void }) => { const goal = calculateDailyGoal(book); const progressPercent = (book.currentPage / book.totalPages) * 100; const todayKey = toLocalDateKey(); const todayPages = book.readingHistory[todayKey] || 0; const safeTitle = sanitizeText(book.title, MAX_TITLE_LEN) || 'Untitled'; const safeAuthor = sanitizeText(book.author || 'Unknown Author', MAX_AUTHOR_LEN) || 'Unknown Author'; return (
{/* Per-card delete button */}

{safeTitle}

{safeAuthor}

= 100 ? 'bg-green-100 text-green-700' : 'bg-blue-100 text-blue-700' }`} > {progressPercent >= 100 ? 'Completed' : `${goal.daysRemaining} days left`}
Progress {book.currentPage} / {book.totalPages} pages
Target: {formatDDMMYYYY(book.targetDate)}

{goal.pages}

pages/day

{todayPages} pages today

{goal.pages > 0 ? `${Math.max(0, goal.pages - todayPages)} remaining` : 'Done!'}

); }; // --------------------------------------------------------------------------- // Book Detail // --------------------------------------------------------------------------- const BookDetailView = ({ book }: { book: BookItem }) => { const [editingProgress, setEditingProgress] = useState(false); const [progressInput, setProgressInput] = useState(''); 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 || ''); const [metaTotalPages, setMetaTotalPages] = useState(String(book.totalPages)); const goal = calculateDailyGoal(book); const progressPercent = (book.currentPage / book.totalPages) * 100; // Generate calendar days for the current month (Monday start) const generateCalendarDays = () => { const anchor = fromLocalDateKey(selectedDate); const year = anchor.getFullYear(); const month = anchor.getMonth(); return computeMonthGridDays(year, month); }; 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)); 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)); if (editDateKey) updateProgress(book.id, editDateKey, pages); setEditingProgress(false); setProgressInput(''); setEditDateKey(''); }; const todayKey = toLocalDateKey(); const todayStart = fromLocalDateKey(todayKey); const safeTitle = sanitizeText(book.title, MAX_TITLE_LEN) || 'Untitled'; const safeAuthor = sanitizeText(book.author || 'Unknown Author', MAX_AUTHOR_LEN) || 'Unknown Author'; return (

{safeTitle}

{safeAuthor}

Progress Overview

Current Progress{book.currentPage} / {book.totalPages} pages
Completion{progressPercent.toFixed(1)}%

Daily Reading Goal

{goal.pages}

pages per day

{goal.pagesRemaining} pages remaining • {goal.daysRemaining} days left

Target Completion Date

{editingDate ? (

Format: DD/MM/YYYY

setNewTargetDate(e.target.value)} />
) : (

{formatDateLong(book.targetDate)}

)}
{editingMeta && (

Edit Book Details

setMetaTitle(e.target.value)} placeholder="Enter book title" autoComplete="off" />
setMetaAuthor(e.target.value)} placeholder="Enter author name" autoComplete="off" />
setMetaTotalPages(e.target.value)} placeholder="Enter total pages" inputMode="numeric" />

Changing total pages won't alter your reading history; progress caps at total pages.

)} {/* Reading Calendar */}

Reading History

Single-click to edit that date • Double-click to edit the previous day

{/* Month Navigation */}

{formatMonthYear(selectedDate)}

{/* Calendar Grid (Monday start) */}
{['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'].map((day) => (
{day}
))} {calendarDays.map((day, index) => { if (!day) return
; const dateKey = toLocalDateKey(day); const pagesRead = book.readingHistory[dateKey] || 0; const isToday = dateKey === todayKey; const isPast = day < todayStart; // start-of-day compare return ( ); })}
{/* Edit Progress */} {editingProgress && (

Edit progress for {formatDDMMYYYY(editDateKey)}

setProgressInput(e.target.value)} placeholder="Pages read" min={0} inputMode="numeric" />
)}
); }; // --------------------------------------------------------------------------- // Dashboard // --------------------------------------------------------------------------- const DashboardView = () => { const todayKey = toLocalDateKey(); const activeBooks = books.filter((b) => b.currentPage < b.totalPages); const completedBooks = books.filter((b) => b.currentPage >= b.totalPages); const todaysTotalGoal = activeBooks.reduce((sum, b) => sum + calculateDailyGoal(b).pages, 0); const todaysTotalRead = activeBooks.reduce((sum, b) => sum + (b.readingHistory[todayKey] || 0), 0); return (

Reading Dashboard

Track your daily reading goals

{activeBooks.length > 0 && (

Today's Reading Goal ({formatDDMMYYYY(fromLocalDateKey(todayKey))})

{todaysTotalGoal}

Total pages to read

{todaysTotalRead}

Pages completed

{Math.max(0, todaysTotalGoal - todaysTotalRead)}

Pages remaining

)}

Currently Reading

{activeBooks.length > 0 ? (
{activeBooks.map((book) => ( { setSelectedBook(book); setActiveView('detail'); }} onDelete={() => setConfirmDelete({ id: book.id, title: sanitizeText(book.title, MAX_TITLE_LEN) || 'Untitled' })} /> ))}
) : (

No active books yet

)}
{completedBooks.length > 0 && (

Completed

{completedBooks.map((book) => ( { setSelectedBook(book); setActiveView('detail'); }} onDelete={() => setConfirmDelete({ id: book.id, title: sanitizeText(book.title, MAX_TITLE_LEN) || 'Untitled' })} /> ))}
)}
); }; return (
{/* ensures Monday-start and DD/MM/YYYY in native date pickers */} {activeView === 'dashboard' && } {activeView === 'detail' && selectedBook && } {showAddBook && } {/* Delete confirmation modal */} {confirmDelete && (

Delete "{confirmDelete.title}"?

This will permanently remove the book and its reading history from this device. This action cannot be undone.

)}
); }; // ----------------------------------------------------------------------------- // Dev-time tests (non-breaking). These run once in dev to validate date logic. // They do NOT modify UI; they only log to the console. // ----------------------------------------------------------------------------- const __runDevTests = () => { try { // Roundtrip local key const k = '2025-09-01'; console.assert(toLocalDateKey(fromLocalDateKey(k)) === k, 'Roundtrip failed'); // Format DD/MM/YYYY console.assert(formatDDMMYYYY('2025-09-01') === '01/09/2025', 'DD/MM/YYYY format failed'); // Monday-start padding checks const sept2025 = computeMonthGridDays(2025, 8); // Sept 2025 starts Monday const aug2025 = computeMonthGridDays(2025, 7); // Aug 2025 starts Friday const sept2024 = computeMonthGridDays(2024, 8); // Sept 2024 starts Sunday -> padding 6 // Count leading nulls const countPad = (arr: (Date | null)[]) => arr.findIndex(x => x !== null); console.assert(countPad(sept2025) === 0, 'September 2025 should have 0 padding (Mon start)'); console.assert(countPad(aug2025) === 4, 'August 2025 should have 4 padding (Fri start -> 4)'); console.assert(countPad(sept2024) === 6, 'September 2024 should have 6 padding (Sun start -> 6)'); // Grid size sanity: padding + days-in-month const sizeSept = sept2025.length; console.assert(sizeSept === 0 + 30, 'September 2025 grid size mismatch'); // Leap year February check const feb2024 = computeMonthGridDays(2024, 1); // Feb 2024 const expectedPadFeb2024 = getMondayStartPadding(new Date(2024, 1, 1)); console.assert(feb2024.length === expectedPadFeb2024 + 29, 'February 2024 should have 29 days + padding'); // Long format includes correct weekday console.assert(formatDateLong('2025-09-01').startsWith('Monday'), 'Long format weekday incorrect for 2025-09-01'); console.assert(formatDateLong('2024-02-29').startsWith('Thursday'), 'Long format weekday incorrect for 2024-02-29'); // formatMonthYear console.assert(formatMonthYear('2025-09-01').toLowerCase().includes('september'), 'Month label should include September'); // Persistence roundtrip (pure functions) const sample: BookItem = { id: 1, title: 'Sample', author: 'Auth', totalPages: 100, currentPage: 10, startDate: '2025-09-01', targetDate: '2025-09-30', readingHistory: { '2025-09-01': 10 }, createdAt: '2025-09-01T00:00:00.000Z', }; const json = serializeBooks([sample]); const restored = deserializeBooks(json); console.assert(Array.isArray(restored) && restored.length === 1, 'Deserialize array size mismatch'); console.assert(restored[0].readingHistory['2025-09-01'] === 10, 'Reading history did not survive roundtrip'); console.assert(formatDDMMYYYY(restored[0].targetDate) === '30/09/2025', 'Target date format unexpected after roundtrip'); // Deletion storage policy const afterDeleteEmpty: BookItem[] = []; console.assert(Array.isArray(afterDeleteEmpty) && afterDeleteEmpty.length === 0, 'Deletion result should be empty array when last item removed'); // Non-array JSON should deserialize to an empty list console.assert(deserializeBooks('{"not":"array"}').length === 0, 'Non-array JSON should return empty array'); // --- Security tests --- // 1) Sanitizer removes angle brackets const evil = ''; // no angle brackets should remain console.assert(!/[<>]/.test(sanitizeText(evil, 200)), 'Sanitizer should strip angle brackets'); // 2) Invalid date keys ignored const poisoned = serializeBooks([{ id: 1, title: 'X', author: 'Y', totalPages: 10, currentPage: 0, startDate: '2024-13-99', targetDate: '2024-00-00', readingHistory: { '__proto__': 999, '2024-02-30': 5 }, createdAt: '2025-01-01T00:00:00.000Z' } as any]); const restored2 = deserializeBooks(poisoned); console.assert(restored2[0].title === 'bX/b' || restored2[0].title === 'bX/b'.slice(0), 'Title should be sanitized (tags removed)'); console.assert(Object.keys(restored2[0].readingHistory).length === 0, 'Invalid reading history keys should be dropped'); } catch (err) { // Never throw - only log // eslint-disable-next-line no-console console.warn('Dev tests encountered an error:', err); } }; // Only attempt to read process in environments where it exists (Node/bundlers) if ( typeof window !== 'undefined' && typeof process !== 'undefined' && process && process.env && process.env.NODE_ENV !== 'production' ) { // Run after a tick to avoid blocking render in some setups setTimeout(__runDevTests, 0); } export default ReadingGoalApp;