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:
nh9961
2026-07-19 21:00:51 +00:00
parent bb5750e928
commit 9e8f3c55d6
12 changed files with 247 additions and 61 deletions
+30 -12
View File
@@ -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} />