From 9e8f3c55d6ef8e2e30cc38e2a8c7fcb63ee748d5 Mon Sep 17 00:00:00 2001 From: nh9961 Date: Sun, 19 Jul 2026 21:00:51 +0000 Subject: [PATCH] perf: logbook memoization and pagination, debounced realtime, lazy exceljs - Precompute per-entry spend/caffeine/sugar map; paginate ledger at 50 rows - Debounce realtime refetches (500ms trailing) and suppress during bulk import - Show import progress (Importing X of N) - Dynamic-import exceljs: entry chunk 1640 -> 700 kB - Skeleton placeholders for initial load on Overview/Trends/Logbook --- src/App.tsx | 48 +++++++++-- src/components/EntryLedger.tsx | 114 +++++++++++++++++++------- src/components/ImportPreviewModal.tsx | 11 ++- src/components/Skeleton.tsx | 7 ++ src/components/views/LogbookView.tsx | 9 +- src/components/views/OverviewView.tsx | 42 +++++++--- src/components/views/TrendsView.tsx | 16 +++- src/index.css | 23 ++++++ src/lib/appwriteEntries.ts | 9 +- src/lib/excel.ts | 4 +- src/lib/metrics.ts | 14 +++- src/types.ts | 11 +++ 12 files changed, 247 insertions(+), 61 deletions(-) create mode 100644 src/components/Skeleton.tsx diff --git a/src/App.tsx b/src/App.tsx index 9f37d95..7b6e955 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -51,7 +51,7 @@ import { parseUserLimits, } from "./lib/userLimits"; import { createExcelExport, downloadBlob, parseExcelImport } from "./lib/excel"; -import { groupByDay, groupByFlavour, groupByWeek, humanDateTime, makeId } from "./lib/metrics"; +import { buildEntryDerived, groupByDay, groupByFlavour, groupByWeek, humanDateTime, makeId } from "./lib/metrics"; import { exportPayload, parseImport } from "./lib/storage"; import { useToasts } from "./lib/toasts"; import type { @@ -60,6 +60,7 @@ import type { EntryDraft, Filters, ImportPreview, + ImportProgress, RedBullEntry, SetupStatus, UserLimits, @@ -101,6 +102,7 @@ function App() { const [busyAction, setBusyAction] = useState(null); const [syncError, setSyncError] = useState(""); const [importPreview, setImportPreview] = useState(null); + const [importProgress, setImportProgress] = useState(null); const [userLimits, setUserLimits] = useState({}); const [limitConfirmOpen, setLimitConfirmOpen] = useState(false); const [limitConfirmMessage, setLimitConfirmMessage] = useState(""); @@ -110,6 +112,8 @@ function App() { const { pushToast } = useToasts(); const excelFileInputRef = useRef(null); const jsonFileInputRef = useRef(null); + const importInFlightRef = useRef(false); + const refreshDebounceRef = useRef(null); useEffect(() => { localStorage.setItem(THEME_STORAGE_KEY, normaliseThemeId(themeId)); @@ -203,16 +207,33 @@ function App() { useEffect(() => { if (!user) return undefined; + // Trailing debounce: collapse bursts of row events (e.g. bulk imports) into one refetch. + const scheduleRefresh = () => { + if (refreshDebounceRef.current !== null) { + window.clearTimeout(refreshDebounceRef.current); + } + refreshDebounceRef.current = window.setTimeout(() => { + refreshDebounceRef.current = null; + void refreshEntries(user.$id, false); + }, 500); + }; + const unsubscribe = client.subscribe>( Channel.tablesdb(appwriteConfig.databaseId).table(appwriteConfig.collectionId).row(), (event) => { - if (event.payload?.userId === user.$id) { - void refreshEntries(user.$id, false); - } + // Bulk imports update local state directly; their row events need no refetch. + if (importInFlightRef.current) return; + if (event.payload?.userId === user.$id) scheduleRefresh(); }, ); - return () => unsubscribe(); + return () => { + if (refreshDebounceRef.current !== null) { + window.clearTimeout(refreshDebounceRef.current); + refreshDebounceRef.current = null; + } + unsubscribe(); + }; }, [refreshEntries, user]); useEffect(() => { @@ -227,6 +248,7 @@ function App() { () => mergedFlavours(entries.map((entry) => entry.flavour)), [entries], ); + const entryDerived = useMemo(() => buildEntryDerived(entries), [entries]); const entriesInView = useMemo( () => sortEntries(applyFilters(entries, filters)), [entries, filters], @@ -538,14 +560,18 @@ function App() { setBusyAction("confirm-excel-import"); setSyncError(""); + importInFlightRef.current = true; + setImportProgress({ done: 0, total: drafts.length }); try { - const saved = await createEntries(user.$id, drafts); + const saved = await createEntries(user.$id, drafts, (done, total) => setImportProgress({ done, total })); setEntries((current) => sortEntries([...saved, ...current])); setImportPreview(null); setNotice(`${saved.length} Excel row${saved.length === 1 ? "" : "s"} saved to Appwrite.`); } catch (error) { setSyncError(appwriteErrorMessage(error)); } finally { + importInFlightRef.current = false; + setImportProgress(null); setBusyAction(null); } } @@ -567,12 +593,13 @@ function App() { setNotice("No new JSON entries found."); return; } + importInFlightRef.current = true; const saved = await createEntries(user.$id, uniqueDrafts.map((draft) => ({ ...draft, source: "json" }))); - setEntries((current) => sortEntries([...saved, ...current])); - setNotice(`${saved.length} JSON entr${saved.length === 1 ? "y" : "ies"} saved to Appwrite.`); + setEntries((current) => sortEntries([...saved, ...current])); setNotice(`${saved.length} JSON entr${saved.length === 1 ? "y" : "ies"} saved to Appwrite.`); } catch (error) { setSyncError(error instanceof Error ? error.message : "JSON import failed."); } finally { + importInFlightRef.current = false; if (jsonFileInputRef.current) jsonFileInputRef.current.value = ""; setBusyAction(null); } @@ -675,6 +702,7 @@ function App() { user={user} userLimits={userLimits} limitCheck={limitCheck} + loading={dataLoading} onQuickAdd={(item) => void quickAdd(item)} onAdd={openNewEntry} onScan={openBarcodeScanner} @@ -687,8 +715,10 @@ function App() { void saveUserLimits(next)} /> @@ -781,6 +812,7 @@ function App() { setImportPreview(null)} onConfirm={() => void confirmExcelImport()} /> diff --git a/src/components/EntryLedger.tsx b/src/components/EntryLedger.tsx index 378a0b6..6283a38 100644 --- a/src/components/EntryLedger.tsx +++ b/src/components/EntryLedger.tsx @@ -1,5 +1,5 @@ import { Edit3, Loader2, RotateCcw, Trash2 } from "lucide-react"; -import { useMemo, type CSSProperties } from "react"; +import { useEffect, useMemo, useState, type CSSProperties } from "react"; import { caffeineFor, currency, @@ -11,11 +11,18 @@ import { } from "../lib/metrics"; import { AppCard } from "./AppCard"; import { EmptyState } from "./EmptyState"; -import type { RedBullEntry } from "../types"; +import { Skeleton } from "./Skeleton"; +import type { EntryDerived, Filters, RedBullEntry } from "../types"; + +const PAGE_SIZE = 50; +const SKELETON_ROWS = 6; type EntryLedgerProps = { entries: RedBullEntry[]; totalEntries: number; + entryDerived: Map; + filters: Filters; + loading: boolean; deletingId: string | null; onAdd: () => void; onEdit: (entry: RedBullEntry) => void; @@ -32,36 +39,74 @@ type DayGroup = { entries: RedBullEntry[]; }; -export function EntryLedger({ entries, totalEntries, deletingId, onAdd, onEdit, onDelete, onLogAgain }: EntryLedgerProps) { - const dayGroups = useMemo(() => groupEntriesByDay(entries), [entries]); +export function EntryLedger({ + entries, + totalEntries, + entryDerived, + filters, + loading, + deletingId, + onAdd, + onEdit, + onDelete, + onLogAgain, +}: EntryLedgerProps) { + const [visibleCount, setVisibleCount] = useState(PAGE_SIZE); + + useEffect(() => { + setVisibleCount(PAGE_SIZE); + }, [filters]); + + const visibleEntries = useMemo(() => entries.slice(0, visibleCount), [entries, visibleCount]); + const dayGroups = useMemo(() => groupEntriesByDay(visibleEntries, entryDerived), [visibleEntries, entryDerived]); + const showSkeleton = loading && entries.length === 0; + const remaining = entries.length - visibleEntries.length; return ( - {entries.length ? ( -
- {dayGroups.map((group) => ( -
-
-

{group.label}

-

- {oneDecimal.format(group.cans)} can{group.cans === 1 ? "" : "s"} · {wholeNumber.format(group.caffeine)}mg · {currency.format(group.spend)} -

-
-
- {group.entries.map((entry) => ( - - ))} -
-
+ {showSkeleton ? ( +
+ {Array.from({ length: SKELETON_ROWS }, (_, index) => ( + ))}
+ ) : entries.length ? ( + <> +
+ {dayGroups.map((group) => ( +
+
+

{group.label}

+

+ {oneDecimal.format(group.cans)} can{group.cans === 1 ? "" : "s"} · {wholeNumber.format(group.caffeine)}mg · {currency.format(group.spend)} +

+
+
+ {group.entries.map((entry) => ( + + ))} +
+
+ ))} +
+ {remaining > 0 ? ( + + ) : null} + ) : ( )} @@ -71,12 +116,14 @@ export function EntryLedger({ entries, totalEntries, deletingId, onAdd, onEdit, function EntryRow({ entry, + derived, deleting, onEdit, onDelete, onLogAgain, }: { entry: RedBullEntry; + derived: EntryDerived; deleting: boolean; onEdit: (entry: RedBullEntry) => void; onDelete: (id: string) => void; @@ -98,7 +145,7 @@ function EntryRow({ {entry.store ? ` · ${entry.store}` : ""}

- {currency.format(spendFor(entry))} · {wholeNumber.format(caffeineFor(entry))}mg caffeine · {oneDecimal.format(sugarFor(entry))}g sugar + {currency.format(derived.spend)} · {wholeNumber.format(derived.caffeine)}mg caffeine · {oneDecimal.format(derived.sugar)}g sugar

{entry.notes ?

{entry.notes}

: null}
@@ -127,7 +174,7 @@ function EntryRow({ ); } -function groupEntriesByDay(entries: RedBullEntry[]): DayGroup[] { +function groupEntriesByDay(entries: RedBullEntry[], entryDerived: Map): DayGroup[] { const groups: DayGroup[] = []; const byKey = new Map(); @@ -140,15 +187,20 @@ function groupEntriesByDay(entries: RedBullEntry[]): DayGroup[] { byKey.set(key, group); groups.push(group); } + const derived = derivedFor(entryDerived, entry); group.entries.push(entry); group.cans += entry.cans; - group.caffeine += caffeineFor(entry); - group.spend += spendFor(entry); + group.caffeine += derived.caffeine; + group.spend += derived.spend; }); return groups; } +function derivedFor(entryDerived: Map, entry: RedBullEntry): EntryDerived { + return entryDerived.get(entry.id) ?? { spend: spendFor(entry), caffeine: caffeineFor(entry), sugar: sugarFor(entry) }; +} + function dayLabel(key: string) { const todayKey = formatDateKey(new Date()); const yesterday = new Date(); diff --git a/src/components/ImportPreviewModal.tsx b/src/components/ImportPreviewModal.tsx index 85bc05c..b0eed80 100644 --- a/src/components/ImportPreviewModal.tsx +++ b/src/components/ImportPreviewModal.tsx @@ -4,11 +4,12 @@ import { humanDateTime } from "../lib/metrics"; import { MODAL_TRANSITION } from "../lib/motion"; import { MiniMetric } from "./MetricTiles"; import { Modal } from "./Modal"; -import type { ImportPreview, ImportPreviewRow } from "../types"; +import type { ImportPreview, ImportPreviewRow, ImportProgress } from "../types"; type ImportPreviewModalProps = { busy: boolean; preview: ImportPreview | null; + importProgress: ImportProgress | null; onClose: () => void; onConfirm: () => void; }; @@ -16,6 +17,7 @@ type ImportPreviewModalProps = { export function ImportPreviewModal({ busy, preview, + importProgress, onClose, onConfirm, }: ImportPreviewModalProps) { @@ -101,7 +103,12 @@ export function ImportPreviewModal({ ))} -
+
+ {busy && importProgress ? ( +

+ Importing {importProgress.done} of {importProgress.total}… +

+ ) : null} diff --git a/src/components/Skeleton.tsx b/src/components/Skeleton.tsx new file mode 100644 index 0000000..f1ef416 --- /dev/null +++ b/src/components/Skeleton.tsx @@ -0,0 +1,7 @@ +type SkeletonProps = { + className?: string; +}; + +export function Skeleton({ className = "" }: SkeletonProps) { + return