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.
This commit is contained in:
Ned Halksworth
2026-05-23 21:17:36 +01:00
parent e3ba9bab6b
commit 34c048d63e
9 changed files with 1683 additions and 86 deletions
+584 -47
View File
@@ -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<Models.Preferences>;
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<string | null>(null);
const [dataError, setDataError] = useState("");
const [importPreview, setImportPreview] = useState<ImportPreview | null>(null);
const [userLimits, setUserLimits] = useState<UserLimits>({});
const [limitConfirmOpen, setLimitConfirmOpen] = useState(false);
const [limitConfirmMessage, setLimitConfirmMessage] = useState("");
const [pendingLimitAction, setPendingLimitAction] = useState<PendingLimitAction | null>(null);
const [showOnboarding, setShowOnboarding] = useState(false);
const excelFileInputRef = useRef<HTMLInputElement>(null);
const jsonFileInputRef = useRef<HTMLInputElement>(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 && (
<OnboardingScreen
userName={user.name || undefined}
activeThemeId={themeId}
onThemeChange={setThemeId}
onSave={saveOnboarding}
onClose={() => setShowOnboarding(false)}
/>
)}
<input
ref={excelFileInputRef}
className="hidden"
@@ -596,6 +727,8 @@ function App() {
chartData={chartData}
flavourData={flavourData}
user={user}
userLimits={userLimits}
limitCheck={limitCheck}
coachSession={coachSession}
onQuickAdd={(item) => 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)}
/>
)}
</motion.main>
@@ -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()}
/>
<ConfirmDialog
busy={Boolean(actionLoading && pendingLimitAction)}
open={limitConfirmOpen}
title="Over your limit?"
body={limitConfirmMessage || "This intake goes past one of your daily limits."}
confirmLabel="Log anyway"
onCancel={() => {
setLimitConfirmOpen(false);
setPendingLimitAction(null);
setLimitConfirmMessage("");
}}
onConfirm={confirmLimitOverride}
/>
</div>
);
}
@@ -1070,6 +1226,8 @@ function TopBar({
month: "long",
}).format(new Date());
const [showActions, setShowActions] = useState(false);
return (
<header className="top-app-bar">
<div className="top-app-bar-main">
@@ -1090,13 +1248,24 @@ function TopBar({
</div>
<div className="top-action-row">
<div className="top-action-primary">
<button className="primary-button" type="button" onClick={onAdd} disabled={Boolean(actionLoading)}>
<div className="top-action-primary w-full md:w-auto grid grid-cols-2 gap-2 md:flex md:items-center">
<button className="primary-button justify-center min-h-12 text-sm active:scale-95" type="button" onClick={onAdd} disabled={Boolean(actionLoading)}>
<Plus size={18} aria-hidden="true" />
Add Intake
</button>
<button
className={`secondary-button justify-center md:hidden min-h-12 text-sm active:scale-95 transition-all ${showActions ? "bg-white/10" : ""}`}
type="button"
onClick={() => setShowActions(!showActions)}
>
<Database size={17} aria-hidden="true" />
Actions
</button>
</div>
<div className="top-action-secondary">
{/* Desktop actions (shown on md and up) */}
<div className="top-action-secondary hidden md:flex md:items-center">
<button className="secondary-button" type="button" onClick={onRefresh} disabled={dataLoading}>
{dataLoading ? <Loader2 className="animate-spin" size={17} aria-hidden="true" /> : <RefreshCcw size={17} aria-hidden="true" />}
Sync
@@ -1110,6 +1279,47 @@ function TopBar({
Import XLSX
</button>
</div>
{/* Mobile actions tray (collapsible dropdown style) */}
<AnimatePresence>
{showActions && (
<motion.div
initial={{ opacity: 0, y: -10, height: 0 }}
animate={{ opacity: 1, y: 0, height: "auto" }}
exit={{ opacity: 0, y: -10, height: 0 }}
transition={{ duration: 0.2 }}
className="md:hidden w-full overflow-hidden p-3 rounded-2xl bg-white/[0.02] border border-white/5 grid grid-cols-1 gap-2 shadow-lg"
>
<button
className="secondary-button w-full justify-start min-h-11 px-4 text-xs font-semibold"
type="button"
onClick={() => { onRefresh(); setShowActions(false); }}
disabled={dataLoading}
>
{dataLoading ? <Loader2 className="animate-spin" size={15} aria-hidden="true" /> : <RefreshCcw size={15} aria-hidden="true" />}
Sync with Cloud
</button>
<button
className="excel-button w-full justify-start min-h-11 px-4 text-xs font-semibold"
type="button"
onClick={() => { onExportExcel(); setShowActions(false); }}
disabled={!entries.length || Boolean(actionLoading)}
>
<FileSpreadsheet size={15} aria-hidden="true" />
Export XLSX Spreadsheet
</button>
<button
className="excel-button w-full justify-start min-h-11 px-4 text-xs font-semibold"
type="button"
onClick={() => { onImportExcel(); setShowActions(false); }}
disabled={Boolean(actionLoading)}
>
<Upload size={15} aria-hidden="true" />
Import XLSX Spreadsheet
</button>
</motion.div>
)}
</AnimatePresence>
</div>
</header>
);
@@ -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 (
<div className="grid gap-4">
<GreetingPanel dashboard={dashboard} user={user} onOpenCoach={onOpenCoach} />
<GreetingPanel dashboard={dashboard} user={user} userLimits={userLimits} limitCheck={limitCheck} onOpenCoach={onOpenCoach} />
<DailyLimitsCard limits={userLimits} check={limitCheck} onOpenSettings={onOpenSettings} />
<section className="grid gap-4 xl:grid-cols-[1.15fr_0.85fr]">
<CoachPanel
@@ -1193,24 +1417,31 @@ function OverviewView({
<QuickAddPanel items={quickAdds} onQuickAdd={onQuickAdd} />
</section>
<section className="grid gap-4 xl:grid-cols-[1.25fr_0.75fr]">
<TodayPanel dashboard={dashboard} entries={entries} onAdd={onAdd} />
<AppCard title="Coach signals" subtitle="Live from your log">
<div className="grid gap-2">
<WellnessPill label="Today" value={`${dashboard.todayCans} cans`} />
<WellnessPill label="Caffeine" value={dashboard.todayCaffeine} />
<WellnessPill label="Favourite" value={dashboard.favouriteFlavour} />
<button className="list-button" type="button" onClick={() => onOpenCoach()}>
Open full coach
<ChevronRight size={16} aria-hidden="true" />
</button>
<TodayPanel dashboard={dashboard} entries={entries} userLimits={userLimits} limitCheck={limitCheck} onAdd={onAdd} />
{limitCheck.violations.length ? (
<section className="glass-panel border border-amber-200/20 bg-amber-200/10 p-4 sm:p-5">
<div className="flex items-start gap-3">
<AlertTriangle className="mt-0.5 shrink-0 text-amber-200" size={20} aria-hidden="true" />
<div>
<p className="font-semibold text-white">Limit alerts</p>
<p className="mt-1 text-sm leading-6 text-slate-300">
{limitStatusMessage(limitCheck.violations, limitCheck, userLimits)}
</p>
</div>
</div>
</AppCard>
</section>
) : null}
<section className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
<MetricTile icon={CalendarDays} label="This Month" value={dashboard.monthCans} detail={`${dashboard.monthSpend} spent`} accent={MATERIAL_ACCENTS.primary} />
<MetricTile icon={PoundSterling} label="Total Spend" value={dashboard.totalSpend} detail={`${dashboard.avgWeeklySpend} weekly average`} accent={MATERIAL_ACCENTS.secondary} />
<MetricTile
icon={PoundSterling}
label={userLimits.dailySpendLimit != null ? "Today's budget" : "Total Spend"}
value={userLimits.dailySpendLimit != null ? currency.format(todaySpendRaw) : dashboard.totalSpend}
detail={spendLimitDetail}
accent={MATERIAL_ACCENTS.secondary}
/>
<MetricTile icon={Activity} label="Favourite" value={dashboard.favouriteFlavour} detail="by total cans" accent={MATERIAL_ACCENTS.tertiary} />
<MetricTile icon={TimerReset} label="Days Without" value={dashboard.daysWithoutRedBull} detail={`${dashboard.currentStreak} day streak`} accent={MATERIAL_ACCENTS.error} />
</section>
@@ -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 (
<section className="oura-hero glass-panel p-5 sm:p-6">
<div className="grid gap-5 xl:grid-cols-[auto_1fr_auto] xl:items-center">
<div className="oura-ring" style={{ "--progress": `${progress}%` } as CSSProperties} aria-label={`${progress}% of daily guide`}>
<div
className={`oura-ring${ringState === "over" ? " oura-ring--over" : ringState === "warn" ? " oura-ring--warn" : ""}`}
style={{ "--progress": `${progress}%` } as CSSProperties}
aria-label={
canLimit ? `${progress}% of ${canLimit} can daily limit` : `${dashboard.todayCans} cans logged today`
}
>
<div>
<span>{dashboard.todayCans}</span>
<small>today</small>
<small>{canLimit ? `of ${canLimit}` : "today"}</small>
</div>
</div>
@@ -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 (
<section className="can-panel today-panel relative overflow-hidden p-5 sm:p-7">
<p className="text-sm font-medium uppercase tracking-[0.18em] text-cyan-100">Today</p>
@@ -1383,6 +1645,7 @@ function TodayPanel({
<div>
<p className="text-7xl font-semibold tracking-tight text-white sm:text-8xl">{dashboard.todayCans}</p>
<p className="mt-2 text-lg text-slate-300">cans logged</p>
{limitSummary ? <p className="mt-2 text-sm text-cyan-100/90">{limitSummary}</p> : null}
</div>
<div className="grid gap-2 sm:grid-cols-3 lg:min-w-[420px]">
<MiniMetric label="Caffeine" value={dashboard.todayCaffeine} accent={MATERIAL_ACCENTS.primary} />
@@ -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 (
<div className="grid gap-4">
@@ -1573,11 +1840,217 @@ function TrendsView({
))}
</div>
</section>
<section className="grid gap-4">
<SpendingPredictionsCard
entries={entries}
userLimits={userLimits}
onSaveLimits={onSaveLimits}
/>
</section>
</div>
);
}
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 (
<AppCard title="Spending predictions" subtitle="Simulated forecast based on past spending">
<EmptyState title="Awaiting intake logs" copy="Predictions require historical logs. Add your first intake to unlock projections!" />
</AppCard>
);
}
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 (
<AppCard
title="Future spending predictions"
subtitle={`Based on last ${activePeriodDays} days: average daily spend of ${currency.format(stats.avgDailySpend)}`}
>
<div className="space-y-6">
{/* Toggle Range */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between border-b border-white/5 pb-4">
<p className="text-sm text-slate-400">Select projection window:</p>
<div className="segmented-control max-w-xs self-start" role="tablist">
{([7, 30, 90, 365] as const).map((days) => (
<button
key={days}
type="button"
role="tab"
aria-selected={projectionDays === days}
onClick={() => setProjectionDays(days)}
className={projectionDays === days ? "segmented-control-active" : ""}
>
{days === 365 ? "1 Year" : `${days} Days`}
</button>
))}
</div>
</div>
{/* Projections Stats Grid */}
<div className="grid gap-3 sm:grid-cols-3">
<div className="p-4 rounded-2xl bg-white/[0.02] border border-white/5 space-y-1">
<span className="text-[10px] font-bold text-slate-500 uppercase tracking-wider block">Projected spend</span>
<p className="text-2xl font-black text-white">{currency.format(projectedSpend)}</p>
<span className="text-[10px] text-slate-400 block font-medium">
~{oneDecimal.format(projectedCans)} cans logged
</span>
</div>
<div className="p-4 rounded-2xl bg-emerald-500/5 border border-emerald-500/10 space-y-1">
<span className="text-[10px] font-bold text-emerald-400/80 uppercase tracking-wider block">Optimal path (-20%)</span>
<p className="text-2xl font-black text-emerald-400">{currency.format(optimalSpend)}</p>
<span className="text-[10px] text-emerald-500 block font-medium">
~{oneDecimal.format(projectedCans * 0.8)} cans logged
</span>
</div>
<div className="p-4 rounded-2xl bg-gradient-to-tr from-emerald-500/10 to-teal-500/5 border border-emerald-500/20 space-y-1 flex flex-col justify-between">
<div>
<span className="text-[10px] font-bold text-teal-300 uppercase tracking-wider block">Potential savings</span>
<p className="text-2xl font-black text-teal-300">{currency.format(potentialSavings)}</p>
</div>
{onSaveLimits && (
<button
type="button"
onClick={handleApplyOptimalLimit}
className="text-[10px] text-left font-bold text-emerald-400 hover:text-emerald-300 underline mt-1 block transition active:scale-[0.98]"
>
Lock daily limit to {currency.format(stats.avgDailySpend * 0.8)}/day
</button>
)}
</div>
</div>
{/* Projections Recharts AreaChart */}
<div className="relative p-2 rounded-2xl bg-black/20 border border-white/5">
<ResponsiveContainer width="100%" height={260}>
<AreaChart data={projectionData} margin={{ top: 12, right: 16, bottom: 0, left: -10 }}>
<defs>
<linearGradient id="currentProj" x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stopColor="var(--primary)" stopOpacity={0.2} />
<stop offset="100%" stopColor="var(--primary)" stopOpacity={0.0} />
</linearGradient>
<linearGradient id="optimalProj" x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stopColor="#10b981" stopOpacity={0.15} />
<stop offset="100%" stopColor="#10b981" stopOpacity={0.0} />
</linearGradient>
</defs>
<CartesianGrid stroke="var(--chart-grid)" vertical={false} />
<XAxis dataKey="label" stroke="var(--subtle)" tickLine={false} axisLine={false} />
<YAxis stroke="var(--subtle)" tickLine={false} axisLine={false} tickFormatter={(val) => `£${val}`} />
<Tooltip content={<ChartTooltip />} />
<Area
type="monotone"
dataKey="Current Path"
stroke="var(--primary)"
fill="url(#currentProj)"
strokeWidth={3}
/>
<Area
type="monotone"
dataKey="Optimal Path (-20%)"
stroke="#10b981"
fill="url(#optimalProj)"
strokeWidth={3}
strokeDasharray="4 4"
/>
{userLimits.dailySpendLimit != null && (
<Line
type="monotone"
dataKey="Daily Limit Path"
stroke="#f59e0b"
strokeWidth={2}
dot={false}
strokeDasharray="6 6"
/>
)}
</AreaChart>
</ResponsiveContainer>
</div>
<div className="text-xs text-slate-400 bg-white/[0.01] p-3 rounded-xl border border-white/5 flex items-start gap-2.5 leading-relaxed">
<Info size={16} className="text-cyan-400 shrink-0 mt-0.5" />
<span>
The <strong>Optimal Path</strong> models a sustainable 20% reduction target, which fits guidelines for a healthy energy drink moderation pace. If a budget is active, the <strong>Limit Path</strong> displays the projection if you exhaust your daily limit budget completely every day.
</span>
</div>
</div>
</AppCard>
);
}
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 (
<div className="grid gap-4 xl:grid-cols-[1fr_0.85fr]">
<div className="grid gap-4">
<AppCard title="Daily limits" subtitle="Personal caps for cans, spend, and stop time (BST)">
<LimitsSettingsForm
limits={userLimits}
check={limitCheck}
saving={actionLoading === "save-limits"}
onSave={onSaveLimits}
/>
<div className="mt-4 border-t border-white/5 pt-4 flex justify-end">
<button
className="inline-flex min-h-10 items-center gap-2 rounded-xl bg-white/5 border border-white/10 px-4 text-xs font-bold text-slate-300 hover:bg-white/10 transition active:scale-95"
type="button"
onClick={onRerunOnboarding}
>
<Sparkles size={14} className="text-cyan-400" />
Re-run onboarding wizard
</button>
</div>
</AppCard>
<AppCard title="Account" subtitle="Your Appwrite profile and sync status">
<div className="rounded-lg border border-white/10 bg-white/[0.05] p-4">
<p className="text-lg font-semibold text-white">{user.name || "Appwrite user"}</p>
<p className="mt-1 text-sm text-slate-400">{user.email}</p>
<p className="text-lg font-semibold text-white">{user?.name || "Appwrite user"}</p>
<p className="mt-1 text-sm text-slate-400">{user?.email}</p>
<div className="mt-4 flex items-center gap-2 text-sm text-slate-300">
{dataLoading ? <Loader2 className="animate-spin text-cyan-200" size={16} aria-hidden="true" /> : <Cloud className="text-cyan-200" size={16} aria-hidden="true" />}
{notice}
@@ -2025,6 +2525,8 @@ function EntryModal({
entry,
flavours,
saving,
userLimits,
entries,
onClose,
onSave,
}: {
@@ -2032,6 +2534,8 @@ function EntryModal({
entry: RedBullEntry | null;
flavours: Flavour[];
saving: boolean;
userLimits: UserLimits;
entries: RedBullEntry[];
onClose: () => void;
onSave: (draft: EntryDraft) => void;
}) {
@@ -2086,8 +2590,8 @@ function EntryModal({
sizePreset === "custom" && caffeineOverride.trim() ? Number(caffeineOverride) : undefined,
);
function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const draftPreview = useMemo((): EntryDraft | null => {
if (!open) return null;
const numericCans = Math.max(0.25, Number(cans) || 1);
const numericPrice = Math.max(0, Number(pricePerCan) || 0);
const finalFlavour = isOther ? customFlavour.trim() || "Other" : selectedFlavour;
@@ -2096,8 +2600,7 @@ function EntryModal({
sizePreset === "custom" && caffeineOverride.trim()
? Math.max(0, Number(caffeineOverride) || 0)
: undefined;
onSave({
return {
cans: numericCans,
flavour: finalFlavour,
flavourAccent: isOther ? customAccent || accentForCustomFlavour(finalFlavour) : meta.accent,
@@ -2109,7 +2612,34 @@ function EntryModal({
sugarFree: sugarFree || Boolean(meta.sugarFree),
caffeineMgPerCan: override,
source: entry?.source ?? "manual",
});
};
}, [
open,
cans,
pricePerCan,
isOther,
customFlavour,
selectedFlavour,
customAccent,
numericSize,
dateTime,
notes,
store,
sugarFree,
sizePreset,
caffeineOverride,
entry?.source,
]);
const draftLimitCheck = useMemo(() => {
if (!draftPreview) return null;
return evaluateLimits(userLimits, entries, { draft: draftPreview, excludeEntryId: entry?.id });
}, [draftPreview, entries, entry?.id, userLimits]);
function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!draftPreview) return;
onSave(draftPreview);
}
return (
@@ -2144,6 +2674,13 @@ function EntryModal({
</button>
</div>
{draftLimitCheck?.violations.length ? (
<p className="limit-banner mb-4" role="status">
{limitStatusMessage(draftLimitCheck.violations, draftLimitCheck, userLimits)} You can still save with
confirmation.
</p>
) : null}
<div className="grid gap-4 sm:grid-cols-2">
<label className="field-label">
Number of cans
+104
View File
@@ -0,0 +1,104 @@
import { Settings2 } from "lucide-react";
import type { LimitCheckResult, UserLimits } from "../types";
import { currency } from "../lib/metrics";
import { formatStopTimeLabel, hasAnyLimit, limitProgress } from "../lib/userLimits";
type DailyLimitsCardProps = {
limits: UserLimits;
check: LimitCheckResult;
onOpenSettings: () => void;
};
export function DailyLimitsCard({ limits, check, onOpenSettings }: DailyLimitsCardProps) {
if (!hasAnyLimit(limits)) {
return (
<section className="limits-card glass-panel p-5 sm:p-6">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<p className="text-sm font-medium uppercase tracking-[0.18em] text-cyan-100">Daily limits</p>
<p className="mt-2 max-w-xl text-sm leading-6 text-slate-400">
Set how many cans you want per day, when to stop, and a spend cap. Limits are optional and stored on your
account.
</p>
</div>
<button className="secondary-button shrink-0" type="button" onClick={onOpenSettings}>
<Settings2 size={17} aria-hidden="true" />
Set limits
</button>
</div>
</section>
);
}
const canOver = check.violations.includes("cans");
const spendOver = check.violations.includes("spend");
const stopActive = limits.stopTime && check.pastStopTime;
return (
<section className="limits-card glass-panel p-5 sm:p-6">
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
<p className="text-sm font-medium uppercase tracking-[0.18em] text-cyan-100">Daily limits</p>
<button className="list-button !min-h-9 !px-3 !py-1.5 text-xs" type="button" onClick={onOpenSettings}>
<Settings2 size={14} aria-hidden="true" />
Edit
</button>
</div>
<div className="grid gap-4 md:grid-cols-3">
{limits.dailyCanLimit != null ? (
<LimitRow
label="Cans today"
value={`${check.todayCans.toFixed(1)} / ${limits.dailyCanLimit}`}
progress={limitProgress(check.todayCans, limits.dailyCanLimit)}
state={canOver ? "over" : check.todayCans >= limits.dailyCanLimit * 0.75 ? "warn" : "ok"}
/>
) : null}
{limits.dailySpendLimit != null ? (
<LimitRow
label="Spend today"
value={`${currency.format(check.todaySpend)} / ${currency.format(limits.dailySpendLimit)}`}
progress={limitProgress(check.todaySpend, limits.dailySpendLimit)}
state={spendOver ? "over" : check.todaySpend >= limits.dailySpendLimit * 0.75 ? "warn" : "ok"}
/>
) : null}
{limits.stopTime ? (
<div className={`limit-row limit-row--${stopActive ? "over" : "ok"}`}>
<div className="limit-row-head">
<span>Stop by</span>
<strong>{formatStopTimeLabel(limits.stopTime)}</strong>
</div>
<p className="limit-row-value">
{stopActive ? "Past your stop time" : "Still within your window"}
</p>
</div>
) : null}
</div>
</section>
);
}
function LimitRow({
label,
value,
progress,
state,
}: {
label: string;
value: string;
progress: number;
state: "ok" | "warn" | "over";
}) {
return (
<div className={`limit-row limit-row--${state}`}>
<div className="limit-row-head">
<span>{label}</span>
<strong>{value}</strong>
</div>
<div className="limit-progress" aria-hidden="true">
<div className="limit-progress-fill" style={{ width: `${progress}%` }} />
</div>
</div>
);
}
+110
View File
@@ -0,0 +1,110 @@
import { Loader2, Target } from "lucide-react";
import { useEffect, useState, type FormEvent } from "react";
import type { LimitCheckResult, UserLimits } from "../types";
import { currency } from "../lib/metrics";
type LimitsSettingsFormProps = {
limits: UserLimits;
check: LimitCheckResult;
saving: boolean;
onSave: (limits: UserLimits) => void;
};
export function LimitsSettingsForm({ limits, check, saving, onSave }: LimitsSettingsFormProps) {
const [canInput, setCanInput] = useState(limits.dailyCanLimit?.toString() ?? "");
const [spendInput, setSpendInput] = useState(limits.dailySpendLimit?.toString() ?? "");
const [stopInput, setStopInput] = useState(limits.stopTime ?? "");
useEffect(() => {
setCanInput(limits.dailyCanLimit?.toString() ?? "");
setSpendInput(limits.dailySpendLimit?.toString() ?? "");
setStopInput(limits.stopTime ?? "");
}, [limits.dailyCanLimit, limits.dailySpendLimit, limits.stopTime]);
function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const next: UserLimits = {};
const canTrim = canInput.trim();
if (canTrim) {
const parsed = Math.max(0.25, Number(canTrim) || 0);
if (parsed > 0) next.dailyCanLimit = parsed;
}
const spendTrim = spendInput.trim();
if (spendTrim) {
const parsed = Math.max(0, Number(spendTrim) || 0);
next.dailySpendLimit = parsed;
}
if (stopInput.trim()) {
next.stopTime = stopInput;
}
onSave(next);
}
const previewParts: string[] = [];
if (limits.dailyCanLimit != null) {
previewParts.push(`${check.todayCans.toFixed(1)}/${limits.dailyCanLimit} cans today`);
}
if (limits.dailySpendLimit != null) {
previewParts.push(`${currency.format(check.todaySpend)} of ${currency.format(limits.dailySpendLimit)} spent today`);
}
return (
<form className="grid gap-4" onSubmit={submit}>
<div className="grid gap-4 sm:grid-cols-2">
<label className="grid gap-2 text-sm">
<span className="font-medium text-slate-300">Cans per day</span>
<input
className="field-input"
type="number"
min={0.25}
step={0.25}
placeholder="e.g. 3"
value={canInput}
onChange={(event) => setCanInput(event.target.value)}
/>
<span className="text-xs text-slate-500">Leave empty to remove. Counts use BST calendar days.</span>
</label>
<label className="grid gap-2 text-sm">
<span className="font-medium text-slate-300">Spend per day (£)</span>
<input
className="field-input"
type="number"
min={0}
step={0.01}
placeholder="e.g. 5.00"
value={spendInput}
onChange={(event) => setSpendInput(event.target.value)}
/>
<span className="text-xs text-slate-500">Based on price per can in your log.</span>
</label>
</div>
<label className="grid gap-2 text-sm sm:max-w-xs">
<span className="font-medium text-slate-300">Stop drinking by</span>
<input
className="field-input"
type="time"
value={stopInput}
onChange={(event) => setStopInput(event.target.value)}
/>
<span className="text-xs text-slate-500">Europe/London (BST/GMT). Leave empty to remove.</span>
</label>
{previewParts.length ? (
<p className="rounded-lg border border-white/10 bg-white/[0.05] px-3 py-2 text-sm text-slate-300">
Today so far: {previewParts.join(" · ")}
</p>
) : null}
<button className="primary-button w-fit" type="submit" disabled={saving}>
{saving ? <Loader2 className="animate-spin" size={17} aria-hidden="true" /> : <Target size={17} aria-hidden="true" />}
Save limits
</button>
</form>
);
}
+494
View File
@@ -0,0 +1,494 @@
import { useMemo, useState } from "react";
import { ArrowRight, Check, ChevronLeft } from "lucide-react";
import { APP_THEMES, THEME_CATEGORIES, type ThemeCategory } from "../data/themes";
import { currency } from "../lib/metrics";
import type { UserLimits } from "../types";
type OnboardingScreenProps = {
onSave: (limits: UserLimits, themeId: string) => Promise<void>;
onClose: () => void;
activeThemeId: string;
onThemeChange: (themeId: string) => void;
userName?: string;
};
const STEP_COUNT = 6;
const curfewOptions: Array<{ id: string; label: string; hint: string }> = [
{ id: "16:00", label: "4:00 PM", hint: "Early cut-off" },
{ id: "18:00", label: "6:00 PM", hint: "Balanced default" },
{ id: "20:00", label: "8:00 PM", hint: "Late schedule" },
{ id: "none", label: "No curfew", hint: "Only track intake" },
];
export function OnboardingScreen({
onSave,
onClose,
activeThemeId,
onThemeChange,
userName,
}: OnboardingScreenProps) {
const [step, setStep] = useState(1);
const [dailyCanLimit, setDailyCanLimit] = useState<number | "none">(2);
const [dailySpendLimit, setDailySpendLimit] = useState<number | "none">(3.5);
const [stopTime, setStopTime] = useState<string | "none">("18:00");
const [saving, setSaving] = useState(false);
const [activeCategory, setActiveCategory] = useState<ThemeCategory>("flavour");
const visibleThemes = useMemo(() => {
return APP_THEMES.filter((theme) => theme.category === activeCategory);
}, [activeCategory]);
const activeTheme = useMemo(() => {
return APP_THEMES.find((theme) => theme.id === activeThemeId) ?? APP_THEMES[0];
}, [activeThemeId]);
const progress = `${(step / STEP_COUNT) * 100}%`;
async function handleFinish() {
setSaving(true);
try {
const limits: UserLimits = {};
if (dailyCanLimit !== "none") limits.dailyCanLimit = dailyCanLimit;
if (dailySpendLimit !== "none") limits.dailySpendLimit = dailySpendLimit;
if (stopTime !== "none") limits.stopTime = stopTime;
await onSave(limits, activeThemeId);
onClose();
} catch (err) {
console.error("Failed to save onboarding preferences", err);
} finally {
setSaving(false);
}
}
function incrementCans() {
if (dailyCanLimit === "none") {
setDailyCanLimit(1);
return;
}
if (dailyCanLimit < 10) setDailyCanLimit(Number((dailyCanLimit + 0.5).toFixed(1)));
}
function decrementCans() {
if (dailyCanLimit === "none") return;
if (dailyCanLimit <= 0.5) {
setDailyCanLimit("none");
return;
}
setDailyCanLimit(Number((dailyCanLimit - 0.5).toFixed(1)));
}
function incrementSpend() {
if (dailySpendLimit === "none") {
setDailySpendLimit(1);
return;
}
if (dailySpendLimit < 30) setDailySpendLimit(Number((dailySpendLimit + 0.5).toFixed(2)));
}
function decrementSpend() {
if (dailySpendLimit === "none") return;
if (dailySpendLimit <= 0.5) {
setDailySpendLimit("none");
return;
}
setDailySpendLimit(Number((dailySpendLimit - 0.5).toFixed(2)));
}
function goNext() {
setStep((current) => Math.min(current + 1, STEP_COUNT));
}
function goBack() {
setStep((current) => Math.max(current - 1, 1));
}
return (
<div
className="fixed inset-0 z-[100] flex min-h-screen flex-col overflow-y-auto px-5 py-6 sm:px-8"
style={{
background: "var(--bg)",
color: "var(--text)",
fontFamily: "inherit",
}}
>
<div
className="pointer-events-none absolute inset-0 opacity-60"
style={{
background:
"radial-gradient(circle at 76% 20%, color-mix(in srgb, var(--primary-container) 62%, transparent) 0 22%, transparent 44%), radial-gradient(circle at 12% 84%, color-mix(in srgb, var(--tertiary-container) 48%, transparent) 0 18%, transparent 42%)",
}}
/>
<header className="relative z-10 mx-auto flex w-full max-w-3xl items-center justify-between gap-4">
<div className="min-w-0 flex-1">
<div className="mb-3 h-1 overflow-hidden rounded-full bg-[var(--surface-container-high)]">
<div className="h-full rounded-full bg-[var(--primary)] transition-all duration-500" style={{ width: progress }} />
</div>
<p className="text-xs font-normal uppercase tracking-[0.18em] text-[var(--muted)]">
Question {step} of {STEP_COUNT}
</p>
</div>
<p className="hidden text-xs font-normal text-[var(--muted)] sm:block">Red Bull Intake Tracker</p>
</header>
<main className="relative z-10 mx-auto flex w-full max-w-3xl flex-1 flex-col justify-center py-10 sm:py-16">
{step === 1 && (
<section className="grid gap-9">
<div className="grid gap-5">
<p className="text-sm font-normal text-[var(--primary)]">Energy setup</p>
<h1 className="max-w-2xl text-5xl font-normal leading-[0.95] tracking-[-0.055em] sm:text-7xl">
Hey {userName || "there"}. Set your baseline.
</h1>
<p className="max-w-xl text-lg font-normal leading-8 text-[var(--muted)]">
Six quick screens. Pick a theme, then set light guardrails for cans, spend, and late caffeine.
</p>
</div>
<button
type="button"
onClick={goNext}
className="inline-flex min-h-12 w-fit items-center gap-3 rounded-full px-6 text-sm font-medium transition active:scale-[0.98]"
style={{ background: "var(--primary)", color: "var(--on-primary)" }}
>
Start
<ArrowRight size={16} />
</button>
</section>
)}
{step === 2 && (
<section className="grid gap-8">
<div className="grid gap-4">
<p className="text-sm font-normal text-[var(--primary)]">1. Visual style</p>
<h2 className="max-w-2xl text-4xl font-normal leading-tight tracking-[-0.04em] sm:text-6xl">
Choose the mood you want to see every day.
</h2>
</div>
<div className="flex flex-wrap gap-2">
{THEME_CATEGORIES.map((cat) => {
const isActive = activeCategory === cat.id;
return (
<button
key={cat.id}
type="button"
onClick={() => setActiveCategory(cat.id)}
className="rounded-full border px-4 py-2 text-sm font-normal transition"
style={{
background: isActive ? "var(--primary-container)" : "var(--surface-container-lowest)",
borderColor: isActive ? "var(--primary)" : "var(--outline-variant)",
color: isActive ? "var(--on-primary-container)" : "var(--muted)",
}}
>
{cat.label}
</button>
);
})}
</div>
<div className="grid max-h-[48vh] gap-2 overflow-y-auto pr-1 sm:grid-cols-2">
{visibleThemes.map((theme) => {
const isActive = activeThemeId === theme.id;
return (
<button
key={theme.id}
type="button"
onClick={() => onThemeChange(theme.id)}
className="flex min-h-16 items-center justify-between rounded-2xl border px-4 text-left text-sm font-normal transition"
style={{
background: isActive ? "var(--surface-container-low)" : "var(--surface-container-lowest)",
borderColor: isActive ? "var(--primary)" : "var(--outline-variant)",
color: "var(--text)",
}}
>
<span className="flex min-w-0 items-center gap-3">
<span className="h-6 w-6 shrink-0 rounded-full border border-white/40" style={{ background: theme.swatch }} />
<span className="truncate">{theme.label}</span>
</span>
{isActive && <Check size={16} style={{ color: "var(--primary)" }} />}
</button>
);
})}
</div>
<button
type="button"
onClick={goNext}
className="inline-flex min-h-12 w-fit items-center gap-3 rounded-full px-6 text-sm font-medium transition active:scale-[0.98]"
style={{ background: "var(--primary)", color: "var(--on-primary)" }}
>
Continue
<ArrowRight size={16} />
</button>
</section>
)}
{step === 3 && (
<section className="grid gap-9">
<div className="grid gap-4">
<p className="text-sm font-normal text-[var(--primary)]">2. Daily cans</p>
<h2 className="max-w-2xl text-4xl font-normal leading-tight tracking-[-0.04em] sm:text-6xl">
What is your daily can ceiling?
</h2>
<p className="max-w-lg text-base leading-7 text-[var(--muted)]">
App warns before logging past this number. You can change it later.
</p>
</div>
<div className="flex flex-wrap items-end gap-5">
<button
type="button"
onClick={decrementCans}
className="grid h-12 w-12 place-items-center rounded-full border text-2xl font-normal transition active:scale-95"
style={{ borderColor: "var(--outline-variant)", color: "var(--text)" }}
>
-
</button>
<div className="min-w-44">
<p className="text-7xl font-normal leading-none tracking-[-0.06em] sm:text-8xl" style={{ color: "var(--primary)" }}>
{dailyCanLimit === "none" ? "No cap" : dailyCanLimit}
</p>
<p className="mt-3 text-sm font-normal text-[var(--muted)]">
{dailyCanLimit === "none" ? "Unlimited daily volume" : dailyCanLimit === 1 ? "can per day" : "cans per day"}
</p>
</div>
<button
type="button"
onClick={incrementCans}
className="grid h-12 w-12 place-items-center rounded-full border text-2xl font-normal transition active:scale-95"
style={{ borderColor: "var(--outline-variant)", color: "var(--text)" }}
>
+
</button>
</div>
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => setDailyCanLimit("none")}
className="rounded-full border px-4 py-2 text-sm font-normal transition"
style={{
background: dailyCanLimit === "none" ? "var(--primary-container)" : "var(--surface-container-lowest)",
borderColor: dailyCanLimit === "none" ? "var(--primary)" : "var(--outline-variant)",
color: dailyCanLimit === "none" ? "var(--on-primary-container)" : "var(--muted)",
}}
>
No daily cap
</button>
{dailyCanLimit === "none" && (
<button
type="button"
onClick={() => setDailyCanLimit(2)}
className="rounded-full border px-4 py-2 text-sm font-normal transition"
style={{ borderColor: "var(--outline-variant)", color: "var(--text)" }}
>
Use 2 cans
</button>
)}
</div>
<button
type="button"
onClick={goNext}
className="inline-flex min-h-12 w-fit items-center gap-3 rounded-full px-6 text-sm font-medium transition active:scale-[0.98]"
style={{ background: "var(--primary)", color: "var(--on-primary)" }}
>
Continue
<ArrowRight size={16} />
</button>
</section>
)}
{step === 4 && (
<section className="grid gap-9">
<div className="grid gap-4">
<p className="text-sm font-normal text-[var(--primary)]">3. Daily spend</p>
<h2 className="max-w-2xl text-4xl font-normal leading-tight tracking-[-0.04em] sm:text-6xl">
Set a daily spend line.
</h2>
<p className="max-w-lg text-base leading-7 text-[var(--muted)]">
Useful for catching small purchases before they stack up.
</p>
</div>
<div className="flex flex-wrap items-end gap-5">
<button
type="button"
onClick={decrementSpend}
className="grid h-12 w-12 place-items-center rounded-full border text-2xl font-normal transition active:scale-95"
style={{ borderColor: "var(--outline-variant)", color: "var(--text)" }}
>
-
</button>
<div className="min-w-52">
<p className="text-7xl font-normal leading-none tracking-[-0.06em] sm:text-8xl" style={{ color: "var(--primary)" }}>
{dailySpendLimit === "none" ? "No cap" : currency.format(dailySpendLimit)}
</p>
<p className="mt-3 text-sm font-normal text-[var(--muted)]">
{dailySpendLimit === "none" ? "No daily budget" : "maximum per day"}
</p>
</div>
<button
type="button"
onClick={incrementSpend}
className="grid h-12 w-12 place-items-center rounded-full border text-2xl font-normal transition active:scale-95"
style={{ borderColor: "var(--outline-variant)", color: "var(--text)" }}
>
+
</button>
</div>
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => setDailySpendLimit("none")}
className="rounded-full border px-4 py-2 text-sm font-normal transition"
style={{
background: dailySpendLimit === "none" ? "var(--primary-container)" : "var(--surface-container-lowest)",
borderColor: dailySpendLimit === "none" ? "var(--primary)" : "var(--outline-variant)",
color: dailySpendLimit === "none" ? "var(--on-primary-container)" : "var(--muted)",
}}
>
No spend cap
</button>
{dailySpendLimit === "none" && (
<button
type="button"
onClick={() => setDailySpendLimit(3.5)}
className="rounded-full border px-4 py-2 text-sm font-normal transition"
style={{ borderColor: "var(--outline-variant)", color: "var(--text)" }}
>
Use £3.50
</button>
)}
</div>
<button
type="button"
onClick={goNext}
className="inline-flex min-h-12 w-fit items-center gap-3 rounded-full px-6 text-sm font-medium transition active:scale-[0.98]"
style={{ background: "var(--primary)", color: "var(--on-primary)" }}
>
Continue
<ArrowRight size={16} />
</button>
</section>
)}
{step === 5 && (
<section className="grid gap-8">
<div className="grid gap-4">
<p className="text-sm font-normal text-[var(--primary)]">4. Caffeine curfew</p>
<h2 className="max-w-2xl text-4xl font-normal leading-tight tracking-[-0.04em] sm:text-6xl">
When should late caffeine stop?
</h2>
<p className="max-w-lg text-base leading-7 text-[var(--muted)]">
Choose when the app should warn you that sleep may take the hit.
</p>
</div>
<div className="grid gap-2 sm:grid-cols-2">
{curfewOptions.map((timeOption) => {
const isSelected = stopTime === timeOption.id;
return (
<button
key={timeOption.id}
type="button"
onClick={() => setStopTime(timeOption.id)}
className="flex min-h-20 items-center justify-between rounded-2xl border px-4 text-left transition"
style={{
background: isSelected ? "var(--surface-container-low)" : "var(--surface-container-lowest)",
borderColor: isSelected ? "var(--primary)" : "var(--outline-variant)",
}}
>
<span>
<span className="block text-lg font-normal text-[var(--text)]">{timeOption.label}</span>
<span className="mt-1 block text-sm font-normal text-[var(--muted)]">{timeOption.hint}</span>
</span>
{isSelected && <Check size={16} style={{ color: "var(--primary)" }} />}
</button>
);
})}
</div>
<button
type="button"
onClick={goNext}
className="inline-flex min-h-12 w-fit items-center gap-3 rounded-full px-6 text-sm font-medium transition active:scale-[0.98]"
style={{ background: "var(--primary)", color: "var(--on-primary)" }}
>
Review
<ArrowRight size={16} />
</button>
</section>
)}
{step === 6 && (
<section className="grid gap-8">
<div className="grid gap-4">
<p className="text-sm font-normal text-[var(--primary)]">Ready</p>
<h2 className="max-w-2xl text-4xl font-normal leading-tight tracking-[-0.04em] sm:text-6xl">
This is your tracking profile.
</h2>
</div>
<div className="grid max-w-xl gap-3 rounded-3xl border p-5" style={{ background: "var(--surface-container-lowest)", borderColor: "var(--outline-variant)" }}>
<div className="flex items-center justify-between gap-4 border-b pb-3" style={{ borderColor: "var(--outline-variant)" }}>
<span className="text-sm font-normal text-[var(--muted)]">Theme</span>
<span className="flex items-center gap-2 text-sm font-normal text-[var(--text)]">
<span className="h-3 w-3 rounded-full" style={{ background: activeTheme.swatch }} />
{activeTheme.label}
</span>
</div>
<div className="flex items-center justify-between gap-4 border-b pb-3" style={{ borderColor: "var(--outline-variant)" }}>
<span className="text-sm font-normal text-[var(--muted)]">Daily cans</span>
<span className="text-sm font-normal text-[var(--text)]">
{dailyCanLimit === "none" ? "No cap" : `${dailyCanLimit} ${dailyCanLimit === 1 ? "can" : "cans"}`}
</span>
</div>
<div className="flex items-center justify-between gap-4 border-b pb-3" style={{ borderColor: "var(--outline-variant)" }}>
<span className="text-sm font-normal text-[var(--muted)]">Daily spend</span>
<span className="text-sm font-normal text-[var(--text)]">
{dailySpendLimit === "none" ? "No cap" : currency.format(dailySpendLimit)}
</span>
</div>
<div className="flex items-center justify-between gap-4">
<span className="text-sm font-normal text-[var(--muted)]">Caffeine curfew</span>
<span className="text-sm font-normal text-[var(--text)]">{stopTime === "none" ? "No curfew" : stopTime}</span>
</div>
</div>
<button
type="button"
onClick={() => void handleFinish()}
disabled={saving}
className="inline-flex min-h-12 w-fit items-center gap-3 rounded-full px-6 text-sm font-medium transition active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-60"
style={{ background: "var(--primary)", color: "var(--on-primary)" }}
>
{saving ? "Saving..." : "Start tracking"}
{!saving && <ArrowRight size={16} />}
</button>
</section>
)}
</main>
<footer className="relative z-10 mx-auto flex w-full max-w-3xl items-center justify-between gap-4 pb-2">
{step > 1 ? (
<button
type="button"
onClick={goBack}
disabled={saving}
className="inline-flex min-h-10 items-center gap-2 text-sm font-normal text-[var(--muted)] transition hover:text-[var(--text)] disabled:opacity-50"
>
<ChevronLeft size={16} />
Back
</button>
) : (
<span />
)}
<p className="text-xs font-normal text-[var(--muted)]">Minimal setup. Editable later.</p>
</footer>
</div>
);
}
+120 -31
View File
@@ -36,6 +36,7 @@ body {
background: #f8fbff;
color: #1f252a;
font-family: "Google Sans", "Google Sans Text", "Product Sans", Roboto, -apple-system, BlinkMacSystemFont, sans-serif;
font-weight: 400;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
@@ -96,7 +97,7 @@ textarea:focus-visible {
}
.state-chip {
@apply inline-flex min-h-10 items-center gap-2 px-3 text-sm font-semibold;
@apply inline-flex min-h-10 items-center gap-2 px-3 text-sm font-medium;
background: var(--primary-container);
border-radius: 999px;
color: var(--on-primary-container);
@@ -110,7 +111,7 @@ textarea:focus-visible {
}
.segmented-control button {
@apply min-h-10 px-3 text-sm font-semibold transition;
@apply min-h-10 px-3 text-sm font-normal transition;
border-radius: 999px;
color: var(--muted);
}
@@ -146,7 +147,7 @@ textarea:focus-visible {
}
.drawer-primary-action {
@apply mb-5 inline-flex min-h-14 items-center justify-center gap-3 px-5 text-sm font-semibold shadow-can transition active:scale-[0.99];
@apply mb-5 inline-flex min-h-14 items-center justify-center gap-3 px-5 text-sm font-medium shadow-can transition active:scale-[0.99];
background: var(--primary-container);
border-radius: 18px;
color: var(--on-primary-container);
@@ -191,12 +192,12 @@ textarea:focus-visible {
}
.top-kicker {
@apply text-sm font-medium;
@apply text-sm font-normal;
color: var(--primary);
}
.top-title {
@apply mt-1 break-words text-4xl font-semibold sm:text-5xl;
@apply mt-1 break-words text-4xl font-medium sm:text-5xl;
color: var(--text);
}
@@ -205,7 +206,7 @@ textarea:focus-visible {
}
.account-chip {
@apply inline-flex min-h-10 max-w-full items-center rounded-md px-3 text-xs font-semibold;
@apply inline-flex min-h-10 max-w-full items-center rounded-md px-3 text-xs font-normal;
background: var(--surface-container-high);
color: var(--muted);
}
@@ -224,10 +225,11 @@ textarea:focus-visible {
background: color-mix(in srgb, var(--surface-container-high) 92%, white);
border-color: var(--outline-variant);
border-radius: 28px;
padding-bottom: calc(0.25rem + env(safe-area-inset-bottom, 0px));
}
.mobile-nav-item {
@apply flex min-h-16 flex-col items-center justify-center gap-1 text-[11px] font-semibold transition;
@apply flex min-h-16 flex-col items-center justify-center gap-1 text-[11px] font-medium transition;
border-radius: 22px;
color: var(--muted);
}
@@ -290,15 +292,86 @@ textarea:focus-visible {
}
.oura-ring span {
@apply text-4xl font-semibold leading-none;
@apply text-4xl font-medium leading-none;
color: var(--text);
}
.oura-ring small {
@apply mt-1 text-xs font-semibold uppercase;
@apply mt-1 text-xs font-normal uppercase;
color: var(--muted);
}
.oura-ring--warn {
background: conic-gradient(var(--chart-tertiary) var(--progress), var(--surface-container-high) 0);
}
.oura-ring--over {
background: conic-gradient(var(--chart-error) var(--progress), var(--surface-container-high) 0);
}
.limits-card {
@apply border border-white/10;
}
.limit-row {
@apply rounded-xl border p-4;
border-color: var(--outline-variant);
background: color-mix(in srgb, var(--surface-container-high) 72%, white);
}
.limit-row--warn {
border-color: color-mix(in srgb, var(--chart-tertiary) 55%, transparent);
background: color-mix(in srgb, var(--chart-tertiary) 12%, var(--surface-container-high));
}
.limit-row--over {
border-color: color-mix(in srgb, var(--chart-error) 55%, transparent);
background: color-mix(in srgb, var(--chart-error) 12%, var(--surface-container-high));
}
.limit-row-head {
@apply flex items-center justify-between gap-2 text-sm;
}
.limit-row-head span {
color: var(--muted);
}
.limit-row-head strong {
color: var(--text);
font-weight: 500;
}
.limit-row-value {
@apply mt-2 text-sm;
color: var(--muted);
}
.limit-progress {
@apply mt-3 h-2 overflow-hidden rounded-full;
background: color-mix(in srgb, var(--outline-variant) 40%, transparent);
}
.limit-progress-fill {
@apply h-full rounded-full transition-all duration-300;
background: var(--primary);
}
.limit-row--warn .limit-progress-fill {
background: var(--chart-tertiary);
}
.limit-row--over .limit-progress-fill {
background: var(--chart-error);
}
.limit-banner {
@apply rounded-lg border px-3 py-2 text-sm leading-6;
border-color: color-mix(in srgb, var(--chart-tertiary) 45%, transparent);
background: color-mix(in srgb, var(--chart-tertiary) 14%, var(--surface-container-high));
color: var(--text);
}
.wellness-pill {
@apply flex items-center justify-between gap-3 rounded-full border px-4 py-3 text-sm;
background: color-mix(in srgb, var(--surface-container-high) 78%, white);
@@ -314,7 +387,7 @@ textarea:focus-visible {
}
.suggestion-chip {
@apply min-h-11 rounded-full border px-4 py-2 text-sm font-semibold transition disabled:cursor-not-allowed;
@apply min-h-11 rounded-full border px-4 py-2 text-sm font-medium transition disabled:cursor-not-allowed;
background: color-mix(in srgb, var(--surface-container-high) 72%, white);
border-color: var(--outline-variant);
color: var(--text);
@@ -353,12 +426,12 @@ textarea:focus-visible {
}
.coach-panel-kicker {
@apply text-xs font-semibold uppercase tracking-wide;
@apply text-xs font-medium uppercase tracking-wide;
color: var(--primary);
}
.coach-panel-heading {
@apply text-lg font-semibold leading-snug;
@apply text-lg font-medium leading-snug;
color: var(--text);
}
@@ -367,7 +440,7 @@ textarea:focus-visible {
}
.coach-status-pill {
@apply inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs font-semibold;
@apply inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs font-medium;
background: var(--surface-container-high);
border-color: var(--outline-variant);
color: var(--muted);
@@ -389,7 +462,7 @@ textarea:focus-visible {
}
.coach-expand-button {
@apply inline-flex items-center gap-1 rounded-full border px-3 py-1 text-xs font-semibold;
@apply inline-flex items-center gap-1 rounded-full border px-3 py-1 text-xs font-medium;
border-color: var(--outline-variant);
color: var(--text);
}
@@ -399,7 +472,7 @@ textarea:focus-visible {
}
.coach-thread-chip {
@apply inline-flex items-center overflow-hidden rounded-full border text-xs font-semibold;
@apply inline-flex items-center overflow-hidden rounded-full border text-xs font-medium;
border-color: var(--outline-variant);
background: var(--surface-container-high);
}
@@ -426,7 +499,7 @@ textarea:focus-visible {
}
.coach-panel-context {
@apply flex flex-wrap gap-3 text-xs font-semibold;
@apply flex flex-wrap gap-3 text-xs font-medium;
color: var(--muted);
}
@@ -461,7 +534,7 @@ textarea:focus-visible {
}
.coach-line-avatar {
@apply flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-[10px] font-semibold;
@apply flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-[10px] font-medium;
background: var(--surface-container-high);
color: var(--text);
}
@@ -522,12 +595,12 @@ textarea:focus-visible {
}
.thinking-pill-label {
@apply relative z-[1] text-xs font-semibold tracking-wide;
@apply relative z-[1] text-xs font-medium tracking-wide;
color: var(--muted);
}
.thinking-pill-chevron {
@apply absolute right-3 z-[1] text-xs font-bold opacity-70;
@apply absolute right-3 z-[1] text-xs font-medium opacity-70;
color: var(--primary);
animation: thinking-unlock-nudge 1.8s ease-in-out infinite;
}
@@ -554,7 +627,7 @@ textarea:focus-visible {
}
.thinking-details summary {
@apply cursor-pointer text-xs font-medium;
@apply cursor-pointer text-xs font-normal;
color: var(--muted);
}
@@ -681,7 +754,7 @@ textarea:focus-visible {
.command-button,
.secondary-button {
@apply inline-flex min-h-11 items-center justify-center gap-2 rounded-md border px-4 py-2 text-sm font-semibold shadow-sm transition active:scale-[0.99] disabled:cursor-not-allowed;
@apply inline-flex min-h-11 items-center justify-center gap-2 rounded-md border px-4 py-2 text-sm font-medium shadow-sm transition active:scale-[0.99] disabled:cursor-not-allowed;
background: var(--secondary-container);
border-color: transparent;
color: var(--on-secondary-container);
@@ -689,7 +762,7 @@ textarea:focus-visible {
}
.primary-button {
@apply inline-flex min-h-11 items-center justify-center gap-2 rounded-md border px-4 py-2 text-sm font-semibold shadow-can transition active:scale-[0.99] disabled:cursor-not-allowed;
@apply inline-flex min-h-11 items-center justify-center gap-2 rounded-md border px-4 py-2 text-sm font-medium shadow-can transition active:scale-[0.99] disabled:cursor-not-allowed;
background: var(--primary);
border-color: transparent;
color: var(--on-primary);
@@ -697,7 +770,7 @@ textarea:focus-visible {
}
.excel-button {
@apply inline-flex min-h-11 items-center justify-center gap-2 rounded-md border px-4 py-2 text-sm font-semibold shadow-sm transition active:scale-[0.99] disabled:cursor-not-allowed;
@apply inline-flex min-h-11 items-center justify-center gap-2 rounded-md border px-4 py-2 text-sm font-medium shadow-sm transition active:scale-[0.99] disabled:cursor-not-allowed;
background: var(--tertiary-container);
border-color: transparent;
color: var(--on-tertiary-container);
@@ -725,7 +798,7 @@ textarea:focus-visible {
}
.danger-button {
@apply inline-flex min-h-11 items-center justify-center gap-2 rounded-md border px-4 py-2 text-sm font-semibold shadow-sm transition active:scale-[0.99] disabled:cursor-not-allowed;
@apply inline-flex min-h-11 items-center justify-center gap-2 rounded-md border px-4 py-2 text-sm font-medium shadow-sm transition active:scale-[0.99] disabled:cursor-not-allowed;
background: var(--error);
border-color: transparent;
color: var(--on-error);
@@ -733,7 +806,7 @@ textarea:focus-visible {
}
.nav-item {
@apply flex min-h-12 items-center gap-3 border border-transparent px-4 text-sm font-medium transition;
@apply flex min-h-12 items-center gap-3 border border-transparent px-4 text-sm font-normal transition;
border-radius: 999px;
color: var(--muted);
}
@@ -757,14 +830,14 @@ textarea:focus-visible {
}
.quick-add {
@apply inline-flex min-h-12 items-center gap-2 rounded-md border px-3 text-sm font-semibold shadow-sm transition;
@apply inline-flex min-h-12 items-center gap-2 rounded-md border px-3 text-sm font-medium shadow-sm transition;
background: var(--surface-container-high);
border-color: var(--outline-variant);
color: var(--text);
}
.field-label {
@apply grid gap-2 text-sm font-medium;
@apply grid gap-2 text-sm font-normal;
color: var(--muted);
}
@@ -801,7 +874,7 @@ textarea:focus-visible {
}
.list-button {
@apply flex min-h-11 items-center justify-between rounded-lg border px-3 text-sm font-semibold transition;
@apply flex min-h-11 items-center justify-between rounded-lg border px-3 text-sm font-medium transition;
background: var(--secondary-container);
border-color: transparent;
color: var(--on-secondary-container);
@@ -812,7 +885,7 @@ textarea:focus-visible {
}
.theme-indicator {
@apply inline-flex min-h-11 items-center gap-2 rounded-full border px-3 text-sm font-semibold transition;
@apply inline-flex min-h-11 items-center gap-2 rounded-full border px-3 text-sm font-medium transition;
background: var(--surface-container-high);
border-color: var(--outline-variant);
color: var(--text);
@@ -842,7 +915,7 @@ textarea:focus-visible {
}
.settings-tabs button {
@apply rounded-full px-4 py-2 text-sm font-semibold transition;
@apply rounded-full px-4 py-2 text-sm font-medium transition;
color: var(--muted);
}
@@ -888,7 +961,7 @@ textarea:focus-visible {
}
.theme-tile-label {
@apply text-sm font-semibold leading-5;
@apply text-sm font-medium leading-5;
}
}
@@ -964,6 +1037,22 @@ textarea:focus-visible {
letter-spacing: 0 !important;
}
.app-shell .font-black,
.app-shell .font-extrabold {
font-weight: 600 !important;
}
.app-shell .font-bold,
.app-shell .font-semibold,
.app-shell strong,
.app-shell b {
font-weight: 500 !important;
}
.app-shell .font-medium {
font-weight: 400 !important;
}
.app-shell .text-white,
.app-shell .text-slate-50,
.app-shell .text-slate-100,
+31 -3
View File
@@ -1,3 +1,5 @@
import type { LimitCheckResult } from "../types";
import { formatStopTimeLabel } from "./userLimits";
import { groupByFlavour } from "./metrics";
type GreetingInput = {
@@ -7,6 +9,8 @@ type GreetingInput = {
currentStreak: number;
todayCaffeineMg: number;
allTimeCans: number;
dailyCanLimit?: number;
limitCheck?: LimitCheckResult;
};
type GreetingResult = {
@@ -42,6 +46,14 @@ export function buildDynamicGreeting(input: GreetingInput): GreetingResult {
: `${input.name}, no Red Bulls logged yet this ${hour < 12 ? "morning" : hour < 17 ? "afternoon" : "evening"}.`;
} else if (cans === 1) {
headline = `${input.name}, one Red Bull in so far today.`;
} else if (input.dailyCanLimit != null) {
if (cans >= input.dailyCanLimit) {
headline = `${input.name}, you're at your ${input.dailyCanLimit}-can daily limit.`;
} else if (cans >= input.dailyCanLimit - 1) {
headline = `${input.name}, ${cans} Red Bulls today — one under your limit.`;
} else {
headline = `${input.name}, ${cans} Red Bulls today — steady pace.`;
}
} else if (cans <= 3) {
headline = `${input.name}, ${cans} Red Bulls today — steady pace.`;
} else {
@@ -54,22 +66,38 @@ export function buildDynamicGreeting(input: GreetingInput): GreetingResult {
: `All-time favourite: ${favourite} (${input.allTimeCans} cans logged).`
: "Your flavour story is just getting started.";
const stopLine =
input.limitCheck?.pastStopTime && input.limitCheck?.violations.includes("stopTime")
? "You're past your stop time for today."
: null;
const caffeineLine =
cans > 0 && input.todayCaffeineMg > 0
stopLine ??
(cans > 0 && input.todayCaffeineMg > 0
? `~${Math.round(input.todayCaffeineMg)}mg caffeine so far.`
: hour >= 17 && cans === 0
? "Evening reset — clean slate if you want it."
: hour >= 22
? "Late night — pace yourself if you're still going."
: "Log an intake to unlock today's signals.";
: "Log an intake to unlock today's signals.");
const limitLine =
input.dailyCanLimit != null && cans > 0
? `${cans}/${input.dailyCanLimit} cans toward your daily limit.`
: null;
return {
badge,
headline,
subline: [flavourLine, caffeineLine].join(" "),
subline: [flavourLine, limitLine ?? caffeineLine].filter(Boolean).join(" "),
};
}
export function stopTimeGreetingHint(stopTime?: string, pastStopTime?: boolean) {
if (!stopTime || !pastStopTime) return null;
return `Past your ${formatStopTimeLabel(stopTime)} stop time.`;
}
export function buildFlavourHistorySummary(entries: Parameters<typeof groupByFlavour>[0]) {
const breakdown = groupByFlavour(entries);
if (!breakdown.length) return "No flavour history yet.";
+18 -4
View File
@@ -18,7 +18,8 @@ import {
sugarFor,
wholeNumber,
} from "./metrics";
import type { CoachChat, CoachMessage, RedBullEntry } from "../types";
import type { CoachChat, CoachMessage, LimitCheckResult, RedBullEntry, UserLimits } from "../types";
import { limitsSummaryForCoach } from "./userLimits";
type AuthUser = Models.User<Models.Preferences>;
@@ -38,7 +39,13 @@ type OllamaStreamChunk = { error?: string; message?: { content?: string; thinkin
export type CoachSession = ReturnType<typeof useCoachSession>;
export function useCoachSession(user: AuthUser, dashboard: Dashboard, entries: RedBullEntry[]) {
export function useCoachSession(
user: AuthUser,
dashboard: Dashboard,
entries: RedBullEntry[],
userLimits: UserLimits = {},
limitCheck?: LimitCheckResult,
) {
const [chats, setChats] = useState<CoachChat[]>([]);
const [activeChatId, setActiveChatId] = useState<string | null>(null);
const [savedChatIds, setSavedChatIds] = useState<Set<string>>(() => new Set());
@@ -165,7 +172,7 @@ export function useCoachSession(user: AuthUser, dashboard: Dashboard, entries: R
try {
const requestMessages: Array<{ role: string; content: string; thinking?: string }> = [
{ role: "system", content: buildCoachSystemPrompt(user, dashboard, entries) },
{ role: "system", content: buildCoachSystemPrompt(user, dashboard, entries, userLimits, limitCheck) },
...conversation
.filter((message) => message.content.trim().length > 0)
.map((message) => ({
@@ -333,7 +340,13 @@ function titleForChat(currentTitle: string, prompt: string) {
return cleaned.length > 48 ? `${cleaned.slice(0, 45)}...` : cleaned || "today";
}
function buildCoachSystemPrompt(user: AuthUser, dashboard: Dashboard, entries: RedBullEntry[]) {
function buildCoachSystemPrompt(
user: AuthUser,
dashboard: Dashboard,
entries: RedBullEntry[],
userLimits: UserLimits,
limitCheck?: LimitCheckResult,
) {
const recent = entries
.slice(0, 12)
.map(
@@ -351,6 +364,7 @@ function buildCoachSystemPrompt(user: AuthUser, dashboard: Dashboard, entries: R
`User: ${user.name || user.email || "Appwrite user"}`,
`Current time (BST): ${getBstHour()}:00.`,
`Today: ${dashboard.todayCans} cans, ${dashboard.todayCaffeine} caffeine, ${dashboard.todaySugar} sugar.`,
`Personal limits: ${limitsSummaryForCoach(userLimits, limitCheck ?? { violations: [], projectedCans: 0, projectedSpend: 0, todayCans: 0, todaySpend: 0, pastStopTime: false })}`,
`All-time favourite: ${dashboard.favouriteFlavour}. Streak: ${dashboard.currentStreak} day(s). Spend: ${dashboard.totalSpend}.`,
`Flavour history:\n${buildFlavourHistorySummary(entries)}`,
`Recent entries:\n${recent || "No entries logged yet."}`,
+204
View File
@@ -0,0 +1,204 @@
import type { EntryDraft, LimitCheckResult, LimitViolation, RedBullEntry, UserLimits } from "../types";
import { getBstHour } from "./greeting";
import { currency, spendFor, sum } from "./metrics";
export const DEFAULT_LIMITS: UserLimits = {};
const PREFS_CAN_KEY = "dailyCanLimit";
const PREFS_SPEND_KEY = "dailySpendLimit";
const PREFS_STOP_KEY = "stopTime";
export function parseUserLimits(prefs: Record<string, unknown> | null | undefined): UserLimits {
if (!prefs) return { ...DEFAULT_LIMITS };
const limits: UserLimits = {};
const canLimit = Number(prefs[PREFS_CAN_KEY]);
const spendLimit = Number(prefs[PREFS_SPEND_KEY]);
const stopTime = typeof prefs[PREFS_STOP_KEY] === "string" ? prefs[PREFS_STOP_KEY] : undefined;
if (Number.isFinite(canLimit) && canLimit > 0) limits.dailyCanLimit = canLimit;
if (Number.isFinite(spendLimit) && spendLimit >= 0) limits.dailySpendLimit = spendLimit;
if (stopTime && /^\d{2}:\d{2}$/.test(stopTime)) limits.stopTime = stopTime;
return limits;
}
export function serializeUserLimits(limits: UserLimits): Record<string, unknown> {
const data: Record<string, unknown> = {};
if (limits.dailyCanLimit != null && limits.dailyCanLimit > 0) {
data[PREFS_CAN_KEY] = limits.dailyCanLimit;
}
if (limits.dailySpendLimit != null && limits.dailySpendLimit >= 0) {
data[PREFS_SPEND_KEY] = limits.dailySpendLimit;
}
if (limits.stopTime) {
data[PREFS_STOP_KEY] = limits.stopTime;
}
return data;
}
export function mergePrefsWithLimits(
existing: Record<string, unknown> | null | undefined,
limits: UserLimits,
): Record<string, unknown> {
const next = { ...(existing ?? {}) };
delete next[PREFS_CAN_KEY];
delete next[PREFS_SPEND_KEY];
delete next[PREFS_STOP_KEY];
return { ...next, ...serializeUserLimits(limits) };
}
export function formatBstDateKey(date = new Date()) {
return new Intl.DateTimeFormat("en-CA", {
timeZone: "Europe/London",
year: "numeric",
month: "2-digit",
day: "2-digit",
}).format(date);
}
export function getBstMinutes(date = new Date()) {
const parts = new Intl.DateTimeFormat("en-GB", {
timeZone: "Europe/London",
hour: "numeric",
minute: "numeric",
hour12: false,
}).formatToParts(date);
const hour = Number(parts.find((part) => part.type === "hour")?.value ?? 0);
const minute = Number(parts.find((part) => part.type === "minute")?.value ?? 0);
return hour * 60 + minute;
}
export function parseStopTimeMinutes(stopTime: string) {
const [hours, minutes] = stopTime.split(":").map((value) => Number(value));
return hours * 60 + minutes;
}
export function isPastStopTime(stopTime: string | undefined, date = new Date()) {
if (!stopTime) return false;
return getBstMinutes(date) >= parseStopTimeMinutes(stopTime);
}
export function formatStopTimeLabel(stopTime: string) {
const [hours, minutes] = stopTime.split(":").map((value) => Number(value));
const date = new Date();
date.setHours(hours, minutes, 0, 0);
return new Intl.DateTimeFormat("en-GB", {
hour: "numeric",
minute: "2-digit",
hour12: true,
}).format(date);
}
function entriesTodayBst(entries: RedBullEntry[], ref = new Date()) {
const key = formatBstDateKey(ref);
return entries.filter((entry) => formatBstDateKey(new Date(entry.dateTime)) === key);
}
function spendForDraft(draft: EntryDraft) {
return draft.cans * draft.pricePerCan;
}
function todayTotals(entries: RedBullEntry[], excludeEntryId?: string, ref = new Date()) {
const todayEntries = entriesTodayBst(entries, ref).filter((entry) => entry.id !== excludeEntryId);
return {
todayCans: sum(todayEntries, (entry) => entry.cans),
todaySpend: sum(todayEntries, spendFor),
};
}
export function evaluateLimits(
limits: UserLimits,
entries: RedBullEntry[],
options?: { draft?: EntryDraft; excludeEntryId?: string; at?: Date },
): LimitCheckResult {
const ref = options?.at ?? new Date();
const { todayCans, todaySpend } = todayTotals(entries, options?.excludeEntryId, ref);
const draft = options?.draft;
const projectedCans = draft ? todayCans + draft.cans : todayCans;
const projectedSpend = draft ? todaySpend + spendForDraft(draft) : todaySpend;
const checkTime = draft?.dateTime ? new Date(draft.dateTime) : ref;
const pastStopTime = limits.stopTime ? isPastStopTime(limits.stopTime, checkTime) : false;
const violations: LimitViolation[] = [];
if (limits.dailyCanLimit != null) {
const over = draft ? projectedCans > limits.dailyCanLimit : todayCans >= limits.dailyCanLimit;
if (over) violations.push("cans");
}
if (limits.dailySpendLimit != null) {
const over = draft ? projectedSpend > limits.dailySpendLimit : todaySpend >= limits.dailySpendLimit;
if (over) violations.push("spend");
}
if (limits.stopTime && pastStopTime) {
violations.push("stopTime");
}
return {
violations,
projectedCans,
projectedSpend,
todayCans,
todaySpend,
pastStopTime,
};
}
export function limitProgress(current: number, limit?: number) {
if (!limit || limit <= 0) return 0;
return Math.min(100, Math.round((current / limit) * 100));
}
export function limitStatusMessage(
violations: LimitViolation[],
check: LimitCheckResult,
limits: UserLimits,
): string {
const lines: string[] = [];
if (violations.includes("cans") && limits.dailyCanLimit != null) {
lines.push(
`This would bring you to ${check.projectedCans.toFixed(1)}/${limits.dailyCanLimit} cans today (BST).`,
);
}
if (violations.includes("spend") && limits.dailySpendLimit != null) {
lines.push(
`This would bring today's spend to ${currency.format(check.projectedSpend)} of your ${currency.format(limits.dailySpendLimit)} limit.`,
);
}
if (violations.includes("stopTime") && limits.stopTime) {
lines.push(`You're past your stop time (${formatStopTimeLabel(limits.stopTime)} BST).`);
}
return lines.join(" ");
}
export function limitsSummaryForCoach(limits: UserLimits, check: LimitCheckResult): string {
const parts: string[] = [];
if (limits.dailyCanLimit != null) {
parts.push(`daily can limit: ${limits.dailyCanLimit} (${check.todayCans} logged today)`);
}
if (limits.dailySpendLimit != null) {
parts.push(`daily spend limit: ${currency.format(limits.dailySpendLimit)} (${currency.format(check.todaySpend)} today)`);
}
if (limits.stopTime) {
parts.push(
`stop drinking by: ${formatStopTimeLabel(limits.stopTime)} bst (${check.pastStopTime ? "past stop time now" : "before stop time"})`,
);
}
if (!parts.length) return "no personal daily limits configured yet.";
return parts.join(". ");
}
export function hasAnyLimit(limits: UserLimits) {
return Boolean(limits.dailyCanLimit != null || limits.dailySpendLimit != null || limits.stopTime);
}
export { getBstHour };
+17
View File
@@ -74,3 +74,20 @@ export type CoachChat = {
createdAt: string;
updatedAt: string;
};
export type UserLimits = {
dailyCanLimit?: number;
dailySpendLimit?: number;
stopTime?: string;
};
export type LimitViolation = "cans" | "spend" | "stopTime";
export type LimitCheckResult = {
violations: LimitViolation[];
projectedCans: number;
projectedSpend: number;
todayCans: number;
todaySpend: number;
pastStopTime: boolean;
};