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:
+584
-47
@@ -14,6 +14,7 @@ import {
|
|||||||
Gauge,
|
Gauge,
|
||||||
Github,
|
Github,
|
||||||
Home,
|
Home,
|
||||||
|
Info,
|
||||||
LineChart,
|
LineChart,
|
||||||
Loader2,
|
Loader2,
|
||||||
LogIn,
|
LogIn,
|
||||||
@@ -84,7 +85,16 @@ import {
|
|||||||
updateEntry,
|
updateEntry,
|
||||||
} from "./lib/appwriteEntries";
|
} from "./lib/appwriteEntries";
|
||||||
import { CoachPanel } from "./components/CoachPanel";
|
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 { buildDynamicGreeting } from "./lib/greeting";
|
||||||
|
import {
|
||||||
|
evaluateLimits,
|
||||||
|
limitStatusMessage,
|
||||||
|
mergePrefsWithLimits,
|
||||||
|
parseUserLimits,
|
||||||
|
} from "./lib/userLimits";
|
||||||
import type { CoachSession } from "./lib/useCoachSession";
|
import type { CoachSession } from "./lib/useCoachSession";
|
||||||
import { useCoachSession } from "./lib/useCoachSession";
|
import { useCoachSession } from "./lib/useCoachSession";
|
||||||
import { createExcelExport, downloadBlob, parseExcelImport } from "./lib/excel";
|
import { createExcelExport, downloadBlob, parseExcelImport } from "./lib/excel";
|
||||||
@@ -115,13 +125,29 @@ import {
|
|||||||
wholeNumber,
|
wholeNumber,
|
||||||
} from "./lib/metrics";
|
} from "./lib/metrics";
|
||||||
import { exportPayload, parseImport } from "./lib/storage";
|
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 AppView = "overview" | "logbook" | "trends" | "coach" | "settings";
|
||||||
type AuthMode = "login" | "signup";
|
type AuthMode = "login" | "signup";
|
||||||
type AuthUser = Models.User<Models.Preferences>;
|
type AuthUser = Models.User<Models.Preferences>;
|
||||||
type SetupStatus = { state: "checking" | "ok" | "error"; message: string };
|
type SetupStatus = { state: "checking" | "ok" | "error"; message: string };
|
||||||
|
|
||||||
|
type PendingLimitAction = {
|
||||||
|
kind: "save" | "quick";
|
||||||
|
draft: EntryDraft;
|
||||||
|
editingId?: string;
|
||||||
|
quickLabel?: string;
|
||||||
|
};
|
||||||
|
|
||||||
const DEFAULT_FILTERS: Filters = {
|
const DEFAULT_FILTERS: Filters = {
|
||||||
flavour: "all",
|
flavour: "all",
|
||||||
dateRange: "all",
|
dateRange: "all",
|
||||||
@@ -175,6 +201,11 @@ function App() {
|
|||||||
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
||||||
const [dataError, setDataError] = useState("");
|
const [dataError, setDataError] = useState("");
|
||||||
const [importPreview, setImportPreview] = useState<ImportPreview | null>(null);
|
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 excelFileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const jsonFileInputRef = useRef<HTMLInputElement>(null);
|
const jsonFileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
@@ -220,7 +251,14 @@ function App() {
|
|||||||
const currentUser = await account.get();
|
const currentUser = await account.get();
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setUser(currentUser);
|
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"}.`);
|
setNotice(`Signed in as ${currentUser.email || currentUser.name || "Appwrite user"}.`);
|
||||||
|
if (!currentUser.prefs.onboarded) {
|
||||||
|
setShowOnboarding(true);
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setUser(null);
|
setUser(null);
|
||||||
@@ -268,12 +306,19 @@ function App() {
|
|||||||
[entries, filters],
|
[entries, filters],
|
||||||
);
|
);
|
||||||
const dashboard = useMemo(() => buildDashboard(entries), [entries]);
|
const dashboard = useMemo(() => buildDashboard(entries), [entries]);
|
||||||
|
const limitCheck = useMemo(() => evaluateLimits(userLimits, entries), [userLimits, entries]);
|
||||||
const chartData = useMemo(() => groupByDay(filteredEntries), [filteredEntries]);
|
const chartData = useMemo(() => groupByDay(filteredEntries), [filteredEntries]);
|
||||||
const weekData = useMemo(() => groupByWeek(filteredEntries), [filteredEntries]);
|
const weekData = useMemo(() => groupByWeek(filteredEntries), [filteredEntries]);
|
||||||
const flavourData = useMemo(() => groupByFlavour(filteredEntries), [filteredEntries]);
|
const flavourData = useMemo(() => groupByFlavour(filteredEntries), [filteredEntries]);
|
||||||
const insights = useMemo(() => buildInsights(entries), [entries]);
|
const insights = useMemo(() => buildInsights(entries), [entries]);
|
||||||
const recentEntries = useMemo(() => entries.slice(0, 5), [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) {
|
async function login(email: string, password: string) {
|
||||||
setActionLoading("auth");
|
setActionLoading("auth");
|
||||||
@@ -282,7 +327,14 @@ function App() {
|
|||||||
await account.createEmailPasswordSession({ email, password });
|
await account.createEmailPasswordSession({ email, password });
|
||||||
const currentUser = await account.get();
|
const currentUser = await account.get();
|
||||||
setUser(currentUser);
|
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}.`);
|
setNotice(`Signed in as ${currentUser.email}.`);
|
||||||
|
if (!currentUser.prefs.onboarded) {
|
||||||
|
setShowOnboarding(true);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setAuthError(appwriteErrorMessage(error));
|
setAuthError(appwriteErrorMessage(error));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -303,7 +355,9 @@ function App() {
|
|||||||
await account.createEmailPasswordSession({ email, password });
|
await account.createEmailPasswordSession({ email, password });
|
||||||
const currentUser = await account.get();
|
const currentUser = await account.get();
|
||||||
setUser(currentUser);
|
setUser(currentUser);
|
||||||
|
setUserLimits(parseUserLimits(currentUser.prefs));
|
||||||
setNotice(`Welcome, ${currentUser.name || currentUser.email}.`);
|
setNotice(`Welcome, ${currentUser.name || currentUser.email}.`);
|
||||||
|
setShowOnboarding(true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setAuthError(appwriteErrorMessage(error));
|
setAuthError(appwriteErrorMessage(error));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -328,6 +382,7 @@ function App() {
|
|||||||
await account.deleteSession({ sessionId: "current" });
|
await account.deleteSession({ sessionId: "current" });
|
||||||
setUser(null);
|
setUser(null);
|
||||||
setEntries([]);
|
setEntries([]);
|
||||||
|
setUserLimits({});
|
||||||
setNotice("Logged out.");
|
setNotice("Logged out.");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setDataError(appwriteErrorMessage(error));
|
setDataError(appwriteErrorMessage(error));
|
||||||
@@ -341,27 +396,91 @@ function App() {
|
|||||||
setIsEntryModalOpen(true);
|
setIsEntryModalOpen(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveEntry(draft: EntryDraft) {
|
async function saveUserLimits(next: UserLimits) {
|
||||||
if (!user) return;
|
if (!user) return;
|
||||||
setActionLoading("save-entry");
|
setActionLoading("save-limits");
|
||||||
setDataError("");
|
setDataError("");
|
||||||
try {
|
try {
|
||||||
const saved = editingEntry
|
const prefs = mergePrefsWithLimits(user.prefs, next);
|
||||||
? await updateEntry(user.$id, editingEntry.id, { ...draft, source: editingEntry.source })
|
await account.updatePrefs(prefs);
|
||||||
: await createEntry(user.$id, { ...draft, source: "manual" });
|
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) =>
|
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);
|
setEditingEntry(null);
|
||||||
setIsEntryModalOpen(false);
|
setIsEntryModalOpen(false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setDataError(appwriteErrorMessage(error));
|
setDataError(appwriteErrorMessage(error));
|
||||||
} finally {
|
} finally {
|
||||||
setActionLoading(null);
|
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]) {
|
async function quickAdd(item: (typeof QUICK_ADDS)[number]) {
|
||||||
if (!user) return;
|
if (!user) return;
|
||||||
const meta = flavourMeta(item.flavour);
|
const meta = flavourMeta(item.flavour);
|
||||||
@@ -378,17 +497,20 @@ function App() {
|
|||||||
source: "quick-add",
|
source: "quick-add",
|
||||||
};
|
};
|
||||||
|
|
||||||
setActionLoading(`quick-${item.label}`);
|
const check = evaluateLimits(userLimits, entries, { draft });
|
||||||
setDataError("");
|
if (check.violations.length) {
|
||||||
try {
|
setPendingLimitAction({ kind: "quick", draft, quickLabel: item.label });
|
||||||
const saved = await createEntry(user.$id, draft);
|
setLimitConfirmMessage(limitStatusMessage(check.violations, check, userLimits));
|
||||||
setEntries((current) => sortEntries([saved, ...current]));
|
setLimitConfirmOpen(true);
|
||||||
setNotice(`${item.label} saved to Appwrite.`);
|
return;
|
||||||
} catch (error) {
|
|
||||||
setDataError(appwriteErrorMessage(error));
|
|
||||||
} finally {
|
|
||||||
setActionLoading(null);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void persistEntry({ kind: "quick", draft, quickLabel: item.label });
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmLimitOverride() {
|
||||||
|
if (!pendingLimitAction) return;
|
||||||
|
void persistEntry(pendingLimitAction);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteEntry(id: string) {
|
async function deleteEntry(id: string) {
|
||||||
@@ -529,6 +651,15 @@ function App() {
|
|||||||
data-theme={themeId}
|
data-theme={themeId}
|
||||||
style={shellStyle}
|
style={shellStyle}
|
||||||
>
|
>
|
||||||
|
{showOnboarding && user && (
|
||||||
|
<OnboardingScreen
|
||||||
|
userName={user.name || undefined}
|
||||||
|
activeThemeId={themeId}
|
||||||
|
onThemeChange={setThemeId}
|
||||||
|
onSave={saveOnboarding}
|
||||||
|
onClose={() => setShowOnboarding(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<input
|
<input
|
||||||
ref={excelFileInputRef}
|
ref={excelFileInputRef}
|
||||||
className="hidden"
|
className="hidden"
|
||||||
@@ -596,6 +727,8 @@ function App() {
|
|||||||
chartData={chartData}
|
chartData={chartData}
|
||||||
flavourData={flavourData}
|
flavourData={flavourData}
|
||||||
user={user}
|
user={user}
|
||||||
|
userLimits={userLimits}
|
||||||
|
limitCheck={limitCheck}
|
||||||
coachSession={coachSession}
|
coachSession={coachSession}
|
||||||
onQuickAdd={(item) => void quickAdd(item)}
|
onQuickAdd={(item) => void quickAdd(item)}
|
||||||
onAdd={openNewEntry}
|
onAdd={openNewEntry}
|
||||||
@@ -604,6 +737,7 @@ function App() {
|
|||||||
setActiveView("coach");
|
setActiveView("coach");
|
||||||
}}
|
}}
|
||||||
onOpenLogbook={() => setActiveView("logbook")}
|
onOpenLogbook={() => setActiveView("logbook")}
|
||||||
|
onOpenSettings={() => setActiveView("settings")}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -633,6 +767,8 @@ function App() {
|
|||||||
filters={filters}
|
filters={filters}
|
||||||
flavours={allFlavours}
|
flavours={allFlavours}
|
||||||
onFilterChange={setFilters}
|
onFilterChange={setFilters}
|
||||||
|
userLimits={userLimits}
|
||||||
|
onSaveLimits={(next) => void saveUserLimits(next)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -655,6 +791,8 @@ function App() {
|
|||||||
setupStatus={setupStatus}
|
setupStatus={setupStatus}
|
||||||
themeId={themeId}
|
themeId={themeId}
|
||||||
user={user}
|
user={user}
|
||||||
|
userLimits={userLimits}
|
||||||
|
limitCheck={limitCheck}
|
||||||
actionLoading={actionLoading}
|
actionLoading={actionLoading}
|
||||||
onExportExcel={() => void exportExcel()}
|
onExportExcel={() => void exportExcel()}
|
||||||
onImportExcel={() => excelFileInputRef.current?.click()}
|
onImportExcel={() => excelFileInputRef.current?.click()}
|
||||||
@@ -663,6 +801,8 @@ function App() {
|
|||||||
onLogout={() => void logout()}
|
onLogout={() => void logout()}
|
||||||
onReset={() => setIsResetOpen(true)}
|
onReset={() => setIsResetOpen(true)}
|
||||||
onThemeChange={setThemeId}
|
onThemeChange={setThemeId}
|
||||||
|
onSaveLimits={(next) => void saveUserLimits(next)}
|
||||||
|
onRerunOnboarding={() => setShowOnboarding(true)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</motion.main>
|
</motion.main>
|
||||||
@@ -675,6 +815,8 @@ function App() {
|
|||||||
flavours={allFlavours}
|
flavours={allFlavours}
|
||||||
open={isEntryModalOpen}
|
open={isEntryModalOpen}
|
||||||
saving={actionLoading === "save-entry"}
|
saving={actionLoading === "save-entry"}
|
||||||
|
userLimits={userLimits}
|
||||||
|
entries={entries}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setIsEntryModalOpen(false);
|
setIsEntryModalOpen(false);
|
||||||
setEditingEntry(null);
|
setEditingEntry(null);
|
||||||
@@ -698,6 +840,20 @@ function App() {
|
|||||||
onCancel={() => setIsResetOpen(false)}
|
onCancel={() => setIsResetOpen(false)}
|
||||||
onConfirm={() => void resetAll()}
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1070,6 +1226,8 @@ function TopBar({
|
|||||||
month: "long",
|
month: "long",
|
||||||
}).format(new Date());
|
}).format(new Date());
|
||||||
|
|
||||||
|
const [showActions, setShowActions] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="top-app-bar">
|
<header className="top-app-bar">
|
||||||
<div className="top-app-bar-main">
|
<div className="top-app-bar-main">
|
||||||
@@ -1090,13 +1248,24 @@ function TopBar({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="top-action-row">
|
<div className="top-action-row">
|
||||||
<div className="top-action-primary">
|
<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" type="button" onClick={onAdd} disabled={Boolean(actionLoading)}>
|
<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" />
|
<Plus size={18} aria-hidden="true" />
|
||||||
Add Intake
|
Add Intake
|
||||||
</button>
|
</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>
|
||||||
<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}>
|
<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" />}
|
{dataLoading ? <Loader2 className="animate-spin" size={17} aria-hidden="true" /> : <RefreshCcw size={17} aria-hidden="true" />}
|
||||||
Sync
|
Sync
|
||||||
@@ -1110,6 +1279,47 @@ function TopBar({
|
|||||||
Import XLSX
|
Import XLSX
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
@@ -1159,10 +1369,13 @@ function OverviewView({
|
|||||||
flavourData,
|
flavourData,
|
||||||
user,
|
user,
|
||||||
coachSession,
|
coachSession,
|
||||||
|
userLimits,
|
||||||
|
limitCheck,
|
||||||
onQuickAdd,
|
onQuickAdd,
|
||||||
onAdd,
|
onAdd,
|
||||||
onOpenCoach,
|
onOpenCoach,
|
||||||
onOpenLogbook,
|
onOpenLogbook,
|
||||||
|
onOpenSettings,
|
||||||
}: {
|
}: {
|
||||||
dashboard: Dashboard;
|
dashboard: Dashboard;
|
||||||
entries: RedBullEntry[];
|
entries: RedBullEntry[];
|
||||||
@@ -1172,15 +1385,26 @@ function OverviewView({
|
|||||||
chartData: Array<{ label: string; spend: number; cans: number; caffeine: number; sugar: number }>;
|
chartData: Array<{ label: string; spend: number; cans: number; caffeine: number; sugar: number }>;
|
||||||
flavourData: Array<{ name: string; value: number; spend: number; accent: string }>;
|
flavourData: Array<{ name: string; value: number; spend: number; accent: string }>;
|
||||||
user: AuthUser;
|
user: AuthUser;
|
||||||
|
userLimits: UserLimits;
|
||||||
|
limitCheck: LimitCheckResult;
|
||||||
coachSession: CoachSession;
|
coachSession: CoachSession;
|
||||||
onQuickAdd: (item: (typeof QUICK_ADDS)[number]) => void;
|
onQuickAdd: (item: (typeof QUICK_ADDS)[number]) => void;
|
||||||
onAdd: () => void;
|
onAdd: () => void;
|
||||||
onOpenCoach: (prompt?: string) => void;
|
onOpenCoach: (prompt?: string) => void;
|
||||||
onOpenLogbook: () => 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 (
|
return (
|
||||||
<div className="grid gap-4">
|
<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]">
|
<section className="grid gap-4 xl:grid-cols-[1.15fr_0.85fr]">
|
||||||
<CoachPanel
|
<CoachPanel
|
||||||
@@ -1193,24 +1417,31 @@ function OverviewView({
|
|||||||
<QuickAddPanel items={quickAdds} onQuickAdd={onQuickAdd} />
|
<QuickAddPanel items={quickAdds} onQuickAdd={onQuickAdd} />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="grid gap-4 xl:grid-cols-[1.25fr_0.75fr]">
|
<TodayPanel dashboard={dashboard} entries={entries} userLimits={userLimits} limitCheck={limitCheck} onAdd={onAdd} />
|
||||||
<TodayPanel dashboard={dashboard} entries={entries} onAdd={onAdd} />
|
|
||||||
<AppCard title="Coach signals" subtitle="Live from your log">
|
{limitCheck.violations.length ? (
|
||||||
<div className="grid gap-2">
|
<section className="glass-panel border border-amber-200/20 bg-amber-200/10 p-4 sm:p-5">
|
||||||
<WellnessPill label="Today" value={`${dashboard.todayCans} cans`} />
|
<div className="flex items-start gap-3">
|
||||||
<WellnessPill label="Caffeine" value={dashboard.todayCaffeine} />
|
<AlertTriangle className="mt-0.5 shrink-0 text-amber-200" size={20} aria-hidden="true" />
|
||||||
<WellnessPill label="Favourite" value={dashboard.favouriteFlavour} />
|
<div>
|
||||||
<button className="list-button" type="button" onClick={() => onOpenCoach()}>
|
<p className="font-semibold text-white">Limit alerts</p>
|
||||||
Open full coach
|
<p className="mt-1 text-sm leading-6 text-slate-300">
|
||||||
<ChevronRight size={16} aria-hidden="true" />
|
{limitStatusMessage(limitCheck.violations, limitCheck, userLimits)}
|
||||||
</button>
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</AppCard>
|
|
||||||
</section>
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<section className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
<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={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={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} />
|
<MetricTile icon={TimerReset} label="Days Without" value={dashboard.daysWithoutRedBull} detail={`${dashboard.currentStreak} day streak`} accent={MATERIAL_ACCENTS.error} />
|
||||||
</section>
|
</section>
|
||||||
@@ -1288,14 +1519,24 @@ function OverviewView({
|
|||||||
function GreetingPanel({
|
function GreetingPanel({
|
||||||
dashboard,
|
dashboard,
|
||||||
user,
|
user,
|
||||||
|
userLimits,
|
||||||
|
limitCheck,
|
||||||
onOpenCoach,
|
onOpenCoach,
|
||||||
}: {
|
}: {
|
||||||
dashboard: Dashboard;
|
dashboard: Dashboard;
|
||||||
user: AuthUser;
|
user: AuthUser;
|
||||||
|
userLimits: UserLimits;
|
||||||
|
limitCheck: LimitCheckResult;
|
||||||
onOpenCoach: (prompt?: string) => void;
|
onOpenCoach: (prompt?: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const todayNumber = Number.parseFloat(dashboard.todayCans) || 0;
|
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 name = firstName(user);
|
||||||
const greeting = buildDynamicGreeting({
|
const greeting = buildDynamicGreeting({
|
||||||
name,
|
name,
|
||||||
@@ -1304,6 +1545,8 @@ function GreetingPanel({
|
|||||||
currentStreak: Number.parseInt(dashboard.currentStreak, 10) || 0,
|
currentStreak: Number.parseInt(dashboard.currentStreak, 10) || 0,
|
||||||
todayCaffeineMg: Number.parseFloat(dashboard.todayCaffeine.replace(/[^\d.]/g, "")) || 0,
|
todayCaffeineMg: Number.parseFloat(dashboard.todayCaffeine.replace(/[^\d.]/g, "")) || 0,
|
||||||
allTimeCans: Number.parseFloat(dashboard.allTimeCans) || 0,
|
allTimeCans: Number.parseFloat(dashboard.allTimeCans) || 0,
|
||||||
|
dailyCanLimit: canLimit,
|
||||||
|
limitCheck,
|
||||||
});
|
});
|
||||||
|
|
||||||
const coachPrompts = [
|
const coachPrompts = [
|
||||||
@@ -1324,10 +1567,16 @@ function GreetingPanel({
|
|||||||
return (
|
return (
|
||||||
<section className="oura-hero glass-panel p-5 sm:p-6">
|
<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="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>
|
<div>
|
||||||
<span>{dashboard.todayCans}</span>
|
<span>{dashboard.todayCans}</span>
|
||||||
<small>today</small>
|
<small>{canLimit ? `of ${canLimit}` : "today"}</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1370,12 +1619,25 @@ function WellnessPill({ label, value }: { label: string; value: string }) {
|
|||||||
function TodayPanel({
|
function TodayPanel({
|
||||||
dashboard,
|
dashboard,
|
||||||
entries,
|
entries,
|
||||||
|
userLimits,
|
||||||
|
limitCheck,
|
||||||
onAdd,
|
onAdd,
|
||||||
}: {
|
}: {
|
||||||
dashboard: Dashboard;
|
dashboard: Dashboard;
|
||||||
entries: RedBullEntry[];
|
entries: RedBullEntry[];
|
||||||
|
userLimits: UserLimits;
|
||||||
|
limitCheck: LimitCheckResult;
|
||||||
onAdd: () => void;
|
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 (
|
return (
|
||||||
<section className="can-panel today-panel relative overflow-hidden p-5 sm:p-7">
|
<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>
|
<p className="text-sm font-medium uppercase tracking-[0.18em] text-cyan-100">Today</p>
|
||||||
@@ -1383,6 +1645,7 @@ function TodayPanel({
|
|||||||
<div>
|
<div>
|
||||||
<p className="text-7xl font-semibold tracking-tight text-white sm:text-8xl">{dashboard.todayCans}</p>
|
<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>
|
<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>
|
||||||
<div className="grid gap-2 sm:grid-cols-3 lg:min-w-[420px]">
|
<div className="grid gap-2 sm:grid-cols-3 lg:min-w-[420px]">
|
||||||
<MiniMetric label="Caffeine" value={dashboard.todayCaffeine} accent={MATERIAL_ACCENTS.primary} />
|
<MiniMetric label="Caffeine" value={dashboard.todayCaffeine} accent={MATERIAL_ACCENTS.primary} />
|
||||||
@@ -1472,6 +1735,8 @@ function TrendsView({
|
|||||||
filters,
|
filters,
|
||||||
flavours,
|
flavours,
|
||||||
onFilterChange,
|
onFilterChange,
|
||||||
|
userLimits,
|
||||||
|
onSaveLimits,
|
||||||
}: {
|
}: {
|
||||||
chartData: Array<{ label: string; spend: number; cans: number; caffeine: number; sugar: number }>;
|
chartData: Array<{ label: string; spend: number; cans: number; caffeine: number; sugar: number }>;
|
||||||
weekData: Array<{ label: string; spend: number; cans: number }>;
|
weekData: Array<{ label: string; spend: number; cans: number }>;
|
||||||
@@ -1481,6 +1746,8 @@ function TrendsView({
|
|||||||
filters: Filters;
|
filters: Filters;
|
||||||
flavours: Flavour[];
|
flavours: Flavour[];
|
||||||
onFilterChange: (filters: Filters) => void;
|
onFilterChange: (filters: Filters) => void;
|
||||||
|
userLimits: UserLimits;
|
||||||
|
onSaveLimits: (limits: UserLimits) => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-4">
|
<div className="grid gap-4">
|
||||||
@@ -1573,11 +1840,217 @@ function TrendsView({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section className="grid gap-4">
|
||||||
|
<SpendingPredictionsCard
|
||||||
|
entries={entries}
|
||||||
|
userLimits={userLimits}
|
||||||
|
onSaveLimits={onSaveLimits}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
</div>
|
</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({
|
function SettingsView({
|
||||||
activeTheme,
|
activeTheme,
|
||||||
dashboard,
|
dashboard,
|
||||||
@@ -1587,6 +2060,8 @@ function SettingsView({
|
|||||||
setupStatus,
|
setupStatus,
|
||||||
themeId,
|
themeId,
|
||||||
user,
|
user,
|
||||||
|
userLimits,
|
||||||
|
limitCheck,
|
||||||
actionLoading,
|
actionLoading,
|
||||||
onExportExcel,
|
onExportExcel,
|
||||||
onImportExcel,
|
onImportExcel,
|
||||||
@@ -1595,6 +2070,8 @@ function SettingsView({
|
|||||||
onLogout,
|
onLogout,
|
||||||
onReset,
|
onReset,
|
||||||
onThemeChange,
|
onThemeChange,
|
||||||
|
onSaveLimits,
|
||||||
|
onRerunOnboarding,
|
||||||
}: {
|
}: {
|
||||||
activeTheme: AppTheme;
|
activeTheme: AppTheme;
|
||||||
dashboard: Dashboard;
|
dashboard: Dashboard;
|
||||||
@@ -1603,7 +2080,9 @@ function SettingsView({
|
|||||||
notice: string;
|
notice: string;
|
||||||
setupStatus: SetupStatus;
|
setupStatus: SetupStatus;
|
||||||
themeId: string;
|
themeId: string;
|
||||||
user: AuthUser;
|
user: AuthUser | null;
|
||||||
|
userLimits: UserLimits;
|
||||||
|
limitCheck: LimitCheckResult;
|
||||||
actionLoading: string | null;
|
actionLoading: string | null;
|
||||||
onExportExcel: () => void;
|
onExportExcel: () => void;
|
||||||
onImportExcel: () => void;
|
onImportExcel: () => void;
|
||||||
@@ -1612,14 +2091,35 @@ function SettingsView({
|
|||||||
onLogout: () => void;
|
onLogout: () => void;
|
||||||
onReset: () => void;
|
onReset: () => void;
|
||||||
onThemeChange: (id: string) => void;
|
onThemeChange: (id: string) => void;
|
||||||
|
onSaveLimits: (limits: UserLimits) => void;
|
||||||
|
onRerunOnboarding: () => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-4 xl:grid-cols-[1fr_0.85fr]">
|
<div className="grid gap-4 xl:grid-cols-[1fr_0.85fr]">
|
||||||
<div className="grid gap-4">
|
<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">
|
<AppCard title="Account" subtitle="Your Appwrite profile and sync status">
|
||||||
<div className="rounded-lg border border-white/10 bg-white/[0.05] p-4">
|
<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="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="mt-1 text-sm text-slate-400">{user?.email}</p>
|
||||||
<div className="mt-4 flex items-center gap-2 text-sm text-slate-300">
|
<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" />}
|
{dataLoading ? <Loader2 className="animate-spin text-cyan-200" size={16} aria-hidden="true" /> : <Cloud className="text-cyan-200" size={16} aria-hidden="true" />}
|
||||||
{notice}
|
{notice}
|
||||||
@@ -2025,6 +2525,8 @@ function EntryModal({
|
|||||||
entry,
|
entry,
|
||||||
flavours,
|
flavours,
|
||||||
saving,
|
saving,
|
||||||
|
userLimits,
|
||||||
|
entries,
|
||||||
onClose,
|
onClose,
|
||||||
onSave,
|
onSave,
|
||||||
}: {
|
}: {
|
||||||
@@ -2032,6 +2534,8 @@ function EntryModal({
|
|||||||
entry: RedBullEntry | null;
|
entry: RedBullEntry | null;
|
||||||
flavours: Flavour[];
|
flavours: Flavour[];
|
||||||
saving: boolean;
|
saving: boolean;
|
||||||
|
userLimits: UserLimits;
|
||||||
|
entries: RedBullEntry[];
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSave: (draft: EntryDraft) => void;
|
onSave: (draft: EntryDraft) => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -2086,8 +2590,8 @@ function EntryModal({
|
|||||||
sizePreset === "custom" && caffeineOverride.trim() ? Number(caffeineOverride) : undefined,
|
sizePreset === "custom" && caffeineOverride.trim() ? Number(caffeineOverride) : undefined,
|
||||||
);
|
);
|
||||||
|
|
||||||
function submit(event: FormEvent<HTMLFormElement>) {
|
const draftPreview = useMemo((): EntryDraft | null => {
|
||||||
event.preventDefault();
|
if (!open) return null;
|
||||||
const numericCans = Math.max(0.25, Number(cans) || 1);
|
const numericCans = Math.max(0.25, Number(cans) || 1);
|
||||||
const numericPrice = Math.max(0, Number(pricePerCan) || 0);
|
const numericPrice = Math.max(0, Number(pricePerCan) || 0);
|
||||||
const finalFlavour = isOther ? customFlavour.trim() || "Other" : selectedFlavour;
|
const finalFlavour = isOther ? customFlavour.trim() || "Other" : selectedFlavour;
|
||||||
@@ -2096,8 +2600,7 @@ function EntryModal({
|
|||||||
sizePreset === "custom" && caffeineOverride.trim()
|
sizePreset === "custom" && caffeineOverride.trim()
|
||||||
? Math.max(0, Number(caffeineOverride) || 0)
|
? Math.max(0, Number(caffeineOverride) || 0)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
return {
|
||||||
onSave({
|
|
||||||
cans: numericCans,
|
cans: numericCans,
|
||||||
flavour: finalFlavour,
|
flavour: finalFlavour,
|
||||||
flavourAccent: isOther ? customAccent || accentForCustomFlavour(finalFlavour) : meta.accent,
|
flavourAccent: isOther ? customAccent || accentForCustomFlavour(finalFlavour) : meta.accent,
|
||||||
@@ -2109,7 +2612,34 @@ function EntryModal({
|
|||||||
sugarFree: sugarFree || Boolean(meta.sugarFree),
|
sugarFree: sugarFree || Boolean(meta.sugarFree),
|
||||||
caffeineMgPerCan: override,
|
caffeineMgPerCan: override,
|
||||||
source: entry?.source ?? "manual",
|
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 (
|
return (
|
||||||
@@ -2144,6 +2674,13 @@ function EntryModal({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
<label className="field-label">
|
<label className="field-label">
|
||||||
Number of cans
|
Number of cans
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
@@ -36,6 +36,7 @@ body {
|
|||||||
background: #f8fbff;
|
background: #f8fbff;
|
||||||
color: #1f252a;
|
color: #1f252a;
|
||||||
font-family: "Google Sans", "Google Sans Text", "Product Sans", Roboto, -apple-system, BlinkMacSystemFont, sans-serif;
|
font-family: "Google Sans", "Google Sans Text", "Product Sans", Roboto, -apple-system, BlinkMacSystemFont, sans-serif;
|
||||||
|
font-weight: 400;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
text-rendering: optimizeLegibility;
|
text-rendering: optimizeLegibility;
|
||||||
}
|
}
|
||||||
@@ -96,7 +97,7 @@ textarea:focus-visible {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.state-chip {
|
.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);
|
background: var(--primary-container);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
color: var(--on-primary-container);
|
color: var(--on-primary-container);
|
||||||
@@ -110,7 +111,7 @@ textarea:focus-visible {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.segmented-control button {
|
.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;
|
border-radius: 999px;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
@@ -146,7 +147,7 @@ textarea:focus-visible {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.drawer-primary-action {
|
.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);
|
background: var(--primary-container);
|
||||||
border-radius: 18px;
|
border-radius: 18px;
|
||||||
color: var(--on-primary-container);
|
color: var(--on-primary-container);
|
||||||
@@ -191,12 +192,12 @@ textarea:focus-visible {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.top-kicker {
|
.top-kicker {
|
||||||
@apply text-sm font-medium;
|
@apply text-sm font-normal;
|
||||||
color: var(--primary);
|
color: var(--primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.top-title {
|
.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);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,7 +206,7 @@ textarea:focus-visible {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.account-chip {
|
.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);
|
background: var(--surface-container-high);
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
@@ -224,10 +225,11 @@ textarea:focus-visible {
|
|||||||
background: color-mix(in srgb, var(--surface-container-high) 92%, white);
|
background: color-mix(in srgb, var(--surface-container-high) 92%, white);
|
||||||
border-color: var(--outline-variant);
|
border-color: var(--outline-variant);
|
||||||
border-radius: 28px;
|
border-radius: 28px;
|
||||||
|
padding-bottom: calc(0.25rem + env(safe-area-inset-bottom, 0px));
|
||||||
}
|
}
|
||||||
|
|
||||||
.mobile-nav-item {
|
.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;
|
border-radius: 22px;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
@@ -290,15 +292,86 @@ textarea:focus-visible {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.oura-ring span {
|
.oura-ring span {
|
||||||
@apply text-4xl font-semibold leading-none;
|
@apply text-4xl font-medium leading-none;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.oura-ring small {
|
.oura-ring small {
|
||||||
@apply mt-1 text-xs font-semibold uppercase;
|
@apply mt-1 text-xs font-normal uppercase;
|
||||||
color: var(--muted);
|
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 {
|
.wellness-pill {
|
||||||
@apply flex items-center justify-between gap-3 rounded-full border px-4 py-3 text-sm;
|
@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);
|
background: color-mix(in srgb, var(--surface-container-high) 78%, white);
|
||||||
@@ -314,7 +387,7 @@ textarea:focus-visible {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.suggestion-chip {
|
.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);
|
background: color-mix(in srgb, var(--surface-container-high) 72%, white);
|
||||||
border-color: var(--outline-variant);
|
border-color: var(--outline-variant);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
@@ -353,12 +426,12 @@ textarea:focus-visible {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.coach-panel-kicker {
|
.coach-panel-kicker {
|
||||||
@apply text-xs font-semibold uppercase tracking-wide;
|
@apply text-xs font-medium uppercase tracking-wide;
|
||||||
color: var(--primary);
|
color: var(--primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.coach-panel-heading {
|
.coach-panel-heading {
|
||||||
@apply text-lg font-semibold leading-snug;
|
@apply text-lg font-medium leading-snug;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -367,7 +440,7 @@ textarea:focus-visible {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.coach-status-pill {
|
.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);
|
background: var(--surface-container-high);
|
||||||
border-color: var(--outline-variant);
|
border-color: var(--outline-variant);
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
@@ -389,7 +462,7 @@ textarea:focus-visible {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.coach-expand-button {
|
.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);
|
border-color: var(--outline-variant);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
@@ -399,7 +472,7 @@ textarea:focus-visible {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.coach-thread-chip {
|
.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);
|
border-color: var(--outline-variant);
|
||||||
background: var(--surface-container-high);
|
background: var(--surface-container-high);
|
||||||
}
|
}
|
||||||
@@ -426,7 +499,7 @@ textarea:focus-visible {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.coach-panel-context {
|
.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);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -461,7 +534,7 @@ textarea:focus-visible {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.coach-line-avatar {
|
.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);
|
background: var(--surface-container-high);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
@@ -522,12 +595,12 @@ textarea:focus-visible {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.thinking-pill-label {
|
.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);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.thinking-pill-chevron {
|
.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);
|
color: var(--primary);
|
||||||
animation: thinking-unlock-nudge 1.8s ease-in-out infinite;
|
animation: thinking-unlock-nudge 1.8s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
@@ -554,7 +627,7 @@ textarea:focus-visible {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.thinking-details summary {
|
.thinking-details summary {
|
||||||
@apply cursor-pointer text-xs font-medium;
|
@apply cursor-pointer text-xs font-normal;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -681,7 +754,7 @@ textarea:focus-visible {
|
|||||||
|
|
||||||
.command-button,
|
.command-button,
|
||||||
.secondary-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);
|
background: var(--secondary-container);
|
||||||
border-color: transparent;
|
border-color: transparent;
|
||||||
color: var(--on-secondary-container);
|
color: var(--on-secondary-container);
|
||||||
@@ -689,7 +762,7 @@ textarea:focus-visible {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.primary-button {
|
.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);
|
background: var(--primary);
|
||||||
border-color: transparent;
|
border-color: transparent;
|
||||||
color: var(--on-primary);
|
color: var(--on-primary);
|
||||||
@@ -697,7 +770,7 @@ textarea:focus-visible {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.excel-button {
|
.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);
|
background: var(--tertiary-container);
|
||||||
border-color: transparent;
|
border-color: transparent;
|
||||||
color: var(--on-tertiary-container);
|
color: var(--on-tertiary-container);
|
||||||
@@ -725,7 +798,7 @@ textarea:focus-visible {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.danger-button {
|
.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);
|
background: var(--error);
|
||||||
border-color: transparent;
|
border-color: transparent;
|
||||||
color: var(--on-error);
|
color: var(--on-error);
|
||||||
@@ -733,7 +806,7 @@ textarea:focus-visible {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.nav-item {
|
.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;
|
border-radius: 999px;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
@@ -757,14 +830,14 @@ textarea:focus-visible {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.quick-add {
|
.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);
|
background: var(--surface-container-high);
|
||||||
border-color: var(--outline-variant);
|
border-color: var(--outline-variant);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.field-label {
|
.field-label {
|
||||||
@apply grid gap-2 text-sm font-medium;
|
@apply grid gap-2 text-sm font-normal;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -801,7 +874,7 @@ textarea:focus-visible {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.list-button {
|
.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);
|
background: var(--secondary-container);
|
||||||
border-color: transparent;
|
border-color: transparent;
|
||||||
color: var(--on-secondary-container);
|
color: var(--on-secondary-container);
|
||||||
@@ -812,7 +885,7 @@ textarea:focus-visible {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.theme-indicator {
|
.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);
|
background: var(--surface-container-high);
|
||||||
border-color: var(--outline-variant);
|
border-color: var(--outline-variant);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
@@ -842,7 +915,7 @@ textarea:focus-visible {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.settings-tabs button {
|
.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);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -888,7 +961,7 @@ textarea:focus-visible {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.theme-tile-label {
|
.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;
|
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-white,
|
||||||
.app-shell .text-slate-50,
|
.app-shell .text-slate-50,
|
||||||
.app-shell .text-slate-100,
|
.app-shell .text-slate-100,
|
||||||
|
|||||||
+31
-3
@@ -1,3 +1,5 @@
|
|||||||
|
import type { LimitCheckResult } from "../types";
|
||||||
|
import { formatStopTimeLabel } from "./userLimits";
|
||||||
import { groupByFlavour } from "./metrics";
|
import { groupByFlavour } from "./metrics";
|
||||||
|
|
||||||
type GreetingInput = {
|
type GreetingInput = {
|
||||||
@@ -7,6 +9,8 @@ type GreetingInput = {
|
|||||||
currentStreak: number;
|
currentStreak: number;
|
||||||
todayCaffeineMg: number;
|
todayCaffeineMg: number;
|
||||||
allTimeCans: number;
|
allTimeCans: number;
|
||||||
|
dailyCanLimit?: number;
|
||||||
|
limitCheck?: LimitCheckResult;
|
||||||
};
|
};
|
||||||
|
|
||||||
type GreetingResult = {
|
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"}.`;
|
: `${input.name}, no Red Bulls logged yet this ${hour < 12 ? "morning" : hour < 17 ? "afternoon" : "evening"}.`;
|
||||||
} else if (cans === 1) {
|
} else if (cans === 1) {
|
||||||
headline = `${input.name}, one Red Bull in so far today.`;
|
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) {
|
} else if (cans <= 3) {
|
||||||
headline = `${input.name}, ${cans} Red Bulls today — steady pace.`;
|
headline = `${input.name}, ${cans} Red Bulls today — steady pace.`;
|
||||||
} else {
|
} else {
|
||||||
@@ -54,22 +66,38 @@ export function buildDynamicGreeting(input: GreetingInput): GreetingResult {
|
|||||||
: `All-time favourite: ${favourite} (${input.allTimeCans} cans logged).`
|
: `All-time favourite: ${favourite} (${input.allTimeCans} cans logged).`
|
||||||
: "Your flavour story is just getting started.";
|
: "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 =
|
const caffeineLine =
|
||||||
cans > 0 && input.todayCaffeineMg > 0
|
stopLine ??
|
||||||
|
(cans > 0 && input.todayCaffeineMg > 0
|
||||||
? `~${Math.round(input.todayCaffeineMg)}mg caffeine so far.`
|
? `~${Math.round(input.todayCaffeineMg)}mg caffeine so far.`
|
||||||
: hour >= 17 && cans === 0
|
: hour >= 17 && cans === 0
|
||||||
? "Evening reset — clean slate if you want it."
|
? "Evening reset — clean slate if you want it."
|
||||||
: hour >= 22
|
: hour >= 22
|
||||||
? "Late night — pace yourself if you're still going."
|
? "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 {
|
return {
|
||||||
badge,
|
badge,
|
||||||
headline,
|
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]) {
|
export function buildFlavourHistorySummary(entries: Parameters<typeof groupByFlavour>[0]) {
|
||||||
const breakdown = groupByFlavour(entries);
|
const breakdown = groupByFlavour(entries);
|
||||||
if (!breakdown.length) return "No flavour history yet.";
|
if (!breakdown.length) return "No flavour history yet.";
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ import {
|
|||||||
sugarFor,
|
sugarFor,
|
||||||
wholeNumber,
|
wholeNumber,
|
||||||
} from "./metrics";
|
} 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>;
|
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 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 [chats, setChats] = useState<CoachChat[]>([]);
|
||||||
const [activeChatId, setActiveChatId] = useState<string | null>(null);
|
const [activeChatId, setActiveChatId] = useState<string | null>(null);
|
||||||
const [savedChatIds, setSavedChatIds] = useState<Set<string>>(() => new Set());
|
const [savedChatIds, setSavedChatIds] = useState<Set<string>>(() => new Set());
|
||||||
@@ -165,7 +172,7 @@ export function useCoachSession(user: AuthUser, dashboard: Dashboard, entries: R
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const requestMessages: Array<{ role: string; content: string; thinking?: string }> = [
|
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
|
...conversation
|
||||||
.filter((message) => message.content.trim().length > 0)
|
.filter((message) => message.content.trim().length > 0)
|
||||||
.map((message) => ({
|
.map((message) => ({
|
||||||
@@ -333,7 +340,13 @@ function titleForChat(currentTitle: string, prompt: string) {
|
|||||||
return cleaned.length > 48 ? `${cleaned.slice(0, 45)}...` : cleaned || "today";
|
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
|
const recent = entries
|
||||||
.slice(0, 12)
|
.slice(0, 12)
|
||||||
.map(
|
.map(
|
||||||
@@ -351,6 +364,7 @@ function buildCoachSystemPrompt(user: AuthUser, dashboard: Dashboard, entries: R
|
|||||||
`User: ${user.name || user.email || "Appwrite user"}`,
|
`User: ${user.name || user.email || "Appwrite user"}`,
|
||||||
`Current time (BST): ${getBstHour()}:00.`,
|
`Current time (BST): ${getBstHour()}:00.`,
|
||||||
`Today: ${dashboard.todayCans} cans, ${dashboard.todayCaffeine} caffeine, ${dashboard.todaySugar} sugar.`,
|
`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}.`,
|
`All-time favourite: ${dashboard.favouriteFlavour}. Streak: ${dashboard.currentStreak} day(s). Spend: ${dashboard.totalSpend}.`,
|
||||||
`Flavour history:\n${buildFlavourHistorySummary(entries)}`,
|
`Flavour history:\n${buildFlavourHistorySummary(entries)}`,
|
||||||
`Recent entries:\n${recent || "No entries logged yet."}`,
|
`Recent entries:\n${recent || "No entries logged yet."}`,
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -74,3 +74,20 @@ export type CoachChat = {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: 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;
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user