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
+40 -8
View File
@@ -51,7 +51,7 @@ import {
parseUserLimits, parseUserLimits,
} from "./lib/userLimits"; } from "./lib/userLimits";
import { createExcelExport, downloadBlob, parseExcelImport } from "./lib/excel"; 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 { exportPayload, parseImport } from "./lib/storage";
import { useToasts } from "./lib/toasts"; import { useToasts } from "./lib/toasts";
import type { import type {
@@ -60,6 +60,7 @@ import type {
EntryDraft, EntryDraft,
Filters, Filters,
ImportPreview, ImportPreview,
ImportProgress,
RedBullEntry, RedBullEntry,
SetupStatus, SetupStatus,
UserLimits, UserLimits,
@@ -101,6 +102,7 @@ function App() {
const [busyAction, setBusyAction] = useState<string | null>(null); const [busyAction, setBusyAction] = useState<string | null>(null);
const [syncError, setSyncError] = useState(""); const [syncError, setSyncError] = useState("");
const [importPreview, setImportPreview] = useState<ImportPreview | null>(null); const [importPreview, setImportPreview] = useState<ImportPreview | null>(null);
const [importProgress, setImportProgress] = useState<ImportProgress | null>(null);
const [userLimits, setUserLimits] = useState<UserLimits>({}); const [userLimits, setUserLimits] = useState<UserLimits>({});
const [limitConfirmOpen, setLimitConfirmOpen] = useState(false); const [limitConfirmOpen, setLimitConfirmOpen] = useState(false);
const [limitConfirmMessage, setLimitConfirmMessage] = useState(""); const [limitConfirmMessage, setLimitConfirmMessage] = useState("");
@@ -110,6 +112,8 @@ function App() {
const { pushToast } = useToasts(); const { pushToast } = useToasts();
const excelFileInputRef = useRef<HTMLInputElement>(null); const excelFileInputRef = useRef<HTMLInputElement>(null);
const jsonFileInputRef = useRef<HTMLInputElement>(null); const jsonFileInputRef = useRef<HTMLInputElement>(null);
const importInFlightRef = useRef(false);
const refreshDebounceRef = useRef<number | null>(null);
useEffect(() => { useEffect(() => {
localStorage.setItem(THEME_STORAGE_KEY, normaliseThemeId(themeId)); localStorage.setItem(THEME_STORAGE_KEY, normaliseThemeId(themeId));
@@ -203,16 +207,33 @@ function App() {
useEffect(() => { useEffect(() => {
if (!user) return undefined; 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<Record<string, unknown>>( const unsubscribe = client.subscribe<Record<string, unknown>>(
Channel.tablesdb(appwriteConfig.databaseId).table(appwriteConfig.collectionId).row(), Channel.tablesdb(appwriteConfig.databaseId).table(appwriteConfig.collectionId).row(),
(event) => { (event) => {
if (event.payload?.userId === user.$id) { // Bulk imports update local state directly; their row events need no refetch.
void refreshEntries(user.$id, false); 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]); }, [refreshEntries, user]);
useEffect(() => { useEffect(() => {
@@ -227,6 +248,7 @@ function App() {
() => mergedFlavours(entries.map((entry) => entry.flavour)), () => mergedFlavours(entries.map((entry) => entry.flavour)),
[entries], [entries],
); );
const entryDerived = useMemo(() => buildEntryDerived(entries), [entries]);
const entriesInView = useMemo( const entriesInView = useMemo(
() => sortEntries(applyFilters(entries, filters)), () => sortEntries(applyFilters(entries, filters)),
[entries, filters], [entries, filters],
@@ -538,14 +560,18 @@ function App() {
setBusyAction("confirm-excel-import"); setBusyAction("confirm-excel-import");
setSyncError(""); setSyncError("");
importInFlightRef.current = true;
setImportProgress({ done: 0, total: drafts.length });
try { 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])); setEntries((current) => sortEntries([...saved, ...current]));
setImportPreview(null); setImportPreview(null);
setNotice(`${saved.length} Excel row${saved.length === 1 ? "" : "s"} saved to Appwrite.`); setNotice(`${saved.length} Excel row${saved.length === 1 ? "" : "s"} saved to Appwrite.`);
} catch (error) { } catch (error) {
setSyncError(appwriteErrorMessage(error)); setSyncError(appwriteErrorMessage(error));
} finally { } finally {
importInFlightRef.current = false;
setImportProgress(null);
setBusyAction(null); setBusyAction(null);
} }
} }
@@ -567,12 +593,13 @@ function App() {
setNotice("No new JSON entries found."); setNotice("No new JSON entries found.");
return; return;
} }
importInFlightRef.current = true;
const saved = await createEntries(user.$id, uniqueDrafts.map((draft) => ({ ...draft, source: "json" }))); const saved = await createEntries(user.$id, uniqueDrafts.map((draft) => ({ ...draft, source: "json" })));
setEntries((current) => sortEntries([...saved, ...current])); setEntries((current) => sortEntries([...saved, ...current])); setNotice(`${saved.length} JSON entr${saved.length === 1 ? "y" : "ies"} saved to Appwrite.`);
setNotice(`${saved.length} JSON entr${saved.length === 1 ? "y" : "ies"} saved to Appwrite.`);
} catch (error) { } catch (error) {
setSyncError(error instanceof Error ? error.message : "JSON import failed."); setSyncError(error instanceof Error ? error.message : "JSON import failed.");
} finally { } finally {
importInFlightRef.current = false;
if (jsonFileInputRef.current) jsonFileInputRef.current.value = ""; if (jsonFileInputRef.current) jsonFileInputRef.current.value = "";
setBusyAction(null); setBusyAction(null);
} }
@@ -675,6 +702,7 @@ function App() {
user={user} user={user}
userLimits={userLimits} userLimits={userLimits}
limitCheck={limitCheck} limitCheck={limitCheck}
loading={dataLoading}
onQuickAdd={(item) => void quickAdd(item)} onQuickAdd={(item) => void quickAdd(item)}
onAdd={openNewEntry} onAdd={openNewEntry}
onScan={openBarcodeScanner} onScan={openBarcodeScanner}
@@ -687,8 +715,10 @@ function App() {
<LogbookView <LogbookView
entries={entriesInView} entries={entriesInView}
totalEntries={entries.length} totalEntries={entries.length}
entryDerived={entryDerived}
filters={filters} filters={filters}
flavours={allFlavours} flavours={allFlavours}
loading={dataLoading}
deletingId={deletingId} deletingId={deletingId}
onFilterChange={setFilters} onFilterChange={setFilters}
onAdd={openNewEntry} onAdd={openNewEntry}
@@ -711,6 +741,7 @@ function App() {
filters={filters} filters={filters}
flavours={allFlavours} flavours={allFlavours}
userLimits={userLimits} userLimits={userLimits}
loading={dataLoading}
onFilterChange={setFilters} onFilterChange={setFilters}
onSaveLimits={(next) => void saveUserLimits(next)} onSaveLimits={(next) => void saveUserLimits(next)}
/> />
@@ -781,6 +812,7 @@ function App() {
<ImportPreviewModal <ImportPreviewModal
busy={busyAction === "confirm-excel-import"} busy={busyAction === "confirm-excel-import"}
preview={importPreview} preview={importPreview}
importProgress={importProgress}
onClose={() => setImportPreview(null)} onClose={() => setImportPreview(null)}
onConfirm={() => void confirmExcelImport()} onConfirm={() => void confirmExcelImport()}
/> />
+61 -9
View File
@@ -1,5 +1,5 @@
import { Edit3, Loader2, RotateCcw, Trash2 } from "lucide-react"; import { Edit3, Loader2, RotateCcw, Trash2 } from "lucide-react";
import { useMemo, type CSSProperties } from "react"; import { useEffect, useMemo, useState, type CSSProperties } from "react";
import { import {
caffeineFor, caffeineFor,
currency, currency,
@@ -11,11 +11,18 @@ import {
} from "../lib/metrics"; } from "../lib/metrics";
import { AppCard } from "./AppCard"; import { AppCard } from "./AppCard";
import { EmptyState } from "./EmptyState"; 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 = { type EntryLedgerProps = {
entries: RedBullEntry[]; entries: RedBullEntry[];
totalEntries: number; totalEntries: number;
entryDerived: Map<string, EntryDerived>;
filters: Filters;
loading: boolean;
deletingId: string | null; deletingId: string | null;
onAdd: () => void; onAdd: () => void;
onEdit: (entry: RedBullEntry) => void; onEdit: (entry: RedBullEntry) => void;
@@ -32,12 +39,39 @@ type DayGroup = {
entries: RedBullEntry[]; entries: RedBullEntry[];
}; };
export function EntryLedger({ entries, totalEntries, deletingId, onAdd, onEdit, onDelete, onLogAgain }: EntryLedgerProps) { export function EntryLedger({
const dayGroups = useMemo(() => groupEntriesByDay(entries), [entries]); 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 ( return (
<AppCard title="Entries" subtitle={`${entries.length} visible of ${totalEntries}`}> <AppCard title="Entries" subtitle={`${entries.length} visible of ${totalEntries}`}>
{entries.length ? ( {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"> <div className="grid gap-4">
{dayGroups.map((group) => ( {dayGroups.map((group) => (
<section className="day-group" key={group.key} aria-label={group.label}> <section className="day-group" key={group.key} aria-label={group.label}>
@@ -52,6 +86,7 @@ export function EntryLedger({ entries, totalEntries, deletingId, onAdd, onEdit,
<EntryRow <EntryRow
key={entry.id} key={entry.id}
entry={entry} entry={entry}
derived={derivedFor(entryDerived, entry)}
deleting={deletingId === entry.id} deleting={deletingId === entry.id}
onEdit={onEdit} onEdit={onEdit}
onDelete={onDelete} onDelete={onDelete}
@@ -62,6 +97,16 @@ export function EntryLedger({ entries, totalEntries, deletingId, onAdd, onEdit,
</section> </section>
))} ))}
</div> </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} /> <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({ function EntryRow({
entry, entry,
derived,
deleting, deleting,
onEdit, onEdit,
onDelete, onDelete,
onLogAgain, onLogAgain,
}: { }: {
entry: RedBullEntry; entry: RedBullEntry;
derived: EntryDerived;
deleting: boolean; deleting: boolean;
onEdit: (entry: RedBullEntry) => void; onEdit: (entry: RedBullEntry) => void;
onDelete: (id: string) => void; onDelete: (id: string) => void;
@@ -98,7 +145,7 @@ function EntryRow({
{entry.store ? ` · ${entry.store}` : ""} {entry.store ? ` · ${entry.store}` : ""}
</p> </p>
<p className="entry-summary mt-2"> <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> </p>
{entry.notes ? <p className="entry-meta mt-2 leading-6">{entry.notes}</p> : null} {entry.notes ? <p className="entry-meta mt-2 leading-6">{entry.notes}</p> : null}
</div> </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 groups: DayGroup[] = [];
const byKey = new Map<string, DayGroup>(); const byKey = new Map<string, DayGroup>();
@@ -140,15 +187,20 @@ function groupEntriesByDay(entries: RedBullEntry[]): DayGroup[] {
byKey.set(key, group); byKey.set(key, group);
groups.push(group); groups.push(group);
} }
const derived = derivedFor(entryDerived, entry);
group.entries.push(entry); group.entries.push(entry);
group.cans += entry.cans; group.cans += entry.cans;
group.caffeine += caffeineFor(entry); group.caffeine += derived.caffeine;
group.spend += spendFor(entry); group.spend += derived.spend;
}); });
return groups; 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) { function dayLabel(key: string) {
const todayKey = formatDateKey(new Date()); const todayKey = formatDateKey(new Date());
const yesterday = new Date(); const yesterday = new Date();
+9 -2
View File
@@ -4,11 +4,12 @@ import { humanDateTime } from "../lib/metrics";
import { MODAL_TRANSITION } from "../lib/motion"; import { MODAL_TRANSITION } from "../lib/motion";
import { MiniMetric } from "./MetricTiles"; import { MiniMetric } from "./MetricTiles";
import { Modal } from "./Modal"; import { Modal } from "./Modal";
import type { ImportPreview, ImportPreviewRow } from "../types"; import type { ImportPreview, ImportPreviewRow, ImportProgress } from "../types";
type ImportPreviewModalProps = { type ImportPreviewModalProps = {
busy: boolean; busy: boolean;
preview: ImportPreview | null; preview: ImportPreview | null;
importProgress: ImportProgress | null;
onClose: () => void; onClose: () => void;
onConfirm: () => void; onConfirm: () => void;
}; };
@@ -16,6 +17,7 @@ type ImportPreviewModalProps = {
export function ImportPreviewModal({ export function ImportPreviewModal({
busy, busy,
preview, preview,
importProgress,
onClose, onClose,
onConfirm, onConfirm,
}: ImportPreviewModalProps) { }: ImportPreviewModalProps) {
@@ -101,7 +103,12 @@ export function ImportPreviewModal({
))} ))}
</div> </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}> <button className="secondary-button justify-center" type="button" onClick={onClose}>
Cancel Cancel
</button> </button>
+7
View File
@@ -0,0 +1,7 @@
type SkeletonProps = {
className?: string;
};
export function Skeleton({ className = "" }: SkeletonProps) {
return <div className={className ? `skeleton ${className}` : "skeleton"} aria-hidden="true" />;
}
+8 -1
View File
@@ -1,13 +1,15 @@
import { EntryLedger } from "../EntryLedger"; import { EntryLedger } from "../EntryLedger";
import { FiltersPanel } from "../FiltersPanel"; import { FiltersPanel } from "../FiltersPanel";
import { LegalFootnote } from "../LegalFootnote"; import { LegalFootnote } from "../LegalFootnote";
import type { Filters, Flavour, RedBullEntry } from "../../types"; import type { EntryDerived, Filters, Flavour, RedBullEntry } from "../../types";
type LogbookViewProps = { type LogbookViewProps = {
entries: RedBullEntry[]; entries: RedBullEntry[];
totalEntries: number; totalEntries: number;
entryDerived: Map<string, EntryDerived>;
filters: Filters; filters: Filters;
flavours: Flavour[]; flavours: Flavour[];
loading: boolean;
deletingId: string | null; deletingId: string | null;
onFilterChange: (filters: Filters) => void; onFilterChange: (filters: Filters) => void;
onAdd: () => void; onAdd: () => void;
@@ -19,8 +21,10 @@ type LogbookViewProps = {
export function LogbookView({ export function LogbookView({
entries, entries,
totalEntries, totalEntries,
entryDerived,
filters, filters,
flavours, flavours,
loading,
deletingId, deletingId,
onFilterChange, onFilterChange,
onAdd, onAdd,
@@ -35,6 +39,9 @@ export function LogbookView({
<EntryLedger <EntryLedger
entries={entries} entries={entries}
totalEntries={totalEntries} totalEntries={totalEntries}
entryDerived={entryDerived}
filters={filters}
loading={loading}
deletingId={deletingId} deletingId={deletingId}
onAdd={onAdd} onAdd={onAdd}
onEdit={onEdit} onEdit={onEdit}
+20 -2
View File
@@ -24,6 +24,7 @@ import { InsightCard } from "../InsightCard";
import { LegalFootnote } from "../LegalFootnote"; import { LegalFootnote } from "../LegalFootnote";
import { MetricTile } from "../MetricTiles"; import { MetricTile } from "../MetricTiles";
import { QuickAddPanel } from "../QuickAddPanel"; import { QuickAddPanel } from "../QuickAddPanel";
import { Skeleton } from "../Skeleton";
import { TodayPanel } from "../TodayPanel"; import { TodayPanel } from "../TodayPanel";
import type { Dashboard, Insight } from "../../lib/dashboard"; import type { Dashboard, Insight } from "../../lib/dashboard";
import type { AuthUser, LimitCheckResult, RedBullEntry, UserLimits } from "../../types"; import type { AuthUser, LimitCheckResult, RedBullEntry, UserLimits } from "../../types";
@@ -39,6 +40,7 @@ type OverviewViewProps = {
user: AuthUser; user: AuthUser;
userLimits: UserLimits; userLimits: UserLimits;
limitCheck: LimitCheckResult; limitCheck: LimitCheckResult;
loading: boolean;
onQuickAdd: (item: QuickAddItem) => void; onQuickAdd: (item: QuickAddItem) => void;
onAdd: () => void; onAdd: () => void;
onScan: () => void; onScan: () => void;
@@ -57,6 +59,7 @@ export function OverviewView({
user, user,
userLimits, userLimits,
limitCheck, limitCheck,
loading,
onQuickAdd, onQuickAdd,
onAdd, onAdd,
onScan, onScan,
@@ -64,6 +67,7 @@ export function OverviewView({
onOpenSettings, onOpenSettings,
}: OverviewViewProps) { }: OverviewViewProps) {
const todaySpendRaw = limitCheck.todaySpend; const todaySpendRaw = limitCheck.todaySpend;
const showSkeleton = loading && entries.length === 0;
const spendLimitDetail = const spendLimitDetail =
userLimits.dailySpendLimit != null userLimits.dailySpendLimit != null
? `${currency.format(todaySpendRaw)} of ${currency.format(userLimits.dailySpendLimit)} today` ? `${currency.format(todaySpendRaw)} of ${currency.format(userLimits.dailySpendLimit)} today`
@@ -102,6 +106,10 @@ export function OverviewView({
) : null} ) : null}
<section className="overview-metrics-grid grid gap-3"> <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={CalendarDays} label="This month" value={summary.monthCans} detail={`${summary.monthSpend} spent`} accent={MATERIAL_ACCENTS.primary} />
<MetricTile <MetricTile
icon={PoundSterling} icon={PoundSterling}
@@ -112,11 +120,15 @@ export function OverviewView({
/> />
<MetricTile icon={Activity} label="Favourite" value={summary.favouriteFlavour} detail="by total cans" accent={MATERIAL_ACCENTS.tertiary} /> <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} /> <MetricTile icon={TimerReset} label="Days without" value={summary.daysWithoutRedBull} detail={`${summary.currentStreak} day streak`} accent={MATERIAL_ACCENTS.error} />
</>
)}
</section> </section>
<section className="overview-charts-grid grid gap-4"> <section className="overview-charts-grid grid gap-4">
<AppCard title="Spend overview" subtitle="Last 30 logged days"> <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}> <div className="chart-shell chart-shell--area" role="img" aria-label={spendChartLabel}>
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<AreaChart data={chartData} margin={{ top: 12, right: 12, bottom: 0, left: -18 }}> <AreaChart data={chartData} margin={{ top: 12, right: 12, bottom: 0, left: -18 }}>
@@ -140,7 +152,13 @@ export function OverviewView({
</AppCard> </AppCard>
<AppCard title="Recent entries" subtitle={`${recentEntries.length} shown`}> <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"> <div className="grid gap-2">
{recentEntries.map((entry) => ( {recentEntries.map((entry) => (
<MiniEntry key={entry.id} entry={entry} /> <MiniEntry key={entry.id} entry={entry} />
+13 -3
View File
@@ -21,6 +21,7 @@ import { EmptyState } from "../EmptyState";
import { FiltersPanel } from "../FiltersPanel"; import { FiltersPanel } from "../FiltersPanel";
import { InsightCard } from "../InsightCard"; import { InsightCard } from "../InsightCard";
import { LegalFootnote } from "../LegalFootnote"; import { LegalFootnote } from "../LegalFootnote";
import { Skeleton } from "../Skeleton";
import { SpendForecastCard } from "../SpendForecastCard"; import { SpendForecastCard } from "../SpendForecastCard";
import type { Insight } from "../../lib/dashboard"; import type { Insight } from "../../lib/dashboard";
import type { Filters, Flavour, RedBullEntry, UserLimits } from "../../types"; import type { Filters, Flavour, RedBullEntry, UserLimits } from "../../types";
@@ -43,6 +44,7 @@ type TrendsViewProps = {
filters: Filters; filters: Filters;
flavours: Flavour[]; flavours: Flavour[];
userLimits: UserLimits; userLimits: UserLimits;
loading: boolean;
onFilterChange: (filters: Filters) => void; onFilterChange: (filters: Filters) => void;
onSaveLimits: (limits: UserLimits) => void; onSaveLimits: (limits: UserLimits) => void;
}; };
@@ -56,9 +58,11 @@ export function TrendsView({
filters, filters,
flavours, flavours,
userLimits, userLimits,
loading,
onFilterChange, onFilterChange,
onSaveLimits, onSaveLimits,
}: TrendsViewProps) { }: TrendsViewProps) {
const showSkeleton = loading && entries.length === 0;
const totalSpend = chartData.reduce((total, point) => total + point.spend, 0); const totalSpend = chartData.reduce((total, point) => total + point.spend, 0);
const totalCans = chartData.reduce((total, point) => total + point.cans, 0); const totalCans = chartData.reduce((total, point) => total + point.cans, 0);
const totalCaffeine = chartData.reduce((total, point) => total + point.caffeine, 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"> <section className="logbook-layout grid gap-4">
<FiltersPanel filters={filters} flavours={flavours} onChange={onFilterChange} compact /> <FiltersPanel filters={filters} flavours={flavours} onChange={onFilterChange} compact />
<AppCard title="Cans and spend" subtitle={`${entries.length} entries in view`}> <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}> <div role="img" aria-label={cansSpendLabel}>
<ResponsiveContainer width="100%" height={CHART_HEIGHT}> <ResponsiveContainer width="100%" height={CHART_HEIGHT}>
<AreaChart data={chartData} margin={{ top: 12, right: 16, bottom: 0, left: -12 }}> <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"> <section className="grid gap-4 xl:grid-cols-2">
<AppCard title="Caffeine by day" subtitle="Estimated mg"> <AppCard title="Caffeine by day" subtitle="Estimated mg">
{chartData.length ? ( {showSkeleton ? (
<Skeleton className="h-[300px]" />
) : chartData.length ? (
<div role="img" aria-label={caffeineLabel}> <div role="img" aria-label={caffeineLabel}>
<ResponsiveContainer width="100%" height={CHART_HEIGHT}> <ResponsiveContainer width="100%" height={CHART_HEIGHT}>
<BarChart data={chartData} margin={{ top: 12, right: 16, bottom: 0, left: -12 }}> <BarChart data={chartData} margin={{ top: 12, right: 16, bottom: 0, left: -12 }}>
@@ -126,7 +134,9 @@ export function TrendsView({
</AppCard> </AppCard>
<AppCard title="Weekly comparison" subtitle="Spend and cans"> <AppCard title="Weekly comparison" subtitle="Spend and cans">
{weekData.length ? ( {showSkeleton ? (
<Skeleton className="h-[300px]" />
) : weekData.length ? (
<div role="img" aria-label={weekLabel}> <div role="img" aria-label={weekLabel}>
<ResponsiveContainer width="100%" height={CHART_HEIGHT}> <ResponsiveContainer width="100%" height={CHART_HEIGHT}>
<RechartsLineChart data={weekData} margin={{ top: 12, right: 16, bottom: 0, left: -12 }}> <RechartsLineChart data={weekData} margin={{ top: 12, right: 16, bottom: 0, left: -12 }}>
+23
View File
@@ -1972,6 +1972,29 @@ textarea:focus-visible {
background: color-mix(in srgb, var(--text, #202124) 6%, transparent); background: color-mix(in srgb, var(--text, #202124) 6%, transparent);
} }
/* --- Skeleton loading placeholders (B3) --- */
.skeleton {
border-radius: 16px;
background: color-mix(in srgb, var(--text, #202124) 8%, transparent);
}
@keyframes skeleton-pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.45;
}
}
@media (prefers-reduced-motion: no-preference) {
.skeleton {
animation: skeleton-pulse 1.6s ease-in-out infinite;
}
}
/* --- Motion & interaction language (A6) --- */ /* --- Motion & interaction language (A6) --- */
/* Neutralize CSS transitions/animations for reduced-motion users. */ /* Neutralize CSS transitions/animations for reduced-motion users. */
+7 -2
View File
@@ -59,10 +59,15 @@ export async function createEntry(userId: string, draft: EntryDraft) {
return fromRow(row); return fromRow(row);
} }
export async function createEntries(userId: string, drafts: EntryDraft[]) { export async function createEntries(
userId: string,
drafts: EntryDraft[],
onProgress?: (done: number, total: number) => void,
) {
const saved: RedBullEntry[] = []; const saved: RedBullEntry[] = [];
for (const draft of drafts) { for (const [index, draft] of drafts.entries()) {
saved.push(await createEntry(userId, draft)); saved.push(await createEntry(userId, draft));
onProgress?.(index + 1, drafts.length);
} }
return saved; return saved;
} }
+3 -1
View File
@@ -1,4 +1,4 @@
import ExcelJS from "exceljs"; import type ExcelJS from "exceljs";
import { flavourMeta } from "../data/flavours"; import { flavourMeta } from "../data/flavours";
import type { EntryDraft, ImportPreview, ImportPreviewRow, RedBullEntry } from "../types"; import type { EntryDraft, ImportPreview, ImportPreviewRow, RedBullEntry } from "../types";
import { import {
@@ -39,6 +39,7 @@ const ENTRY_COLUMNS = [
] as const; ] as const;
export async function createExcelExport(entries: RedBullEntry[]) { export async function createExcelExport(entries: RedBullEntry[]) {
const { default: ExcelJS } = await import("exceljs");
const workbook = new ExcelJS.Workbook(); const workbook = new ExcelJS.Workbook();
workbook.creator = "Red Bull Intake Tracker"; workbook.creator = "Red Bull Intake Tracker";
workbook.created = new Date(); workbook.created = new Date();
@@ -53,6 +54,7 @@ export async function createExcelExport(entries: RedBullEntry[]) {
} }
export async function parseExcelImport(file: File, existingEntries: RedBullEntry[]): Promise<ImportPreview> { export async function parseExcelImport(file: File, existingEntries: RedBullEntry[]): Promise<ImportPreview> {
const { default: ExcelJS } = await import("exceljs");
const workbook = new ExcelJS.Workbook(); const workbook = new ExcelJS.Workbook();
await workbook.xlsx.load(await file.arrayBuffer()); await workbook.xlsx.load(await file.arrayBuffer());
+13 -1
View File
@@ -1,4 +1,4 @@
import type { BuiltInSize, RedBullEntry } from "../types"; import type { BuiltInSize, EntryDerived, RedBullEntry } from "../types";
export const CAFFEINE_PER_250ML = 80; export const CAFFEINE_PER_250ML = 80;
export const SUGAR_PER_250ML = 27; export const SUGAR_PER_250ML = 27;
@@ -53,6 +53,18 @@ export function sugarFor(entry: RedBullEntry) {
return entry.cans * (entry.sizeMl / 250) * SUGAR_PER_250ML; return entry.cans * (entry.sizeMl / 250) * SUGAR_PER_250ML;
} }
export function buildEntryDerived(entries: RedBullEntry[]) {
const derived = new Map<string, EntryDerived>();
entries.forEach((entry) => {
derived.set(entry.id, {
spend: spendFor(entry),
caffeine: caffeineFor(entry),
sugar: sugarFor(entry),
});
});
return derived;
}
export function startOfDay(date: Date) { export function startOfDay(date: Date) {
const next = new Date(date); const next = new Date(date);
next.setHours(0, 0, 0, 0); next.setHours(0, 0, 0, 0);
+11
View File
@@ -114,6 +114,17 @@ export type ImportPreview = {
rows: ImportPreviewRow[]; rows: ImportPreviewRow[];
}; };
export type ImportProgress = {
done: number;
total: number;
};
export type EntryDerived = {
spend: number;
caffeine: number;
sugar: number;
};
export type UserLimits = { export type UserLimits = {
dailyCanLimit?: number; dailyCanLimit?: number;
dailySpendLimit?: number; dailySpendLimit?: number;