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
This commit is contained in:
@@ -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<string, EntryDerived>;
|
||||
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 (
|
||||
<AppCard title="Entries" subtitle={`${entries.length} visible of ${totalEntries}`}>
|
||||
{entries.length ? (
|
||||
<div className="grid gap-4">
|
||||
{dayGroups.map((group) => (
|
||||
<section className="day-group" key={group.key} aria-label={group.label}>
|
||||
<div className="day-group-header">
|
||||
<h3 className="day-group-title">{group.label}</h3>
|
||||
<p className="day-group-subtotals">
|
||||
{oneDecimal.format(group.cans)} can{group.cans === 1 ? "" : "s"} · {wholeNumber.format(group.caffeine)}mg · {currency.format(group.spend)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
{group.entries.map((entry) => (
|
||||
<EntryRow
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
deleting={deletingId === entry.id}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
onLogAgain={onLogAgain}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
{showSkeleton ? (
|
||||
<div className="grid gap-2">
|
||||
{Array.from({ length: SKELETON_ROWS }, (_, index) => (
|
||||
<Skeleton key={index} className="h-24" />
|
||||
))}
|
||||
</div>
|
||||
) : entries.length ? (
|
||||
<>
|
||||
<div className="grid gap-4">
|
||||
{dayGroups.map((group) => (
|
||||
<section className="day-group" key={group.key} aria-label={group.label}>
|
||||
<div className="day-group-header">
|
||||
<h3 className="day-group-title">{group.label}</h3>
|
||||
<p className="day-group-subtotals">
|
||||
{oneDecimal.format(group.cans)} can{group.cans === 1 ? "" : "s"} · {wholeNumber.format(group.caffeine)}mg · {currency.format(group.spend)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
{group.entries.map((entry) => (
|
||||
<EntryRow
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
derived={derivedFor(entryDerived, entry)}
|
||||
deleting={deletingId === entry.id}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
onLogAgain={onLogAgain}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
{remaining > 0 ? (
|
||||
<button
|
||||
className="list-button mt-4 w-full"
|
||||
type="button"
|
||||
onClick={() => setVisibleCount((count) => count + PAGE_SIZE)}
|
||||
>
|
||||
Load {Math.min(PAGE_SIZE, remaining)} more — {remaining} remaining
|
||||
</button>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<EmptyState title="No entries found" copy="Add your first intake or clear the current filters." actionLabel="Add intake" onAction={onAdd} />
|
||||
)}
|
||||
@@ -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}` : ""}
|
||||
</p>
|
||||
<p className="entry-summary mt-2">
|
||||
{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
|
||||
</p>
|
||||
{entry.notes ? <p className="entry-meta mt-2 leading-6">{entry.notes}</p> : null}
|
||||
</div>
|
||||
@@ -127,7 +174,7 @@ function EntryRow({
|
||||
);
|
||||
}
|
||||
|
||||
function groupEntriesByDay(entries: RedBullEntry[]): DayGroup[] {
|
||||
function groupEntriesByDay(entries: RedBullEntry[], entryDerived: Map<string, EntryDerived>): DayGroup[] {
|
||||
const groups: DayGroup[] = [];
|
||||
const byKey = new Map<string, DayGroup>();
|
||||
|
||||
@@ -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<string, EntryDerived>, 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();
|
||||
|
||||
@@ -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({
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||
<div className="mt-6 flex flex-col-reverse gap-2 sm:flex-row sm:items-center sm:justify-end">
|
||||
{busy && importProgress ? (
|
||||
<p className="text-center text-sm text-slate-400 sm:mr-auto" role="status" aria-live="polite">
|
||||
Importing {importProgress.done} of {importProgress.total}…
|
||||
</p>
|
||||
) : null}
|
||||
<button className="secondary-button justify-center" type="button" onClick={onClose}>
|
||||
Cancel
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
type SkeletonProps = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function Skeleton({ className = "" }: SkeletonProps) {
|
||||
return <div className={className ? `skeleton ${className}` : "skeleton"} aria-hidden="true" />;
|
||||
}
|
||||
@@ -1,13 +1,15 @@
|
||||
import { EntryLedger } from "../EntryLedger";
|
||||
import { FiltersPanel } from "../FiltersPanel";
|
||||
import { LegalFootnote } from "../LegalFootnote";
|
||||
import type { Filters, Flavour, RedBullEntry } from "../../types";
|
||||
import type { EntryDerived, Filters, Flavour, RedBullEntry } from "../../types";
|
||||
|
||||
type LogbookViewProps = {
|
||||
entries: RedBullEntry[];
|
||||
totalEntries: number;
|
||||
entryDerived: Map<string, EntryDerived>;
|
||||
filters: Filters;
|
||||
flavours: Flavour[];
|
||||
loading: boolean;
|
||||
deletingId: string | null;
|
||||
onFilterChange: (filters: Filters) => void;
|
||||
onAdd: () => void;
|
||||
@@ -19,8 +21,10 @@ type LogbookViewProps = {
|
||||
export function LogbookView({
|
||||
entries,
|
||||
totalEntries,
|
||||
entryDerived,
|
||||
filters,
|
||||
flavours,
|
||||
loading,
|
||||
deletingId,
|
||||
onFilterChange,
|
||||
onAdd,
|
||||
@@ -35,6 +39,9 @@ export function LogbookView({
|
||||
<EntryLedger
|
||||
entries={entries}
|
||||
totalEntries={totalEntries}
|
||||
entryDerived={entryDerived}
|
||||
filters={filters}
|
||||
loading={loading}
|
||||
deletingId={deletingId}
|
||||
onAdd={onAdd}
|
||||
onEdit={onEdit}
|
||||
|
||||
@@ -24,6 +24,7 @@ import { InsightCard } from "../InsightCard";
|
||||
import { LegalFootnote } from "../LegalFootnote";
|
||||
import { MetricTile } from "../MetricTiles";
|
||||
import { QuickAddPanel } from "../QuickAddPanel";
|
||||
import { Skeleton } from "../Skeleton";
|
||||
import { TodayPanel } from "../TodayPanel";
|
||||
import type { Dashboard, Insight } from "../../lib/dashboard";
|
||||
import type { AuthUser, LimitCheckResult, RedBullEntry, UserLimits } from "../../types";
|
||||
@@ -39,6 +40,7 @@ type OverviewViewProps = {
|
||||
user: AuthUser;
|
||||
userLimits: UserLimits;
|
||||
limitCheck: LimitCheckResult;
|
||||
loading: boolean;
|
||||
onQuickAdd: (item: QuickAddItem) => void;
|
||||
onAdd: () => void;
|
||||
onScan: () => void;
|
||||
@@ -57,6 +59,7 @@ export function OverviewView({
|
||||
user,
|
||||
userLimits,
|
||||
limitCheck,
|
||||
loading,
|
||||
onQuickAdd,
|
||||
onAdd,
|
||||
onScan,
|
||||
@@ -64,6 +67,7 @@ export function OverviewView({
|
||||
onOpenSettings,
|
||||
}: OverviewViewProps) {
|
||||
const todaySpendRaw = limitCheck.todaySpend;
|
||||
const showSkeleton = loading && entries.length === 0;
|
||||
const spendLimitDetail =
|
||||
userLimits.dailySpendLimit != null
|
||||
? `${currency.format(todaySpendRaw)} of ${currency.format(userLimits.dailySpendLimit)} today`
|
||||
@@ -102,21 +106,29 @@ export function OverviewView({
|
||||
) : null}
|
||||
|
||||
<section className="overview-metrics-grid grid gap-3">
|
||||
<MetricTile icon={CalendarDays} label="This month" value={summary.monthCans} detail={`${summary.monthSpend} spent`} accent={MATERIAL_ACCENTS.primary} />
|
||||
<MetricTile
|
||||
icon={PoundSterling}
|
||||
label={userLimits.dailySpendLimit != null ? "Today's budget" : "Total spend"}
|
||||
value={userLimits.dailySpendLimit != null ? currency.format(todaySpendRaw) : summary.totalSpend}
|
||||
detail={spendLimitDetail}
|
||||
accent={MATERIAL_ACCENTS.secondary}
|
||||
/>
|
||||
<MetricTile icon={Activity} label="Favourite" value={summary.favouriteFlavour} detail="by total cans" accent={MATERIAL_ACCENTS.tertiary} />
|
||||
<MetricTile icon={TimerReset} label="Days without" value={summary.daysWithoutRedBull} detail={`${summary.currentStreak} day streak`} accent={MATERIAL_ACCENTS.error} />
|
||||
{showSkeleton ? (
|
||||
Array.from({ length: 4 }, (_, index) => <Skeleton key={index} className="h-28" />)
|
||||
) : (
|
||||
<>
|
||||
<MetricTile icon={CalendarDays} label="This month" value={summary.monthCans} detail={`${summary.monthSpend} spent`} accent={MATERIAL_ACCENTS.primary} />
|
||||
<MetricTile
|
||||
icon={PoundSterling}
|
||||
label={userLimits.dailySpendLimit != null ? "Today's budget" : "Total spend"}
|
||||
value={userLimits.dailySpendLimit != null ? currency.format(todaySpendRaw) : summary.totalSpend}
|
||||
detail={spendLimitDetail}
|
||||
accent={MATERIAL_ACCENTS.secondary}
|
||||
/>
|
||||
<MetricTile icon={Activity} label="Favourite" value={summary.favouriteFlavour} detail="by total cans" accent={MATERIAL_ACCENTS.tertiary} />
|
||||
<MetricTile icon={TimerReset} label="Days without" value={summary.daysWithoutRedBull} detail={`${summary.currentStreak} day streak`} accent={MATERIAL_ACCENTS.error} />
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="overview-charts-grid grid gap-4">
|
||||
<AppCard title="Spend overview" subtitle="Last 30 logged days">
|
||||
{chartData.length ? (
|
||||
{showSkeleton ? (
|
||||
<Skeleton className="chart-shell chart-shell--area" />
|
||||
) : chartData.length ? (
|
||||
<div className="chart-shell chart-shell--area" role="img" aria-label={spendChartLabel}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={chartData} margin={{ top: 12, right: 12, bottom: 0, left: -18 }}>
|
||||
@@ -140,7 +152,13 @@ export function OverviewView({
|
||||
</AppCard>
|
||||
|
||||
<AppCard title="Recent entries" subtitle={`${recentEntries.length} shown`}>
|
||||
{recentEntries.length ? (
|
||||
{showSkeleton ? (
|
||||
<div className="grid gap-2">
|
||||
{Array.from({ length: 5 }, (_, index) => (
|
||||
<Skeleton key={index} className="h-12" />
|
||||
))}
|
||||
</div>
|
||||
) : recentEntries.length ? (
|
||||
<div className="grid gap-2">
|
||||
{recentEntries.map((entry) => (
|
||||
<MiniEntry key={entry.id} entry={entry} />
|
||||
|
||||
@@ -21,6 +21,7 @@ import { EmptyState } from "../EmptyState";
|
||||
import { FiltersPanel } from "../FiltersPanel";
|
||||
import { InsightCard } from "../InsightCard";
|
||||
import { LegalFootnote } from "../LegalFootnote";
|
||||
import { Skeleton } from "../Skeleton";
|
||||
import { SpendForecastCard } from "../SpendForecastCard";
|
||||
import type { Insight } from "../../lib/dashboard";
|
||||
import type { Filters, Flavour, RedBullEntry, UserLimits } from "../../types";
|
||||
@@ -43,6 +44,7 @@ type TrendsViewProps = {
|
||||
filters: Filters;
|
||||
flavours: Flavour[];
|
||||
userLimits: UserLimits;
|
||||
loading: boolean;
|
||||
onFilterChange: (filters: Filters) => void;
|
||||
onSaveLimits: (limits: UserLimits) => void;
|
||||
};
|
||||
@@ -56,9 +58,11 @@ export function TrendsView({
|
||||
filters,
|
||||
flavours,
|
||||
userLimits,
|
||||
loading,
|
||||
onFilterChange,
|
||||
onSaveLimits,
|
||||
}: TrendsViewProps) {
|
||||
const showSkeleton = loading && entries.length === 0;
|
||||
const totalSpend = chartData.reduce((total, point) => total + point.spend, 0);
|
||||
const totalCans = chartData.reduce((total, point) => total + point.cans, 0);
|
||||
const totalCaffeine = chartData.reduce((total, point) => total + point.caffeine, 0);
|
||||
@@ -77,7 +81,9 @@ export function TrendsView({
|
||||
<section className="logbook-layout grid gap-4">
|
||||
<FiltersPanel filters={filters} flavours={flavours} onChange={onFilterChange} compact />
|
||||
<AppCard title="Cans and spend" subtitle={`${entries.length} entries in view`}>
|
||||
{chartData.length ? (
|
||||
{showSkeleton ? (
|
||||
<Skeleton className="h-[300px]" />
|
||||
) : chartData.length ? (
|
||||
<div role="img" aria-label={cansSpendLabel}>
|
||||
<ResponsiveContainer width="100%" height={CHART_HEIGHT}>
|
||||
<AreaChart data={chartData} margin={{ top: 12, right: 16, bottom: 0, left: -12 }}>
|
||||
@@ -108,7 +114,9 @@ export function TrendsView({
|
||||
|
||||
<section className="grid gap-4 xl:grid-cols-2">
|
||||
<AppCard title="Caffeine by day" subtitle="Estimated mg">
|
||||
{chartData.length ? (
|
||||
{showSkeleton ? (
|
||||
<Skeleton className="h-[300px]" />
|
||||
) : chartData.length ? (
|
||||
<div role="img" aria-label={caffeineLabel}>
|
||||
<ResponsiveContainer width="100%" height={CHART_HEIGHT}>
|
||||
<BarChart data={chartData} margin={{ top: 12, right: 16, bottom: 0, left: -12 }}>
|
||||
@@ -126,7 +134,9 @@ export function TrendsView({
|
||||
</AppCard>
|
||||
|
||||
<AppCard title="Weekly comparison" subtitle="Spend and cans">
|
||||
{weekData.length ? (
|
||||
{showSkeleton ? (
|
||||
<Skeleton className="h-[300px]" />
|
||||
) : weekData.length ? (
|
||||
<div role="img" aria-label={weekLabel}>
|
||||
<ResponsiveContainer width="100%" height={CHART_HEIGHT}>
|
||||
<RechartsLineChart data={weekData} margin={{ top: 12, right: 16, bottom: 0, left: -12 }}>
|
||||
|
||||
Reference in New Issue
Block a user