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(() => 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(null); const [authLoading, setAuthLoading] = useState(true); const [authError, setAuthError] = useState(""); const [setupStatus, setSetupStatus] = useState({ state: "checking", message: "Pinging Appwrite...", }); const [entries, setEntries] = useState([]); const [filters, setFilters] = useState(DEFAULT_FILTERS); const [activeView, setActiveView] = useState("overview"); const [isEntryModalOpen, setIsEntryModalOpen] = useState(false); const [entryInitialDraft, setEntryInitialDraft] = useState(null); const [editingEntry, setEditingEntry] = useState(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(null); const [syncError, setSyncError] = useState(""); const [importPreview, setImportPreview] = useState(null); const [importProgress, setImportProgress] = useState(null); const [userLimits, setUserLimits] = useState({}); const [limitConfirmOpen, setLimitConfirmOpen] = useState(false); const [limitConfirmMessage, setLimitConfirmMessage] = useState(""); const [pendingLimitAction, setPendingLimitAction] = useState(null); const [setupOpen, setSetupOpen] = useState(false); const [pendingDeleteId, setPendingDeleteId] = useState(null); const { pushToast } = useToasts(); const excelFileInputRef = useRef(null); const jsonFileInputRef = useRef(null); const importInFlightRef = useRef(false); const refreshDebounceRef = useRef(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>( 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 { 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 ; } if (!user) { return ( ); } return (
{setupOpen && user && ( setSetupOpen(false)} initialLimits={userLimits} /> )} void importExcel(event.currentTarget.files?.[0])} /> void importJson(event.currentTarget.files?.[0])} />
setActiveView("settings")} />
{activeView === "overview" && ( void quickAdd(item)} onAdd={openNewEntry} onScan={openBarcodeScanner} onOpenLogbook={() => setActiveView("logbook")} onOpenSettings={() => setActiveView("settings")} /> )} {activeView === "logbook" && ( { setEditingEntry(entry); setIsEntryModalOpen(true); }} onDelete={(id) => setPendingDeleteId(id)} onLogAgain={logEntryAgain} /> )} {activeView === "trends" && ( void saveUserLimits(next)} /> )} {activeView === "settings" && ( 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)} /> )}
{activeView === "overview" && ( )} { setIsEntryModalOpen(false); setEditingEntry(null); setEntryInitialDraft(null); }} onSave={(draft) => void saveEntryDraft(draft)} /> setIsBarcodeScannerOpen(false)} onEditBeforeAdding={editBarcodeDraft} /> setImportPreview(null)} onConfirm={() => void confirmImportPreview()} /> setIsResetOpen(false)} onConfirm={() => void resetAll()} /> setPendingDeleteId(null)} onConfirm={() => { if (pendingDeleteId) void deleteEntry(pendingDeleteId); }} /> { setLimitConfirmOpen(false); setPendingLimitAction(null); setLimitConfirmMessage(""); }} onConfirm={confirmLimitOverride} />
); } function sortEntries(entries: RedBullEntry[]) { return [...entries].sort((left, right) => new Date(right.dateTime).getTime() - new Date(left.dateTime).getTime()); } export default App;