987 lines
42 KiB
JavaScript
987 lines
42 KiB
JavaScript
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;
|