- Add manifest.webmanifest, generated 192/512 icons, theme-color, apple-touch-icon - Cache entries in localStorage per user; hydrate on load before network refresh - Single info toast when offline on cached data; cache cleared on logout - Fix inaccurate local-first meta description
882 lines
30 KiB
TypeScript
882 lines
30 KiB
TypeScript
import { AnimatePresence, motion } from "framer-motion";
|
|
import { Plus } from "lucide-react";
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import { flavourMeta, mergedFlavours } from "./data/flavours";
|
|
import { QUICK_ADDS, type QuickAddItem } from "./data/quickAdds";
|
|
import {
|
|
THEME_MODE_STORAGE_KEY,
|
|
THEME_STORAGE_KEY,
|
|
getThemeById,
|
|
getThemeTokens,
|
|
normaliseThemeId,
|
|
normaliseThemeMode,
|
|
readStoredThemeId,
|
|
readStoredThemeMode,
|
|
type ThemeMode,
|
|
} from "./data/themes";
|
|
import { themeTokensToStyle } from "./lib/themeTokens";
|
|
import { account, appwriteConfig, Channel, client, pingAppwrite } from "./lib/appwrite";
|
|
import {
|
|
appwriteErrorMessage,
|
|
createEntries,
|
|
createEntry,
|
|
deleteEntry as deleteEntryDocument,
|
|
listEntries,
|
|
updateEntry,
|
|
} from "./lib/appwriteEntries";
|
|
import { BarcodeScannerModal } from "./components/BarcodeScannerModal";
|
|
import { ConfirmDialog } from "./components/ConfirmDialog";
|
|
import { EntryModal } from "./components/EntryModal";
|
|
import { ImportPreviewModal } from "./components/ImportPreviewModal";
|
|
import { LoadingScreen } from "./components/LoadingScreen";
|
|
import { MobileNav } from "./components/MobileNav";
|
|
import { OnboardingScreen } from "./components/OnboardingScreen";
|
|
import { ShellBackdrop } from "./components/ShellBackdrop";
|
|
import { Sidebar } from "./components/Sidebar";
|
|
import { StatusRail } from "./components/StatusRail";
|
|
import { TopBar } from "./components/TopBar";
|
|
import { AuthView } from "./components/views/AuthView";
|
|
import { LogbookView } from "./components/views/LogbookView";
|
|
import { OverviewView } from "./components/views/OverviewView";
|
|
import { SettingsView } from "./components/views/SettingsView";
|
|
import { TrendsView } from "./components/views/TrendsView";
|
|
import { buildDashboard, buildInsights } from "./lib/dashboard";
|
|
import { applyFilters, DEFAULT_FILTERS } from "./lib/filters";
|
|
import { VIEW_TRANSITION } from "./lib/motion";
|
|
import {
|
|
evaluateLimits,
|
|
limitStatusMessage,
|
|
mergePrefsWithLimits,
|
|
parseUserLimits,
|
|
} from "./lib/userLimits";
|
|
import { createExcelExport, downloadBlob, parseExcelImport } from "./lib/excel";
|
|
import { buildEntryDerived, groupByDay, groupByFlavour, groupByWeek, humanDateTime, makeId } from "./lib/metrics";
|
|
import { clearCachedEntries, readCachedEntries, writeCachedEntries } from "./lib/entryCache";
|
|
import { buildJsonImportPreview, exportPayload, parseImport } from "./lib/storage";
|
|
import { useToasts } from "./lib/toasts";
|
|
import type {
|
|
AppView,
|
|
AuthUser,
|
|
EntryDraft,
|
|
Filters,
|
|
ImportPreview,
|
|
ImportProgress,
|
|
RedBullEntry,
|
|
SetupStatus,
|
|
UserLimits,
|
|
} from "./types";
|
|
|
|
type PendingLimitAction = {
|
|
kind: "save" | "quick";
|
|
draft: EntryDraft;
|
|
editingId?: string;
|
|
quickLabel?: string;
|
|
};
|
|
|
|
function App() {
|
|
const [themeId, setThemeId] = useState(() => readStoredThemeId());
|
|
const [themeMode, setThemeMode] = useState<ThemeMode>(() => readStoredThemeMode());
|
|
const [systemDark, setSystemDark] = useState(
|
|
() => typeof window !== "undefined" && window.matchMedia("(prefers-color-scheme: dark)").matches,
|
|
);
|
|
const activeTheme = useMemo(() => getThemeById(themeId), [themeId]);
|
|
const resolvedMode: "light" | "dark" = themeMode === "system" ? (systemDark ? "dark" : "light") : themeMode;
|
|
const shellStyle = useMemo(() => themeTokensToStyle(getThemeTokens(themeId, resolvedMode)), [themeId, resolvedMode]);
|
|
const [user, setUser] = useState<AuthUser | null>(null);
|
|
const [authLoading, setAuthLoading] = useState(true);
|
|
const [authError, setAuthError] = useState("");
|
|
const [setupStatus, setSetupStatus] = useState<SetupStatus>({
|
|
state: "checking",
|
|
message: "Pinging Appwrite...",
|
|
});
|
|
const [entries, setEntries] = useState<RedBullEntry[]>([]);
|
|
const [filters, setFilters] = useState<Filters>(DEFAULT_FILTERS);
|
|
const [activeView, setActiveView] = useState<AppView>("overview");
|
|
const [isEntryModalOpen, setIsEntryModalOpen] = useState(false);
|
|
const [entryInitialDraft, setEntryInitialDraft] = useState<EntryDraft | null>(null);
|
|
const [editingEntry, setEditingEntry] = useState<RedBullEntry | null>(null);
|
|
const [isBarcodeScannerOpen, setIsBarcodeScannerOpen] = useState(false);
|
|
const [isResetOpen, setIsResetOpen] = useState(false);
|
|
const [notice, setNotice] = useState("Appwrite session pending.");
|
|
const [dataLoading, setDataLoading] = useState(false);
|
|
const [busyAction, setBusyAction] = useState<string | null>(null);
|
|
const [syncError, setSyncError] = useState("");
|
|
const [importPreview, setImportPreview] = useState<ImportPreview | null>(null);
|
|
const [importProgress, setImportProgress] = useState<ImportProgress | null>(null);
|
|
const [userLimits, setUserLimits] = useState<UserLimits>({});
|
|
const [limitConfirmOpen, setLimitConfirmOpen] = useState(false);
|
|
const [limitConfirmMessage, setLimitConfirmMessage] = useState("");
|
|
const [pendingLimitAction, setPendingLimitAction] = useState<PendingLimitAction | null>(null);
|
|
const [setupOpen, setSetupOpen] = useState(false);
|
|
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null);
|
|
const { pushToast } = useToasts();
|
|
const excelFileInputRef = useRef<HTMLInputElement>(null);
|
|
const jsonFileInputRef = useRef<HTMLInputElement>(null);
|
|
const importInFlightRef = useRef(false);
|
|
const refreshDebounceRef = useRef<number | null>(null);
|
|
const cacheHydratedRef = useRef(false);
|
|
|
|
useEffect(() => {
|
|
localStorage.setItem(THEME_STORAGE_KEY, normaliseThemeId(themeId));
|
|
}, [themeId]);
|
|
|
|
useEffect(() => {
|
|
localStorage.setItem(THEME_MODE_STORAGE_KEY, themeMode);
|
|
}, [themeMode]);
|
|
|
|
useEffect(() => {
|
|
if (typeof window === "undefined") return undefined;
|
|
const media = window.matchMedia("(prefers-color-scheme: dark)");
|
|
const handleChange = (event: MediaQueryListEvent) => setSystemDark(event.matches);
|
|
setSystemDark(media.matches);
|
|
media.addEventListener("change", handleChange);
|
|
return () => media.removeEventListener("change", handleChange);
|
|
}, []);
|
|
|
|
const refreshEntries = useCallback(
|
|
async (userId: string, showLoader = true) => {
|
|
if (showLoader) setDataLoading(true);
|
|
setSyncError("");
|
|
try {
|
|
const remoteEntries = await listEntries(userId);
|
|
setEntries(sortEntries(remoteEntries));
|
|
writeCachedEntries(userId, remoteEntries);
|
|
cacheHydratedRef.current = false;
|
|
setNotice(`Synced ${remoteEntries.length} Appwrite entr${remoteEntries.length === 1 ? "y" : "ies"}.`);
|
|
} catch (error) {
|
|
if (cacheHydratedRef.current) {
|
|
// Cached entries are on screen, so one info toast replaces the generic error toast.
|
|
pushToast({ tone: "info", message: "Couldn't reach the server — showing saved data" });
|
|
} else {
|
|
const message = appwriteErrorMessage(error);
|
|
setSyncError(message);
|
|
setNotice("Appwrite sync failed.");
|
|
}
|
|
} finally {
|
|
if (showLoader) setDataLoading(false);
|
|
}
|
|
},
|
|
[pushToast],
|
|
);
|
|
|
|
useEffect(() => {
|
|
let mounted = true;
|
|
|
|
async function bootstrap() {
|
|
setAuthLoading(true);
|
|
setAuthError("");
|
|
try {
|
|
await pingAppwrite();
|
|
if (!mounted) return;
|
|
setSetupStatus({ state: "ok", message: "Appwrite ping succeeded." });
|
|
} catch (error) {
|
|
if (!mounted) return;
|
|
setSetupStatus({
|
|
state: "error",
|
|
message: error instanceof Error ? error.message : "Appwrite ping failed.",
|
|
});
|
|
}
|
|
|
|
try {
|
|
const currentUser = await account.get();
|
|
if (!mounted) return;
|
|
setUser(currentUser);
|
|
setUserLimits(parseUserLimits(currentUser.prefs));
|
|
if (typeof currentUser.prefs.themeId === "string" && currentUser.prefs.themeId) {
|
|
setThemeId(normaliseThemeId(currentUser.prefs.themeId));
|
|
}
|
|
if (typeof currentUser.prefs.themeMode === "string" && currentUser.prefs.themeMode) {
|
|
setThemeMode(normaliseThemeMode(currentUser.prefs.themeMode));
|
|
}
|
|
setNotice(`Signed in as ${currentUser.email || currentUser.name || "Appwrite user"}.`);
|
|
if (!currentUser.prefs.onboarded) {
|
|
setSetupOpen(true);
|
|
}
|
|
} catch {
|
|
if (!mounted) return;
|
|
setUser(null);
|
|
setNotice("Sign in to sync entries across devices.");
|
|
} finally {
|
|
if (mounted) setAuthLoading(false);
|
|
}
|
|
}
|
|
|
|
void bootstrap();
|
|
return () => {
|
|
mounted = false;
|
|
};
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!user) {
|
|
cacheHydratedRef.current = false;
|
|
setEntries([]);
|
|
return;
|
|
}
|
|
const cachedEntries = readCachedEntries(user.$id);
|
|
cacheHydratedRef.current = cachedEntries !== null;
|
|
if (cachedEntries) {
|
|
// Render the last synced entries immediately; the refresh below replaces them.
|
|
setEntries(sortEntries(cachedEntries));
|
|
}
|
|
void refreshEntries(user.$id);
|
|
}, [refreshEntries, user]);
|
|
|
|
useEffect(() => {
|
|
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>>(
|
|
Channel.tablesdb(appwriteConfig.databaseId).table(appwriteConfig.collectionId).row(),
|
|
(event) => {
|
|
// Bulk imports update local state directly; their row events need no refetch.
|
|
if (importInFlightRef.current) return;
|
|
if (event.payload?.userId === user.$id) scheduleRefresh();
|
|
},
|
|
);
|
|
|
|
return () => {
|
|
if (refreshDebounceRef.current !== null) {
|
|
window.clearTimeout(refreshDebounceRef.current);
|
|
refreshDebounceRef.current = null;
|
|
}
|
|
unsubscribe();
|
|
};
|
|
}, [refreshEntries, user]);
|
|
|
|
useEffect(() => {
|
|
if (notice) pushToast({ tone: "success", message: notice });
|
|
}, [notice, pushToast]);
|
|
|
|
useEffect(() => {
|
|
if (syncError) pushToast({ tone: "error", message: syncError });
|
|
}, [syncError, pushToast]);
|
|
|
|
const allFlavours = useMemo(
|
|
() => mergedFlavours(entries.map((entry) => entry.flavour)),
|
|
[entries],
|
|
);
|
|
const entryDerived = useMemo(() => buildEntryDerived(entries), [entries]);
|
|
const entriesInView = useMemo(
|
|
() => sortEntries(applyFilters(entries, filters)),
|
|
[entries, filters],
|
|
);
|
|
const summary = useMemo(() => buildDashboard(entries), [entries]);
|
|
const limitCheck = useMemo(() => evaluateLimits(userLimits, entries), [userLimits, entries]);
|
|
const chartData = useMemo(() => groupByDay(entriesInView), [entriesInView]);
|
|
const weekData = useMemo(() => groupByWeek(entriesInView), [entriesInView]);
|
|
const flavourData = useMemo(() => groupByFlavour(entriesInView), [entriesInView]);
|
|
const insights = useMemo(() => buildInsights(entries), [entries]);
|
|
const recentEntries = useMemo(() => entries.slice(0, 5), [entries]);
|
|
const pendingDeleteEntry = pendingDeleteId ? entries.find((entry) => entry.id === pendingDeleteId) ?? null : null;
|
|
const deletingId = busyAction?.startsWith("delete-") ? busyAction.slice("delete-".length) : null;
|
|
|
|
async function login(email: string, password: string) {
|
|
setBusyAction("auth");
|
|
setAuthError("");
|
|
try {
|
|
await account.createEmailPasswordSession({ email, password });
|
|
const currentUser = await account.get();
|
|
setUser(currentUser);
|
|
setUserLimits(parseUserLimits(currentUser.prefs));
|
|
if (typeof currentUser.prefs.themeId === "string" && currentUser.prefs.themeId) {
|
|
setThemeId(normaliseThemeId(currentUser.prefs.themeId));
|
|
}
|
|
if (typeof currentUser.prefs.themeMode === "string" && currentUser.prefs.themeMode) {
|
|
setThemeMode(normaliseThemeMode(currentUser.prefs.themeMode));
|
|
}
|
|
setNotice(`Signed in as ${currentUser.email}.`);
|
|
if (!currentUser.prefs.onboarded) {
|
|
setSetupOpen(true);
|
|
}
|
|
} catch (error) {
|
|
setAuthError(appwriteErrorMessage(error));
|
|
} finally {
|
|
setBusyAction(null);
|
|
}
|
|
}
|
|
|
|
async function signup(name: string, email: string, password: string) {
|
|
setBusyAction("auth");
|
|
setAuthError("");
|
|
try {
|
|
await account.create({
|
|
userId: makeId(),
|
|
email,
|
|
password,
|
|
name: name.trim() || undefined,
|
|
});
|
|
await account.createEmailPasswordSession({ email, password });
|
|
const currentUser = await account.get();
|
|
setUser(currentUser);
|
|
setNotice(`Welcome, ${currentUser.name || currentUser.email}.`);
|
|
setSetupOpen(true);
|
|
} catch (error) {
|
|
setAuthError(appwriteErrorMessage(error));
|
|
} finally {
|
|
setBusyAction(null);
|
|
}
|
|
}
|
|
|
|
async function logout() {
|
|
setBusyAction("logout");
|
|
setSyncError("");
|
|
try {
|
|
await account.deleteSession({ sessionId: "current" });
|
|
if (user) clearCachedEntries(user.$id);
|
|
setUser(null);
|
|
setEntries([]);
|
|
setNotice("Logged out.");
|
|
} catch (error) {
|
|
setSyncError(appwriteErrorMessage(error));
|
|
} finally {
|
|
setBusyAction(null);
|
|
}
|
|
}
|
|
|
|
function openNewEntry() {
|
|
setEditingEntry(null);
|
|
setEntryInitialDraft(null);
|
|
setIsEntryModalOpen(true);
|
|
}
|
|
|
|
function openBarcodeScanner() {
|
|
setIsBarcodeScannerOpen(true);
|
|
}
|
|
|
|
function addBarcodeDraft(draft: EntryDraft) {
|
|
setIsBarcodeScannerOpen(false);
|
|
saveDraftWithLimitCheck(draft);
|
|
}
|
|
|
|
function editBarcodeDraft(draft: EntryDraft) {
|
|
setIsBarcodeScannerOpen(false);
|
|
setEditingEntry(null);
|
|
setEntryInitialDraft(draft);
|
|
setIsEntryModalOpen(true);
|
|
}
|
|
|
|
function logEntryAgain(entry: RedBullEntry) {
|
|
setEditingEntry(null);
|
|
setEntryInitialDraft({
|
|
cans: entry.cans,
|
|
flavour: entry.flavour,
|
|
flavourAccent: entry.flavourAccent,
|
|
sizeMl: entry.sizeMl,
|
|
pricePerCan: entry.pricePerCan,
|
|
dateTime: new Date().toISOString(),
|
|
notes: entry.notes ?? "",
|
|
store: entry.store ?? "",
|
|
sugarFree: entry.sugarFree,
|
|
caffeineMgPerCan: entry.caffeineMgPerCan,
|
|
source: entry.source,
|
|
});
|
|
setIsEntryModalOpen(true);
|
|
}
|
|
|
|
async function saveUserLimits(next: UserLimits) {
|
|
if (!user) return;
|
|
setBusyAction("save-limits");
|
|
setSyncError("");
|
|
try {
|
|
const prefs = mergePrefsWithLimits(user.prefs, next);
|
|
await account.updatePrefs(prefs);
|
|
const currentUser = await account.get();
|
|
setUser(currentUser);
|
|
setUserLimits(parseUserLimits(currentUser.prefs));
|
|
setNotice("Daily limits saved to your account.");
|
|
} catch (error) {
|
|
setSyncError(appwriteErrorMessage(error));
|
|
} finally {
|
|
setBusyAction(null);
|
|
}
|
|
}
|
|
|
|
async function saveOnboarding(limits: UserLimits, onboardingThemeId: string): Promise<boolean> {
|
|
if (!user) return false;
|
|
setBusyAction("save-onboarding");
|
|
setSyncError("");
|
|
try {
|
|
const limitsPrefs = mergePrefsWithLimits(user.prefs, limits);
|
|
const nextPrefs = {
|
|
...limitsPrefs,
|
|
themeId: onboardingThemeId,
|
|
themeMode,
|
|
onboarded: true,
|
|
};
|
|
await account.updatePrefs(nextPrefs);
|
|
const currentUser = await account.get();
|
|
setUser(currentUser);
|
|
setUserLimits(parseUserLimits(currentUser.prefs));
|
|
setThemeId(onboardingThemeId);
|
|
setSetupOpen(false);
|
|
setNotice("Setup saved.");
|
|
return true;
|
|
} catch (error) {
|
|
setSyncError(appwriteErrorMessage(error));
|
|
return false;
|
|
} finally {
|
|
setBusyAction(null);
|
|
}
|
|
}
|
|
|
|
async function saveDraft(action: PendingLimitAction) {
|
|
if (!user) return;
|
|
const loadingKey = action.kind === "quick" ? `quick-${action.quickLabel ?? "add"}` : "save-entry";
|
|
setBusyAction(loadingKey);
|
|
setSyncError("");
|
|
try {
|
|
const editing = action.editingId ? entries.find((entry) => entry.id === action.editingId) : null;
|
|
const saved = editing
|
|
? await updateEntry(user.$id, editing.id, { ...action.draft, source: editing.source })
|
|
: await createEntry(user.$id, { ...action.draft, source: action.draft.source ?? "manual" });
|
|
setEntries((current) =>
|
|
sortEntries(editing ? current.map((entry) => (entry.id === saved.id ? saved : entry)) : [saved, ...current]),
|
|
);
|
|
setNotice(editing ? "Entry updated in Appwrite." : "Entry saved to Appwrite.");
|
|
setEditingEntry(null);
|
|
setEntryInitialDraft(null);
|
|
setIsEntryModalOpen(false);
|
|
} catch (error) {
|
|
setSyncError(appwriteErrorMessage(error));
|
|
} finally {
|
|
setBusyAction(null);
|
|
setLimitConfirmOpen(false);
|
|
setPendingLimitAction(null);
|
|
setLimitConfirmMessage("");
|
|
}
|
|
}
|
|
|
|
function saveDraftWithLimitCheck(draft: EntryDraft, editingId?: string) {
|
|
const check = evaluateLimits(userLimits, entries, { draft, excludeEntryId: editingId });
|
|
if (check.violations.length) {
|
|
setPendingLimitAction({ kind: "save", draft, editingId });
|
|
setLimitConfirmMessage(limitStatusMessage(check.violations, check, userLimits));
|
|
setLimitConfirmOpen(true);
|
|
return;
|
|
}
|
|
void saveDraft({ kind: "save", draft, editingId });
|
|
}
|
|
|
|
async function saveEntryDraft(draft: EntryDraft) {
|
|
if (!user) return;
|
|
saveDraftWithLimitCheck(draft, editingEntry?.id);
|
|
}
|
|
|
|
async function quickAdd(item: QuickAddItem) {
|
|
if (!user) return;
|
|
const meta = flavourMeta(item.flavour);
|
|
const draft: EntryDraft = {
|
|
cans: 1,
|
|
flavour: item.flavour,
|
|
flavourAccent: meta.accent,
|
|
sizeMl: item.sizeMl,
|
|
pricePerCan: item.pricePerCan,
|
|
dateTime: new Date().toISOString(),
|
|
sugarFree: Boolean(meta.sugarFree),
|
|
notes: "Quick add",
|
|
store: "",
|
|
source: "quick-add",
|
|
};
|
|
|
|
const check = evaluateLimits(userLimits, entries, { draft });
|
|
if (check.violations.length) {
|
|
setPendingLimitAction({ kind: "quick", draft, quickLabel: item.label });
|
|
setLimitConfirmMessage(limitStatusMessage(check.violations, check, userLimits));
|
|
setLimitConfirmOpen(true);
|
|
return;
|
|
}
|
|
|
|
void saveDraft({ kind: "quick", draft, quickLabel: item.label });
|
|
}
|
|
|
|
function confirmLimitOverride() {
|
|
if (!pendingLimitAction) return;
|
|
void saveDraft(pendingLimitAction);
|
|
}
|
|
|
|
async function deleteEntry(id: string) {
|
|
setBusyAction(`delete-${id}`);
|
|
setSyncError("");
|
|
try {
|
|
await deleteEntryDocument(id);
|
|
setEntries((current) => current.filter((entry) => entry.id !== id));
|
|
setPendingDeleteId(null);
|
|
setNotice("Entry deleted from Appwrite.");
|
|
} catch (error) {
|
|
setSyncError(appwriteErrorMessage(error));
|
|
} finally {
|
|
setBusyAction(null);
|
|
}
|
|
}
|
|
|
|
async function resetAll() {
|
|
setBusyAction("reset");
|
|
setSyncError("");
|
|
try {
|
|
await Promise.all(entries.map((entry) => deleteEntryDocument(entry.id)));
|
|
setEntries([]);
|
|
setFilters(DEFAULT_FILTERS);
|
|
setIsResetOpen(false);
|
|
setNotice("All Appwrite entries deleted.");
|
|
} catch (error) {
|
|
setSyncError(appwriteErrorMessage(error));
|
|
} finally {
|
|
setBusyAction(null);
|
|
}
|
|
}
|
|
|
|
async function exportExcel() {
|
|
setBusyAction("excel-export");
|
|
setSyncError("");
|
|
try {
|
|
const blob = await createExcelExport(entries);
|
|
downloadBlob(blob, `red-bull-intake-${new Date().toISOString().slice(0, 10)}.xlsx`);
|
|
setNotice("Excel workbook exported.");
|
|
} catch (error) {
|
|
setSyncError(error instanceof Error ? error.message : "Excel export failed.");
|
|
} finally {
|
|
setBusyAction(null);
|
|
}
|
|
}
|
|
|
|
async function importExcel(file: File | undefined) {
|
|
if (!file) return;
|
|
setBusyAction("excel-import");
|
|
setSyncError("");
|
|
try {
|
|
const preview = await parseExcelImport(file, entries);
|
|
setImportPreview(preview);
|
|
setNotice(`${preview.rows.length} Excel row${preview.rows.length === 1 ? "" : "s"} parsed for review.`);
|
|
} catch (error) {
|
|
setSyncError(error instanceof Error ? error.message : "Excel import failed.");
|
|
} finally {
|
|
if (excelFileInputRef.current) excelFileInputRef.current.value = "";
|
|
setBusyAction(null);
|
|
}
|
|
}
|
|
|
|
async function confirmImportPreview() {
|
|
if (!user || !importPreview) return;
|
|
const drafts = importPreview.rows
|
|
.filter((row) => row.entry && !row.errors.length && !row.duplicate)
|
|
.map((row) => row.entry as EntryDraft);
|
|
|
|
if (!drafts.length) {
|
|
setNotice("No valid new rows to import.");
|
|
return;
|
|
}
|
|
|
|
setBusyAction("confirm-import");
|
|
setSyncError("");
|
|
importInFlightRef.current = true;
|
|
setImportProgress({ done: 0, total: drafts.length });
|
|
try {
|
|
const saved = await createEntries(user.$id, drafts, (done, total) => setImportProgress({ done, total }));
|
|
setEntries((current) => sortEntries([...saved, ...current]));
|
|
setImportPreview(null);
|
|
setNotice(`${saved.length} row${saved.length === 1 ? "" : "s"} saved to Appwrite.`);
|
|
} catch (error) {
|
|
setSyncError(appwriteErrorMessage(error));
|
|
} finally {
|
|
importInFlightRef.current = false;
|
|
setImportProgress(null);
|
|
setBusyAction(null);
|
|
}
|
|
}
|
|
|
|
function exportJson() {
|
|
const blob = new Blob([exportPayload(entries)], { type: "application/json" });
|
|
downloadBlob(blob, `red-bull-intake-${new Date().toISOString().slice(0, 10)}.json`);
|
|
setNotice("JSON backup exported.");
|
|
}
|
|
|
|
async function importJson(file: File | undefined) {
|
|
if (!file || !user) return;
|
|
setBusyAction("json-import");
|
|
setSyncError("");
|
|
try {
|
|
const drafts = parseImport(await file.text());
|
|
const preview = buildJsonImportPreview(drafts, entries, file.name);
|
|
setImportPreview(preview);
|
|
setNotice(`${preview.rows.length} JSON row${preview.rows.length === 1 ? "" : "s"} parsed for review.`);
|
|
} catch (error) {
|
|
setSyncError(error instanceof Error ? error.message : "JSON import failed.");
|
|
} finally {
|
|
if (jsonFileInputRef.current) jsonFileInputRef.current.value = "";
|
|
setBusyAction(null);
|
|
}
|
|
}
|
|
|
|
if (authLoading) {
|
|
return <LoadingScreen setupStatus={setupStatus} shellStyle={shellStyle} themeId={themeId} resolvedMode={resolvedMode} />;
|
|
}
|
|
|
|
if (!user) {
|
|
return (
|
|
<AuthView
|
|
authError={authError}
|
|
busy={busyAction === "auth"}
|
|
setupStatus={setupStatus}
|
|
shellStyle={shellStyle}
|
|
themeId={themeId}
|
|
resolvedMode={resolvedMode}
|
|
onLogin={login}
|
|
onSignup={signup}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className="app-shell min-h-screen overflow-x-hidden"
|
|
data-theme={themeId}
|
|
data-mode={resolvedMode}
|
|
style={shellStyle}
|
|
>
|
|
{setupOpen && user && (
|
|
<OnboardingScreen
|
|
userName={user.name || undefined}
|
|
activeThemeId={themeId}
|
|
onThemeChange={setThemeId}
|
|
onSave={saveOnboarding}
|
|
onClose={() => setSetupOpen(false)}
|
|
initialLimits={userLimits}
|
|
/>
|
|
)}
|
|
<input
|
|
ref={excelFileInputRef}
|
|
className="hidden"
|
|
type="file"
|
|
accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
|
onChange={(event) => void importExcel(event.currentTarget.files?.[0])}
|
|
/>
|
|
<input
|
|
ref={jsonFileInputRef}
|
|
className="hidden"
|
|
type="file"
|
|
accept="application/json"
|
|
onChange={(event) => void importJson(event.currentTarget.files?.[0])}
|
|
/>
|
|
|
|
<ShellBackdrop />
|
|
|
|
<div className="app-layout">
|
|
<Sidebar
|
|
activeView={activeView}
|
|
dataLoading={dataLoading}
|
|
setupStatus={setupStatus}
|
|
user={user}
|
|
onAdd={openNewEntry}
|
|
onScan={openBarcodeScanner}
|
|
onChange={setActiveView}
|
|
onOpenSettings={() => setActiveView("settings")}
|
|
/>
|
|
|
|
<div className="app-content">
|
|
<TopBar
|
|
activeView={activeView}
|
|
busyAction={busyAction}
|
|
onAdd={openNewEntry}
|
|
onScan={openBarcodeScanner}
|
|
className={activeView === "overview" ? "top-app-bar--overview" : ""}
|
|
/>
|
|
|
|
<StatusRail busyAction={busyAction} setupStatus={setupStatus} />
|
|
|
|
<AnimatePresence mode="wait">
|
|
<motion.main
|
|
key={activeView}
|
|
initial={{ opacity: 0, y: 10 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
exit={{ opacity: 0, y: -8 }}
|
|
transition={VIEW_TRANSITION}
|
|
className="app-view"
|
|
>
|
|
{activeView === "overview" && (
|
|
<OverviewView
|
|
summary={summary}
|
|
entries={entries}
|
|
insights={insights}
|
|
quickAdds={QUICK_ADDS}
|
|
recentEntries={recentEntries}
|
|
chartData={chartData}
|
|
flavourData={flavourData}
|
|
user={user}
|
|
userLimits={userLimits}
|
|
limitCheck={limitCheck}
|
|
loading={dataLoading}
|
|
onQuickAdd={(item) => void quickAdd(item)}
|
|
onAdd={openNewEntry}
|
|
onScan={openBarcodeScanner}
|
|
onOpenLogbook={() => setActiveView("logbook")}
|
|
onOpenSettings={() => setActiveView("settings")}
|
|
/>
|
|
)}
|
|
|
|
{activeView === "logbook" && (
|
|
<LogbookView
|
|
entries={entriesInView}
|
|
totalEntries={entries.length}
|
|
entryDerived={entryDerived}
|
|
filters={filters}
|
|
flavours={allFlavours}
|
|
loading={dataLoading}
|
|
deletingId={deletingId}
|
|
onFilterChange={setFilters}
|
|
onAdd={openNewEntry}
|
|
onEdit={(entry) => {
|
|
setEditingEntry(entry);
|
|
setIsEntryModalOpen(true);
|
|
}}
|
|
onDelete={(id) => setPendingDeleteId(id)}
|
|
onLogAgain={logEntryAgain}
|
|
/>
|
|
)}
|
|
|
|
{activeView === "trends" && (
|
|
<TrendsView
|
|
chartData={chartData}
|
|
weekData={weekData}
|
|
flavourData={flavourData}
|
|
insights={insights}
|
|
entries={entriesInView}
|
|
filters={filters}
|
|
flavours={allFlavours}
|
|
userLimits={userLimits}
|
|
loading={dataLoading}
|
|
onFilterChange={setFilters}
|
|
onSaveLimits={(next) => void saveUserLimits(next)}
|
|
/>
|
|
)}
|
|
|
|
{activeView === "settings" && (
|
|
<SettingsView
|
|
activeTheme={activeTheme}
|
|
summary={summary}
|
|
dataLoading={dataLoading}
|
|
entries={entries}
|
|
setupStatus={setupStatus}
|
|
themeId={themeId}
|
|
themeMode={themeMode}
|
|
user={user}
|
|
userLimits={userLimits}
|
|
limitCheck={limitCheck}
|
|
busyAction={busyAction}
|
|
onExportExcel={() => void exportExcel()}
|
|
onImportExcel={() => excelFileInputRef.current?.click()}
|
|
onExportJson={exportJson}
|
|
onImportJson={() => jsonFileInputRef.current?.click()}
|
|
onLogout={() => void logout()}
|
|
onReset={() => setIsResetOpen(true)}
|
|
onThemeChange={setThemeId}
|
|
onThemeModeChange={setThemeMode}
|
|
onSaveLimits={(next) => void saveUserLimits(next)}
|
|
onRerunOnboarding={() => setSetupOpen(true)}
|
|
/>
|
|
)}
|
|
</motion.main>
|
|
</AnimatePresence>
|
|
</div>
|
|
</div>
|
|
|
|
<MobileNav activeView={activeView} onChange={setActiveView} />
|
|
|
|
{activeView === "overview" && (
|
|
<button className="fab-add" type="button" onClick={openNewEntry} aria-label="Add intake">
|
|
<Plus size={26} aria-hidden="true" />
|
|
</button>
|
|
)}
|
|
|
|
<EntryModal
|
|
entry={editingEntry}
|
|
initialDraft={entryInitialDraft}
|
|
flavours={allFlavours}
|
|
open={isEntryModalOpen}
|
|
saving={busyAction === "save-entry"}
|
|
onClose={() => {
|
|
setIsEntryModalOpen(false);
|
|
setEditingEntry(null);
|
|
setEntryInitialDraft(null);
|
|
}}
|
|
onSave={(draft) => void saveEntryDraft(draft)}
|
|
/>
|
|
|
|
<BarcodeScannerModal
|
|
busy={busyAction === "save-entry"}
|
|
flavours={allFlavours}
|
|
open={isBarcodeScannerOpen}
|
|
userId={user.$id}
|
|
onAddNow={addBarcodeDraft}
|
|
onClose={() => setIsBarcodeScannerOpen(false)}
|
|
onEditBeforeAdding={editBarcodeDraft}
|
|
/>
|
|
|
|
<ImportPreviewModal
|
|
busy={busyAction === "confirm-import"}
|
|
preview={importPreview}
|
|
importProgress={importProgress}
|
|
onClose={() => setImportPreview(null)}
|
|
onConfirm={() => void confirmImportPreview()}
|
|
/>
|
|
|
|
<ConfirmDialog
|
|
busy={busyAction === "reset"}
|
|
open={isResetOpen}
|
|
title="Delete all Appwrite entries?"
|
|
body="This removes every intake entry owned by your current Appwrite user. Export first if you want a backup."
|
|
confirmLabel="Delete all"
|
|
onCancel={() => setIsResetOpen(false)}
|
|
onConfirm={() => void resetAll()}
|
|
/>
|
|
|
|
<ConfirmDialog
|
|
busy={pendingDeleteId !== null && busyAction === `delete-${pendingDeleteId}`}
|
|
open={pendingDeleteId !== null}
|
|
title="Delete this entry?"
|
|
body={
|
|
pendingDeleteEntry
|
|
? `${pendingDeleteEntry.flavour} · ${pendingDeleteEntry.cans} can${pendingDeleteEntry.cans === 1 ? "" : "s"} · ${pendingDeleteEntry.sizeMl}ml · ${humanDateTime(pendingDeleteEntry.dateTime)}`
|
|
: "This entry will be permanently removed."
|
|
}
|
|
confirmLabel="Delete"
|
|
onCancel={() => setPendingDeleteId(null)}
|
|
onConfirm={() => {
|
|
if (pendingDeleteId) void deleteEntry(pendingDeleteId);
|
|
}}
|
|
/>
|
|
|
|
<ConfirmDialog
|
|
busy={Boolean(busyAction && pendingLimitAction)}
|
|
open={limitConfirmOpen}
|
|
title="Over your limit?"
|
|
body={limitConfirmMessage || "This intake goes past one of your daily limits."}
|
|
confirmLabel="Log anyway"
|
|
tone="warning"
|
|
onCancel={() => {
|
|
setLimitConfirmOpen(false);
|
|
setPendingLimitAction(null);
|
|
setLimitConfirmMessage("");
|
|
}}
|
|
onConfirm={confirmLimitOverride}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function sortEntries(entries: RedBullEntry[]) {
|
|
return [...entries].sort((left, right) => new Date(right.dateTime).getTime() - new Date(left.dateTime).getTime());
|
|
}
|
|
|
|
export default App;
|