feat(doclist): add recently opened sorting and metadata to documents list

Introduce recentlyOpenedAt field to document types and fetch per-document
recently opened timestamps from local cache. Update the "Recently Opened"
sidebar filter to sort by this value and show only documents with recent
activity. Adjust grid layout logic for icons view to better handle small
numbers of documents. Update sidebar label for clarity.
This commit is contained in:
Richard R 2026-05-27 23:13:53 -06:00
parent 13e0647b47
commit 495c197c4c
5 changed files with 81 additions and 16 deletions

View file

@ -12,7 +12,11 @@ import type {
SortDirection,
ViewMode,
} from '@/types/documents';
import { getDocumentListState, saveDocumentListState } from '@/lib/client/dexie';
import {
getDocumentListState,
getDocumentRecentlyOpenedMap,
saveDocumentListState,
} from '@/lib/client/dexie';
import { ConfirmDialog } from '@/components/ConfirmDialog';
import { CreateFolderDialog } from '@/components/doclist/CreateFolderDialog';
import { DocumentListSkeleton } from '@/components/doclist/DocumentListSkeleton';
@ -126,6 +130,7 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) {
const [sidebarOpen, setSidebarOpen] = useState(true);
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
const [query, setQuery] = useState('');
const [recentlyOpenedById, setRecentlyOpenedById] = useState<Record<string, number>>({});
const [isInitialized, setIsInitialized] = useState(false);
@ -214,7 +219,7 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) {
}, [isNarrow]);
// Build the union document list.
const allDocuments: DocumentListDocument[] = useMemo(
const rawDocuments: DocumentListDocument[] = useMemo(
() => [
...pdfDocs.map((d) => ({ ...d, type: 'pdf' as const })),
...epubDocs.map((d) => ({ ...d, type: 'epub' as const })),
@ -222,6 +227,35 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) {
],
[pdfDocs, epubDocs, htmlDocs],
);
const rawDocumentIdsKey = useMemo(
() => rawDocuments.map((d) => d.id).sort().join('|'),
[rawDocuments],
);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const recentMap = await getDocumentRecentlyOpenedMap();
if (cancelled) return;
setRecentlyOpenedById(recentMap);
} catch (err) {
console.warn('Failed to load recently opened cache metadata:', err);
}
})();
return () => {
cancelled = true;
};
}, [rawDocumentIdsKey]);
const allDocuments: DocumentListDocument[] = useMemo(
() =>
rawDocuments.map((doc) => ({
...doc,
recentlyOpenedAt: recentlyOpenedById[doc.id] ?? 0,
})),
[rawDocuments, recentlyOpenedById],
);
// Reconcile folders against server.
useEffect(() => {
@ -244,7 +278,8 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) {
else if (sidebarFilter === 'html') docs = docs.filter((d) => d.type === 'html');
else if (sidebarFilter === 'recents') {
docs = [...docs]
.sort((a, b) => b.lastModified - a.lastModified)
.filter((d) => (d.recentlyOpenedAt ?? 0) > 0)
.sort((a, b) => (b.recentlyOpenedAt ?? 0) - (a.recentlyOpenedAt ?? 0))
.slice(0, 20);
} else if (sidebarFilter.startsWith('folder:')) {
const fid = sidebarFilter.slice('folder:'.length);
@ -256,14 +291,13 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) {
}, [allDocuments, sidebarFilter, query, folders]);
// Apply sort.
const sortedVisible = useMemo(
() => sortDocs(visibleAll, sortBy, sortDirection),
[visibleAll, sortBy, sortDirection],
);
const sortedVisible = useMemo(() => {
if (sidebarFilter === 'recents') return visibleAll;
return sortDocs(visibleAll, sortBy, sortDirection);
}, [visibleAll, sidebarFilter, sortBy, sortDirection]);
// Split into folders + unfoldered for the current filter context.
const showAllScope =
sidebarFilter === 'all' || sidebarFilter === 'recents';
const showAllScope = sidebarFilter === 'all';
const visibleFolders = useMemo<Folder[]>(() => {
if (!showAllScope) return [];
// Within a kind-filter (pdf/epub/html), still show folders that contain matches.
@ -280,10 +314,11 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) {
}, [folders, showAllScope, query, sortBy, sortDirection]);
const unfolderedDocs = useMemo(() => {
if (sidebarFilter === 'recents') return sortedVisible;
const inFolder = new Set<string>();
folders.forEach((f) => f.documents.forEach((d) => inFolder.add(d.id)));
return sortedVisible.filter((d) => !inFolder.has(d.id));
}, [folders, sortedVisible]);
}, [folders, sidebarFilter, sortedVisible]);
const counts = useMemo(
() => ({

View file

@ -34,17 +34,23 @@ const TILE_WIDTH_PX: Record<IconSize, number> = {
xl: 192,
};
function responsiveGridTemplate(iconSize: IconSize): string {
const SMALL_GRID_ITEM_COUNT = 3;
function responsiveGridTemplate(iconSize: IconSize, itemCount: number): string {
const width = TILE_WIDTH_PX[iconSize];
if (itemCount <= SMALL_GRID_ITEM_COUNT) {
return `repeat(auto-fill, minmax(${width}px, ${width}px))`;
}
return `repeat(auto-fit, minmax(${width}px, 1fr))`;
}
const gridGap = '12px';
function gridStyle(iconSize: IconSize): React.CSSProperties {
function gridStyle(iconSize: IconSize, itemCount: number): React.CSSProperties {
return {
gridTemplateColumns: responsiveGridTemplate(iconSize),
gridTemplateColumns: responsiveGridTemplate(iconSize, itemCount),
gap: gridGap,
justifyContent: itemCount <= SMALL_GRID_ITEM_COUNT ? 'start' : undefined,
};
}
@ -136,7 +142,7 @@ function FolderGridRow({
>
<div
className="grid p-2"
style={gridStyle(iconSize)}
style={gridStyle(iconSize, folder.documents.length)}
>
{folder.documents.map((doc) => (
<DocumentTile
@ -186,7 +192,7 @@ export function IconsView({
>
<div
className="grid"
style={gridStyle(iconSize)}
style={gridStyle(iconSize, unfolderedDocs.length)}
>
{folders.map((folder) => (
<FolderGridRow

View file

@ -179,7 +179,7 @@ export function FinderSidebar({
active={filter === 'recents'}
onClick={() => onFilterChange('recents')}
icon={<ClockIcon className="w-3.5 h-3.5" />}
label="Recents"
label="Recently Opened"
/>
<SectionLabel>Kinds</SectionLabel>

View file

@ -825,6 +825,29 @@ export async function clearHtmlDocuments(): Promise<void> {
});
}
export async function getDocumentRecentlyOpenedMap(): Promise<Record<string, number>> {
return withDB(async () => {
const [pdfRows, epubRows, htmlRows] = await Promise.all([
db[PDF_TABLE].toArray(),
db[EPUB_TABLE].toArray(),
db[HTML_TABLE].toArray(),
]);
const byId: Record<string, number> = {};
const write = (id: string, ts: unknown) => {
const value = toPositiveInt(ts, 0);
if (value <= 0) return;
if (!byId[id] || value > byId[id]) byId[id] = value;
};
pdfRows.forEach((row) => write(row.id, row.cacheAccessedAt));
epubRows.forEach((row) => write(row.id, row.cacheAccessedAt));
htmlRows.forEach((row) => write(row.id, row.cacheAccessedAt));
return byId;
});
}
export async function getAppConfig(): Promise<AppConfigRow | null> {
return withDB(async () => {
const row = await db[APP_CONFIG_TABLE].get('singleton');

View file

@ -5,6 +5,7 @@ export interface BaseDocument {
name: string;
size: number;
lastModified: number;
recentlyOpenedAt?: number;
type: DocumentType;
parseStatus?: 'pending' | 'running' | 'ready' | 'failed' | null;
parsedJsonKey?: string | null;