diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index 49e81ce..4913a47 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -33,6 +33,7 @@ Also follow the repository root guide at `../AGENTS.md`. - Use theme tokens defined in `src/index.css` `@theme` (`bg-surface`, `text-content`, `border-border`, `primary`, etc.) for new UI work instead of adding raw light/dark color pairs. There is no `tailwind.config.js` — Tailwind v4 config is CSS-first. - Semantic colors map to runtime CSS variables (`--color-x: rgb(var(--x))`) via `@theme inline`; light/dark values live in `:root` / `.dark` in `src/index.css`. Dark mode is class-based via `@custom-variant dark`, toggled by `ThemeProvider`. - Store frontend-only preferences in `src/utils/localSettings.ts` so appearance and view settings share one local-storage shape. +- Reuse shared primitives instead of re-rolling them: `formatDate` / `formatDateTime` (`src/utils/formatters.ts`) for user-facing timestamps (`formatUtcDate` is intentionally UTC, for graph day-buckets only); `SegmentedControl` for toggle groups (default `pill` variant needs only `options`/`value`/`onChange`; pass `variant="unstyled"` for bespoke shapes like grids/inline text); `usePaginatedList` + `documentColumn` for paginated list/table pages. ## 3) Generated API client @@ -77,6 +78,7 @@ Also follow the repository root guide at `../AGENTS.md`. - Run frontend tests with `pnpm run test`. - `pnpm run build` still runs `tsc && vite build`, so unrelated TypeScript issues elsewhere in `src/` can fail the build. - When possible, validate changed files directly before escalating to full-project fixes. +- `pnpm run format` currently reports pre-existing style violations in files unrelated to most changes; format only the files you touched (`npx prettier --write `) rather than reformatting the whole tree. ## 7) Live Dev Server Debugging diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index ecb3e8d..5ab3537 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -4,6 +4,7 @@ import { useAuth } from '../auth/AuthContext'; import { UserIcon, DropdownIcon } from '../icons'; import { useTheme } from '../theme/ThemeProvider'; import { THEME_MODES } from '../utils/localSettings'; +import { SegmentedControl } from './SegmentedControl'; import HamburgerMenu from './HamburgerMenu'; import { getPageTitle } from './navigation'; @@ -61,30 +62,17 @@ export default function Layout() { {isUserDropdownOpen && (
-
+

Theme

-
- {THEME_MODES.map(mode => ( - - ))} -
+ ({ value: mode, label: mode }))} + />
- -// Text variant - - -// Circular variant (for avatars) - - -// Rectangular variant - + + ``` #### `SkeletonTable` diff --git a/frontend/src/components/SegmentedControl.tsx b/frontend/src/components/SegmentedControl.tsx index 9c4ae4c..20b5556 100644 --- a/frontend/src/components/SegmentedControl.tsx +++ b/frontend/src/components/SegmentedControl.tsx @@ -10,39 +10,67 @@ interface SegmentedControlProps { options: SegmentedOption[]; value: T; onChange: (value: T) => void; - activeClassName: string; - inactiveClassName: string; + variant?: 'pill' | 'unstyled'; className?: string; buttonClassName?: string; + activeClassName?: string; + inactiveClassName?: string; ariaLabel?: string; } +// Pill Variant Defaults - The bordered "segmented" look most call sites want, so they pass only +// options/value/onChange. `unstyled` opts out entirely for callers with a bespoke shape (grid, inline text). +const PILL = { + container: 'inline-flex rounded border border-border bg-surface-muted p-1', + button: 'flex-1 rounded px-3 py-1 text-sm font-medium capitalize transition-colors', + active: 'bg-content text-content-inverse', + inactive: 'text-content-muted hover:bg-surface hover:text-content', +}; + export function SegmentedControl({ options, value, onChange, - activeClassName, - inactiveClassName, + variant = 'pill', className, buttonClassName, + activeClassName, + inactiveClassName, ariaLabel, }: SegmentedControlProps) { + const styles = + variant === 'pill' + ? { + container: cn(PILL.container, className), + button: cn(PILL.button, buttonClassName), + active: activeClassName ?? PILL.active, + inactive: inactiveClassName ?? PILL.inactive, + } + : { + container: className, + button: buttonClassName, + active: activeClassName, + inactive: inactiveClassName, + }; + return ( -
- {options.map(option => ( - - ))} +
+ {options.map(option => { + const isActive = value === option.value; + return ( + + ); + })}
); } diff --git a/frontend/src/components/Skeleton.tsx b/frontend/src/components/Skeleton.tsx index 1ba5b6e..43a151b 100644 --- a/frontend/src/components/Skeleton.tsx +++ b/frontend/src/components/Skeleton.tsx @@ -2,46 +2,10 @@ import { cn } from '../utils/cn'; interface SkeletonProps { className?: string; - variant?: 'default' | 'text' | 'circular' | 'rectangular'; - width?: string | number; - height?: string | number; - animation?: 'pulse' | 'wave' | 'none'; } -export function Skeleton({ - className = '', - variant = 'default', - width, - height, - animation = 'pulse', -}: SkeletonProps) { - const baseClasses = 'bg-surface-strong'; - - const variantClasses = { - default: 'rounded', - text: 'h-4 rounded-md', - circular: 'rounded-full', - rectangular: 'rounded-none', - }; - - const animationClasses = { - pulse: 'animate-pulse', - wave: 'animate-wave', - none: '', - }; - - const style = { - width: width !== undefined ? (typeof width === 'number' ? `${width}px` : width) : undefined, - height: - height !== undefined ? (typeof height === 'number' ? `${height}px` : height) : undefined, - }; - - return ( -
- ); +export function Skeleton({ className }: SkeletonProps) { + return
; } interface SkeletonTableProps { @@ -65,7 +29,7 @@ export function SkeletonTable({ {Array.from({ length: columns }).map((_, i) => ( - + ))} @@ -76,10 +40,7 @@ export function SkeletonTable({ {Array.from({ length: columns }).map((_, colIndex) => ( - + ))} diff --git a/frontend/src/components/ToastContext.tsx b/frontend/src/components/ToastContext.tsx index 09efeef..e8839aa 100644 --- a/frontend/src/components/ToastContext.tsx +++ b/frontend/src/components/ToastContext.tsx @@ -14,7 +14,6 @@ interface ToastContextType { showError: (message: string, duration?: number) => string; updateToast: (id: string, update: ToastUpdate) => void; removeToast: (id: string) => void; - clearToasts: () => void; } const ToastContext = createContext(undefined); @@ -61,13 +60,9 @@ export function ToastProvider({ children }: { children: ReactNode }) { setToasts(prev => prev.map(toast => (toast.id === id ? { ...toast, ...update } : toast))); }, []); - const clearToasts = useCallback(() => { - setToasts([]); - }, []); - return ( {children} diff --git a/frontend/src/components/documentColumn.tsx b/frontend/src/components/documentColumn.tsx new file mode 100644 index 0000000..e16e7a5 --- /dev/null +++ b/frontend/src/components/documentColumn.tsx @@ -0,0 +1,19 @@ +import { Link } from 'react-router-dom'; +import type { Column } from './Table'; + +interface DocumentRow { + document_id?: string; + author?: string; + title?: string; +} + +// Shared "Document" Column - Progress and Activity render the same author/title link to the doc. +export const documentColumn: Column = { + id: 'document', + header: 'Document', + render: row => ( + + {row.author || 'Unknown'} - {row.title || 'Unknown'} + + ), +}; diff --git a/frontend/src/hooks/usePaginatedList.ts b/frontend/src/hooks/usePaginatedList.ts new file mode 100644 index 0000000..31b753a --- /dev/null +++ b/frontend/src/hooks/usePaginatedList.ts @@ -0,0 +1,15 @@ +import { useEffect, useState } from 'react'; + +/** + * Page state for a paginated list. Passing `resetKey` (e.g. a search term or filter id) resets + * back to page 1 whenever it changes, so a filter change never strands the user on a now-empty page. + */ +export function usePaginatedList(resetKey?: unknown) { + const [page, setPage] = useState(1); + + useEffect(() => { + setPage(1); + }, [resetKey]); + + return { page, setPage }; +} diff --git a/frontend/src/lib/reader/gestures.ts b/frontend/src/lib/reader/gestures.ts index 4ee2b3f..cad4c85 100644 --- a/frontend/src/lib/reader/gestures.ts +++ b/frontend/src/lib/reader/gestures.ts @@ -14,7 +14,8 @@ const SWIPE_THRESHOLD = 25; /** * Registers touch / click-zone / wheel listeners on every rendered section. Returns a dispose - * that clears the pending wheel-cooldown timeout (called from EBookReader.destroy). + * (called from EBookReader.destroy) that clears the pending wheel-cooldown timeout and removes + * every listener added across rendered sections, so they don't accumulate on re-render. */ export function registerRenditionGestures( rendition: EpubRendition, @@ -25,6 +26,7 @@ export function registerRenditionGestures( let touchEndX = 0; let touchEndY = 0; let wheelTimeoutId: ReturnType | null = null; + const listenerCleanups: Array<() => void> = []; const resetWheelCooldown = () => { if (wheelTimeoutId) { @@ -68,14 +70,11 @@ export function registerRenditionGestures( rendition.hooks.render.register((contents: EpubContents) => { const renderDoc = contents.document; - const wakeLockListener = () => { + const onWakeLock = () => { renderDoc.dispatchEvent(new CustomEvent('wakelock')); }; - renderDoc.addEventListener('click', wakeLockListener); - renderDoc.addEventListener('gesturechange', wakeLockListener); - renderDoc.addEventListener('touchstart', wakeLockListener); - renderDoc.addEventListener('click', (event: MouseEvent) => { + const onClick = (event: MouseEvent) => { const windowWidth = window.innerWidth; const windowHeight = window.innerHeight; const barPixels = windowHeight * 0.2; @@ -99,9 +98,9 @@ export function registerRenditionGestures( } else { handlers.onCenterTap(); } - }); + }; - renderDoc.addEventListener('wheel', (event: WheelEvent) => { + const onWheel = (event: WheelEvent) => { if (wheelTimeoutId) { return; } @@ -113,26 +112,36 @@ export function registerRenditionGestures( if (event.deltaY < -SWIPE_THRESHOLD) { handleSwipeDown(); } + }; + + const onTouchStart = (event: TouchEvent) => { + touchStartX = event.changedTouches[0]?.screenX ?? 0; + touchStartY = event.changedTouches[0]?.screenY ?? 0; + }; + + const onTouchEnd = (event: TouchEvent) => { + touchEndX = event.changedTouches[0]?.screenX ?? 0; + touchEndY = event.changedTouches[0]?.screenY ?? 0; + handleGesture(); + }; + + renderDoc.addEventListener('click', onWakeLock); + renderDoc.addEventListener('gesturechange', onWakeLock); + renderDoc.addEventListener('touchstart', onWakeLock); + renderDoc.addEventListener('click', onClick); + renderDoc.addEventListener('wheel', onWheel); + renderDoc.addEventListener('touchstart', onTouchStart); + renderDoc.addEventListener('touchend', onTouchEnd); + + listenerCleanups.push(() => { + renderDoc.removeEventListener('click', onWakeLock); + renderDoc.removeEventListener('gesturechange', onWakeLock); + renderDoc.removeEventListener('touchstart', onWakeLock); + renderDoc.removeEventListener('click', onClick); + renderDoc.removeEventListener('wheel', onWheel); + renderDoc.removeEventListener('touchstart', onTouchStart); + renderDoc.removeEventListener('touchend', onTouchEnd); }); - - renderDoc.addEventListener( - 'touchstart', - (event: TouchEvent) => { - touchStartX = event.changedTouches[0]?.screenX ?? 0; - touchStartY = event.changedTouches[0]?.screenY ?? 0; - }, - false - ); - - renderDoc.addEventListener( - 'touchend', - (event: TouchEvent) => { - touchEndX = event.changedTouches[0]?.screenX ?? 0; - touchEndY = event.changedTouches[0]?.screenY ?? 0; - handleGesture(); - }, - false - ); }); return () => { @@ -140,5 +149,7 @@ export function registerRenditionGestures( clearTimeout(wheelTimeoutId); wheelTimeoutId = null; } + listenerCleanups.forEach(cleanup => cleanup()); + listenerCleanups.length = 0; }; } diff --git a/frontend/src/pages/ActivityPage.tsx b/frontend/src/pages/ActivityPage.tsx index 9040efd..1b6736a 100644 --- a/frontend/src/pages/ActivityPage.tsx +++ b/frontend/src/pages/ActivityPage.tsx @@ -1,10 +1,11 @@ -import { useEffect, useState } from 'react'; -import { Link, useSearchParams } from 'react-router-dom'; +import { useSearchParams } from 'react-router-dom'; import { useGetActivity } from '../generated/anthoLumeAPIV1'; import type { Activity } from '../generated/model'; import { Pagination } from '../components'; import { Table, type Column } from '../components/Table'; -import { formatDuration } from '../utils/formatters'; +import { documentColumn } from '../components/documentColumn'; +import { usePaginatedList } from '../hooks/usePaginatedList'; +import { formatDuration, formatDateTime } from '../utils/formatters'; import { dataForStatus } from '../utils/apiResponses'; const ACTIVITY_PAGE_SIZE = 25; @@ -12,13 +13,9 @@ const ACTIVITY_PAGE_SIZE = 25; export default function ActivityPage() { const [searchParams] = useSearchParams(); const documentID = searchParams.get('document') || undefined; - const [page, setPage] = useState(1); + const { page, setPage } = usePaginatedList(documentID); const limit = ACTIVITY_PAGE_SIZE; - useEffect(() => { - setPage(1); - }, [documentID]); - const { data, isLoading } = useGetActivity({ doc_filter: Boolean(documentID), document_id: documentID, @@ -29,19 +26,11 @@ export default function ActivityPage() { const activities = response?.activities ?? []; const columns: Column[] = [ - { - id: 'document', - header: 'Document', - render: row => ( - - {row.author || 'Unknown'} - {row.title || 'Unknown'} - - ), - }, + documentColumn, { id: 'start_time', header: 'Time', - render: row => row.start_time || 'N/A', + render: row => formatDateTime(row.start_time), }, { id: 'duration', diff --git a/frontend/src/pages/AdminUsersPage.tsx b/frontend/src/pages/AdminUsersPage.tsx index 65aa74c..1db1e52 100644 --- a/frontend/src/pages/AdminUsersPage.tsx +++ b/frontend/src/pages/AdminUsersPage.tsx @@ -7,6 +7,7 @@ import type { User } from '../generated/model'; import { AddIcon, DeleteIcon } from '../icons'; import { useMutationWithToast } from '../hooks/useMutationWithToast'; import { useToasts } from '../components/ToastContext'; +import { formatDate } from '../utils/formatters'; import { dataForStatus } from '../utils/apiResponses'; interface AddUserFormProps { @@ -238,7 +239,12 @@ export default function AdminUsersPage() {
), }, - { id: 'created', header: 'Created', className: 'w-48', render: user => user.created_at }, + { + id: 'created', + header: 'Created', + className: 'w-48', + render: user => formatDate(user.created_at), + }, ]; if (isLoading) { diff --git a/frontend/src/pages/DocumentPage.tsx b/frontend/src/pages/DocumentPage.tsx index f1a83fc..2b98528 100644 --- a/frontend/src/pages/DocumentPage.tsx +++ b/frontend/src/pages/DocumentPage.tsx @@ -122,21 +122,20 @@ export default function DocumentPage() { return ; } - const document = dataForStatus(docData, 200)?.document; + const doc = dataForStatus(docData, 200)?.document; - if (!document) { + if (!doc) { return
Document not found
; } - const percentage = document.percentage ?? 0; - const secondsPerPercent = document.seconds_per_percent || 0; + const percentage = doc.percentage ?? 0; + const secondsPerPercent = doc.seconds_per_percent || 0; const totalTimeLeftSeconds = Math.round((100 - percentage) * secondsPerPercent); const save = (data: EditDocumentBody): Promise => - runWithToast(() => editMutation.mutateAsync({ id: document.id, data }), { + runWithToast(() => editMutation.mutateAsync({ id: doc.id, data }), { error: 'Failed to save', - onSuccess: response => - queryClient.setQueryData(getGetDocumentQueryKey(document.id), response), + onSuccess: response => queryClient.setQueryData(getGetDocumentQueryKey(doc.id), response), }); return ( @@ -145,13 +144,13 @@ export default function DocumentPage() {
{`${document.title} - {document.filepath && ( + {doc.filepath && ( Read @@ -162,26 +161,26 @@ export default function DocumentPage() {

ISBN-10:

-

{document.isbn10 || 'N/A'}

+

{doc.isbn10 || 'N/A'}

ISBN-13:

-

{document.isbn13 || 'N/A'}

+

{doc.isbn13 || 'N/A'}

- save({ title: value })} - /> + save({ title: value })} /> save({ author: value })} /> @@ -234,9 +229,7 @@ export default function DocumentPage() {

Words / Minute

-

- {document.wpm && document.wpm > 0 ? document.wpm : 'N/A'} -

+

{doc.wpm && doc.wpm > 0 ? doc.wpm : 'N/A'}

Est. Time Left

@@ -249,8 +242,8 @@ export default function DocumentPage() { } > - {document.total_time_seconds && document.total_time_seconds > 0 - ? formatDuration(document.total_time_seconds) + {doc.total_time_seconds && doc.total_time_seconds > 0 + ? formatDuration(doc.total_time_seconds) : 'N/A'} @@ -262,7 +255,7 @@ export default function DocumentPage() { save({ description: value })} diff --git a/frontend/src/pages/DocumentsPage.tsx b/frontend/src/pages/DocumentsPage.tsx index 6c49e2a..156fc36 100644 --- a/frontend/src/pages/DocumentsPage.tsx +++ b/frontend/src/pages/DocumentsPage.tsx @@ -1,4 +1,4 @@ -import { useState, useRef, useEffect } from 'react'; +import { useState, useRef } from 'react'; import { Link } from 'react-router-dom'; import { useGetDocuments, useCreateDocument } from '../generated/anthoLumeAPIV1'; import type { Document } from '../generated/model'; @@ -9,6 +9,7 @@ import { useMutationWithToast } from '../hooks/useMutationWithToast'; import { formatDuration } from '../utils/formatters'; import { cn } from '../utils/cn'; import { useDebouncedState } from '../hooks/useDebouncedState'; +import { usePaginatedList } from '../hooks/usePaginatedList'; import { useLocalSetting, type DocumentsViewMode } from '../utils/localSettings'; import { dataForStatus } from '../utils/apiResponses'; @@ -106,7 +107,7 @@ function EmptyDocuments({ className }: { className?: string }) { export default function DocumentsPage() { const [search, setSearch, debouncedSearch] = useDebouncedState('', 300); - const [page, setPage] = useState(1); + const { page, setPage } = usePaginatedList(debouncedSearch); const limit = DOCUMENTS_PAGE_SIZE; const [uploadMode, setUploadMode] = useState(false); const [viewMode, setViewMode] = useLocalSetting('documentsViewMode', 'grid'); @@ -114,10 +115,6 @@ export default function DocumentsPage() { const { showWarning } = useToasts(); const toastMutationOptions = useMutationWithToast(); - useEffect(() => { - setPage(1); - }, [debouncedSearch]); - const { data, isLoading, refetch } = useGetDocuments({ page, limit, search: debouncedSearch }); const createMutation = useCreateDocument(); const documentsResponse = dataForStatus(data, 200); @@ -169,13 +166,9 @@ export default function DocumentsPage() {
- className="inline-flex rounded border border-border bg-surface p-1" ariaLabel="Document view mode" value={viewMode} onChange={setViewMode} - buttonClassName="rounded px-3 py-1 text-sm font-medium transition-colors" - activeClassName="bg-content text-content-inverse" - inactiveClassName="text-content-muted hover:bg-surface-muted" options={[ { value: 'grid', label: 'Grid' }, { value: 'list', label: 'List' }, diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx index 96386f5..2516a0e 100644 --- a/frontend/src/pages/HomePage.tsx +++ b/frontend/src/pages/HomePage.tsx @@ -35,51 +35,39 @@ function InfoCard({ title, size, link }: InfoCardProps) { } interface StreakCardProps { - window: 'DAY' | 'WEEK'; - currentStreak: number; - currentStreakStartDate: string; - currentStreakEndDate: string; - maxStreak: number; - maxStreakStartDate: string; - maxStreakEndDate: string; + streak: UserStreak; } -function StreakCard({ - window, - currentStreak, - currentStreakStartDate, - currentStreakEndDate, - maxStreak, - maxStreakStartDate, - maxStreakEndDate, -}: StreakCardProps) { +function StreakCard({ streak }: StreakCardProps) { + const isWeekly = streak.window === 'WEEK'; + return (

- {window === 'WEEK' ? 'Weekly Read Streak' : 'Daily Read Streak'} + {isWeekly ? 'Weekly Read Streak' : 'Daily Read Streak'}

-

{currentStreak}

+

{streak.current_streak}

-

{window === 'WEEK' ? 'Current Weekly Streak' : 'Current Daily Streak'}

+

{isWeekly ? 'Current Weekly Streak' : 'Current Daily Streak'}

- {currentStreakStartDate} ➞ {currentStreakEndDate} + {streak.current_streak_start_date} ➞ {streak.current_streak_end_date}
-
{currentStreak}
+
{streak.current_streak}
-

{window === 'WEEK' ? 'Best Weekly Streak' : 'Best Daily Streak'}

+

{isWeekly ? 'Best Weekly Streak' : 'Best Daily Streak'}

- {maxStreakStartDate} ➞ {maxStreakEndDate} + {streak.max_streak_start_date} ➞ {streak.max_streak_end_date}
-
{maxStreak}
+
{streak.max_streak}
@@ -121,6 +109,7 @@ function LeaderboardCard({ name, data }: LeaderboardCardProps) { {name} Leaderboard

+ variant="unstyled" className="flex items-center gap-2 text-xs" ariaLabel={`${name} leaderboard period`} value={selectedPeriod} @@ -197,16 +186,7 @@ export default function HomePage() {
{streaks?.map((streak: UserStreak) => ( - + ))}
diff --git a/frontend/src/pages/LoginPage.test.tsx b/frontend/src/pages/LoginPage.test.tsx index c9f750a..aa6def8 100644 --- a/frontend/src/pages/LoginPage.test.tsx +++ b/frontend/src/pages/LoginPage.test.tsx @@ -53,7 +53,6 @@ describe('LoginPage', () => { showError: vi.fn(), updateToast: vi.fn(), removeToast: vi.fn(), - clearToasts: vi.fn(), }); mockedUseGetInfo.mockReturnValue({ @@ -115,7 +114,6 @@ describe('LoginPage', () => { showError: showErrorMock, updateToast: vi.fn(), removeToast: vi.fn(), - clearToasts: vi.fn(), }); render( diff --git a/frontend/src/pages/ProgressPage.tsx b/frontend/src/pages/ProgressPage.tsx index d827463..ca6c8bc 100644 --- a/frontend/src/pages/ProgressPage.tsx +++ b/frontend/src/pages/ProgressPage.tsx @@ -1,30 +1,23 @@ -import { useState } from 'react'; -import { Link } from 'react-router-dom'; import { useGetProgressList } from '../generated/anthoLumeAPIV1'; import type { Progress } from '../generated/model'; import { Pagination } from '../components'; import { Table, type Column } from '../components/Table'; +import { documentColumn } from '../components/documentColumn'; +import { usePaginatedList } from '../hooks/usePaginatedList'; +import { formatDate } from '../utils/formatters'; import { dataForStatus } from '../utils/apiResponses'; const PROGRESS_PAGE_SIZE = 15; export default function ProgressPage() { - const [page, setPage] = useState(1); + const { page, setPage } = usePaginatedList(); const limit = PROGRESS_PAGE_SIZE; const { data, isLoading } = useGetProgressList({ page, limit }); const response = dataForStatus(data, 200); const progress = response?.progress ?? []; const columns: Column[] = [ - { - id: 'document', - header: 'Document', - render: row => ( - - {row.author || 'Unknown'} - {row.title || 'Unknown'} - - ), - }, + documentColumn, { id: 'device_name', header: 'Device Name', @@ -38,7 +31,7 @@ export default function ProgressPage() { { id: 'created_at', header: 'Created At', - render: row => (row.created_at ? new Date(row.created_at).toLocaleDateString() : 'N/A'), + render: row => formatDate(row.created_at), }, ]; diff --git a/frontend/src/pages/ReaderPage.tsx b/frontend/src/pages/ReaderPage.tsx index 2e9dd61..d9f481f 100644 --- a/frontend/src/pages/ReaderPage.tsx +++ b/frontend/src/pages/ReaderPage.tsx @@ -40,7 +40,7 @@ export default function ReaderPage() { enabled: Boolean(id), }, }); - const document = dataForStatus(documentResponse, 200)?.document; + const doc = dataForStatus(documentResponse, 200)?.document; const progress = dataForStatus(progressResponse, 200)?.progress; const deviceId = defaultDeviceId; @@ -89,17 +89,17 @@ export default function ReaderPage() { }); useEffect(() => { - if (document?.title) { - window.document.title = `AnthoLume - Reader - ${document.title}`; + if (doc?.title) { + document.title = `AnthoLume - Reader - ${doc.title}`; } - }, [document?.title]); + }, [doc?.title]); useEffect(() => { if (isTopBarOpen || isBottomBarOpen) { return; } - const activeElement = window.document.activeElement; + const activeElement = document.activeElement; if (activeElement instanceof HTMLElement) { activeElement.blur(); } @@ -109,7 +109,7 @@ export default function ReaderPage() { return ; } - if (!id || !document) { + if (!id || !doc) { return
Document not found
; } @@ -124,24 +124,24 @@ export default function ReaderPage() {
- + {`${document.title}

Title

-

{document.title}

+

{doc.title}

Author

-

{document.author}

+

{doc.author}

Back @@ -224,6 +224,7 @@ export default function ReaderPage() { Theme

+ variant="unstyled" className="grid w-full grid-cols-2 gap-1.5 sm:grid-cols-3 lg:grid-cols-5" ariaLabel="Reader theme" value={colorScheme} @@ -238,6 +239,7 @@ export default function ReaderPage() {

Font

+ variant="unstyled" className="grid w-full grid-cols-1 gap-1.5 sm:grid-cols-2 lg:grid-cols-4" ariaLabel="Reader font" value={fontFamily} diff --git a/frontend/src/pages/RegisterPage.test.tsx b/frontend/src/pages/RegisterPage.test.tsx index 48723cb..d54b1d8 100644 --- a/frontend/src/pages/RegisterPage.test.tsx +++ b/frontend/src/pages/RegisterPage.test.tsx @@ -53,7 +53,6 @@ describe('RegisterPage', () => { showError: vi.fn(), updateToast: vi.fn(), removeToast: vi.fn(), - clearToasts: vi.fn(), }); mockedUseGetInfo.mockReturnValue({ @@ -116,7 +115,6 @@ describe('RegisterPage', () => { showError: showErrorMock, updateToast: vi.fn(), removeToast: vi.fn(), - clearToasts: vi.fn(), }); render( diff --git a/frontend/src/pages/SearchPage.tsx b/frontend/src/pages/SearchPage.tsx index e2be8e6..fab57d7 100644 --- a/frontend/src/pages/SearchPage.tsx +++ b/frontend/src/pages/SearchPage.tsx @@ -6,6 +6,7 @@ import { Button, Table, type Column, TextInput, IconInput } from '../components' import { inputClassName } from '../components/TextInput'; import { useDebouncedState } from '../hooks/useDebouncedState'; import { Search2Icon, BookIcon } from '../icons'; +import { formatDate } from '../utils/formatters'; import { dataForStatus } from '../utils/apiResponses'; const searchColumns: Column[] = [ @@ -21,7 +22,7 @@ const searchColumns: Column[] = [ id: 'date', header: 'Date', className: 'hidden md:table-cell', - render: item => item.upload_date || 'N/A', + render: item => formatDate(item.upload_date), }, ]; diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx index 7718acf..b151749 100644 --- a/frontend/src/pages/SettingsPage.tsx +++ b/frontend/src/pages/SettingsPage.tsx @@ -8,10 +8,9 @@ import { useToasts } from '../components/ToastContext'; import { useMutationWithToast, useToastMutation } from '../hooks/useMutationWithToast'; import { useTheme } from '../theme/ThemeProvider'; import type { ThemeMode } from '../utils/localSettings'; +import { formatDateTime } from '../utils/formatters'; import { dataForStatus } from '../utils/apiResponses'; -const formatDeviceDate = (value?: string) => (value ? new Date(value).toLocaleString() : 'N/A'); - const deviceColumns: Column[] = [ { id: 'name', @@ -22,9 +21,9 @@ const deviceColumns: Column[] = [ { id: 'last_synced', header: 'Last Sync', - render: device => formatDeviceDate(device.last_synced), + render: device => formatDateTime(device.last_synced), }, - { id: 'created_at', header: 'Created', render: device => formatDeviceDate(device.created_at) }, + { id: 'created_at', header: 'Created', render: device => formatDateTime(device.created_at) }, ]; const themeModes: Array<{ value: ThemeMode; label: string; description: string }> = [ diff --git a/frontend/src/utils/apiResponses.ts b/frontend/src/utils/apiResponses.ts index e8b1be9..c22117c 100644 --- a/frontend/src/utils/apiResponses.ts +++ b/frontend/src/utils/apiResponses.ts @@ -11,11 +11,3 @@ export function dataForStatus['data']) : undefined; } - -export function dataForSuccess( - response: TResponse | undefined -): Extract['data'] | undefined { - return response && response.status >= 200 && response.status < 300 - ? (response.data as Extract['data']) - : undefined; -} diff --git a/frontend/src/utils/formatters.test.ts b/frontend/src/utils/formatters.test.ts index 9965f60..de6f7e7 100644 --- a/frontend/src/utils/formatters.test.ts +++ b/frontend/src/utils/formatters.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { formatNumber, formatDuration } from './formatters'; +import { formatNumber, formatDuration, formatDate, formatDateTime } from './formatters'; describe('formatNumber', () => { it('formats zero', () => { @@ -33,7 +33,6 @@ describe('formatNumber', () => { expect(formatNumber(-12345)).toBe('-12.3k'); expect(formatNumber(-1500000)).toBe('-1.50M'); }); - }); describe('formatDuration', () => { @@ -61,5 +60,21 @@ describe('formatDuration', () => { it('formats days, hours, minutes, and seconds', () => { expect(formatDuration(1928371)).toBe('22d 7h 39m 31s'); }); - +}); + +describe('formatDate / formatDateTime', () => { + it('returns N/A for empty or unparseable values', () => { + expect(formatDate(undefined)).toBe('N/A'); + expect(formatDate('')).toBe('N/A'); + expect(formatDate('not-a-date')).toBe('N/A'); + expect(formatDateTime(undefined)).toBe('N/A'); + expect(formatDateTime('not-a-date')).toBe('N/A'); + }); + + it('formats a valid ISO timestamp to a non-empty, non-N/A string', () => { + const date = formatDate('2023-06-15T12:00:00Z'); + expect(date).not.toBe('N/A'); + expect(date.length).toBeGreaterThan(0); + expect(formatDateTime('2023-06-15T12:00:00Z')).not.toBe('N/A'); + }); }); diff --git a/frontend/src/utils/formatters.ts b/frontend/src/utils/formatters.ts index 6375c00..c90f1cd 100644 --- a/frontend/src/utils/formatters.ts +++ b/frontend/src/utils/formatters.ts @@ -71,6 +71,28 @@ export function formatDuration(seconds: number): string { return parts.join(' '); } +function toValidDate(value?: string): Date | null { + if (!value) { + return null; + } + const date = new Date(value); + return Number.isNaN(date.getTime()) ? null : date; +} + +// Local Display Dates - Shared formatters for user-facing timestamps so every list/table renders +// them the same way, returning 'N/A' for empty or unparseable values. +export function formatDate(value?: string): string { + const date = toValidDate(value); + return date ? date.toLocaleDateString() : 'N/A'; +} + +export function formatDateTime(value?: string): string { + const date = toValidDate(value); + return date ? date.toLocaleString() : 'N/A'; +} + +// UTC Date - Intentionally UTC (not local): the reading graph buckets activity by UTC day, so +// converting to local time could shift a point to the wrong day. export function formatUtcDate(dateString: string): string { const date = new Date(dateString); const year = date.getUTCFullYear(); diff --git a/frontend/src/utils/localSettings.ts b/frontend/src/utils/localSettings.ts index 7eeed4f..263e531 100644 --- a/frontend/src/utils/localSettings.ts +++ b/frontend/src/utils/localSettings.ts @@ -101,7 +101,9 @@ export function useLocalSetting( key: K, defaultValue: LocalSettingsMap[K] ) { - const [value, setValue] = useState(() => readLocalSetting(key, defaultValue)); + const [value, setValue] = useState(() => + readLocalSetting(key, defaultValue) + ); useEffect(() => { const handleStorage = (event: StorageEvent) => { @@ -145,8 +147,3 @@ export function getReaderDevice(): { id: string; name: string } { return { id, name }; } - -export function setReaderDevice(name: string, id?: string): void { - writeLocalSetting('readerDeviceName', name); - writeLocalSetting('readerDeviceId', id ?? crypto.randomUUID()); -}