From 0db81bddd5a7be2860baf0f617ebb97f2d475fdc Mon Sep 17 00:00:00 2001 From: Ned Halksworth Date: Sat, 23 May 2026 21:17:36 +0100 Subject: [PATCH] feat: implement user limits and onboarding features - Added user limits management with daily can and spend limits. - Integrated onboarding flow to guide users through setting limits. - Enhanced greeting messages to reflect user limits and violations. - Updated CSS for new limit-related components and improved UI consistency. - Refactored coach session to utilize user limits in interactions. --- src/App.tsx | 633 ++++++++++++++++++++++++-- src/components/DailyLimitsCard.tsx | 104 +++++ src/components/LimitsSettingsForm.tsx | 110 +++++ src/components/OnboardingScreen.tsx | 494 ++++++++++++++++++++ src/index.css | 151 ++++-- src/lib/greeting.ts | 34 +- src/lib/useCoachSession.ts | 22 +- src/lib/userLimits.ts | 204 +++++++++ src/types.ts | 17 + 9 files changed, 1683 insertions(+), 86 deletions(-) create mode 100644 src/components/DailyLimitsCard.tsx create mode 100644 src/components/LimitsSettingsForm.tsx create mode 100644 src/components/OnboardingScreen.tsx create mode 100644 src/lib/userLimits.ts diff --git a/src/App.tsx b/src/App.tsx index 8d3718f..f50912e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -14,6 +14,7 @@ import { Gauge, Github, Home, + Info, LineChart, Loader2, LogIn, @@ -84,7 +85,16 @@ import { updateEntry, } from "./lib/appwriteEntries"; import { CoachPanel } from "./components/CoachPanel"; +import { DailyLimitsCard } from "./components/DailyLimitsCard"; +import { LimitsSettingsForm } from "./components/LimitsSettingsForm"; +import { OnboardingScreen } from "./components/OnboardingScreen"; import { buildDynamicGreeting } from "./lib/greeting"; +import { + evaluateLimits, + limitStatusMessage, + mergePrefsWithLimits, + parseUserLimits, +} from "./lib/userLimits"; import type { CoachSession } from "./lib/useCoachSession"; import { useCoachSession } from "./lib/useCoachSession"; import { createExcelExport, downloadBlob, parseExcelImport } from "./lib/excel"; @@ -115,13 +125,29 @@ import { wholeNumber, } from "./lib/metrics"; import { exportPayload, parseImport } from "./lib/storage"; -import type { DateFilter, EntryDraft, Filters, Flavour, ImportPreview, RedBullEntry } from "./types"; +import type { + DateFilter, + EntryDraft, + Filters, + Flavour, + ImportPreview, + LimitCheckResult, + RedBullEntry, + UserLimits, +} from "./types"; type AppView = "overview" | "logbook" | "trends" | "coach" | "settings"; type AuthMode = "login" | "signup"; type AuthUser = Models.User; type SetupStatus = { state: "checking" | "ok" | "error"; message: string }; +type PendingLimitAction = { + kind: "save" | "quick"; + draft: EntryDraft; + editingId?: string; + quickLabel?: string; +}; + const DEFAULT_FILTERS: Filters = { flavour: "all", dateRange: "all", @@ -175,6 +201,11 @@ function App() { const [actionLoading, setActionLoading] = useState(null); const [dataError, setDataError] = 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 [showOnboarding, setShowOnboarding] = useState(false); const excelFileInputRef = useRef(null); const jsonFileInputRef = useRef(null); @@ -220,7 +251,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(currentUser.prefs.themeId); + } setNotice(`Signed in as ${currentUser.email || currentUser.name || "Appwrite user"}.`); + if (!currentUser.prefs.onboarded) { + setShowOnboarding(true); + } } catch { if (!mounted) return; setUser(null); @@ -268,12 +306,19 @@ function App() { [entries, filters], ); const dashboard = useMemo(() => buildDashboard(entries), [entries]); + const limitCheck = useMemo(() => evaluateLimits(userLimits, entries), [userLimits, entries]); const chartData = useMemo(() => groupByDay(filteredEntries), [filteredEntries]); const weekData = useMemo(() => groupByWeek(filteredEntries), [filteredEntries]); const flavourData = useMemo(() => groupByFlavour(filteredEntries), [filteredEntries]); const insights = useMemo(() => buildInsights(entries), [entries]); const recentEntries = useMemo(() => entries.slice(0, 5), [entries]); - const coachSession = useCoachSession(user ?? { $id: "", email: "", name: "" } as AuthUser, dashboard, entries); + const coachSession = useCoachSession( + user ?? ({ $id: "", email: "", name: "" } as AuthUser), + dashboard, + entries, + userLimits, + limitCheck, + ); async function login(email: string, password: string) { setActionLoading("auth"); @@ -282,7 +327,14 @@ function App() { 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(currentUser.prefs.themeId); + } setNotice(`Signed in as ${currentUser.email}.`); + if (!currentUser.prefs.onboarded) { + setShowOnboarding(true); + } } catch (error) { setAuthError(appwriteErrorMessage(error)); } finally { @@ -303,7 +355,9 @@ function App() { await account.createEmailPasswordSession({ email, password }); const currentUser = await account.get(); setUser(currentUser); + setUserLimits(parseUserLimits(currentUser.prefs)); setNotice(`Welcome, ${currentUser.name || currentUser.email}.`); + setShowOnboarding(true); } catch (error) { setAuthError(appwriteErrorMessage(error)); } finally { @@ -328,6 +382,7 @@ function App() { await account.deleteSession({ sessionId: "current" }); setUser(null); setEntries([]); + setUserLimits({}); setNotice("Logged out."); } catch (error) { setDataError(appwriteErrorMessage(error)); @@ -341,27 +396,91 @@ function App() { setIsEntryModalOpen(true); } - async function saveEntry(draft: EntryDraft) { + async function saveUserLimits(next: UserLimits) { if (!user) return; - setActionLoading("save-entry"); + setActionLoading("save-limits"); setDataError(""); 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) { + setDataError(appwriteErrorMessage(error)); + } finally { + setActionLoading(null); + } + } + + async function saveOnboarding(limits: UserLimits, onboardingThemeId: string) { + if (!user) return; + setActionLoading("save-onboarding"); + setDataError(""); + 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); + setShowOnboarding(false); + setNotice("Onboarding limits and theme saved successfully."); + } catch (error) { + setDataError(appwriteErrorMessage(error)); + } finally { + setActionLoading(null); + } + } + + async function persistEntry(action: PendingLimitAction) { + if (!user) return; + const loadingKey = action.kind === "quick" ? `quick-${action.quickLabel ?? "add"}` : "save-entry"; + setActionLoading(loadingKey); + setDataError(""); + 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]), + sortEntries(editing ? current.map((entry) => (entry.id === saved.id ? saved : entry)) : [saved, ...current]), ); - setNotice(editingEntry ? "Entry updated in Appwrite." : "Entry saved to Appwrite."); + setNotice(editing ? "Entry updated in Appwrite." : "Entry saved to Appwrite."); setEditingEntry(null); setIsEntryModalOpen(false); } catch (error) { setDataError(appwriteErrorMessage(error)); } finally { setActionLoading(null); + setLimitConfirmOpen(false); + setPendingLimitAction(null); + setLimitConfirmMessage(""); } } + function requestEntrySave(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 persistEntry({ kind: "save", draft, editingId }); + } + + async function saveEntry(draft: EntryDraft) { + if (!user) return; + requestEntrySave(draft, editingEntry?.id); + } + async function quickAdd(item: (typeof QUICK_ADDS)[number]) { if (!user) return; const meta = flavourMeta(item.flavour); @@ -378,17 +497,20 @@ function App() { source: "quick-add", }; - setActionLoading(`quick-${item.label}`); - setDataError(""); - try { - const saved = await createEntry(user.$id, draft); - setEntries((current) => sortEntries([saved, ...current])); - setNotice(`${item.label} saved to Appwrite.`); - } catch (error) { - setDataError(appwriteErrorMessage(error)); - } finally { - setActionLoading(null); + 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 persistEntry({ kind: "quick", draft, quickLabel: item.label }); + } + + function confirmLimitOverride() { + if (!pendingLimitAction) return; + void persistEntry(pendingLimitAction); } async function deleteEntry(id: string) { @@ -529,6 +651,15 @@ function App() { data-theme={themeId} style={shellStyle} > + {showOnboarding && user && ( + setShowOnboarding(false)} + /> + )} void quickAdd(item)} onAdd={openNewEntry} @@ -604,6 +737,7 @@ function App() { setActiveView("coach"); }} onOpenLogbook={() => setActiveView("logbook")} + onOpenSettings={() => setActiveView("settings")} /> )} @@ -633,6 +767,8 @@ function App() { filters={filters} flavours={allFlavours} onFilterChange={setFilters} + userLimits={userLimits} + onSaveLimits={(next) => void saveUserLimits(next)} /> )} @@ -655,6 +791,8 @@ function App() { setupStatus={setupStatus} themeId={themeId} user={user} + userLimits={userLimits} + limitCheck={limitCheck} actionLoading={actionLoading} onExportExcel={() => void exportExcel()} onImportExcel={() => excelFileInputRef.current?.click()} @@ -663,6 +801,8 @@ function App() { onLogout={() => void logout()} onReset={() => setIsResetOpen(true)} onThemeChange={setThemeId} + onSaveLimits={(next) => void saveUserLimits(next)} + onRerunOnboarding={() => setShowOnboarding(true)} /> )} @@ -675,6 +815,8 @@ function App() { flavours={allFlavours} open={isEntryModalOpen} saving={actionLoading === "save-entry"} + userLimits={userLimits} + entries={entries} onClose={() => { setIsEntryModalOpen(false); setEditingEntry(null); @@ -698,6 +840,20 @@ function App() { onCancel={() => setIsResetOpen(false)} onConfirm={() => void resetAll()} /> + + { + setLimitConfirmOpen(false); + setPendingLimitAction(null); + setLimitConfirmMessage(""); + }} + onConfirm={confirmLimitOverride} + /> ); } @@ -1070,6 +1226,8 @@ function TopBar({ month: "long", }).format(new Date()); + const [showActions, setShowActions] = useState(false); + return (
@@ -1090,13 +1248,24 @@ function TopBar({
-
- + +
-
+ + {/* Desktop actions (shown on md and up) */} +
+ + {/* Mobile actions tray (collapsible dropdown style) */} + + {showActions && ( + + + + + + )} +
); @@ -1159,10 +1369,13 @@ function OverviewView({ flavourData, user, coachSession, + userLimits, + limitCheck, onQuickAdd, onAdd, onOpenCoach, onOpenLogbook, + onOpenSettings, }: { dashboard: Dashboard; entries: RedBullEntry[]; @@ -1172,15 +1385,26 @@ function OverviewView({ chartData: Array<{ label: string; spend: number; cans: number; caffeine: number; sugar: number }>; flavourData: Array<{ name: string; value: number; spend: number; accent: string }>; user: AuthUser; + userLimits: UserLimits; + limitCheck: LimitCheckResult; coachSession: CoachSession; onQuickAdd: (item: (typeof QUICK_ADDS)[number]) => void; onAdd: () => void; onOpenCoach: (prompt?: string) => void; onOpenLogbook: () => void; + onOpenSettings: () => void; }) { + const todaySpendRaw = limitCheck.todaySpend; + const spendLimitDetail = + userLimits.dailySpendLimit != null + ? `${currency.format(todaySpendRaw)} of ${currency.format(userLimits.dailySpendLimit)} today` + : `${dashboard.monthSpend} this month`; + return (
- + + +
-
- - -
- - - - + + + {limitCheck.violations.length ? ( +
+
+
- -
+
+ ) : null}
- +
@@ -1288,14 +1519,24 @@ function OverviewView({ function GreetingPanel({ dashboard, user, + userLimits, + limitCheck, onOpenCoach, }: { dashboard: Dashboard; user: AuthUser; + userLimits: UserLimits; + limitCheck: LimitCheckResult; onOpenCoach: (prompt?: string) => void; }) { const todayNumber = Number.parseFloat(dashboard.todayCans) || 0; - const progress = Math.min(100, Math.round((todayNumber / 4) * 100)); + const canLimit = userLimits.dailyCanLimit; + const progress = canLimit ? Math.min(100, Math.round((todayNumber / canLimit) * 100)) : 0; + const ringState = limitCheck.violations.includes("cans") + ? "over" + : canLimit && todayNumber >= canLimit * 0.75 + ? "warn" + : "ok"; const name = firstName(user); const greeting = buildDynamicGreeting({ name, @@ -1304,6 +1545,8 @@ function GreetingPanel({ currentStreak: Number.parseInt(dashboard.currentStreak, 10) || 0, todayCaffeineMg: Number.parseFloat(dashboard.todayCaffeine.replace(/[^\d.]/g, "")) || 0, allTimeCans: Number.parseFloat(dashboard.allTimeCans) || 0, + dailyCanLimit: canLimit, + limitCheck, }); const coachPrompts = [ @@ -1324,10 +1567,16 @@ function GreetingPanel({ return (
-
+
{dashboard.todayCans} - today + {canLimit ? `of ${canLimit}` : "today"}
@@ -1370,12 +1619,25 @@ function WellnessPill({ label, value }: { label: string; value: string }) { function TodayPanel({ dashboard, entries, + userLimits, + limitCheck, onAdd, }: { dashboard: Dashboard; entries: RedBullEntry[]; + userLimits: UserLimits; + limitCheck: LimitCheckResult; onAdd: () => void; }) { + const limitSummary = [ + userLimits.dailyCanLimit != null ? `${limitCheck.todayCans.toFixed(1)}/${userLimits.dailyCanLimit} cans` : null, + userLimits.dailySpendLimit != null + ? `${currency.format(limitCheck.todaySpend)} of ${currency.format(userLimits.dailySpendLimit)} spend` + : null, + ] + .filter(Boolean) + .join(" · "); + return (

Today

@@ -1383,6 +1645,7 @@ function TodayPanel({

{dashboard.todayCans}

cans logged

+ {limitSummary ?

{limitSummary}

: null}
@@ -1472,6 +1735,8 @@ function TrendsView({ filters, flavours, onFilterChange, + userLimits, + onSaveLimits, }: { chartData: Array<{ label: string; spend: number; cans: number; caffeine: number; sugar: number }>; weekData: Array<{ label: string; spend: number; cans: number }>; @@ -1481,6 +1746,8 @@ function TrendsView({ filters: Filters; flavours: Flavour[]; onFilterChange: (filters: Filters) => void; + userLimits: UserLimits; + onSaveLimits: (limits: UserLimits) => void; }) { return (
@@ -1573,11 +1840,217 @@ function TrendsView({ ))}
+ +
+ +
); } +function SpendingPredictionsCard({ + entries, + userLimits, + onSaveLimits, +}: { + entries: RedBullEntry[]; + userLimits: UserLimits; + onSaveLimits?: (limits: UserLimits) => void; +}) { + const [projectionDays, setProjectionDays] = useState<7 | 30 | 90 | 365>(30); + const now = new Date(); + + // Establish typical daily averages over last 30 calendar days (or all time if tracked less than 30 days) + const firstEntryDate = useMemo(() => { + if (!entries.length) return now; + return new Date( + [...entries].sort( + (a, b) => new Date(a.dateTime).getTime() - new Date(b.dateTime).getTime() + )[0].dateTime + ); + }, [entries]); + + const trackingDays = useMemo(() => { + const diffTime = Math.abs(now.getTime() - firstEntryDate.getTime()); + return Math.max(1, Math.ceil(diffTime / (1000 * 60 * 60 * 24))); + }, [firstEntryDate]); + + const activePeriodDays = Math.min(30, trackingDays); + + const stats = useMemo(() => { + const cutoff = new Date(now.getTime() - activePeriodDays * 24 * 60 * 60 * 1000); + const recent = entries.filter((e) => new Date(e.dateTime) >= cutoff); + const totalSpend = recent.reduce((sum, e) => sum + e.cans * e.pricePerCan, 0); + const totalCans = recent.reduce((sum, e) => sum + e.cans, 0); + + return { + avgDailySpend: totalSpend / activePeriodDays, + avgDailyCans: totalCans / activePeriodDays, + hasData: entries.length > 0, + }; + }, [entries, activePeriodDays]); + + const projectionData = useMemo(() => { + return Array.from({ length: projectionDays }).map((_, index) => { + const day = index + 1; + const dataPoint: any = { + label: `Day ${day}`, + "Current Path": Number((day * stats.avgDailySpend).toFixed(2)), + "Optimal Path (-20%)": Number((day * stats.avgDailySpend * 0.8).toFixed(2)), + }; + if (userLimits.dailySpendLimit != null) { + dataPoint["Daily Limit Path"] = Number((day * userLimits.dailySpendLimit).toFixed(2)); + } + return dataPoint; + }); + }, [projectionDays, stats, userLimits.dailySpendLimit]); + + if (!stats.hasData) { + return ( + + + + ); + } + + const projectedSpend = stats.avgDailySpend * projectionDays; + const projectedCans = stats.avgDailyCans * projectionDays; + const optimalSpend = projectedSpend * 0.8; + const potentialSavings = projectedSpend - optimalSpend; + + const handleApplyOptimalLimit = () => { + if (!onSaveLimits) return; + const optimalDailySpendLimit = Math.round(stats.avgDailySpend * 0.8 * 100) / 100; + onSaveLimits({ + ...userLimits, + dailySpendLimit: optimalDailySpendLimit, + }); + }; + + return ( + +
+ {/* Toggle Range */} +
+

Select projection window:

+
+ {([7, 30, 90, 365] as const).map((days) => ( + + ))} +
+
+ + {/* Projections Stats Grid */} +
+
+ Projected spend +

{currency.format(projectedSpend)}

+ + ~{oneDecimal.format(projectedCans)} cans logged + +
+ +
+ Optimal path (-20%) +

{currency.format(optimalSpend)}

+ + ~{oneDecimal.format(projectedCans * 0.8)} cans logged + +
+ +
+
+ Potential savings +

{currency.format(potentialSavings)}

+
+ {onSaveLimits && ( + + )} +
+
+ + {/* Projections Recharts AreaChart */} +
+ + + + + + + + + + + + + + + `£${val}`} /> + } /> + + + {userLimits.dailySpendLimit != null && ( + + )} + + +
+ +
+ + + The Optimal Path models a sustainable 20% reduction target, which fits guidelines for a healthy energy drink moderation pace. If a budget is active, the Limit Path displays the projection if you exhaust your daily limit budget completely every day. + +
+
+
+ ); +} + + function SettingsView({ activeTheme, dashboard, @@ -1587,6 +2060,8 @@ function SettingsView({ setupStatus, themeId, user, + userLimits, + limitCheck, actionLoading, onExportExcel, onImportExcel, @@ -1595,6 +2070,8 @@ function SettingsView({ onLogout, onReset, onThemeChange, + onSaveLimits, + onRerunOnboarding, }: { activeTheme: AppTheme; dashboard: Dashboard; @@ -1603,7 +2080,9 @@ function SettingsView({ notice: string; setupStatus: SetupStatus; themeId: string; - user: AuthUser; + user: AuthUser | null; + userLimits: UserLimits; + limitCheck: LimitCheckResult; actionLoading: string | null; onExportExcel: () => void; onImportExcel: () => void; @@ -1612,14 +2091,35 @@ function SettingsView({ onLogout: () => void; onReset: () => void; onThemeChange: (id: string) => void; + onSaveLimits: (limits: UserLimits) => void; + onRerunOnboarding: () => void; }) { return (
+ + +
+ +
+
+
-

{user.name || "Appwrite user"}

-

{user.email}

+

{user?.name || "Appwrite user"}

+

{user?.email}

{dataLoading ?
+ {draftLimitCheck?.violations.length ? ( +

+ {limitStatusMessage(draftLimitCheck.violations, draftLimitCheck, userLimits)} You can still save with + confirmation. +

+ ) : null} +