cleanup 7
continuous-integration/drone/pr Build is failing

This commit is contained in:
2026-07-03 20:22:26 -04:00
parent 457eb550e4
commit 1ff96ee9c8
24 changed files with 258 additions and 273 deletions
+2
View File
@@ -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. - 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`. - 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. - 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 ## 3) Generated API client
@@ -77,6 +78,7 @@ Also follow the repository root guide at `../AGENTS.md`.
- Run frontend tests with `pnpm run test`. - 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. - `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. - 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 <files>`) rather than reformatting the whole tree.
## 7) Live Dev Server Debugging ## 7) Live Dev Server Debugging
+9 -21
View File
@@ -4,6 +4,7 @@ import { useAuth } from '../auth/AuthContext';
import { UserIcon, DropdownIcon } from '../icons'; import { UserIcon, DropdownIcon } from '../icons';
import { useTheme } from '../theme/ThemeProvider'; import { useTheme } from '../theme/ThemeProvider';
import { THEME_MODES } from '../utils/localSettings'; import { THEME_MODES } from '../utils/localSettings';
import { SegmentedControl } from './SegmentedControl';
import HamburgerMenu from './HamburgerMenu'; import HamburgerMenu from './HamburgerMenu';
import { getPageTitle } from './navigation'; import { getPageTitle } from './navigation';
@@ -61,30 +62,17 @@ export default function Layout() {
{isUserDropdownOpen && ( {isUserDropdownOpen && (
<div className="absolute right-4 top-16 z-20 pt-4 transition duration-200"> <div className="absolute right-4 top-16 z-20 pt-4 transition duration-200">
<div className="w-64 origin-top-right rounded-md bg-surface shadow-lg ring-1 ring-border/30"> <div className="w-64 origin-top-right rounded-md bg-surface shadow-lg ring-1 ring-border/30">
<div <div className="border-b border-border px-4 py-3">
className="border-b border-border px-4 py-3"
role="group"
aria-label="Theme mode"
>
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-content-subtle"> <p className="mb-2 text-xs font-semibold uppercase tracking-wide text-content-subtle">
Theme Theme
</p> </p>
<div className="inline-flex w-full rounded border border-border bg-surface-muted p-1"> <SegmentedControl
{THEME_MODES.map(mode => ( className="w-full"
<button ariaLabel="Theme mode"
key={mode} value={themeMode}
type="button" onChange={setThemeMode}
onClick={() => setThemeMode(mode)} options={THEME_MODES.map(mode => ({ value: mode, label: mode }))}
className={`flex-1 rounded px-2 py-1 text-xs font-medium capitalize transition-colors ${ />
themeMode === mode
? 'bg-content text-content-inverse'
: 'text-content-muted hover:bg-surface hover:text-content'
}`}
>
{mode}
</button>
))}
</div>
</div> </div>
<div <div
className="py-1" className="py-1"
+3 -15
View File
@@ -47,9 +47,6 @@ function MyComponent() {
- `removeToast(id: string): void` - `removeToast(id: string): void`
- Manually remove a toast by ID - Manually remove a toast by ID
- `clearToasts(): void`
- Clear all active toasts
### Examples ### Examples
```tsx ```tsx
@@ -76,22 +73,13 @@ Skeleton components provide placeholder content while data is loading. They auto
#### `Skeleton` #### `Skeleton`
Basic skeleton element with various variants: A pulsing placeholder block. Size and shape are controlled with Tailwind classes via `className`:
```tsx ```tsx
import { Skeleton } from './components/Skeleton'; import { Skeleton } from './components/Skeleton';
// Default (rounded rectangle) <Skeleton className="h-8 w-full" />
<Skeleton className="w-full h-8" /> <Skeleton className="w-3/4" />
// Text variant
<Skeleton variant="text" className="w-3/4" />
// Circular variant (for avatars)
<Skeleton variant="circular" width={40} height={40} />
// Rectangular variant
<Skeleton variant="rectangular" width="100%" height={200} />
``` ```
#### `SkeletonTable` #### `SkeletonTable`
+47 -19
View File
@@ -10,39 +10,67 @@ interface SegmentedControlProps<T extends string> {
options: SegmentedOption<T>[]; options: SegmentedOption<T>[];
value: T; value: T;
onChange: (value: T) => void; onChange: (value: T) => void;
activeClassName: string; variant?: 'pill' | 'unstyled';
inactiveClassName: string;
className?: string; className?: string;
buttonClassName?: string; buttonClassName?: string;
activeClassName?: string;
inactiveClassName?: string;
ariaLabel?: 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<T extends string>({ export function SegmentedControl<T extends string>({
options, options,
value, value,
onChange, onChange,
activeClassName, variant = 'pill',
inactiveClassName,
className, className,
buttonClassName, buttonClassName,
activeClassName,
inactiveClassName,
ariaLabel, ariaLabel,
}: SegmentedControlProps<T>) { }: SegmentedControlProps<T>) {
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 ( return (
<div className={className} role="group" aria-label={ariaLabel}> <div className={styles.container} role="group" aria-label={ariaLabel}>
{options.map(option => ( {options.map(option => {
<button const isActive = value === option.value;
key={option.value} return (
type="button" <button
onClick={() => onChange(option.value)} key={option.value}
aria-pressed={value === option.value} type="button"
className={cn( onClick={() => {
buttonClassName, if (!isActive) onChange(option.value);
value === option.value ? activeClassName : inactiveClassName }}
)} aria-pressed={isActive}
> className={cn(styles.button, isActive ? styles.active : styles.inactive)}
{option.label} >
</button> {option.label}
))} </button>
);
})}
</div> </div>
); );
} }
+4 -43
View File
@@ -2,46 +2,10 @@ import { cn } from '../utils/cn';
interface SkeletonProps { interface SkeletonProps {
className?: string; className?: string;
variant?: 'default' | 'text' | 'circular' | 'rectangular';
width?: string | number;
height?: string | number;
animation?: 'pulse' | 'wave' | 'none';
} }
export function Skeleton({ export function Skeleton({ className }: SkeletonProps) {
className = '', return <div className={cn('h-4 animate-pulse rounded-md bg-surface-strong', 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 (
<div
className={cn(baseClasses, variantClasses[variant], animationClasses[animation], className)}
style={style}
/>
);
} }
interface SkeletonTableProps { interface SkeletonTableProps {
@@ -65,7 +29,7 @@ export function SkeletonTable({
<tr className="border-b border-border"> <tr className="border-b border-border">
{Array.from({ length: columns }).map((_, i) => ( {Array.from({ length: columns }).map((_, i) => (
<th key={i} className="p-3"> <th key={i} className="p-3">
<Skeleton variant="text" className="h-5 w-3/4" /> <Skeleton className="h-5 w-3/4" />
</th> </th>
))} ))}
</tr> </tr>
@@ -76,10 +40,7 @@ export function SkeletonTable({
<tr key={rowIndex} className="border-b border-border last:border-0"> <tr key={rowIndex} className="border-b border-border last:border-0">
{Array.from({ length: columns }).map((_, colIndex) => ( {Array.from({ length: columns }).map((_, colIndex) => (
<td key={colIndex} className="p-3"> <td key={colIndex} className="p-3">
<Skeleton <Skeleton className={colIndex === columns - 1 ? 'w-1/2' : 'w-full'} />
variant="text"
className={colIndex === columns - 1 ? 'w-1/2' : 'w-full'}
/>
</td> </td>
))} ))}
</tr> </tr>
+1 -6
View File
@@ -14,7 +14,6 @@ interface ToastContextType {
showError: (message: string, duration?: number) => string; showError: (message: string, duration?: number) => string;
updateToast: (id: string, update: ToastUpdate) => void; updateToast: (id: string, update: ToastUpdate) => void;
removeToast: (id: string) => void; removeToast: (id: string) => void;
clearToasts: () => void;
} }
const ToastContext = createContext<ToastContextType | undefined>(undefined); const ToastContext = createContext<ToastContextType | undefined>(undefined);
@@ -61,13 +60,9 @@ export function ToastProvider({ children }: { children: ReactNode }) {
setToasts(prev => prev.map(toast => (toast.id === id ? { ...toast, ...update } : toast))); setToasts(prev => prev.map(toast => (toast.id === id ? { ...toast, ...update } : toast)));
}, []); }, []);
const clearToasts = useCallback(() => {
setToasts([]);
}, []);
return ( return (
<ToastContext.Provider <ToastContext.Provider
value={{ showToast, showInfo, showWarning, showError, updateToast, removeToast, clearToasts }} value={{ showToast, showInfo, showWarning, showError, updateToast, removeToast }}
> >
{children} {children}
<ToastContainer toasts={toasts} /> <ToastContainer toasts={toasts} />
@@ -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<DocumentRow> = {
id: 'document',
header: 'Document',
render: row => (
<Link to={`/documents/${row.document_id}`} className="text-secondary-600 hover:underline">
{row.author || 'Unknown'} - {row.title || 'Unknown'}
</Link>
),
};
+15
View File
@@ -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 };
}
+38 -27
View File
@@ -14,7 +14,8 @@ const SWIPE_THRESHOLD = 25;
/** /**
* Registers touch / click-zone / wheel listeners on every rendered section. Returns a dispose * 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( export function registerRenditionGestures(
rendition: EpubRendition, rendition: EpubRendition,
@@ -25,6 +26,7 @@ export function registerRenditionGestures(
let touchEndX = 0; let touchEndX = 0;
let touchEndY = 0; let touchEndY = 0;
let wheelTimeoutId: ReturnType<typeof setTimeout> | null = null; let wheelTimeoutId: ReturnType<typeof setTimeout> | null = null;
const listenerCleanups: Array<() => void> = [];
const resetWheelCooldown = () => { const resetWheelCooldown = () => {
if (wheelTimeoutId) { if (wheelTimeoutId) {
@@ -68,14 +70,11 @@ export function registerRenditionGestures(
rendition.hooks.render.register((contents: EpubContents) => { rendition.hooks.render.register((contents: EpubContents) => {
const renderDoc = contents.document; const renderDoc = contents.document;
const wakeLockListener = () => { const onWakeLock = () => {
renderDoc.dispatchEvent(new CustomEvent('wakelock')); 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 windowWidth = window.innerWidth;
const windowHeight = window.innerHeight; const windowHeight = window.innerHeight;
const barPixels = windowHeight * 0.2; const barPixels = windowHeight * 0.2;
@@ -99,9 +98,9 @@ export function registerRenditionGestures(
} else { } else {
handlers.onCenterTap(); handlers.onCenterTap();
} }
}); };
renderDoc.addEventListener('wheel', (event: WheelEvent) => { const onWheel = (event: WheelEvent) => {
if (wheelTimeoutId) { if (wheelTimeoutId) {
return; return;
} }
@@ -113,26 +112,36 @@ export function registerRenditionGestures(
if (event.deltaY < -SWIPE_THRESHOLD) { if (event.deltaY < -SWIPE_THRESHOLD) {
handleSwipeDown(); 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 () => { return () => {
@@ -140,5 +149,7 @@ export function registerRenditionGestures(
clearTimeout(wheelTimeoutId); clearTimeout(wheelTimeoutId);
wheelTimeoutId = null; wheelTimeoutId = null;
} }
listenerCleanups.forEach(cleanup => cleanup());
listenerCleanups.length = 0;
}; };
} }
+7 -18
View File
@@ -1,10 +1,11 @@
import { useEffect, useState } from 'react'; import { useSearchParams } from 'react-router-dom';
import { Link, useSearchParams } from 'react-router-dom';
import { useGetActivity } from '../generated/anthoLumeAPIV1'; import { useGetActivity } from '../generated/anthoLumeAPIV1';
import type { Activity } from '../generated/model'; import type { Activity } from '../generated/model';
import { Pagination } from '../components'; import { Pagination } from '../components';
import { Table, type Column } from '../components/Table'; 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'; import { dataForStatus } from '../utils/apiResponses';
const ACTIVITY_PAGE_SIZE = 25; const ACTIVITY_PAGE_SIZE = 25;
@@ -12,13 +13,9 @@ const ACTIVITY_PAGE_SIZE = 25;
export default function ActivityPage() { export default function ActivityPage() {
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const documentID = searchParams.get('document') || undefined; const documentID = searchParams.get('document') || undefined;
const [page, setPage] = useState(1); const { page, setPage } = usePaginatedList(documentID);
const limit = ACTIVITY_PAGE_SIZE; const limit = ACTIVITY_PAGE_SIZE;
useEffect(() => {
setPage(1);
}, [documentID]);
const { data, isLoading } = useGetActivity({ const { data, isLoading } = useGetActivity({
doc_filter: Boolean(documentID), doc_filter: Boolean(documentID),
document_id: documentID, document_id: documentID,
@@ -29,19 +26,11 @@ export default function ActivityPage() {
const activities = response?.activities ?? []; const activities = response?.activities ?? [];
const columns: Column<Activity>[] = [ const columns: Column<Activity>[] = [
{ documentColumn,
id: 'document',
header: 'Document',
render: row => (
<Link to={`/documents/${row.document_id}`} className="text-secondary-600 hover:underline">
{row.author || 'Unknown'} - {row.title || 'Unknown'}
</Link>
),
},
{ {
id: 'start_time', id: 'start_time',
header: 'Time', header: 'Time',
render: row => row.start_time || 'N/A', render: row => formatDateTime(row.start_time),
}, },
{ {
id: 'duration', id: 'duration',
+7 -1
View File
@@ -7,6 +7,7 @@ import type { User } from '../generated/model';
import { AddIcon, DeleteIcon } from '../icons'; import { AddIcon, DeleteIcon } from '../icons';
import { useMutationWithToast } from '../hooks/useMutationWithToast'; import { useMutationWithToast } from '../hooks/useMutationWithToast';
import { useToasts } from '../components/ToastContext'; import { useToasts } from '../components/ToastContext';
import { formatDate } from '../utils/formatters';
import { dataForStatus } from '../utils/apiResponses'; import { dataForStatus } from '../utils/apiResponses';
interface AddUserFormProps { interface AddUserFormProps {
@@ -238,7 +239,12 @@ export default function AdminUsersPage() {
</div> </div>
), ),
}, },
{ 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) { if (isLoading) {
+21 -28
View File
@@ -122,21 +122,20 @@ export default function DocumentPage() {
return <LoadingState />; return <LoadingState />;
} }
const document = dataForStatus(docData, 200)?.document; const doc = dataForStatus(docData, 200)?.document;
if (!document) { if (!doc) {
return <div className="text-content-muted">Document not found</div>; return <div className="text-content-muted">Document not found</div>;
} }
const percentage = document.percentage ?? 0; const percentage = doc.percentage ?? 0;
const secondsPerPercent = document.seconds_per_percent || 0; const secondsPerPercent = doc.seconds_per_percent || 0;
const totalTimeLeftSeconds = Math.round((100 - percentage) * secondsPerPercent); const totalTimeLeftSeconds = Math.round((100 - percentage) * secondsPerPercent);
const save = (data: EditDocumentBody): Promise<boolean> => const save = (data: EditDocumentBody): Promise<boolean> =>
runWithToast(() => editMutation.mutateAsync({ id: document.id, data }), { runWithToast(() => editMutation.mutateAsync({ id: doc.id, data }), {
error: 'Failed to save', error: 'Failed to save',
onSuccess: response => onSuccess: response => queryClient.setQueryData(getGetDocumentQueryKey(doc.id), response),
queryClient.setQueryData(getGetDocumentQueryKey(document.id), response),
}); });
return ( return (
@@ -145,13 +144,13 @@ export default function DocumentPage() {
<div className="relative float-left mb-2 mr-4 flex w-44 flex-col gap-2 md:w-60 lg:w-80"> <div className="relative float-left mb-2 mr-4 flex w-44 flex-col gap-2 md:w-60 lg:w-80">
<img <img
className="w-full rounded object-fill" className="w-full rounded object-fill"
src={`/api/v1/documents/${document.id}/cover`} src={`/api/v1/documents/${doc.id}/cover`}
alt={`${document.title} cover`} alt={`${doc.title} cover`}
/> />
{document.filepath && ( {doc.filepath && (
<a <a
href={`/reader/${document.id}`} href={`/reader/${doc.id}`}
className="z-10 mt-2 w-full rounded bg-secondary-700 py-1 text-center text-sm font-medium text-secondary-foreground hover:bg-secondary-800 focus:outline-hidden focus:ring-4 focus:ring-secondary-500" className="z-10 mt-2 w-full rounded bg-secondary-700 py-1 text-center text-sm font-medium text-secondary-foreground hover:bg-secondary-800 focus:outline-hidden focus:ring-4 focus:ring-secondary-500"
> >
Read Read
@@ -162,26 +161,26 @@ export default function DocumentPage() {
<div className="min-w-[50%] md:mr-2"> <div className="min-w-[50%] md:mr-2">
<div className="flex gap-1 text-sm"> <div className="flex gap-1 text-sm">
<p className="text-content-muted">ISBN-10:</p> <p className="text-content-muted">ISBN-10:</p>
<p className="font-medium">{document.isbn10 || 'N/A'}</p> <p className="font-medium">{doc.isbn10 || 'N/A'}</p>
</div> </div>
<div className="flex gap-1 text-sm"> <div className="flex gap-1 text-sm">
<p className="text-content-muted">ISBN-13:</p> <p className="text-content-muted">ISBN-13:</p>
<p className="font-medium">{document.isbn13 || 'N/A'}</p> <p className="font-medium">{doc.isbn13 || 'N/A'}</p>
</div> </div>
</div> </div>
<div className="relative my-auto flex grow justify-between text-content-muted"> <div className="relative my-auto flex grow justify-between text-content-muted">
<a <a
href={`/activity?document=${document.id}`} href={`/activity?document=${doc.id}`}
aria-label="Activity" aria-label="Activity"
className={iconButtonClassName} className={iconButtonClassName}
> >
<ActivityIcon size={28} /> <ActivityIcon size={28} />
</a> </a>
{document.filepath ? ( {doc.filepath ? (
<a <a
href={`/api/v1/documents/${document.id}/file`} href={`/api/v1/documents/${doc.id}/file`}
aria-label="Download" aria-label="Download"
className={iconButtonClassName} className={iconButtonClassName}
> >
@@ -197,15 +196,11 @@ export default function DocumentPage() {
</div> </div>
<div className="grid justify-between gap-4 pb-4 sm:grid-cols-2"> <div className="grid justify-between gap-4 pb-4 sm:grid-cols-2">
<EditableField <EditableField label="Title" value={doc.title} onSave={value => save({ title: value })} />
label="Title"
value={document.title}
onSave={value => save({ title: value })}
/>
<EditableField <EditableField
label="Author" label="Author"
value={document.author} value={doc.author}
onSave={value => save({ author: value })} onSave={value => save({ author: value })}
/> />
@@ -234,9 +229,7 @@ export default function DocumentPage() {
</div> </div>
<div className="flex text-xs"> <div className="flex text-xs">
<p className="w-32 text-content-subtle">Words / Minute</p> <p className="w-32 text-content-subtle">Words / Minute</p>
<p className="font-medium"> <p className="font-medium">{doc.wpm && doc.wpm > 0 ? doc.wpm : 'N/A'}</p>
{document.wpm && document.wpm > 0 ? document.wpm : 'N/A'}
</p>
</div> </div>
<div className="flex text-xs"> <div className="flex text-xs">
<p className="w-32 text-content-subtle">Est. Time Left</p> <p className="w-32 text-content-subtle">Est. Time Left</p>
@@ -249,8 +242,8 @@ export default function DocumentPage() {
} }
> >
<FieldValue> <FieldValue>
{document.total_time_seconds && document.total_time_seconds > 0 {doc.total_time_seconds && doc.total_time_seconds > 0
? formatDuration(document.total_time_seconds) ? formatDuration(doc.total_time_seconds)
: 'N/A'} : 'N/A'}
</FieldValue> </FieldValue>
</Field> </Field>
@@ -262,7 +255,7 @@ export default function DocumentPage() {
<EditableField <EditableField
label="Description" label="Description"
value={document.description || ''} value={doc.description || ''}
multiline multiline
valueClassName="hyphens-auto text-justify" valueClassName="hyphens-auto text-justify"
onSave={value => save({ description: value })} onSave={value => save({ description: value })}
+3 -10
View File
@@ -1,4 +1,4 @@
import { useState, useRef, useEffect } from 'react'; import { useState, useRef } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { useGetDocuments, useCreateDocument } from '../generated/anthoLumeAPIV1'; import { useGetDocuments, useCreateDocument } from '../generated/anthoLumeAPIV1';
import type { Document } from '../generated/model'; import type { Document } from '../generated/model';
@@ -9,6 +9,7 @@ import { useMutationWithToast } from '../hooks/useMutationWithToast';
import { formatDuration } from '../utils/formatters'; import { formatDuration } from '../utils/formatters';
import { cn } from '../utils/cn'; import { cn } from '../utils/cn';
import { useDebouncedState } from '../hooks/useDebouncedState'; import { useDebouncedState } from '../hooks/useDebouncedState';
import { usePaginatedList } from '../hooks/usePaginatedList';
import { useLocalSetting, type DocumentsViewMode } from '../utils/localSettings'; import { useLocalSetting, type DocumentsViewMode } from '../utils/localSettings';
import { dataForStatus } from '../utils/apiResponses'; import { dataForStatus } from '../utils/apiResponses';
@@ -106,7 +107,7 @@ function EmptyDocuments({ className }: { className?: string }) {
export default function DocumentsPage() { export default function DocumentsPage() {
const [search, setSearch, debouncedSearch] = useDebouncedState('', 300); const [search, setSearch, debouncedSearch] = useDebouncedState('', 300);
const [page, setPage] = useState(1); const { page, setPage } = usePaginatedList(debouncedSearch);
const limit = DOCUMENTS_PAGE_SIZE; const limit = DOCUMENTS_PAGE_SIZE;
const [uploadMode, setUploadMode] = useState(false); const [uploadMode, setUploadMode] = useState(false);
const [viewMode, setViewMode] = useLocalSetting('documentsViewMode', 'grid'); const [viewMode, setViewMode] = useLocalSetting('documentsViewMode', 'grid');
@@ -114,10 +115,6 @@ export default function DocumentsPage() {
const { showWarning } = useToasts(); const { showWarning } = useToasts();
const toastMutationOptions = useMutationWithToast(); const toastMutationOptions = useMutationWithToast();
useEffect(() => {
setPage(1);
}, [debouncedSearch]);
const { data, isLoading, refetch } = useGetDocuments({ page, limit, search: debouncedSearch }); const { data, isLoading, refetch } = useGetDocuments({ page, limit, search: debouncedSearch });
const createMutation = useCreateDocument(); const createMutation = useCreateDocument();
const documentsResponse = dataForStatus(data, 200); const documentsResponse = dataForStatus(data, 200);
@@ -169,13 +166,9 @@ export default function DocumentsPage() {
</IconInput> </IconInput>
</div> </div>
<SegmentedControl<DocumentsViewMode> <SegmentedControl<DocumentsViewMode>
className="inline-flex rounded border border-border bg-surface p-1"
ariaLabel="Document view mode" ariaLabel="Document view mode"
value={viewMode} value={viewMode}
onChange={setViewMode} 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={[ options={[
{ value: 'grid', label: 'Grid' }, { value: 'grid', label: 'Grid' },
{ value: 'list', label: 'List' }, { value: 'list', label: 'List' },
+14 -34
View File
@@ -35,51 +35,39 @@ function InfoCard({ title, size, link }: InfoCardProps) {
} }
interface StreakCardProps { interface StreakCardProps {
window: 'DAY' | 'WEEK'; streak: UserStreak;
currentStreak: number;
currentStreakStartDate: string;
currentStreakEndDate: string;
maxStreak: number;
maxStreakStartDate: string;
maxStreakEndDate: string;
} }
function StreakCard({ function StreakCard({ streak }: StreakCardProps) {
window, const isWeekly = streak.window === 'WEEK';
currentStreak,
currentStreakStartDate,
currentStreakEndDate,
maxStreak,
maxStreakStartDate,
maxStreakEndDate,
}: StreakCardProps) {
return ( return (
<div className="w-full"> <div className="w-full">
<div className="relative w-full rounded bg-surface px-4 py-6 text-content shadow-lg"> <div className="relative w-full rounded bg-surface px-4 py-6 text-content shadow-lg">
<p className="w-max border-b border-border text-sm font-semibold text-content-muted"> <p className="w-max border-b border-border text-sm font-semibold text-content-muted">
{window === 'WEEK' ? 'Weekly Read Streak' : 'Daily Read Streak'} {isWeekly ? 'Weekly Read Streak' : 'Daily Read Streak'}
</p> </p>
<div className="my-6 flex items-end space-x-2"> <div className="my-6 flex items-end space-x-2">
<p className="text-5xl font-bold">{currentStreak}</p> <p className="text-5xl font-bold">{streak.current_streak}</p>
</div> </div>
<div> <div>
<div className="mb-2 flex items-center justify-between border-b border-border pb-2 text-sm"> <div className="mb-2 flex items-center justify-between border-b border-border pb-2 text-sm">
<div> <div>
<p>{window === 'WEEK' ? 'Current Weekly Streak' : 'Current Daily Streak'}</p> <p>{isWeekly ? 'Current Weekly Streak' : 'Current Daily Streak'}</p>
<div className="flex items-end text-sm text-content-subtle"> <div className="flex items-end text-sm text-content-subtle">
{currentStreakStartDate} {currentStreakEndDate} {streak.current_streak_start_date} {streak.current_streak_end_date}
</div> </div>
</div> </div>
<div className="flex items-end font-bold">{currentStreak}</div> <div className="flex items-end font-bold">{streak.current_streak}</div>
</div> </div>
<div className="mb-2 flex items-center justify-between pb-2 text-sm"> <div className="mb-2 flex items-center justify-between pb-2 text-sm">
<div> <div>
<p>{window === 'WEEK' ? 'Best Weekly Streak' : 'Best Daily Streak'}</p> <p>{isWeekly ? 'Best Weekly Streak' : 'Best Daily Streak'}</p>
<div className="flex items-end text-sm text-content-subtle"> <div className="flex items-end text-sm text-content-subtle">
{maxStreakStartDate} {maxStreakEndDate} {streak.max_streak_start_date} {streak.max_streak_end_date}
</div> </div>
</div> </div>
<div className="flex items-end font-bold">{maxStreak}</div> <div className="flex items-end font-bold">{streak.max_streak}</div>
</div> </div>
</div> </div>
</div> </div>
@@ -121,6 +109,7 @@ function LeaderboardCard({ name, data }: LeaderboardCardProps) {
{name} Leaderboard {name} Leaderboard
</p> </p>
<SegmentedControl<TimePeriod> <SegmentedControl<TimePeriod>
variant="unstyled"
className="flex items-center gap-2 text-xs" className="flex items-center gap-2 text-xs"
ariaLabel={`${name} leaderboard period`} ariaLabel={`${name} leaderboard period`}
value={selectedPeriod} value={selectedPeriod}
@@ -197,16 +186,7 @@ export default function HomePage() {
<div className="grid grid-cols-1 gap-4 md:grid-cols-2"> <div className="grid grid-cols-1 gap-4 md:grid-cols-2">
{streaks?.map((streak: UserStreak) => ( {streaks?.map((streak: UserStreak) => (
<StreakCard <StreakCard key={streak.window} streak={streak} />
key={streak.window}
window={streak.window as 'DAY' | 'WEEK'}
currentStreak={streak.current_streak}
currentStreakStartDate={streak.current_streak_start_date}
currentStreakEndDate={streak.current_streak_end_date}
maxStreak={streak.max_streak}
maxStreakStartDate={streak.max_streak_start_date}
maxStreakEndDate={streak.max_streak_end_date}
/>
))} ))}
</div> </div>
-2
View File
@@ -53,7 +53,6 @@ describe('LoginPage', () => {
showError: vi.fn(), showError: vi.fn(),
updateToast: vi.fn(), updateToast: vi.fn(),
removeToast: vi.fn(), removeToast: vi.fn(),
clearToasts: vi.fn(),
}); });
mockedUseGetInfo.mockReturnValue({ mockedUseGetInfo.mockReturnValue({
@@ -115,7 +114,6 @@ describe('LoginPage', () => {
showError: showErrorMock, showError: showErrorMock,
updateToast: vi.fn(), updateToast: vi.fn(),
removeToast: vi.fn(), removeToast: vi.fn(),
clearToasts: vi.fn(),
}); });
render( render(
+6 -13
View File
@@ -1,30 +1,23 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { useGetProgressList } from '../generated/anthoLumeAPIV1'; import { useGetProgressList } from '../generated/anthoLumeAPIV1';
import type { Progress } from '../generated/model'; import type { Progress } from '../generated/model';
import { Pagination } from '../components'; import { Pagination } from '../components';
import { Table, type Column } from '../components/Table'; 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'; import { dataForStatus } from '../utils/apiResponses';
const PROGRESS_PAGE_SIZE = 15; const PROGRESS_PAGE_SIZE = 15;
export default function ProgressPage() { export default function ProgressPage() {
const [page, setPage] = useState(1); const { page, setPage } = usePaginatedList();
const limit = PROGRESS_PAGE_SIZE; const limit = PROGRESS_PAGE_SIZE;
const { data, isLoading } = useGetProgressList({ page, limit }); const { data, isLoading } = useGetProgressList({ page, limit });
const response = dataForStatus(data, 200); const response = dataForStatus(data, 200);
const progress = response?.progress ?? []; const progress = response?.progress ?? [];
const columns: Column<Progress>[] = [ const columns: Column<Progress>[] = [
{ documentColumn,
id: 'document',
header: 'Document',
render: row => (
<Link to={`/documents/${row.document_id}`} className="text-secondary-600 hover:underline">
{row.author || 'Unknown'} - {row.title || 'Unknown'}
</Link>
),
},
{ {
id: 'device_name', id: 'device_name',
header: 'Device Name', header: 'Device Name',
@@ -38,7 +31,7 @@ export default function ProgressPage() {
{ {
id: 'created_at', id: 'created_at',
header: 'Created At', header: 'Created At',
render: row => (row.created_at ? new Date(row.created_at).toLocaleDateString() : 'N/A'), render: row => formatDate(row.created_at),
}, },
]; ];
+14 -12
View File
@@ -40,7 +40,7 @@ export default function ReaderPage() {
enabled: Boolean(id), enabled: Boolean(id),
}, },
}); });
const document = dataForStatus(documentResponse, 200)?.document; const doc = dataForStatus(documentResponse, 200)?.document;
const progress = dataForStatus(progressResponse, 200)?.progress; const progress = dataForStatus(progressResponse, 200)?.progress;
const deviceId = defaultDeviceId; const deviceId = defaultDeviceId;
@@ -89,17 +89,17 @@ export default function ReaderPage() {
}); });
useEffect(() => { useEffect(() => {
if (document?.title) { if (doc?.title) {
window.document.title = `AnthoLume - Reader - ${document.title}`; document.title = `AnthoLume - Reader - ${doc.title}`;
} }
}, [document?.title]); }, [doc?.title]);
useEffect(() => { useEffect(() => {
if (isTopBarOpen || isBottomBarOpen) { if (isTopBarOpen || isBottomBarOpen) {
return; return;
} }
const activeElement = window.document.activeElement; const activeElement = document.activeElement;
if (activeElement instanceof HTMLElement) { if (activeElement instanceof HTMLElement) {
activeElement.blur(); activeElement.blur();
} }
@@ -109,7 +109,7 @@ export default function ReaderPage() {
return <LoadingState className="min-h-screen bg-canvas" message="Loading reader..." />; return <LoadingState className="min-h-screen bg-canvas" message="Loading reader..." />;
} }
if (!id || !document) { if (!id || !doc) {
return <div className="p-6 text-content-muted">Document not found</div>; return <div className="p-6 text-content-muted">Document not found</div>;
} }
@@ -124,24 +124,24 @@ export default function ReaderPage() {
<div className="mx-auto flex max-h-[70vh] min-h-0 w-full max-w-6xl flex-col gap-4 p-4"> <div className="mx-auto flex max-h-[70vh] min-h-0 w-full max-w-6xl flex-col gap-4 p-4">
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
<div className="flex min-w-0 items-start gap-4"> <div className="flex min-w-0 items-start gap-4">
<Link to={`/documents/${document.id}`} className="block shrink-0"> <Link to={`/documents/${doc.id}`} className="block shrink-0">
<img <img
className="h-28 w-20 rounded object-cover shadow-sm" className="h-28 w-20 rounded object-cover shadow-sm"
src={`/api/v1/documents/${document.id}/cover`} src={`/api/v1/documents/${doc.id}/cover`}
alt={`${document.title} cover`} alt={`${doc.title} cover`}
/> />
</Link> </Link>
<div className="min-w-0"> <div className="min-w-0">
<p className="text-xs uppercase tracking-wide text-content-subtle">Title</p> <p className="text-xs uppercase tracking-wide text-content-subtle">Title</p>
<p className="truncate text-lg font-semibold text-content">{document.title}</p> <p className="truncate text-lg font-semibold text-content">{doc.title}</p>
<p className="mt-3 text-xs uppercase tracking-wide text-content-subtle">Author</p> <p className="mt-3 text-xs uppercase tracking-wide text-content-subtle">Author</p>
<p className="truncate text-sm text-content-muted">{document.author}</p> <p className="truncate text-sm text-content-muted">{doc.author}</p>
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Link <Link
to={`/documents/${document.id}`} to={`/documents/${doc.id}`}
className="rounded border border-border px-3 py-2 text-sm text-content-muted hover:bg-surface-muted hover:text-content" className="rounded border border-border px-3 py-2 text-sm text-content-muted hover:bg-surface-muted hover:text-content"
> >
Back Back
@@ -224,6 +224,7 @@ export default function ReaderPage() {
Theme Theme
</p> </p>
<SegmentedControl<ReaderColorScheme> <SegmentedControl<ReaderColorScheme>
variant="unstyled"
className="grid w-full grid-cols-2 gap-1.5 sm:grid-cols-3 lg:grid-cols-5" className="grid w-full grid-cols-2 gap-1.5 sm:grid-cols-3 lg:grid-cols-5"
ariaLabel="Reader theme" ariaLabel="Reader theme"
value={colorScheme} value={colorScheme}
@@ -238,6 +239,7 @@ export default function ReaderPage() {
<div className="min-w-0"> <div className="min-w-0">
<p className="mb-1 text-[10px] uppercase tracking-wide text-content-subtle">Font</p> <p className="mb-1 text-[10px] uppercase tracking-wide text-content-subtle">Font</p>
<SegmentedControl<ReaderFontFamily> <SegmentedControl<ReaderFontFamily>
variant="unstyled"
className="grid w-full grid-cols-1 gap-1.5 sm:grid-cols-2 lg:grid-cols-4" className="grid w-full grid-cols-1 gap-1.5 sm:grid-cols-2 lg:grid-cols-4"
ariaLabel="Reader font" ariaLabel="Reader font"
value={fontFamily} value={fontFamily}
-2
View File
@@ -53,7 +53,6 @@ describe('RegisterPage', () => {
showError: vi.fn(), showError: vi.fn(),
updateToast: vi.fn(), updateToast: vi.fn(),
removeToast: vi.fn(), removeToast: vi.fn(),
clearToasts: vi.fn(),
}); });
mockedUseGetInfo.mockReturnValue({ mockedUseGetInfo.mockReturnValue({
@@ -116,7 +115,6 @@ describe('RegisterPage', () => {
showError: showErrorMock, showError: showErrorMock,
updateToast: vi.fn(), updateToast: vi.fn(),
removeToast: vi.fn(), removeToast: vi.fn(),
clearToasts: vi.fn(),
}); });
render( render(
+2 -1
View File
@@ -6,6 +6,7 @@ import { Button, Table, type Column, TextInput, IconInput } from '../components'
import { inputClassName } from '../components/TextInput'; import { inputClassName } from '../components/TextInput';
import { useDebouncedState } from '../hooks/useDebouncedState'; import { useDebouncedState } from '../hooks/useDebouncedState';
import { Search2Icon, BookIcon } from '../icons'; import { Search2Icon, BookIcon } from '../icons';
import { formatDate } from '../utils/formatters';
import { dataForStatus } from '../utils/apiResponses'; import { dataForStatus } from '../utils/apiResponses';
const searchColumns: Column<SearchItem>[] = [ const searchColumns: Column<SearchItem>[] = [
@@ -21,7 +22,7 @@ const searchColumns: Column<SearchItem>[] = [
id: 'date', id: 'date',
header: 'Date', header: 'Date',
className: 'hidden md:table-cell', className: 'hidden md:table-cell',
render: item => item.upload_date || 'N/A', render: item => formatDate(item.upload_date),
}, },
]; ];
+3 -4
View File
@@ -8,10 +8,9 @@ import { useToasts } from '../components/ToastContext';
import { useMutationWithToast, useToastMutation } from '../hooks/useMutationWithToast'; import { useMutationWithToast, useToastMutation } from '../hooks/useMutationWithToast';
import { useTheme } from '../theme/ThemeProvider'; import { useTheme } from '../theme/ThemeProvider';
import type { ThemeMode } from '../utils/localSettings'; import type { ThemeMode } from '../utils/localSettings';
import { formatDateTime } from '../utils/formatters';
import { dataForStatus } from '../utils/apiResponses'; import { dataForStatus } from '../utils/apiResponses';
const formatDeviceDate = (value?: string) => (value ? new Date(value).toLocaleString() : 'N/A');
const deviceColumns: Column<Device>[] = [ const deviceColumns: Column<Device>[] = [
{ {
id: 'name', id: 'name',
@@ -22,9 +21,9 @@ const deviceColumns: Column<Device>[] = [
{ {
id: 'last_synced', id: 'last_synced',
header: 'Last Sync', 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 }> = [ const themeModes: Array<{ value: ThemeMode; label: string; description: string }> = [
-8
View File
@@ -11,11 +11,3 @@ export function dataForStatus<TResponse extends ApiResponse, TStatus extends TRe
? (response.data as Extract<TResponse, { status: TStatus }>['data']) ? (response.data as Extract<TResponse, { status: TStatus }>['data'])
: undefined; : undefined;
} }
export function dataForSuccess<TResponse extends ApiResponse>(
response: TResponse | undefined
): Extract<TResponse, { status: 200 | 201 | 202 | 204 }>['data'] | undefined {
return response && response.status >= 200 && response.status < 300
? (response.data as Extract<TResponse, { status: 200 | 201 | 202 | 204 }>['data'])
: undefined;
}
+18 -3
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { formatNumber, formatDuration } from './formatters'; import { formatNumber, formatDuration, formatDate, formatDateTime } from './formatters';
describe('formatNumber', () => { describe('formatNumber', () => {
it('formats zero', () => { it('formats zero', () => {
@@ -33,7 +33,6 @@ describe('formatNumber', () => {
expect(formatNumber(-12345)).toBe('-12.3k'); expect(formatNumber(-12345)).toBe('-12.3k');
expect(formatNumber(-1500000)).toBe('-1.50M'); expect(formatNumber(-1500000)).toBe('-1.50M');
}); });
}); });
describe('formatDuration', () => { describe('formatDuration', () => {
@@ -61,5 +60,21 @@ describe('formatDuration', () => {
it('formats days, hours, minutes, and seconds', () => { it('formats days, hours, minutes, and seconds', () => {
expect(formatDuration(1928371)).toBe('22d 7h 39m 31s'); 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');
});
}); });
+22
View File
@@ -71,6 +71,28 @@ export function formatDuration(seconds: number): string {
return parts.join(' '); 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 { export function formatUtcDate(dateString: string): string {
const date = new Date(dateString); const date = new Date(dateString);
const year = date.getUTCFullYear(); const year = date.getUTCFullYear();
+3 -6
View File
@@ -101,7 +101,9 @@ export function useLocalSetting<K extends LocalSettingKey>(
key: K, key: K,
defaultValue: LocalSettingsMap[K] defaultValue: LocalSettingsMap[K]
) { ) {
const [value, setValue] = useState<LocalSettingsMap[K]>(() => readLocalSetting(key, defaultValue)); const [value, setValue] = useState<LocalSettingsMap[K]>(() =>
readLocalSetting(key, defaultValue)
);
useEffect(() => { useEffect(() => {
const handleStorage = (event: StorageEvent) => { const handleStorage = (event: StorageEvent) => {
@@ -145,8 +147,3 @@ export function getReaderDevice(): { id: string; name: string } {
return { id, name }; return { id, name };
} }
export function setReaderDevice(name: string, id?: string): void {
writeLocalSetting('readerDeviceName', name);
writeLocalSetting('readerDeviceId', id ?? crypto.randomUUID());
}