55 archiveCategory ,
66 autoCopyBudgetsFromPreviousMonth ,
77 Category ,
8+ deleteBudget ,
89 Expense ,
910 getMonthActivitySummary ,
1011 MonthActivityActionType ,
@@ -43,11 +44,22 @@ type CategoryDropPlacement = 'before' | 'after';
4344type BudgetSort = 'custom' | 'alpha-asc' | 'alpha-desc' | 'budget-desc' | 'spent-desc' | 'remaining-desc' ;
4445
4546const DEFAULT_SUBCATEGORY_ORDER = [ 'Bills' , 'Savings' , 'Debts' , 'Subscriptions' , 'Variable Spending' ] ;
47+ const budgetTableDesktopColumns = 'sm:grid-cols-[minmax(0,2fr)_minmax(0,1fr)_minmax(0,1fr)_minmax(0,1fr)_minmax(0,1fr)]' ;
4648
4749const today = new Date ( ) ;
4850const monthString = ( date : Date ) : string => `${ date . getFullYear ( ) } -${ String ( date . getMonth ( ) + 1 ) . padStart ( 2 , '0' ) } ` ;
4951const dateString = ( date : Date ) : string => `${ monthString ( date ) } -${ String ( date . getDate ( ) ) . padStart ( 2 , '0' ) } ` ;
50- const formatCurrency = ( cents : number ) : string => ( cents / 100 ) . toLocaleString ( undefined , { style : 'currency' , currency : 'USD' } ) ;
52+ const usdCurrencyFormatter = new Intl . NumberFormat ( 'en-US' , { style : 'currency' , currency : 'USD' } ) ;
53+ const budgetEditFormatter = new Intl . NumberFormat ( 'en-US' , {
54+ minimumFractionDigits : 0 ,
55+ maximumFractionDigits : 2 ,
56+ useGrouping : false
57+ } ) ;
58+ const formatCurrency = ( cents : number ) : string => usdCurrencyFormatter . format ( cents / 100 ) ;
59+ const formatBudgetDisplayValue = ( amountCents : number | undefined ) : string =>
60+ typeof amountCents === 'number' && Number . isFinite ( amountCents ) ? formatCurrency ( amountCents ) : '' ;
61+ const formatBudgetEditValue = ( amountCents : number | undefined ) : string =>
62+ typeof amountCents === 'number' && Number . isFinite ( amountCents ) ? budgetEditFormatter . format ( amountCents / 100 ) : '' ;
5163const formatMonthLabel = ( month : string ) : string => {
5264 const [ year , monthPart ] = month . split ( '-' ) . map ( Number ) ;
5365 return new Date ( year , monthPart - 1 , 1 ) . toLocaleDateString ( undefined , { month : 'long' , year : 'numeric' } ) ;
@@ -69,6 +81,28 @@ const parseCurrencyInputToCents = (value: string): number | null => {
6981 return Math . round ( parsed * 100 ) ;
7082} ;
7183
84+ const parseBudgetInputToCents = ( value : string ) : { amountCents : number | null ; isBlank : boolean } => {
85+ const trimmed = value . trim ( ) ;
86+ if ( ! trimmed ) {
87+ return { amountCents : null , isBlank : true } ;
88+ }
89+
90+ const normalized = trimmed . replace ( / [ $ , \s ] / g, '' ) ;
91+ if ( ! normalized ) {
92+ return { amountCents : null , isBlank : true } ;
93+ }
94+
95+ const parsed = Number ( normalized ) ;
96+ if ( ! Number . isFinite ( parsed ) || parsed < 0 ) {
97+ return { amountCents : null , isBlank : false } ;
98+ }
99+
100+ return { amountCents : Math . round ( parsed * 100 ) , isBlank : false } ;
101+ } ;
102+
103+ const hasBudgetValue = ( budgetMap : Record < string , number > , categoryId : string ) : boolean =>
104+ Object . prototype . hasOwnProperty . call ( budgetMap , categoryId ) ;
105+
72106export default function ExpenseTrackerPage ( ) {
73107 const [ selectedMonth , setSelectedMonth ] = useState ( monthString ( today ) ) ;
74108 const [ categories , setCategories ] = useState < Category [ ] > ( [ ] ) ;
@@ -182,7 +216,7 @@ export default function ExpenseTrackerPage() {
182216 ) ;
183217 setBudgetInputs (
184218 nextCategories . reduce < Record < string , string > > ( ( acc , category ) => {
185- acc [ category . id ] = budgetMap [ category . id ] ? String ( budgetMap [ category . id ] / 100 ) : '' ;
219+ acc [ category . id ] = formatBudgetDisplayValue ( budgetMap [ category . id ] ) ;
186220 return acc ;
187221 } , { } )
188222 ) ;
@@ -798,15 +832,43 @@ export default function ExpenseTrackerPage() {
798832 const handleBudgetAutoSave = async ( categoryId : string ) => {
799833 try {
800834 const rawValue = budgetInputs [ categoryId ] ?? '' ;
801- const amount = Number ( rawValue ) ;
802- if ( ! Number . isFinite ( amount ) || amount < 0 ) {
835+ const { amountCents, isBlank } = parseBudgetInputToCents ( rawValue ) ;
836+ const current = budgets [ categoryId ] ?? 0 ;
837+ const hasSavedBudget = hasBudgetValue ( budgets , categoryId ) ;
838+
839+ if ( isBlank ) {
840+ if ( ! hasSavedBudget ) {
841+ setBudgetInputs ( prev => ( { ...prev , [ categoryId ] : '' } ) ) ;
842+ setBudgetSaveStatus ( prev => ( { ...prev , [ categoryId ] : 'idle' } ) ) ;
843+ return ;
844+ }
845+
846+ setBudgetSaveStatus ( prev => ( { ...prev , [ categoryId ] : 'saving' } ) ) ;
847+ await deleteBudget ( selectedMonth , categoryId ) ;
848+ await recordMonthActivity ( 'budget-updated' , 'Updated budget amount' , `${ getCategoryName ( categoryId ) } • cleared` ) ;
849+ setBudgets ( prev => {
850+ const next = { ...prev } ;
851+ delete next [ categoryId ] ;
852+ return next ;
853+ } ) ;
854+ setBudgetInputs ( prev => ( { ...prev , [ categoryId ] : '' } ) ) ;
855+ setBudgetSaveStatus ( prev => ( { ...prev , [ categoryId ] : 'saved' } ) ) ;
856+ if ( budgetStatusTimeouts . current [ categoryId ] ) {
857+ window . clearTimeout ( budgetStatusTimeouts . current [ categoryId ] ) ;
858+ }
859+ budgetStatusTimeouts . current [ categoryId ] = window . setTimeout ( ( ) => {
860+ setBudgetSaveStatus ( prev => ( { ...prev , [ categoryId ] : 'idle' } ) ) ;
861+ } , 1500 ) ;
862+ return ;
863+ }
864+
865+ if ( amountCents === null ) {
803866 setBudgetSaveStatus ( prev => ( { ...prev , [ categoryId ] : 'error' } ) ) ;
804867 return ;
805868 }
806869
807- const amountCents = Math . round ( amount * 100 ) ;
808- const current = budgets [ categoryId ] ?? 0 ;
809- if ( amountCents === current ) {
870+ if ( amountCents === current && hasSavedBudget ) {
871+ setBudgetInputs ( prev => ( { ...prev , [ categoryId ] : formatBudgetDisplayValue ( amountCents ) } ) ) ;
810872 setBudgetSaveStatus ( prev => ( { ...prev , [ categoryId ] : 'idle' } ) ) ;
811873 return ;
812874 }
@@ -815,6 +877,7 @@ export default function ExpenseTrackerPage() {
815877 await upsertBudget ( selectedMonth , categoryId , amountCents ) ;
816878 await recordMonthActivity ( 'budget-updated' , 'Updated budget amount' , `${ getCategoryName ( categoryId ) } • ${ formatCurrency ( amountCents ) } ` ) ;
817879 setBudgets ( prev => ( { ...prev , [ categoryId ] : amountCents } ) ) ;
880+ setBudgetInputs ( prev => ( { ...prev , [ categoryId ] : formatBudgetDisplayValue ( amountCents ) } ) ) ;
818881 setBudgetSaveStatus ( prev => ( { ...prev , [ categoryId ] : 'saved' } ) ) ;
819882 if ( budgetStatusTimeouts . current [ categoryId ] ) {
820883 window . clearTimeout ( budgetStatusTimeouts . current [ categoryId ] ) ;
@@ -827,6 +890,25 @@ export default function ExpenseTrackerPage() {
827890 }
828891 } ;
829892
893+ const handleBudgetInputFocus = ( categoryId : string ) => {
894+ const savedDisplayValue = hasBudgetValue ( budgets , categoryId ) ? formatBudgetDisplayValue ( budgets [ categoryId ] ) : '' ;
895+ const currentValue = budgetInputs [ categoryId ] ?? '' ;
896+
897+ if ( currentValue && currentValue !== savedDisplayValue ) {
898+ return ;
899+ }
900+
901+ setBudgetInputs ( prev => ( {
902+ ...prev ,
903+ [ categoryId ] : hasBudgetValue ( budgets , categoryId ) ? formatBudgetEditValue ( budgets [ categoryId ] ) : ''
904+ } ) ) ;
905+ } ;
906+
907+ const handleBudgetInputChange = ( categoryId : string , value : string ) => {
908+ setBudgetInputs ( prev => ( { ...prev , [ categoryId ] : value } ) ) ;
909+ setBudgetSaveStatus ( prev => ( prev [ categoryId ] ? { ...prev , [ categoryId ] : 'idle' } : prev ) ) ;
910+ } ;
911+
830912 const handleDelete = async ( expense : Expense ) => {
831913 try {
832914 await deleteExpense ( expense . id ) ;
@@ -1242,12 +1324,12 @@ export default function ExpenseTrackerPage() {
12421324 </ div >
12431325 { ! isBudgetsCollapsed && (
12441326 < div className = "rounded border border-slate-800" >
1245- < div className = " hidden items-center gap-x-3 border-b border-slate-800 px-3 py-2 text-xs uppercase tracking-wide text-slate-400 sm:grid sm:grid-cols-[minmax(0,2fr)_minmax(0,1fr)_minmax(0,1fr)_minmax(0,1fr)_minmax(0,14ch)]" >
1327+ < div className = { ` hidden items-center gap-x-3 border-b border-slate-800 px-3 py-2 text-xs uppercase tracking-wide text-slate-400 sm:grid ${ budgetTableDesktopColumns } ` } >
12461328 < div > Item</ div >
1247- < div className = "justify-self-center text-center" > Budget</ div >
1248- < div className = "justify-self-center text-center" > Spent</ div >
1249- < div className = "justify-self-center text-center" > Remaining</ div >
1250- < div className = "justify-self-center text-center" > Status</ div >
1329+ < div className = "text-center" > Budget</ div >
1330+ < div className = "text-center" > Spent</ div >
1331+ < div className = "text-center" > Remaining</ div >
1332+ < div className = "text-center" > Status</ div >
12511333 </ div >
12521334 < div className = "divide-y divide-slate-800" >
12531335 { groupedBudgetItems . map ( group => {
@@ -1310,7 +1392,7 @@ export default function ExpenseTrackerPage() {
13101392 return (
13111393 < div
13121394 key = { category . id }
1313- className = { `grid grid-cols-2 gap-x-3 gap-y-1 px-3 py-2 text-sm sm:grid-cols-[minmax(0,2fr)_minmax(0,1fr)_minmax(0,1fr)_minmax(0,1fr)_minmax(0,14ch)] ${
1395+ className = { `grid grid-cols-2 gap-x-3 gap-y-1 px-3 py-2 text-sm sm:grid ${ budgetTableDesktopColumns } ${
13141396 budgetSort === 'custom' ? 'cursor-move' : ''
13151397 } ${ isDragging ? 'opacity-60' : '' } ${ dragIndicator } `}
13161398 draggable = { budgetSort === 'custom' }
@@ -1320,35 +1402,35 @@ export default function ExpenseTrackerPage() {
13201402 onDragEnd = { handleBudgetDragEnd }
13211403 >
13221404 < div className = "order-1 font-medium sm:order-none" > { category . name } </ div >
1323- < div className = "order-4 justify-self-stretch sm:order-none sm:w-full sm:max-w-[12rem] sm:justify-self -center" >
1405+ < div className = "order-4 justify-self-stretch sm:order-none sm:flex sm: w-full sm:items-center sm:justify-center" >
13241406 < input
1325- type = "number"
1326- min = "0"
1327- step = "0.01"
1407+ type = "text"
13281408 inputMode = "decimal"
1329- className = "w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-center text-sm tabular-nums"
1409+ className = "w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-center text-sm tabular-nums sm:max-w-[12rem] "
13301410 value = { budgetInputs [ category . id ] ?? '' }
1331- onChange = { e => setBudgetInputs ( prev => ( { ...prev , [ category . id ] : e . target . value } ) ) }
1411+ onFocus = { ( ) => handleBudgetInputFocus ( category . id ) }
1412+ onChange = { e => handleBudgetInputChange ( category . id , e . target . value ) }
13321413 onBlur = { ( ) => void handleBudgetAutoSave ( category . id ) }
13331414 onKeyDown = { event => {
13341415 if ( event . key === 'Enter' ) {
13351416 event . preventDefault ( ) ;
13361417 event . currentTarget . blur ( ) ;
13371418 }
13381419 } }
1420+ placeholder = "$0.00"
13391421 aria-label = { `Budget for ${ category . name } ` }
13401422 />
13411423 </ div >
1342- < div className = "order-3 justify-self-end text-right tabular-nums text-slate-300 sm:order-none sm:justify-self-center sm:text-center" > { formatCurrency ( spent ) } </ div >
1424+ < div className = "order-3 justify-self-end text-right tabular-nums text-slate-300 sm:order-none sm:w-full sm:text-center" > { formatCurrency ( spent ) } </ div >
13431425 < div
1344- className = { `order-2 justify-self-end text-right font-semibold tabular-nums sm:order-none sm:justify-self-center sm:text-center ${
1426+ className = { `order-2 justify-self-end text-right font-semibold tabular-nums sm:order-none sm:w-full sm:text-center ${
13451427 delta >= 0 ? 'text-emerald-300' : 'text-rose-300'
13461428 } `}
13471429 >
13481430 { formatCurrency ( delta ) }
13491431 </ div >
13501432 < div
1351- className = { `order-5 col-span-2 justify-self-end text-right text-xs sm:col-span-1 sm:order-none sm:justify-self-center sm:text-center ${
1433+ className = { `order-5 col-span-2 justify-self-end text-right text-xs sm:col-span-1 sm:order-none sm:w-full sm:text-center ${
13521434 status === 'error' ? 'text-rose-300' : status === 'saved' ? 'text-emerald-300' : 'text-slate-400'
13531435 } `}
13541436 >
@@ -1358,34 +1440,34 @@ export default function ExpenseTrackerPage() {
13581440 ) ;
13591441 } )
13601442 ) ) }
1361- < div className = " grid grid-cols-2 gap-x-3 gap-y-1 bg-slate-950/60 px-3 py-2 text-xs sm:grid-cols-[minmax(0,2fr)_minmax(0,1fr)_minmax(0,1fr)_minmax(0,1fr)_minmax(0,14ch)]" >
1443+ < div className = { ` grid grid-cols-2 gap-x-3 gap-y-1 bg-slate-950/60 px-3 py-2 text-xs sm:grid ${ budgetTableDesktopColumns } ` } >
13621444 < div className = "order-1 font-semibold text-slate-300 sm:order-none" > Subtotal</ div >
1363- < div className = "order-4 justify-self-end text-right font-semibold tabular-nums text-slate-300 sm:order-none sm:justify-self-center sm:text-center" >
1445+ < div className = "order-4 justify-self-end text-right font-semibold tabular-nums text-slate-300 sm:order-none sm:w-full sm:text-center" >
13641446 { formatCurrency ( group . totals . budget ) }
13651447 </ div >
1366- < div className = "order-3 justify-self-end text-right font-semibold tabular-nums text-slate-300 sm:order-none sm:justify-self-center sm:text-center" >
1448+ < div className = "order-3 justify-self-end text-right font-semibold tabular-nums text-slate-300 sm:order-none sm:w-full sm:text-center" >
13671449 { formatCurrency ( group . totals . spent ) }
13681450 </ div >
1369- < div className = "order-2 justify-self-end text-right font-semibold tabular-nums text-slate-300 sm:order-none sm:justify-self-center sm:text-center" >
1451+ < div className = "order-2 justify-self-end text-right font-semibold tabular-nums text-slate-300 sm:order-none sm:w-full sm:text-center" >
13701452 { formatCurrency ( group . totals . remaining ) }
13711453 </ div >
1372- < div className = "order-5 col-span-2 justify-self-end text-right text-xs text-slate-500 sm:col-span-1 sm:order-none sm:justify-self-center sm:text-center" >
1454+ < div className = "order-5 col-span-2 justify-self-end text-right text-xs text-slate-500 sm:col-span-1 sm:order-none sm:w-full sm:text-center" >
13731455 { group . name }
13741456 </ div >
13751457 </ div >
13761458 </ Fragment >
13771459 ) ;
13781460 } ) }
1379- < div className = " grid grid-cols-2 gap-x-3 gap-y-1 bg-slate-950/40 px-3 py-2 text-sm sm:grid-cols-[minmax(0,2fr)_minmax(0,1fr)_minmax(0,1fr)_minmax(0,1fr)_minmax(0,14ch)]" >
1461+ < div className = { ` grid grid-cols-2 gap-x-3 gap-y-1 bg-slate-950/40 px-3 py-2 text-sm sm:grid ${ budgetTableDesktopColumns } ` } >
13801462 < div className = "order-1 font-semibold text-slate-200 sm:order-none" > Total</ div >
1381- < div className = "order-4 justify-self-end text-right font-semibold tabular-nums text-slate-200 sm:order-none sm:justify-self-center sm:text-center" >
1463+ < div className = "order-4 justify-self-end text-right font-semibold tabular-nums text-slate-200 sm:order-none sm:w-full sm:text-center" >
13821464 { formatCurrency ( budgetTotals . budget ) }
13831465 </ div >
1384- < div className = "order-3 justify-self-end text-right font-semibold tabular-nums text-slate-200 sm:order-none sm:justify-self-center sm:text-center" >
1466+ < div className = "order-3 justify-self-end text-right font-semibold tabular-nums text-slate-200 sm:order-none sm:w-full sm:text-center" >
13851467 { formatCurrency ( budgetTotals . spent ) }
13861468 </ div >
1387- < div className = "order-2 justify-self-end text-right text-slate-500 sm:order-none sm:justify-self-center sm:text-center" > —</ div >
1388- < div className = "order-5 col-span-2 justify-self-end text-right text-xs text-slate-500 sm:col-span-1 sm:order-none sm:justify-self-center sm:text-center" > Totals</ div >
1469+ < div className = "order-2 justify-self-end text-right text-slate-500 sm:order-none sm:w-full sm:text-center" > —</ div >
1470+ < div className = "order-5 col-span-2 justify-self-end text-right text-xs text-slate-500 sm:col-span-1 sm:order-none sm:w-full sm:text-center" > Totals</ div >
13891471 </ div >
13901472 </ div >
13911473 </ div >
0 commit comments