diff --git a/reading_tracker.jsx b/reading_tracker.jsx new file mode 100644 index 0000000..1a11a23 --- /dev/null +++ b/reading_tracker.jsx @@ -0,0 +1,986 @@ +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;