diff --git a/src/App.tsx b/src/App.tsx index 28fceca..8194f92 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -94,6 +94,7 @@ import { createExcelExport, downloadBlob, parseExcelImport } from "./lib/excel"; import { caffeineFor, caffeinePerCan, + canLimitFromSpend, currency, currentStreak, daysSinceLast, @@ -656,6 +657,7 @@ function App() { onThemeChange={setThemeId} onSave={saveOnboarding} onClose={() => setSetupOpen(false)} + initialLimits={userLimits} /> )} { if (!onSaveLimits) return; const lowerDailyLimit = Math.round(stats.avgDailySpend * 0.8 * 100) / 100; + const size = userLimits.limitCanSizeMl ?? 250; onSaveLimits({ ...userLimits, + limitCanSizeMl: size, dailySpendLimit: lowerDailyLimit, + dailyCanLimit: canLimitFromSpend(lowerDailyLimit, size), }); }; diff --git a/src/components/DailyLimitsCard.tsx b/src/components/DailyLimitsCard.tsx index f48e7f3..d6dc987 100644 --- a/src/components/DailyLimitsCard.tsx +++ b/src/components/DailyLimitsCard.tsx @@ -17,8 +17,8 @@ export function DailyLimitsCard({ limits, check, onOpenSettings }: DailyLimitsCa

Daily limits

- Set how many cans you want per day, when to stop, and a spend cap. Limits are optional and stored on your - account. + Set your usual can size and daily ceiling. Spend is calculated automatically. Limits are optional and stored + on your account.

+ ); + })} + + + Spend is based on your usual can size. Changing cans or spend updates the other. + + +
@@ -78,9 +154,11 @@ export function LimitsSettingsForm({ limits, check, saving, onSave }: LimitsSett step={0.01} placeholder="e.g. 5.00" value={spendInput} - onChange={(event) => setSpendInput(event.target.value)} + onChange={(event) => handleSpendInputChange(event.target.value)} /> - Based on price per can in your log. + + Linked to {canSizeMl}ml at {currency.format(priceForLimitSize(canSizeMl))}/can. +
diff --git a/src/components/OnboardingScreen.tsx b/src/components/OnboardingScreen.tsx index 2210ca2..a7e573b 100644 --- a/src/components/OnboardingScreen.tsx +++ b/src/components/OnboardingScreen.tsx @@ -1,8 +1,13 @@ import { useMemo, useState } from "react"; import { ArrowRight, Check, ChevronLeft } from "lucide-react"; import { APP_THEMES } from "../data/themes"; -import { currency } from "../lib/metrics"; -import type { UserLimits } from "../types"; +import { + BUILT_IN_SIZES, + currency, + priceForLimitSize, + spendLimitFromCans, +} from "../lib/metrics"; +import type { BuiltInSize, UserLimits } from "../types"; type OnboardingScreenProps = { onSave: (limits: UserLimits, themeId: string) => Promise; @@ -10,9 +15,10 @@ type OnboardingScreenProps = { activeThemeId: string; onThemeChange: (themeId: string) => void; userName?: string; + initialLimits?: UserLimits; }; -const STEP_COUNT = 6; +const STEP_COUNT = 5; const curfewOptions: Array<{ id: string; label: string; hint: string }> = [ { id: "16:00", label: "4:00 PM", hint: "Early cut-off" }, @@ -27,24 +33,36 @@ export function OnboardingScreen({ activeThemeId, onThemeChange, userName, + initialLimits, }: OnboardingScreenProps) { const [step, setStep] = useState(1); - const [dailyCanLimit, setDailyCanLimit] = useState(2); - const [dailySpendLimit, setDailySpendLimit] = useState(3.5); - const [stopTime, setStopTime] = useState("18:00"); + const [limitCanSizeMl, setLimitCanSizeMl] = useState( + initialLimits?.limitCanSizeMl ?? 250, + ); + const [dailyCanLimit, setDailyCanLimit] = useState( + initialLimits?.dailyCanLimit ?? 2, + ); + const [stopTime, setStopTime] = useState(initialLimits?.stopTime ?? "18:00"); const [saving, setSaving] = useState(false); const activeTheme = useMemo(() => { return APP_THEMES.find((theme) => theme.id === activeThemeId) ?? APP_THEMES[0]; }, [activeThemeId]); + const derivedSpend = + dailyCanLimit !== "none" ? spendLimitFromCans(dailyCanLimit, limitCanSizeMl) : null; + const unitPrice = priceForLimitSize(limitCanSizeMl); + 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 (dailyCanLimit !== "none") { + limits.dailyCanLimit = dailyCanLimit; + limits.limitCanSizeMl = limitCanSizeMl; + limits.dailySpendLimit = spendLimitFromCans(dailyCanLimit, limitCanSizeMl); + } if (stopTime !== "none") limits.stopTime = stopTime; await onSave(limits, activeThemeId); @@ -73,23 +91,6 @@ export function OnboardingScreen({ 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)); } @@ -136,7 +137,7 @@ export function OnboardingScreen({ Hey {userName || "there"}. Set your baseline.

- Pick a theme, then set optional limits for cans, spend, and time. + Pick a theme, choose your usual can size, set a daily ceiling, and optionally a curfew.

-
-

- {dailyCanLimit === "none" ? "No cap" : dailyCanLimit} -

-

- {dailyCanLimit === "none" ? "Unlimited daily volume" : dailyCanLimit === 1 ? "can per day" : "cans per day"} -

-
- +
+ {BUILT_IN_SIZES.map((size) => { + const isSelected = limitCanSizeMl === size; + return ( + + ); + })}
+
+

Daily can ceiling

+
+ +
+

+ {dailyCanLimit === "none" ? "No cap" : dailyCanLimit} +

+

+ {dailyCanLimit === "none" ? "Unlimited daily volume" : dailyCanLimit === 1 ? "can per day" : "cans per day"} +

+
+ +
+
+ + {derivedSpend != null ? ( +

+ Daily budget: {currency.format(derivedSpend)} ({dailyCanLimit} × {currency.format(unitPrice)}) +

+ ) : ( +

No daily spend cap when cans are unlimited.

+ )} +
-
-

- {dailySpendLimit === "none" ? "No cap" : currency.format(dailySpendLimit)} -

-

- {dailySpendLimit === "none" ? "No daily budget" : "maximum per day"} -

-
- -
- -
- - {dailySpendLimit === "none" && ( - - )} -
- - - - )} - - {step === 5 && (

time limit

@@ -397,7 +363,7 @@ export function OnboardingScreen({
)} - {step === 6 && ( + {step === 5 && (

done

@@ -414,6 +380,12 @@ export function OnboardingScreen({ {activeTheme.label}
+
+ Usual can size + + {dailyCanLimit === "none" ? "—" : `${limitCanSizeMl}ml (${currency.format(unitPrice)}/can)`} + +
Daily cans @@ -423,7 +395,7 @@ export function OnboardingScreen({
Daily spend - {dailySpendLimit === "none" ? "No cap" : currency.format(dailySpendLimit)} + {derivedSpend == null ? "No cap" : currency.format(derivedSpend)}
diff --git a/src/lib/metrics.ts b/src/lib/metrics.ts index 5ab1a9e..4cac485 100644 --- a/src/lib/metrics.ts +++ b/src/lib/metrics.ts @@ -1,4 +1,4 @@ -import type { RedBullEntry } from "../types"; +import type { BuiltInSize, RedBullEntry } from "../types"; export const CAFFEINE_PER_250ML = 80; export const SUGAR_PER_250ML = 27; @@ -8,6 +8,21 @@ export const STANDARD_CAN_VALUES = { 473: { pricePerCan: 2.85, caffeineMg: 151 }, } as const; +export const BUILT_IN_SIZES: BuiltInSize[] = [250, 355, 473]; + +export function priceForLimitSize(size: BuiltInSize): number { + return STANDARD_CAN_VALUES[size].pricePerCan; +} + +export function spendLimitFromCans(cans: number, size: BuiltInSize): number { + return Math.round(cans * priceForLimitSize(size) * 100) / 100; +} + +export function canLimitFromSpend(spend: number, size: BuiltInSize): number { + const raw = spend / priceForLimitSize(size); + return Math.round(raw * 4) / 4; +} + export function spendFor(entry: RedBullEntry) { return entry.cans * entry.pricePerCan; } diff --git a/src/lib/userLimits.ts b/src/lib/userLimits.ts index dcb611f..0997bb3 100644 --- a/src/lib/userLimits.ts +++ b/src/lib/userLimits.ts @@ -7,6 +7,9 @@ export const DEFAULT_LIMITS: UserLimits = {}; const PREFS_CAN_KEY = "dailyCanLimit"; const PREFS_SPEND_KEY = "dailySpendLimit"; const PREFS_STOP_KEY = "stopTime"; +const PREFS_SIZE_KEY = "limitCanSizeMl"; + +const VALID_LIMIT_SIZES = new Set([250, 355, 473]); export function parseUserLimits(prefs: Record | null | undefined): UserLimits { if (!prefs) return { ...DEFAULT_LIMITS }; @@ -16,9 +19,12 @@ export function parseUserLimits(prefs: Record | null | undefine const spendLimit = Number(prefs[PREFS_SPEND_KEY]); const stopTime = typeof prefs[PREFS_STOP_KEY] === "string" ? prefs[PREFS_STOP_KEY] : undefined; + const sizeLimit = Number(prefs[PREFS_SIZE_KEY]); + 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; + if (VALID_LIMIT_SIZES.has(sizeLimit)) limits.limitCanSizeMl = sizeLimit as 250 | 355 | 473; return limits; } @@ -34,6 +40,9 @@ export function serializeUserLimits(limits: UserLimits): Record if (limits.stopTime) { data[PREFS_STOP_KEY] = limits.stopTime; } + if (limits.limitCanSizeMl != null && VALID_LIMIT_SIZES.has(limits.limitCanSizeMl)) { + data[PREFS_SIZE_KEY] = limits.limitCanSizeMl; + } return data; } @@ -45,6 +54,7 @@ export function mergePrefsWithLimits( delete next[PREFS_CAN_KEY]; delete next[PREFS_SPEND_KEY]; delete next[PREFS_STOP_KEY]; + delete next[PREFS_SIZE_KEY]; return { ...next, ...serializeUserLimits(limits) }; } diff --git a/src/types.ts b/src/types.ts index 2880c38..6671a11 100644 --- a/src/types.ts +++ b/src/types.ts @@ -110,6 +110,7 @@ export type UserLimits = { dailyCanLimit?: number; dailySpendLimit?: number; stopTime?: string; + limitCanSizeMl?: BuiltInSize; }; export type LimitViolation = "cans" | "spend" | "stopTime";