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 { import {
caffeineFor, caffeineFor,
caffeinePerCan, caffeinePerCan,
canLimitFromSpend,
currency, currency,
currentStreak, currentStreak,
daysSinceLast, daysSinceLast,
@@ -656,6 +657,7 @@ function App() {
onThemeChange={setThemeId} onThemeChange={setThemeId}
onSave={saveOnboarding} onSave={saveOnboarding}
onClose={() => setSetupOpen(false)} onClose={() => setSetupOpen(false)}
initialLimits={userLimits}
/> />
)} )}
<input <input
@@ -1753,9 +1755,12 @@ function SpendForecastCard({
const saveLowerLimit = () => { const saveLowerLimit = () => {
if (!onSaveLimits) return; if (!onSaveLimits) return;
const lowerDailyLimit = Math.round(stats.avgDailySpend * 0.8 * 100) / 100; const lowerDailyLimit = Math.round(stats.avgDailySpend * 0.8 * 100) / 100;
const size = userLimits.limitCanSizeMl ?? 250;
onSaveLimits({ onSaveLimits({
...userLimits, ...userLimits,
limitCanSizeMl: size,
dailySpendLimit: lowerDailyLimit, dailySpendLimit: lowerDailyLimit,
dailyCanLimit: canLimitFromSpend(lowerDailyLimit, size),
}); });
}; };
+2 -2
View File
@@ -17,8 +17,8 @@ export function DailyLimitsCard({ limits, check, onOpenSettings }: DailyLimitsCa
<div> <div>
<p className="section-kicker">Daily limits</p> <p className="section-kicker">Daily limits</p>
<p className="section-meta mt-2 max-w-xl leading-6"> <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 Set your usual can size and daily ceiling. Spend is calculated automatically. Limits are optional and stored
account. on your account.
</p> </p>
</div> </div>
<button className="secondary-button shrink-0" type="button" onClick={onOpenSettings}> <button className="secondary-button shrink-0" type="button" onClick={onOpenSettings}>
+90 -12
View File
@@ -1,7 +1,13 @@
import { Loader2, Target } from "lucide-react"; import { Loader2, Target } from "lucide-react";
import { useEffect, useState, type FormEvent } from "react"; import { useEffect, useState, type FormEvent } from "react";
import type { LimitCheckResult, UserLimits } from "../types"; import {
import { currency } from "../lib/metrics"; BUILT_IN_SIZES,
canLimitFromSpend,
currency,
priceForLimitSize,
spendLimitFromCans,
} from "../lib/metrics";
import type { BuiltInSize, LimitCheckResult, UserLimits } from "../types";
type LimitsSettingsFormProps = { type LimitsSettingsFormProps = {
limits: UserLimits; limits: UserLimits;
@@ -11,15 +17,57 @@ type LimitsSettingsFormProps = {
}; };
export function LimitsSettingsForm({ limits, check, saving, onSave }: 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 [canInput, setCanInput] = useState(limits.dailyCanLimit?.toString() ?? "");
const [spendInput, setSpendInput] = useState(limits.dailySpendLimit?.toString() ?? ""); const [spendInput, setSpendInput] = useState(limits.dailySpendLimit?.toString() ?? "");
const [stopInput, setStopInput] = useState(limits.stopTime ?? ""); const [stopInput, setStopInput] = useState(limits.stopTime ?? "");
useEffect(() => { useEffect(() => {
setCanSizeMl(limits.limitCanSizeMl ?? 250);
setCanInput(limits.dailyCanLimit?.toString() ?? ""); setCanInput(limits.dailyCanLimit?.toString() ?? "");
setSpendInput(limits.dailySpendLimit?.toString() ?? ""); setSpendInput(limits.dailySpendLimit?.toString() ?? "");
setStopInput(limits.stopTime ?? ""); 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>) { function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault(); event.preventDefault();
@@ -28,13 +76,11 @@ export function LimitsSettingsForm({ limits, check, saving, onSave }: LimitsSett
const canTrim = canInput.trim(); const canTrim = canInput.trim();
if (canTrim) { if (canTrim) {
const parsed = Math.max(0.25, Number(canTrim) || 0); const parsed = Math.max(0.25, Number(canTrim) || 0);
if (parsed > 0) next.dailyCanLimit = parsed; if (parsed > 0) {
next.dailyCanLimit = parsed;
next.limitCanSizeMl = canSizeMl;
next.dailySpendLimit = spendLimitFromCans(parsed, canSizeMl);
} }
const spendTrim = spendInput.trim();
if (spendTrim) {
const parsed = Math.max(0, Number(spendTrim) || 0);
next.dailySpendLimit = parsed;
} }
if (stopInput.trim()) { if (stopInput.trim()) {
@@ -51,9 +97,39 @@ export function LimitsSettingsForm({ limits, check, saving, onSave }: LimitsSett
if (limits.dailySpendLimit != null) { if (limits.dailySpendLimit != null) {
previewParts.push(`${currency.format(check.todaySpend)} of ${currency.format(limits.dailySpendLimit)} spent today`); previewParts.push(`${currency.format(check.todaySpend)} of ${currency.format(limits.dailySpendLimit)} spent today`);
} }
if (limits.limitCanSizeMl != null) {
previewParts.push(`${limits.limitCanSizeMl}ml cans`);
}
return ( return (
<form className="grid gap-4" onSubmit={submit}> <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"> <div className="grid gap-4 sm:grid-cols-2">
<label className="grid gap-2 text-sm"> <label className="grid gap-2 text-sm">
<span className="font-medium text-slate-700">Cans per day</span> <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} step={0.25}
placeholder="e.g. 3" placeholder="e.g. 3"
value={canInput} 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> <span className="text-xs text-slate-500">Leave empty to remove. Counts use BST calendar days.</span>
</label> </label>
@@ -78,9 +154,11 @@ export function LimitsSettingsForm({ limits, check, saving, onSave }: LimitsSett
step={0.01} step={0.01}
placeholder="e.g. 5.00" placeholder="e.g. 5.00"
value={spendInput} 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> </label>
</div> </div>
+79 -107
View File
@@ -1,8 +1,13 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { ArrowRight, Check, ChevronLeft } from "lucide-react"; import { ArrowRight, Check, ChevronLeft } from "lucide-react";
import { APP_THEMES } from "../data/themes"; import { APP_THEMES } from "../data/themes";
import { currency } from "../lib/metrics"; import {
import type { UserLimits } from "../types"; BUILT_IN_SIZES,
currency,
priceForLimitSize,
spendLimitFromCans,
} from "../lib/metrics";
import type { BuiltInSize, UserLimits } from "../types";
type OnboardingScreenProps = { type OnboardingScreenProps = {
onSave: (limits: UserLimits, themeId: string) => Promise<void>; onSave: (limits: UserLimits, themeId: string) => Promise<void>;
@@ -10,9 +15,10 @@ type OnboardingScreenProps = {
activeThemeId: string; activeThemeId: string;
onThemeChange: (themeId: string) => void; onThemeChange: (themeId: string) => void;
userName?: string; userName?: string;
initialLimits?: UserLimits;
}; };
const STEP_COUNT = 6; const STEP_COUNT = 5;
const curfewOptions: Array<{ id: string; label: string; hint: string }> = [ const curfewOptions: Array<{ id: string; label: string; hint: string }> = [
{ id: "16:00", label: "4:00 PM", hint: "Early cut-off" }, { id: "16:00", label: "4:00 PM", hint: "Early cut-off" },
@@ -27,24 +33,36 @@ export function OnboardingScreen({
activeThemeId, activeThemeId,
onThemeChange, onThemeChange,
userName, userName,
initialLimits,
}: OnboardingScreenProps) { }: OnboardingScreenProps) {
const [step, setStep] = useState(1); const [step, setStep] = useState(1);
const [dailyCanLimit, setDailyCanLimit] = useState<number | "none">(2); const [limitCanSizeMl, setLimitCanSizeMl] = useState<BuiltInSize>(
const [dailySpendLimit, setDailySpendLimit] = useState<number | "none">(3.5); initialLimits?.limitCanSizeMl ?? 250,
const [stopTime, setStopTime] = useState<string | "none">("18:00"); );
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 [saving, setSaving] = useState(false);
const activeTheme = useMemo(() => { const activeTheme = useMemo(() => {
return APP_THEMES.find((theme) => theme.id === activeThemeId) ?? APP_THEMES[0]; return APP_THEMES.find((theme) => theme.id === activeThemeId) ?? APP_THEMES[0];
}, [activeThemeId]); }, [activeThemeId]);
const derivedSpend =
dailyCanLimit !== "none" ? spendLimitFromCans(dailyCanLimit, limitCanSizeMl) : null;
const unitPrice = priceForLimitSize(limitCanSizeMl);
const progress = `${(step / STEP_COUNT) * 100}%`; const progress = `${(step / STEP_COUNT) * 100}%`;
async function handleFinish() { async function handleFinish() {
setSaving(true); setSaving(true);
try { try {
const limits: UserLimits = {}; const limits: UserLimits = {};
if (dailyCanLimit !== "none") limits.dailyCanLimit = dailyCanLimit; if (dailyCanLimit !== "none") {
if (dailySpendLimit !== "none") limits.dailySpendLimit = dailySpendLimit; limits.dailyCanLimit = dailyCanLimit;
limits.limitCanSizeMl = limitCanSizeMl;
limits.dailySpendLimit = spendLimitFromCans(dailyCanLimit, limitCanSizeMl);
}
if (stopTime !== "none") limits.stopTime = stopTime; if (stopTime !== "none") limits.stopTime = stopTime;
await onSave(limits, activeThemeId); await onSave(limits, activeThemeId);
@@ -73,23 +91,6 @@ export function OnboardingScreen({
setDailyCanLimit(Number((dailyCanLimit - 0.5).toFixed(1))); 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() { function goNext() {
setStep((current) => Math.min(current + 1, STEP_COUNT)); setStep((current) => Math.min(current + 1, STEP_COUNT));
} }
@@ -136,7 +137,7 @@ export function OnboardingScreen({
Hey {userName || "there"}. Set your baseline. Hey {userName || "there"}. Set your baseline.
</h1> </h1>
<p className="max-w-xl text-lg font-normal leading-8 text-[var(--muted)]"> <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> </p>
</div> </div>
<button <button
@@ -200,15 +201,40 @@ export function OnboardingScreen({
{step === 3 && ( {step === 3 && (
<section className="grid gap-9"> <section className="grid gap-9">
<div className="grid gap-4"> <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"> <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> </h2>
<p className="max-w-lg text-base leading-7 text-[var(--muted)]"> <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> </p>
</div> </div>
<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"> <div className="flex flex-wrap items-end gap-5">
<button <button
type="button" type="button"
@@ -235,6 +261,22 @@ export function OnboardingScreen({
+ +
</button> </button>
</div> </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"> <div className="flex flex-wrap gap-2">
<button <button
@@ -274,82 +316,6 @@ export function OnboardingScreen({
)} )}
{step === 4 && ( {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"> <section className="grid gap-8">
<div className="grid gap-4"> <div className="grid gap-4">
<p className="text-sm font-normal text-[var(--primary)]">time limit</p> <p className="text-sm font-normal text-[var(--primary)]">time limit</p>
@@ -397,7 +363,7 @@ export function OnboardingScreen({
</section> </section>
)} )}
{step === 6 && ( {step === 5 && (
<section className="grid gap-8"> <section className="grid gap-8">
<div className="grid gap-4"> <div className="grid gap-4">
<p className="text-sm font-normal text-[var(--primary)]">done</p> <p className="text-sm font-normal text-[var(--primary)]">done</p>
@@ -414,6 +380,12 @@ export function OnboardingScreen({
{activeTheme.label} {activeTheme.label}
</span> </span>
</div> </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)" }}> <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(--muted)]">Daily cans</span>
<span className="text-sm font-normal text-[var(--text)]"> <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)" }}> <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(--muted)]">Daily spend</span>
<span className="text-sm font-normal text-[var(--text)]"> <span className="text-sm font-normal text-[var(--text)]">
{dailySpendLimit === "none" ? "No cap" : currency.format(dailySpendLimit)} {derivedSpend == null ? "No cap" : currency.format(derivedSpend)}
</span> </span>
</div> </div>
<div className="flex items-center justify-between gap-4"> <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 CAFFEINE_PER_250ML = 80;
export const SUGAR_PER_250ML = 27; export const SUGAR_PER_250ML = 27;
@@ -8,6 +8,21 @@ export const STANDARD_CAN_VALUES = {
473: { pricePerCan: 2.85, caffeineMg: 151 }, 473: { pricePerCan: 2.85, caffeineMg: 151 },
} as const; } 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) { export function spendFor(entry: RedBullEntry) {
return entry.cans * entry.pricePerCan; return entry.cans * entry.pricePerCan;
} }
+10
View File
@@ -7,6 +7,9 @@ export const DEFAULT_LIMITS: UserLimits = {};
const PREFS_CAN_KEY = "dailyCanLimit"; const PREFS_CAN_KEY = "dailyCanLimit";
const PREFS_SPEND_KEY = "dailySpendLimit"; const PREFS_SPEND_KEY = "dailySpendLimit";
const PREFS_STOP_KEY = "stopTime"; 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 { export function parseUserLimits(prefs: Record<string, unknown> | null | undefined): UserLimits {
if (!prefs) return { ...DEFAULT_LIMITS }; 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 spendLimit = Number(prefs[PREFS_SPEND_KEY]);
const stopTime = typeof prefs[PREFS_STOP_KEY] === "string" ? prefs[PREFS_STOP_KEY] : undefined; 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(canLimit) && canLimit > 0) limits.dailyCanLimit = canLimit;
if (Number.isFinite(spendLimit) && spendLimit >= 0) limits.dailySpendLimit = spendLimit; if (Number.isFinite(spendLimit) && spendLimit >= 0) limits.dailySpendLimit = spendLimit;
if (stopTime && /^\d{2}:\d{2}$/.test(stopTime)) limits.stopTime = stopTime; 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; return limits;
} }
@@ -34,6 +40,9 @@ export function serializeUserLimits(limits: UserLimits): Record<string, unknown>
if (limits.stopTime) { if (limits.stopTime) {
data[PREFS_STOP_KEY] = 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; return data;
} }
@@ -45,6 +54,7 @@ export function mergePrefsWithLimits(
delete next[PREFS_CAN_KEY]; delete next[PREFS_CAN_KEY];
delete next[PREFS_SPEND_KEY]; delete next[PREFS_SPEND_KEY];
delete next[PREFS_STOP_KEY]; delete next[PREFS_STOP_KEY];
delete next[PREFS_SIZE_KEY];
return { ...next, ...serializeUserLimits(limits) }; return { ...next, ...serializeUserLimits(limits) };
} }
+1
View File
@@ -110,6 +110,7 @@ export type UserLimits = {
dailyCanLimit?: number; dailyCanLimit?: number;
dailySpendLimit?: number; dailySpendLimit?: number;
stopTime?: string; stopTime?: string;
limitCanSizeMl?: BuiltInSize;
}; };
export type LimitViolation = "cans" | "spend" | "stopTime"; export type LimitViolation = "cans" | "spend" | "stopTime";