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< type BaseProps = Omit<
React.InputHTMLAttributes<HTMLInputElement>, React.InputHTMLAttributes<HTMLInputElement>,
@@ -39,21 +39,32 @@ export default function CurrencyInput({
if ("valueCents" in rest) { if ("valueCents" in rest) {
const { valueCents, onChange, ...inputProps } = rest as CentsProps; 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 handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const formatted = formatString(e.target.value); const formatted = formatString(e.target.value);
const parsed = Number.parseFloat(formatted || "0"); setDisplay(formatted);
onChange(Number.isFinite(parsed) ? Math.round(parsed * 100) : 0); 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 ( return (
<input <input
{...inputProps} {...inputProps}
className={mergedClass} className={mergedClass}
inputMode="decimal" inputMode="decimal"
placeholder={placeholder} placeholder={placeholder}
value={value} value={display}
onChange={handleChange} onChange={handleChange}
pattern="[0-9]*[.,]?[0-9]*"
/> />
); );
} }
@@ -70,6 +81,7 @@ export default function CurrencyInput({
placeholder={placeholder} placeholder={placeholder}
value={value} value={value}
onChange={handleChange} onChange={handleChange}
pattern="[0-9]*[.,]?[0-9]*"
/> />
); );
} }

View File

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