First real commit with all the functionalities including security
This commit is contained in:
parent
423a068ac8
commit
7bb78ad89d
986
reading_tracker.jsx
Normal file
986
reading_tracker.jsx
Normal file
@ -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<BookItem[]>([]);
|
||||
const [activeView, setActiveView] = useState<'dashboard' | 'detail'>('dashboard');
|
||||
const [selectedBook, setSelectedBook] = useState<BookItem | null>(null);
|
||||
const [showAddBook, setShowAddBook] = useState(false);
|
||||
const [selectedDate, setSelectedDate] = useState<string>(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<string>('');
|
||||
const [targetDate, setTargetDate] = useState<string>(''); // 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 (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
||||
<div className="bg-white rounded-lg p-6 w-full max-w-md">
|
||||
<h2 className="text-xl font-bold mb-4">Add New Book</h2>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Book Title</label>
|
||||
<input
|
||||
type="text"
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Enter book title"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Author</label>
|
||||
<input
|
||||
type="text"
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
value={author}
|
||||
onChange={(e) => setAuthor(e.target.value)}
|
||||
placeholder="Enter author name"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Total Pages</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={MAX_PAGES}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
value={totalPages}
|
||||
onChange={(e) => setTotalPages(e.target.value)}
|
||||
placeholder="Enter total pages"
|
||||
inputMode="numeric"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Target Completion Date (DD/MM/YYYY)</label>
|
||||
<input
|
||||
lang="en-GB"
|
||||
type="date"
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
min={minDateKey}
|
||||
value={targetDate}
|
||||
onChange={(e) => setTargetDate(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">Shown and stored in local time; week starts on Monday.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3 mt-6">
|
||||
<button
|
||||
onClick={() => setShowAddBook(false)}
|
||||
className="flex-1 px-4 py-2 border rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!canSubmit) return;
|
||||
const newBook: BookItem = {
|
||||
id: Date.now(),
|
||||
title: sanitizeText(title, MAX_TITLE_LEN) || 'Untitled',
|
||||
author: sanitizeText(author, MAX_AUTHOR_LEN) || 'Unknown Author',
|
||||
totalPages: clampInt(totalPages, 1, MAX_PAGES, 1),
|
||||
currentPage: 0,
|
||||
startDate: toLocalDateKey(),
|
||||
targetDate: coerceDateKey(targetDate, toLocalDateKey()),
|
||||
readingHistory: Object.create(null),
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
applyBooks([...books, newBook]);
|
||||
setShowAddBook(false);
|
||||
}}
|
||||
className="flex-1 px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 disabled:opacity-50"
|
||||
disabled={!canSubmit}
|
||||
>
|
||||
Add Book
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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 (
|
||||
<div onClick={onClick} className="relative bg-white rounded-lg shadow-md p-4 hover:shadow-lg transition-shadow cursor-pointer">
|
||||
{/* Per-card delete button */}
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
// Stop React's synthetic event bubbling thoroughly
|
||||
// @ts-ignore
|
||||
if (e.nativeEvent && typeof e.nativeEvent.stopImmediatePropagation === 'function') {
|
||||
// @ts-ignore
|
||||
e.nativeEvent.stopImmediatePropagation();
|
||||
}
|
||||
onDelete();
|
||||
}}
|
||||
className="absolute top-2 right-2 z-10 p-1 rounded hover:bg-red-50 text-red-600"
|
||||
aria-label={`Delete ${safeTitle}`}
|
||||
title="Delete book"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
|
||||
<div className="flex justify-between items-start mb-3 pr-6">
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-lg line-clamp-1">{safeTitle}</h3>
|
||||
<p className="text-sm text-gray-600">{safeAuthor}</p>
|
||||
</div>
|
||||
<div
|
||||
className={`px-2 py-1 rounded text-xs font-medium ${
|
||||
progressPercent >= 100 ? 'bg-green-100 text-green-700' : 'bg-blue-100 text-blue-700'
|
||||
}`}
|
||||
>
|
||||
{progressPercent >= 100 ? 'Completed' : `${goal.daysRemaining} days left`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<div className="flex justify-between text-sm mb-1">
|
||||
<span className="text-gray-600">Progress</span>
|
||||
<span className="font-medium">{book.currentPage} / {book.totalPages} pages</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||
<div className="bg-blue-500 h-2 rounded-full transition-all" style={{ width: `${Math.min(progressPercent, 100)}%` }} />
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mt-1">Target: {formatDDMMYYYY(book.targetDate)}</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-center">
|
||||
<p className="text-2xl font-bold text-blue-600">{goal.pages}</p>
|
||||
<p className="text-xs text-gray-600">pages/day</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-medium">{todayPages} pages today</p>
|
||||
<p className="text-xs text-gray-600">{goal.pages > 0 ? `${Math.max(0, goal.pages - todayPages)} remaining` : 'Done!'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Book Detail
|
||||
// ---------------------------------------------------------------------------
|
||||
const BookDetailView = ({ book }: { book: BookItem }) => {
|
||||
const [editingProgress, setEditingProgress] = useState(false);
|
||||
const [progressInput, setProgressInput] = useState<string>('');
|
||||
const [editingDate, setEditingDate] = useState(false);
|
||||
const [newTargetDate, setNewTargetDate] = useState<string>(book.targetDate);
|
||||
const [editDateKey, setEditDateKey] = useState<string>('');
|
||||
const [clickTimer, setClickTimer] = useState<number | null>(null);
|
||||
const [editingMeta, setEditingMeta] = useState(false);
|
||||
const [metaTitle, setMetaTitle] = useState<string>(book.title);
|
||||
const [metaAuthor, setMetaAuthor] = useState<string>(book.author || '');
|
||||
const [metaTotalPages, setMetaTotalPages] = useState<string>(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 (
|
||||
<div className="max-w-4xl mx-auto p-4">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<button onClick={() => { setSelectedBook(null); setActiveView('dashboard'); }} className="flex items-center gap-2 text-gray-600 hover:text-gray-900">
|
||||
<ChevronLeft size={20} />
|
||||
Back to Dashboard
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setConfirmDelete({ id: book.id, title: safeTitle }); }}
|
||||
className="text-red-500 hover:text-red-700"
|
||||
aria-label="Delete book"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow-lg p-6 mb-6">
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">{safeTitle}</h1>
|
||||
<p className="text-gray-600">{safeAuthor}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => { setMetaTitle(book.title); setMetaAuthor(book.author || ''); setMetaTotalPages(String(book.totalPages)); setEditingMeta(true); }}
|
||||
className="flex items-center gap-2 px-3 py-2 border rounded-lg hover:bg-gray-50"
|
||||
aria-label="Edit book details"
|
||||
>
|
||||
<Edit2 size={16} />
|
||||
<span className="text-sm">Edit details</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<h3 className="font-semibold mb-3">Progress Overview</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between"><span className="text-gray-600">Current Progress</span><span className="font-medium">{book.currentPage} / {book.totalPages} pages</span></div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-3"><div className="bg-blue-500 h-3 rounded-full transition-all" style={{ width: `${Math.min(progressPercent, 100)}%` }} /></div>
|
||||
<div className="flex justify-between"><span className="text-gray-600">Completion</span><span className="font-medium">{progressPercent.toFixed(1)}%</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold mb-3">Daily Reading Goal</h3>
|
||||
<div className="bg-blue-50 rounded-lg p-4 text-center">
|
||||
<p className="text-3xl font-bold text-blue-600">{goal.pages}</p>
|
||||
<p className="text-sm text-gray-600">pages per day</p>
|
||||
<p className="text-xs text-gray-500 mt-2">{goal.pagesRemaining} pages remaining • {goal.daysRemaining} days left</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="font-semibold">Target Completion Date</h3>
|
||||
<button onClick={() => setEditingDate(!editingDate)} className="text-blue-500 hover:text-blue-700" aria-label="Edit target date">
|
||||
<Edit2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
{editingDate ? (
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 mb-2">Format: DD/MM/YYYY</p>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
lang="en-GB"
|
||||
type="date"
|
||||
className="flex-1 px-3 py-2 border rounded-lg"
|
||||
value={newTargetDate}
|
||||
min={toLocalDateKey()}
|
||||
onChange={(e) => setNewTargetDate(e.target.value)}
|
||||
/>
|
||||
<button onClick={() => { updateTargetDate(book.id, newTargetDate); setEditingDate(false); }} className="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600" aria-label="Save target date">
|
||||
<Check size={16} />
|
||||
</button>
|
||||
<button onClick={() => { setNewTargetDate(book.targetDate); setEditingDate(false); }} className="px-4 py-2 border rounded-lg hover:bg-gray-50" aria-label="Cancel editing date">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-lg">{formatDateLong(book.targetDate)}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{editingMeta && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
||||
<div className="bg-white rounded-lg p-6 w-full max-w-md">
|
||||
<h2 className="text-xl font-bold mb-4">Edit Book Details</h2>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Title</label>
|
||||
<input
|
||||
type="text"
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
value={metaTitle}
|
||||
onChange={(e) => setMetaTitle(e.target.value)}
|
||||
placeholder="Enter book title"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Author</label>
|
||||
<input
|
||||
type="text"
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
value={metaAuthor}
|
||||
onChange={(e) => setMetaAuthor(e.target.value)}
|
||||
placeholder="Enter author name"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Total Pages</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={MAX_PAGES}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
value={metaTotalPages}
|
||||
onChange={(e) => setMetaTotalPages(e.target.value)}
|
||||
placeholder="Enter total pages"
|
||||
inputMode="numeric"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">Changing total pages won't alter your reading history; progress caps at total pages.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3 mt-6">
|
||||
<button
|
||||
onClick={() => setEditingMeta(false)}
|
||||
className="flex-1 px-4 py-2 border rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { const tp = clampInt(metaTotalPages, 1, MAX_PAGES, 1); updateBookMeta(book.id, { title: metaTitle, author: metaAuthor, totalPages: tp }); setEditingMeta(false); }}
|
||||
className="flex-1 px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 disabled:opacity-50"
|
||||
disabled={!metaTitle.trim() || (clampInt(metaTotalPages, 1, MAX_PAGES, 1) < 1)}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reading Calendar */}
|
||||
<div className="bg-white rounded-lg shadow-lg p-6">
|
||||
<h3 className="font-semibold mb-4">Reading History</h3>
|
||||
<p className="text-sm text-gray-600 mb-4">Single-click to edit that date • Double-click to edit the previous day</p>
|
||||
|
||||
{/* Month Navigation */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<button
|
||||
onClick={() => {
|
||||
const d = fromLocalDateKey(selectedDate);
|
||||
d.setMonth(d.getMonth() - 1);
|
||||
setSelectedDate(toLocalDateKey(d));
|
||||
}}
|
||||
className="p-2 hover:bg-gray-100 rounded"
|
||||
aria-label="Previous month"
|
||||
>
|
||||
<ChevronLeft size={20} />
|
||||
</button>
|
||||
<h4 className="font-medium">{formatMonthYear(selectedDate)}</h4>
|
||||
<button
|
||||
onClick={() => {
|
||||
const d = fromLocalDateKey(selectedDate);
|
||||
d.setMonth(d.getMonth() + 1);
|
||||
setSelectedDate(toLocalDateKey(d));
|
||||
}}
|
||||
className="p-2 hover:bg-gray-100 rounded"
|
||||
aria-label="Next month"
|
||||
>
|
||||
<ChevronRight size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Calendar Grid (Monday start) */}
|
||||
<div className="grid grid-cols-7 gap-1 mb-4">
|
||||
{['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'].map((day) => (
|
||||
<div key={day} className="text-center text-xs font-medium text-gray-600 py-2">
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
{calendarDays.map((day, index) => {
|
||||
if (!day) return <div key={index} />;
|
||||
const dateKey = toLocalDateKey(day);
|
||||
const pagesRead = book.readingHistory[dateKey] || 0;
|
||||
const isToday = dateKey === todayKey;
|
||||
const isPast = day < todayStart; // start-of-day compare
|
||||
return (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => handleDateInteraction(dateKey, false)}
|
||||
onDoubleClick={() => handleDateInteraction(dateKey, true)}
|
||||
className={`p-2 rounded-lg text-center hover:bg-gray-100 relative ${
|
||||
isToday ? 'ring-2 ring-blue-500' : ''
|
||||
} ${pagesRead > 0 ? 'bg-green-50' : isPast ? 'bg-gray-50' : ''}`}
|
||||
>
|
||||
<div className="text-sm">{day.getDate()}</div>
|
||||
{pagesRead > 0 && <div className="text-xs font-medium text-green-600">{pagesRead}p</div>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Edit Progress */}
|
||||
{editingProgress && (
|
||||
<div className="border-t pt-4">
|
||||
<h4 className="font-medium mb-2">Edit progress for {formatDDMMYYYY(editDateKey)}</h4>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="number"
|
||||
className="flex-1 px-3 py-2 border rounded-lg"
|
||||
value={progressInput}
|
||||
onChange={(e) => setProgressInput(e.target.value)}
|
||||
placeholder="Pages read"
|
||||
min={0}
|
||||
inputMode="numeric"
|
||||
/>
|
||||
<button onClick={handleSaveProgress} className="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600">Save</button>
|
||||
<button onClick={() => { setEditingProgress(false); setProgressInput(''); setEditDateKey(''); }} className="px-4 py-2 border rounded-lg hover:bg-gray-50">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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 (
|
||||
<div className="max-w-6xl mx-auto p-4">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-3xl font-bold mb-2">Reading Dashboard</h1>
|
||||
<p className="text-gray-600">Track your daily reading goals</p>
|
||||
</div>
|
||||
|
||||
{activeBooks.length > 0 && (
|
||||
<div className="bg-gradient-to-r from-blue-500 to-blue-600 text-white rounded-lg p-6 mb-6">
|
||||
<h2 className="text-xl font-semibold mb-4">Today's Reading Goal ({formatDDMMYYYY(fromLocalDateKey(todayKey))})</h2>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div><p className="text-3xl font-bold">{todaysTotalGoal}</p><p className="text-blue-100">Total pages to read</p></div>
|
||||
<div><p className="text-3xl font-bold">{todaysTotalRead}</p><p className="text-blue-100">Pages completed</p></div>
|
||||
<div><p className="text-3xl font-bold">{Math.max(0, todaysTotalGoal - todaysTotalRead)}</p><p className="text-blue-100">Pages remaining</p></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-6">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-xl font-semibold">Currently Reading</h2>
|
||||
<button onClick={() => setShowAddBook(true)} className="flex items-center gap-2 px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600">
|
||||
<Plus size={16} />
|
||||
Add Book
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{activeBooks.length > 0 ? (
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{activeBooks.map((book) => (
|
||||
<BookCard
|
||||
key={book.id}
|
||||
book={book}
|
||||
onClick={() => { setSelectedBook(book); setActiveView('detail'); }}
|
||||
onDelete={() => setConfirmDelete({ id: book.id, title: sanitizeText(book.title, MAX_TITLE_LEN) || 'Untitled' })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-gray-50 rounded-lg p-8 text-center">
|
||||
<BookOpen size={48} className="mx-auto text-gray-400 mb-3" />
|
||||
<p className="text-gray-600 mb-4">No active books yet</p>
|
||||
<button onClick={() => setShowAddBook(true)} className="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600">Add Your First Book</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{completedBooks.length > 0 && (
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold mb-4">Completed</h2>
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{completedBooks.map((book) => (
|
||||
<BookCard
|
||||
key={book.id}
|
||||
book={book}
|
||||
onClick={() => { setSelectedBook(book); setActiveView('detail'); }}
|
||||
onDelete={() => setConfirmDelete({ id: book.id, title: sanitizeText(book.title, MAX_TITLE_LEN) || 'Untitled' })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100" lang="en-GB">{/* ensures Monday-start and DD/MM/YYYY in native date pickers */}
|
||||
{activeView === 'dashboard' && <DashboardView />}
|
||||
{activeView === 'detail' && selectedBook && <BookDetailView book={selectedBook} />}
|
||||
{showAddBook && <AddBookForm />}
|
||||
|
||||
{/* Delete confirmation modal */}
|
||||
{confirmDelete && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||
<div role="dialog" aria-modal="true" aria-labelledby="delete-title" className="bg-white rounded-lg w-full max-w-md p-6 shadow-xl">
|
||||
<h2 id="delete-title" className="text-xl font-bold mb-2">Delete "{confirmDelete.title}"?</h2>
|
||||
<p className="text-sm text-gray-600 mb-4">This will permanently remove the book and its reading history from this device. This action cannot be undone.</p>
|
||||
<div className="flex gap-3 justify-end">
|
||||
<button onClick={() => setConfirmDelete(null)} className="px-4 py-2 border rounded-lg hover:bg-gray-50">Cancel</button>
|
||||
<button onClick={() => { deleteBook(confirmDelete.id); setConfirmDelete(null); }} className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// 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 = '<img src=x onerror=alert(1)>'; // 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: '<b>X</b>', author: '<i>Y</i>', 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;
|
||||
Loading…
x
Reference in New Issue
Block a user