Couple daily can and spend limits via usual can size

Link daily caps through the user's chosen standard can size (250/355/473ml)
and built-in prices. Onboarding asks for size and can ceiling with derived
spend preview. Settings syncs cans and spend bidirectionally. Forecast lock
button sets both limits together.

Co-authored-by: nh9961 <hello@nedhalksworth.com>
This commit is contained in:
Cursor Agent
2026-07-01 14:45:45 +00:00
parent 7f56274e65
commit 7a461af9ee
7 changed files with 229 additions and 148 deletions
+5
View File
@@ -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}
/>
)}
<input
@@ -1753,9 +1755,12 @@ function SpendForecastCard({
const saveLowerLimit = () => {
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),
});
};
+2 -2
View File
@@ -17,8 +17,8 @@ export function DailyLimitsCard({ limits, check, onOpenSettings }: DailyLimitsCa
<div>
<p className="section-kicker">Daily limits</p>
<p className="section-meta mt-2 max-w-xl leading-6">
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.
</p>
</div>
<button className="secondary-button shrink-0" type="button" onClick={onOpenSettings}>
+91 -13
View File
@@ -1,7 +1,13 @@
import { Loader2, Target } from "lucide-react";
import { useEffect, useState, type FormEvent } from "react";
import type { LimitCheckResult, UserLimits } from "../types";
import { currency } from "../lib/metrics";
import {
BUILT_IN_SIZES,
canLimitFromSpend,
currency,
priceForLimitSize,
spendLimitFromCans,
} from "../lib/metrics";
import type { BuiltInSize, LimitCheckResult, UserLimits } from "../types";
type LimitsSettingsFormProps = {
limits: UserLimits;
@@ -11,15 +17,57 @@ type LimitsSettingsFormProps = {
};
export function LimitsSettingsForm({ limits, check, saving, onSave }: LimitsSettingsFormProps) {
const [canSizeMl, setCanSizeMl] = useState<BuiltInSize>(limits.limitCanSizeMl ?? 250);
const [canInput, setCanInput] = useState(limits.dailyCanLimit?.toString() ?? "");
const [spendInput, setSpendInput] = useState(limits.dailySpendLimit?.toString() ?? "");
const [stopInput, setStopInput] = useState(limits.stopTime ?? "");
useEffect(() => {
setCanSizeMl(limits.limitCanSizeMl ?? 250);
setCanInput(limits.dailyCanLimit?.toString() ?? "");
setSpendInput(limits.dailySpendLimit?.toString() ?? "");
setStopInput(limits.stopTime ?? "");
}, [limits.dailyCanLimit, limits.dailySpendLimit, limits.stopTime]);
}, [limits.dailyCanLimit, limits.dailySpendLimit, limits.limitCanSizeMl, limits.stopTime]);
function syncFromCans(cans: number, size: BuiltInSize) {
setCanInput(cans.toString());
setSpendInput(spendLimitFromCans(cans, size).toFixed(2));
}
function handleCanSizeChange(size: BuiltInSize) {
setCanSizeMl(size);
const canTrim = canInput.trim();
if (canTrim) {
const cans = Math.max(0.25, Number(canTrim) || 0);
syncFromCans(cans, size);
}
}
function handleCanInputChange(value: string) {
setCanInput(value);
const canTrim = value.trim();
if (!canTrim) {
setSpendInput("");
return;
}
const cans = Math.max(0.25, Number(canTrim) || 0);
if (cans > 0) {
setSpendInput(spendLimitFromCans(cans, canSizeMl).toFixed(2));
}
}
function handleSpendInputChange(value: string) {
setSpendInput(value);
const spendTrim = value.trim();
if (!spendTrim) {
setCanInput("");
return;
}
const spend = Math.max(0, Number(spendTrim) || 0);
if (spend >= 0) {
setCanInput(canLimitFromSpend(spend, canSizeMl).toString());
}
}
function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
@@ -28,13 +76,11 @@ export function LimitsSettingsForm({ limits, check, saving, onSave }: LimitsSett
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 (parsed > 0) {
next.dailyCanLimit = parsed;
next.limitCanSizeMl = canSizeMl;
next.dailySpendLimit = spendLimitFromCans(parsed, canSizeMl);
}
}
if (stopInput.trim()) {
@@ -51,9 +97,39 @@ export function LimitsSettingsForm({ limits, check, saving, onSave }: LimitsSett
if (limits.dailySpendLimit != null) {
previewParts.push(`${currency.format(check.todaySpend)} of ${currency.format(limits.dailySpendLimit)} spent today`);
}
if (limits.limitCanSizeMl != null) {
previewParts.push(`${limits.limitCanSizeMl}ml cans`);
}
return (
<form className="grid gap-4" onSubmit={submit}>
<div className="grid gap-2">
<span className="text-sm font-medium text-slate-700">Usual can size</span>
<div className="flex flex-wrap gap-2">
{BUILT_IN_SIZES.map((size) => {
const isActive = canSizeMl === size;
return (
<button
key={size}
type="button"
onClick={() => handleCanSizeChange(size)}
className="rounded-full border px-4 py-2 text-sm transition"
style={{
borderColor: isActive ? "var(--primary, #2563eb)" : "#cbd5e1",
background: isActive ? "#eff6ff" : "white",
color: isActive ? "#1d4ed8" : "#475569",
}}
>
{size}ml ({currency.format(priceForLimitSize(size))})
</button>
);
})}
</div>
<span className="text-xs text-slate-500">
Spend is based on your usual can size. Changing cans or spend updates the other.
</span>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<label className="grid gap-2 text-sm">
<span className="font-medium text-slate-700">Cans per day</span>
@@ -64,7 +140,7 @@ export function LimitsSettingsForm({ limits, check, saving, onSave }: LimitsSett
step={0.25}
placeholder="e.g. 3"
value={canInput}
onChange={(event) => setCanInput(event.target.value)}
onChange={(event) => handleCanInputChange(event.target.value)}
/>
<span className="text-xs text-slate-500">Leave empty to remove. Counts use BST calendar days.</span>
</label>
@@ -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)}
/>
<span className="text-xs text-slate-500">Based on price per can in your log.</span>
<span className="text-xs text-slate-500">
Linked to {canSizeMl}ml at {currency.format(priceForLimitSize(canSizeMl))}/can.
</span>
</label>
</div>
+104 -132
View File
@@ -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<void>;
@@ -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<number | "none">(2);
const [dailySpendLimit, setDailySpendLimit] = useState<number | "none">(3.5);
const [stopTime, setStopTime] = useState<string | "none">("18:00");
const [limitCanSizeMl, setLimitCanSizeMl] = useState<BuiltInSize>(
initialLimits?.limitCanSizeMl ?? 250,
);
const [dailyCanLimit, setDailyCanLimit] = useState<number | "none">(
initialLimits?.dailyCanLimit ?? 2,
);
const [stopTime, setStopTime] = useState<string | "none">(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.
</h1>
<p className="max-w-xl text-lg font-normal leading-8 text-[var(--muted)]">
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.
</p>
</div>
<button
@@ -200,42 +201,83 @@ export function OnboardingScreen({
{step === 3 && (
<section className="grid gap-9">
<div className="grid gap-4">
<p className="text-sm font-normal text-[var(--primary)]">daily cans</p>
<p className="text-sm font-normal text-[var(--primary)]">daily limit</p>
<h2 className="max-w-2xl text-4xl font-normal leading-tight tracking-[-0.04em] sm:text-6xl">
What is your daily can ceiling?
What size can do you usually have?
</h2>
<p className="max-w-lg text-base leading-7 text-[var(--muted)]">
The app warns before saving an entry over this number. You can change it later.
Your spend cap is calculated from your can size and daily ceiling. You can change this later in settings.
</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 className="grid gap-2 sm:grid-cols-3">
{BUILT_IN_SIZES.map((size) => {
const isSelected = limitCanSizeMl === size;
return (
<button
key={size}
type="button"
onClick={() => setLimitCanSizeMl(size)}
className="flex min-h-20 flex-col items-start justify-center 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 className="text-lg font-normal text-[var(--text)]">{size}ml</span>
<span className="mt-1 text-sm font-normal text-[var(--muted)]">
{currency.format(priceForLimitSize(size))} per can
</span>
</button>
);
})}
</div>
<div className="grid gap-4">
<p className="text-sm font-normal text-[var(--muted)]">Daily can ceiling</p>
<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>
{derivedSpend != null ? (
<p
className="rounded-2xl border px-4 py-3 text-sm font-normal"
style={{
background: "var(--surface-container-lowest)",
borderColor: "var(--outline-variant)",
color: "var(--text)",
}}
>
Daily budget: {currency.format(derivedSpend)} ({dailyCanLimit} × {currency.format(unitPrice)})
</p>
) : (
<p className="text-sm font-normal text-[var(--muted)]">No daily spend cap when cans are unlimited.</p>
)}
<div className="flex flex-wrap gap-2">
<button
type="button"
@@ -274,82 +316,6 @@ export function OnboardingScreen({
)}
{step === 4 && (
<section className="grid gap-9">
<div className="grid gap-4">
<p className="text-sm font-normal text-[var(--primary)]">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 if you want a spending line for the day.
</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)]">time limit</p>
@@ -397,7 +363,7 @@ export function OnboardingScreen({
</section>
)}
{step === 6 && (
{step === 5 && (
<section className="grid gap-8">
<div className="grid gap-4">
<p className="text-sm font-normal text-[var(--primary)]">done</p>
@@ -414,6 +380,12 @@ export function OnboardingScreen({
{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)]">Usual can size</span>
<span className="text-sm font-normal text-[var(--text)]">
{dailyCanLimit === "none" ? "—" : `${limitCanSizeMl}ml (${currency.format(unitPrice)}/can)`}
</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)]">
@@ -423,7 +395,7 @@ export function OnboardingScreen({
<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)}
{derivedSpend == null ? "No cap" : currency.format(derivedSpend)}
</span>
</div>
<div className="flex items-center justify-between gap-4">
+16 -1
View File
@@ -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;
}
+10
View File
@@ -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<string, unknown> | null | undefined): UserLimits {
if (!prefs) return { ...DEFAULT_LIMITS };
@@ -16,9 +19,12 @@ export function parseUserLimits(prefs: Record<string, unknown> | 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<string, unknown>
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) };
}
+1
View File
@@ -110,6 +110,7 @@ export type UserLimits = {
dailyCanLimit?: number;
dailySpendLimit?: number;
stopTime?: string;
limitCanSizeMl?: BuiltInSize;
};
export type LimitViolation = "cans" | "spend" | "stopTime";