diff --git a/web/src/components/CurrencyInput.tsx b/web/src/components/CurrencyInput.tsx index c8184d7..daa494b 100644 --- a/web/src/components/CurrencyInput.tsx +++ b/web/src/components/CurrencyInput.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { useEffect, useState } from "react"; type BaseProps = Omit< React.InputHTMLAttributes, @@ -39,21 +39,32 @@ export default function CurrencyInput({ if ("valueCents" in rest) { const { valueCents, onChange, ...inputProps } = rest as CentsProps; + const [display, setDisplay] = useState(""); + + // 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) => { 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 ( ); } @@ -70,6 +81,7 @@ export default function CurrencyInput({ placeholder={placeholder} value={value} onChange={handleChange} + pattern="[0-9]*[.,]?[0-9]*" /> ); } diff --git a/web/src/pages/OnboardingPage.tsx b/web/src/pages/OnboardingPage.tsx index 1ad8e04..dc47447 100644 --- a/web/src/pages/OnboardingPage.tsx +++ b/web/src/pages/OnboardingPage.tsx @@ -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() {

)} + + {hasMissingVarNames && ( +
+ Every category needs a name before you can continue. +
+ )} {varsTotal !== 100 && varsTotal > 0 && (