fixed inputs to accept decimals
All checks were successful
Deploy / deploy (push) Successful in 59s
Security Tests / security-non-db (push) Successful in 18s
Security Tests / security-db (push) Successful in 24s

This commit is contained in:
2026-03-11 20:02:32 -05:00
parent 72334b2583
commit cccce2c854
2 changed files with 51 additions and 11 deletions

View File

@@ -1,4 +1,4 @@
import React from "react";
import React, { useEffect, useState } from "react";
type BaseProps = Omit<
React.InputHTMLAttributes<HTMLInputElement>,
@@ -39,21 +39,32 @@ export default function CurrencyInput({
if ("valueCents" in rest) {
const { valueCents, onChange, ...inputProps } = rest as CentsProps;
const [display, setDisplay] = useState<string>("");
// keep display in sync when valueCents prop changes externally
useEffect(() => {
const asNumber = (valueCents ?? 0) / 100;
setDisplay(Number.isFinite(asNumber) ? asNumber.toString() : "");
}, [valueCents]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const formatted = formatString(e.target.value);
const parsed = Number.parseFloat(formatted || "0");
onChange(Number.isFinite(parsed) ? Math.round(parsed * 100) : 0);
setDisplay(formatted);
const parsed = Number.parseFloat(formatted);
if (Number.isFinite(parsed)) {
onChange(Math.round(parsed * 100));
}
};
const displayValue = (valueCents ?? 0) / 100;
const value = Number.isFinite(displayValue) ? displayValue.toString() : "";
return (
<input
{...inputProps}
className={mergedClass}
inputMode="decimal"
placeholder={placeholder}
value={value}
value={display}
onChange={handleChange}
pattern="[0-9]*[.,]?[0-9]*"
/>
);
}
@@ -70,6 +81,7 @@ export default function CurrencyInput({
placeholder={placeholder}
value={value}
onChange={handleChange}
pattern="[0-9]*[.,]?[0-9]*"
/>
);
}

View File

@@ -75,6 +75,14 @@ const defaultSchedule = (): AutoPaySchedule => ({
const DEFAULT_SAVINGS_PERCENT = 20;
const MIN_SAVINGS_PERCENT = DEFAULT_SAVINGS_PERCENT;
const normalizeCategoryName = (value: string) => value.trim().toLowerCase();
const normalizePercentValue = (value: unknown) => {
const parsed =
typeof value === "number"
? value
: Number.parseFloat(String(value ?? ""));
if (!Number.isFinite(parsed)) return 0;
return Math.max(0, Math.min(100, Math.round(parsed)));
};
const createDefaultSavingsCategory = (): VariableCat => ({
id: crypto.randomUUID(),
name: "savings",
@@ -136,7 +144,13 @@ export default function OnboardingPage() {
if (s.budgetConservatism) setBudgetConservatism(s.budgetConservatism);
if (Number.isFinite(s.customFixedPercentage)) setCustomFixedPercentage(s.customFixedPercentage);
if (Array.isArray(s.vars) && s.vars.length > 0) {
setVars(s.vars.map((v: VariableCat) => ({ ...v, name: normalizeCategoryName(v.name) })));
setVars(
s.vars.map((v: VariableCat) => ({
...v,
name: normalizeCategoryName(v.name ?? ""),
percent: normalizePercentValue(v.percent),
}))
);
}
if (Array.isArray(s.fixeds))
setFixeds(
@@ -181,11 +195,15 @@ export default function OnboardingPage() {
// ── computed
const varsTotal = useMemo(
() => vars.reduce((s, v) => s + (v.percent || 0), 0),
() => vars.reduce((s, v) => s + normalizePercentValue(v.percent), 0),
[vars]
);
const savingsTotal = useMemo(
() => vars.reduce((s, v) => s + (v.isSavings ? v.percent || 0 : 0), 0),
() =>
vars.reduce(
(s, v) => s + (v.isSavings ? normalizePercentValue(v.percent) : 0),
0
),
[vars]
);
const varsRemaining = Math.max(0, 100 - varsTotal);
@@ -200,6 +218,10 @@ export default function OnboardingPage() {
.filter(([, count]) => count > 1)
.map(([name]) => name);
}, [vars]);
const hasMissingVarNames = useMemo(
() => vars.some((v) => !normalizeCategoryName(v.name)),
[vars]
);
const fixedTotal = useMemo(
() => fixeds.reduce((s, f) => s + (f.amountCents || 0), 0),
[fixeds]
@@ -241,7 +263,7 @@ export default function OnboardingPage() {
const canNext4 =
vars.length > 0 &&
varsTotal === 100 &&
vars.every((v) => v.name.trim()) &&
!hasMissingVarNames &&
vars.some((v) => v.isSavings) &&
savingsTotal >= MIN_SAVINGS_PERCENT &&
duplicateVarNames.length === 0;
@@ -520,7 +542,7 @@ export default function OnboardingPage() {
dashboardSnapshot.variableCategories.map((c, i) => ({
id: c.id,
name: normalizeCategoryName(c.name),
percent: c.percent,
percent: normalizePercentValue(c.percent),
priority: i + 1,
isSavings: !!c.isSavings,
}))
@@ -943,6 +965,12 @@ export default function OnboardingPage() {
</p>
</div>
)}
{hasMissingVarNames && (
<div className="toast-err">
Every category needs a name before you can continue.
</div>
)}
{varsTotal !== 100 && varsTotal > 0 && (
<div className="toast-err">