From cbdd98e13329ae4d9f705e552a4cd134f1eda2ab Mon Sep 17 00:00:00 2001 From: Ned Halksworth Date: Wed, 27 May 2026 17:30:55 +0100 Subject: [PATCH] rework app shell, strip coach hooks, wire barcode flow rename state vars throughout, add barcode scanner trigger and draft flow, drop coach navigation and session, add ForecastPoint type --- src/App.tsx | 1619 ++++++++++++++++++++++++++------------------------ src/types.ts | 80 ++- 2 files changed, 904 insertions(+), 795 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 502c1e2..606ae58 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,7 +4,7 @@ import { AlertTriangle, Brain, CalendarDays, - CheckCircle2, + Camera, ChevronRight, Cloud, Command, @@ -20,8 +20,6 @@ import { Lock, LogIn, LogOut, - MessageCircle, - MessageSquarePlus, Plus, PoundSterling, RefreshCcw, @@ -70,12 +68,11 @@ import { import { BUILT_IN_FLAVOURS, DEFAULT_FLAVOUR, accentForCustomFlavour, flavourMeta, mergedFlavours } from "./data/flavours"; import { APP_THEMES, - THEME_CATEGORIES, THEME_STORAGE_KEY, getThemeById, + normaliseThemeId, readStoredThemeId, type AppTheme, - type ThemeCategory, } from "./data/themes"; import { themeTokensToStyle } from "./lib/themeTokens"; import { account, appwriteConfig, Channel, client, OAuthProvider, pingAppwrite } from "./lib/appwrite"; @@ -88,13 +85,17 @@ import { listEntries, updateEntry, } from "./lib/appwriteEntries"; +import { BarcodeScannerModal } from "./components/BarcodeScannerModal"; +import { DailyLimitsCard } from "./components/DailyLimitsCard"; +import { LimitsSettingsForm } from "./components/LimitsSettingsForm"; +import { OnboardingScreen } from "./components/OnboardingScreen"; +import { buildDynamicGreeting } from "./lib/greeting"; import { - chatStorageErrorMessage, - createEncryptedChat, - deleteEncryptedChat, - listEncryptedChats, - updateEncryptedChat, -} from "./lib/encryptedChats"; + evaluateLimits, + limitStatusMessage, + mergePrefsWithLimits, + parseUserLimits, +} from "./lib/userLimits"; import { createExcelExport, downloadBlob, parseExcelImport } from "./lib/excel"; import { caffeineFor, @@ -125,7 +126,7 @@ import { import { exportPayload, parseImport } from "./lib/storage"; import type { CoachChat, CoachMessage, DateFilter, EntryDraft, Filters, Flavour, ImportPreview, RedBullEntry } from "./types"; -type AppView = "overview" | "logbook" | "trends" | "coach" | "settings"; +type AppView = "overview" | "logbook" | "trends" | "settings"; type AuthMode = "login" | "signup"; type AuthUser = Models.User; type SetupStatus = { state: "checking" | "ok" | "error"; message: string }; @@ -133,6 +134,13 @@ type OllamaStreamChunk = { error?: string; message?: { content?: string; thinkin const OLLAMA_MODEL = "deepseek-v4-pro:cloud"; const OLLAMA_PROXY_URL = import.meta.env.VITE_OLLAMA_PROXY_URL?.trim() || "/api/ollama-chat"; +type ForecastPoint = { + label: string; + current: number; + lower: number; + limit?: number; +}; + const DEFAULT_FILTERS: Filters = { flavour: "all", dateRange: "all", @@ -152,7 +160,6 @@ const NAV_ITEMS: Array<{ id: AppView; label: string; icon: LucideIcon }> = [ { id: "overview", label: "Overview", icon: Home }, { id: "logbook", label: "Logbook", icon: CalendarDays }, { id: "trends", label: "Trends", icon: LineChart }, - { id: "coach", label: "Coach", icon: MessageCircle }, { id: "settings", label: "Settings", icon: Settings2 }, ]; @@ -171,30 +178,37 @@ function App() { 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 [actionLoading, setActionLoading] = useState(null); - const [dataError, setDataError] = useState(""); + const [busyAction, setBusyAction] = useState(null); + const [syncError, setSyncError] = useState(""); const [importPreview, setImportPreview] = 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 excelFileInputRef = useRef(null); const jsonFileInputRef = useRef(null); useEffect(() => { - localStorage.setItem(THEME_STORAGE_KEY, themeId); + localStorage.setItem(THEME_STORAGE_KEY, normaliseThemeId(themeId)); }, [themeId]); const refreshEntries = useCallback(async (userId: string, showLoader = true) => { if (showLoader) setDataLoading(true); - setDataError(""); + setSyncError(""); try { const remoteEntries = await listEntries(userId); setEntries(sortEntries(remoteEntries)); setNotice(`Synced ${remoteEntries.length} Appwrite entr${remoteEntries.length === 1 ? "y" : "ies"}.`); } catch (error) { const message = appwriteErrorMessage(error); - setDataError(message); + setSyncError(message); setNotice("Appwrite sync failed."); } finally { if (showLoader) setDataLoading(false); @@ -223,7 +237,14 @@ function App() { 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)); + } setNotice(`Signed in as ${currentUser.email || currentUser.name || "Appwrite user"}.`); + if (!currentUser.prefs.onboarded) { + setSetupOpen(true); + } } catch { if (!mounted) return; setUser(null); @@ -266,34 +287,42 @@ function App() { () => mergedFlavours(entries.map((entry) => entry.flavour)), [entries], ); - const filteredEntries = useMemo( + const entriesInView = useMemo( () => sortEntries(applyFilters(entries, filters)), [entries, filters], ); - const dashboard = useMemo(() => buildDashboard(entries), [entries]); - const chartData = useMemo(() => groupByDay(filteredEntries), [filteredEntries]); - const weekData = useMemo(() => groupByWeek(filteredEntries), [filteredEntries]); - const flavourData = useMemo(() => groupByFlavour(filteredEntries), [filteredEntries]); + 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]); async function login(email: string, password: string) { - setActionLoading("auth"); + 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)); + } setNotice(`Signed in as ${currentUser.email}.`); + if (!currentUser.prefs.onboarded) { + setSetupOpen(true); + } } catch (error) { setAuthError(appwriteErrorMessage(error)); } finally { - setActionLoading(null); + setBusyAction(null); } } async function signup(name: string, email: string, password: string) { - setActionLoading("auth"); + setBusyAction("auth"); setAuthError(""); try { await account.create({ @@ -306,16 +335,17 @@ function App() { const currentUser = await account.get(); setUser(currentUser); setNotice(`Welcome, ${currentUser.name || currentUser.email}.`); + setSetupOpen(true); } catch (error) { setAuthError(appwriteErrorMessage(error)); } finally { - setActionLoading(null); + setBusyAction(null); } } function startOAuth(provider: "github" | "google") { const selectedProvider = provider === "github" ? OAuthProvider.Github : OAuthProvider.Google; - setActionLoading("oauth"); + setBusyAction("oauth"); account.createOAuth2Session({ provider: selectedProvider, success: appwriteConfig.oauthSuccessUrl, @@ -324,46 +354,128 @@ function App() { } async function logout() { - setActionLoading("logout"); - setDataError(""); + setBusyAction("logout"); + setSyncError(""); try { await account.deleteSession({ sessionId: "current" }); setUser(null); setEntries([]); setNotice("Logged out."); } catch (error) { - setDataError(appwriteErrorMessage(error)); + setSyncError(appwriteErrorMessage(error)); } finally { - setActionLoading(null); + 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); } async function saveEntry(draft: EntryDraft) { if (!user) return; - setActionLoading("save-entry"); - setDataError(""); + setBusyAction("save-limits"); + setSyncError(""); try { - const saved = editingEntry - ? await updateEntry(user.$id, editingEntry.id, { ...draft, source: editingEntry.source }) - : await createEntry(user.$id, { ...draft, source: "manual" }); + 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) { + if (!user) return; + setBusyAction("save-onboarding"); + setSyncError(""); + try { + const limitsPrefs = mergePrefsWithLimits(user.prefs, limits); + const nextPrefs = { + ...limitsPrefs, + themeId: onboardingThemeId, + onboarded: true, + }; + await account.updatePrefs(nextPrefs); + const currentUser = await account.get(); + setUser(currentUser); + setUserLimits(parseUserLimits(currentUser.prefs)); + setThemeId(onboardingThemeId); + setSetupOpen(false); + setNotice("Setup saved."); + } catch (error) { + setSyncError(appwriteErrorMessage(error)); + } 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(editingEntry ? current.map((entry) => (entry.id === saved.id ? saved : entry)) : [saved, ...current]), ); setNotice(editingEntry ? "Entry updated in Appwrite." : "Entry saved to Appwrite."); setEditingEntry(null); + setEntryInitialDraft(null); setIsEntryModalOpen(false); } catch (error) { - setDataError(appwriteErrorMessage(error)); + setSyncError(appwriteErrorMessage(error)); } finally { - setActionLoading(null); + 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: (typeof QUICK_ADDS)[number]) { if (!user) return; const meta = flavourMeta(item.flavour); @@ -391,25 +503,32 @@ function App() { } finally { setActionLoading(null); } + + void saveDraft({ kind: "quick", draft, quickLabel: item.label }); + } + + function confirmLimitOverride() { + if (!pendingLimitAction) return; + void saveDraft(pendingLimitAction); } async function deleteEntry(id: string) { - setActionLoading(`delete-${id}`); - setDataError(""); + setBusyAction(`delete-${id}`); + setSyncError(""); try { await deleteEntryDocument(id); setEntries((current) => current.filter((entry) => entry.id !== id)); setNotice("Entry deleted from Appwrite."); } catch (error) { - setDataError(appwriteErrorMessage(error)); + setSyncError(appwriteErrorMessage(error)); } finally { - setActionLoading(null); + setBusyAction(null); } } async function resetAll() { - setActionLoading("reset"); - setDataError(""); + setBusyAction("reset"); + setSyncError(""); try { await Promise.all(entries.map((entry) => deleteEntryDocument(entry.id))); setEntries([]); @@ -417,39 +536,39 @@ function App() { setIsResetOpen(false); setNotice("All Appwrite entries deleted."); } catch (error) { - setDataError(appwriteErrorMessage(error)); + setSyncError(appwriteErrorMessage(error)); } finally { - setActionLoading(null); + setBusyAction(null); } } async function exportExcel() { - setActionLoading("excel-export"); - setDataError(""); + 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) { - setDataError(error instanceof Error ? error.message : "Excel export failed."); + setSyncError(error instanceof Error ? error.message : "Excel export failed."); } finally { - setActionLoading(null); + setBusyAction(null); } } async function importExcel(file: File | undefined) { if (!file) return; - setActionLoading("excel-import"); - setDataError(""); + 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) { - setDataError(error instanceof Error ? error.message : "Excel import failed."); + setSyncError(error instanceof Error ? error.message : "Excel import failed."); } finally { if (excelFileInputRef.current) excelFileInputRef.current.value = ""; - setActionLoading(null); + setBusyAction(null); } } @@ -464,17 +583,17 @@ function App() { return; } - setActionLoading("confirm-excel-import"); - setDataError(""); + setBusyAction("confirm-excel-import"); + setSyncError(""); try { const saved = await createEntries(user.$id, drafts); setEntries((current) => sortEntries([...saved, ...current])); setImportPreview(null); setNotice(`${saved.length} Excel row${saved.length === 1 ? "" : "s"} saved to Appwrite.`); } catch (error) { - setDataError(appwriteErrorMessage(error)); + setSyncError(appwriteErrorMessage(error)); } finally { - setActionLoading(null); + setBusyAction(null); } } @@ -486,8 +605,8 @@ function App() { async function importJson(file: File | undefined) { if (!file || !user) return; - setActionLoading("json-import"); - setDataError(""); + setBusyAction("json-import"); + setSyncError(""); try { const drafts = parseImport(await file.text()); const uniqueDrafts = drafts.filter((draft) => !isDuplicateDraft(entries, draft)); @@ -499,10 +618,10 @@ function App() { setEntries((current) => sortEntries([...saved, ...current])); setNotice(`${saved.length} JSON entr${saved.length === 1 ? "y" : "ies"} saved to Appwrite.`); } catch (error) { - setDataError(error instanceof Error ? error.message : "JSON import failed."); + setSyncError(error instanceof Error ? error.message : "JSON import failed."); } finally { if (jsonFileInputRef.current) jsonFileInputRef.current.value = ""; - setActionLoading(null); + setBusyAction(null); } } @@ -514,7 +633,7 @@ function App() { return ( + {setupOpen && user && ( + setSetupOpen(false)} + /> + )} setActiveView("settings")} /> @@ -566,18 +695,13 @@ function App() { void exportExcel()} - onImportExcel={() => excelFileInputRef.current?.click()} - onOpenSettings={() => setActiveView("settings")} - onRefresh={() => void refreshEntries(user.$id)} + onScan={openBarcodeScanner} + className={activeView === "overview" ? "top-app-bar--overview" : ""} /> - + {activeView === "overview" && ( void quickAdd(item)} onAdd={openNewEntry} - onOpenCoach={() => setActiveView("coach")} + onScan={openBarcodeScanner} onOpenLogbook={() => setActiveView("logbook")} /> )} {activeView === "logbook" && ( )} - {activeView === "coach" && } - {activeView === "settings" && ( void exportExcel()} onImportExcel={() => excelFileInputRef.current?.click()} onExportJson={exportJson} @@ -654,6 +780,8 @@ function App() { onLogout={() => void logout()} onReset={() => setIsResetOpen(true)} onThemeChange={setThemeId} + onSaveLimits={(next) => void saveUserLimits(next)} + onRerunOnboarding={() => setSetupOpen(true)} /> )} @@ -663,25 +791,39 @@ function App() { { setIsEntryModalOpen(false); setEditingEntry(null); + setEntryInitialDraft(null); }} - onSave={(draft) => void saveEntry(draft)} + onSave={(draft) => void saveEntryDraft(draft)} + /> + + setIsBarcodeScannerOpen(false)} + onEditBeforeAdding={editBarcodeDraft} /> setImportPreview(null)} onConfirm={() => void confirmExcelImport()} /> setIsResetOpen(false)} onConfirm={() => void resetAll()} /> + + { + setLimitConfirmOpen(false); + setPendingLimitAction(null); + setLimitConfirmMessage(""); + }} + onConfirm={confirmLimitOverride} + /> ); } @@ -713,15 +869,15 @@ function LoadingScreen({ themeId: string; }) { return ( -
+
-
+
-

Red Bull command centre

-

{setupStatus.message}

+

Red Bull tracker

+

{setupStatus.message}

@@ -762,82 +918,75 @@ function AuthView({ } return ( -
+
-
-
-
-
-

- Red Bull Tracker App -

-

- Glossy intake telemetry with Appwrite authentication, device sync, and finance-grade Excel exports. -

-
- - - -
- {setupStatus.state !== "ok" && ( -
- {setupStatus.message} -
- )} -
- -
-
- - +
+
+
+

Red Bull tracker

+

Track intake, sync across devices.

-
- {mode === "signup" && ( - - )} - - - - {authError && ( -
- {authError} +
+ {setupStatus.state !== "ok" && ( +
+ {setupStatus.message}
)} - - +
+ + +
-
- - OAuth - +
+ {mode === "signup" && ( + + )} + + + + {authError && ( +
+ {authError} +
+ )} + + +
+ +
+ + or + +
+ +
+ + +
@@ -856,31 +1005,6 @@ function AuthView({ ); } -function AuthSignal({ icon: Icon, label, value }: { icon: LucideIcon; label: string; value: string }) { - return ( -
-
- ); -} - -function CurrentThemeIndicator({ - theme, - onClick, -}: { - theme: AppTheme; - onClick: () => void; -}) { - return ( - - ); -} - function ThemePicker({ themeId, onChange, @@ -888,37 +1012,20 @@ function ThemePicker({ themeId: string; onChange: (id: string) => void; }) { - const [category, setCategory] = useState("vocaloid"); const activeTheme = getThemeById(themeId); - const visibleThemes = APP_THEMES.filter((theme) => theme.category === category); return (
-
- {THEME_CATEGORIES.map((entry) => ( - - ))} -
-
-
Primary
-
Surface
+
Button
+
Panel
Chart
- {visibleThemes.map((theme) => ( + {APP_THEMES.map((theme) => (
); @@ -947,6 +1054,7 @@ function Sidebar({ setupStatus, user, onAdd, + onScan, onChange, onOpenSettings, }: { @@ -956,6 +1064,7 @@ function Sidebar({ setupStatus: SetupStatus; user: AuthUser; onAdd: () => void; + onScan: () => void; onChange: (view: AppView) => void; onOpenSettings: () => void; }) { @@ -966,20 +1075,32 @@ function Sidebar({
-

Red Bull

-

Intake telemetry

+

Red Bull

+

Intake tracker

-