Files
Red-Bull-Tracker/src/components/views/OverviewView.tsx
T
nh9961 9e8f3c55d6 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
2026-07-19 21:00:51 +00:00

221 lines
9.0 KiB
TypeScript

import { Activity, AlertTriangle, CalendarDays, ChevronRight, PoundSterling, TimerReset } from "lucide-react";
import {
Area,
AreaChart,
CartesianGrid,
Cell,
Pie,
PieChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { MATERIAL_ACCENTS } from "../../data/accents";
import type { QuickAddItem } from "../../data/quickAdds";
import { currency, humanDateTime, spendFor } from "../../lib/metrics";
import { limitStatusMessage } from "../../lib/userLimits";
import { AppCard } from "../AppCard";
import { ChartTooltip } from "../ChartTooltip";
import { DailyLimitsCard } from "../DailyLimitsCard";
import { EmptyState } from "../EmptyState";
import { GreetingPanel } from "../GreetingPanel";
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";
type OverviewViewProps = {
summary: Dashboard;
entries: RedBullEntry[];
insights: Insight[];
quickAdds: QuickAddItem[];
recentEntries: RedBullEntry[];
chartData: Array<{ label: string; spend: number; cans: number; caffeine: number; sugar: number }>;
flavourData: Array<{ name: string; value: number; spend: number; accent: string }>;
user: AuthUser;
userLimits: UserLimits;
limitCheck: LimitCheckResult;
loading: boolean;
onQuickAdd: (item: QuickAddItem) => void;
onAdd: () => void;
onScan: () => void;
onOpenLogbook: () => void;
onOpenSettings: () => void;
};
export function OverviewView({
summary,
entries,
insights,
quickAdds,
recentEntries,
chartData,
flavourData,
user,
userLimits,
limitCheck,
loading,
onQuickAdd,
onAdd,
onScan,
onOpenLogbook,
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`
: `${summary.monthSpend} this month`;
const chartTotalSpend = chartData.reduce((total, point) => total + point.spend, 0);
const chartTotalCans = chartData.reduce((total, point) => total + point.cans, 0);
const spendChartLabel = `Spend over the last ${chartData.length} logged days, total ${currency.format(chartTotalSpend)} across ${chartTotalCans} cans`;
const flavourChartLabel = flavourData.length
? `Cans by flavour across ${flavourData.length} flavours. Most logged: ${flavourData[0].name}`
: "";
return (
<div className="grid gap-4">
<GreetingPanel summary={summary} user={user} userLimits={userLimits} limitCheck={limitCheck} onAdd={onAdd} onScan={onScan} />
<DailyLimitsCard limits={userLimits} check={limitCheck} onOpenSettings={onOpenSettings} />
<QuickAddPanel items={quickAdds} onQuickAdd={onQuickAdd} />
<div className="hidden lg:block">
<TodayPanel summary={summary} entries={entries} userLimits={userLimits} limitCheck={limitCheck} onAdd={onAdd} onScan={onScan} />
</div>
{limitCheck.violations.length ? (
<section className="limit-alert">
<div className="flex items-start gap-3">
<AlertTriangle className="mt-0.5 shrink-0" size={20} aria-hidden="true" style={{ color: "var(--warning)" }} />
<div>
<p className="limit-alert-title">Limit alerts</p>
<p className="limit-alert-copy mt-1">
{limitStatusMessage(limitCheck.violations, limitCheck, userLimits)}
</p>
</div>
</div>
</section>
) : null}
<section className="overview-metrics-grid grid gap-3">
{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">
{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 }}>
<defs>
<linearGradient id="mikuSpend" x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stopColor="#39d5ff" stopOpacity={0.36} />
<stop offset="100%" stopColor="#39d5ff" stopOpacity={0.03} />
</linearGradient>
</defs>
<CartesianGrid stroke="rgba(203,213,225,0.12)" vertical={false} />
<XAxis dataKey="label" stroke="#94a3b8" tickLine={false} axisLine={false} />
<YAxis stroke="#94a3b8" tickLine={false} axisLine={false} />
<Tooltip content={<ChartTooltip />} />
<Area type="monotone" dataKey="spend" name="Spend" stroke="#39d5ff" fill="url(#mikuSpend)" strokeWidth={3} />
</AreaChart>
</ResponsiveContainer>
</div>
) : (
<EmptyState title="No spend data yet" copy="Add an intake or use quick add to start the chart." actionLabel="Add intake" onAction={onAdd} />
)}
</AppCard>
<AppCard title="Recent entries" subtitle={`${recentEntries.length} shown`}>
{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} />
))}
<button className="list-button" type="button" onClick={onOpenLogbook}>
Open logbook
<ChevronRight size={16} aria-hidden="true" />
</button>
</div>
) : (
<EmptyState title="Nothing logged" copy="Your newest entries will appear here." actionLabel="Add intake" onAction={onAdd} />
)}
</AppCard>
</section>
<section className="overview-insights-grid grid gap-3">
{insights.map((insight) => (
<InsightCard key={insight.label} insight={insight} />
))}
</section>
<section className="grid gap-4">
<AppCard title="Flavour mix" subtitle="Cans by flavour">
{flavourData.length ? (
<div className="chart-shell chart-shell--pie" role="img" aria-label={flavourChartLabel}>
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie data={flavourData} dataKey="value" nameKey="name" innerRadius={70} outerRadius={104} paddingAngle={4} stroke="#080d1f" strokeWidth={4}>
{flavourData.map((entry) => (
<Cell key={entry.name} fill={entry.accent} />
))}
</Pie>
<Tooltip content={<ChartTooltip />} />
</PieChart>
</ResponsiveContainer>
</div>
) : (
<EmptyState title="No flavours yet" copy="Flavour breakdown appears after your first entry." />
)}
</AppCard>
</section>
<LegalFootnote className="mt-2" />
</div>
);
}
function MiniEntry({ entry }: { entry: RedBullEntry }) {
return (
<div className="mini-entry-card">
<span className="h-3 w-3 rounded-full" style={{ backgroundColor: entry.flavourAccent }} />
<div className="min-w-0">
<p className="mini-entry-title truncate">{entry.flavour}</p>
<p className="mini-entry-meta truncate">{humanDateTime(entry.dateTime)}</p>
</div>
<p className="mini-entry-price">{currency.format(spendFor(entry))}</p>
</div>
);
}