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:
+40
-8
@@ -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()}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 { 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}
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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 }}>
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user