@@ -6,6 +6,7 @@ import { Card } from "@/components/ui/card";
66import { Button } from "@/components/ui/button" ;
77import { Input } from "@/components/ui/input" ;
88import { Label } from "@/components/ui/label" ;
9+ import { DateField } from "@/components/ui/date-field" ;
910import {
1011 Dialog ,
1112 DialogContent ,
@@ -42,6 +43,7 @@ interface BudgetCategory {
4243 target_monthly : number ;
4344 target_amount : number ;
4445 target_cadence : string ;
46+ target_date : string ;
4547 snooze_until_month : string ;
4648 target_active : boolean ;
4749}
@@ -97,11 +99,27 @@ function availEps(decimals: number): number {
9799const CADENCES = [ "daily" , "weekly" , "monthly" , "yearly" ] as const ;
98100
99101function cadenceLabel ( cadence : string , locale : string ) : string {
100- const fi : Record < string , string > = { daily : "Päivä" , weekly : "Viikko" , monthly : "Kuukausi" , yearly : "Vuosi" } ;
101- const en : Record < string , string > = { daily : "Day" , weekly : "Week" , monthly : "Month" , yearly : "Year" } ;
102+ const fi : Record < string , string > = { daily : "Päivä" , weekly : "Viikko" , monthly : "Kuukausi" , yearly : "Vuosi" , by_date : "Päivään mennessä" } ;
103+ const en : Record < string , string > = { daily : "Day" , weekly : "Week" , monthly : "Month" , yearly : "Year" , by_date : "By date" } ;
102104 return ( locale === "fi" ? fi : en ) [ cadence ] || cadence ;
103105}
104106
107+ // Months from the viewed month to the target date, inclusive (mirrors lib/budget-math).
108+ function monthsUntilInclusive ( month : string , targetDate : string ) : number {
109+ const [ cy , cm ] = month . split ( "-" ) . map ( Number ) ;
110+ const ty = Number ( targetDate . slice ( 0 , 4 ) ) ;
111+ const tm = Number ( targetDate . slice ( 5 , 7 ) ) ;
112+ if ( ! ty || ! tm ) return 1 ;
113+ return Math . max ( 1 , ( ty * 12 + tm ) - ( cy * 12 + cm ) + 1 ) ;
114+ }
115+
116+ // Format an ISO date (YYYY-MM-DD) as Finnish d.m.yyyy for display.
117+ function formatIsoDate ( iso : string ) : string {
118+ const p = iso . split ( "-" ) ;
119+ if ( p . length !== 3 ) return iso ;
120+ return `${ Number ( p [ 2 ] ) } .${ Number ( p [ 1 ] ) } .${ p [ 0 ] } ` ;
121+ }
122+
105123function cadenceSuffix ( cadence : string , locale : string ) : string {
106124 const fi : Record < string , string > = { daily : "/ pv" , weekly : "/ vko" , monthly : "/ kk" , yearly : "/ v" } ;
107125 const en : Record < string , string > = { daily : "/ day" , weekly : "/ wk" , monthly : "/ mo" , yearly : "/ yr" } ;
@@ -133,6 +151,7 @@ export default function BudgetPage() {
133151 const [ targetEditing , setTargetEditing ] = useState ( false ) ;
134152 const [ targetDraft , setTargetDraft ] = useState < string > ( "" ) ;
135153 const [ targetCadence , setTargetCadence ] = useState < string > ( "monthly" ) ;
154+ const [ targetDate , setTargetDate ] = useState < string > ( "" ) ;
136155 const [ moveOpen , setMoveOpen ] = useState ( false ) ;
137156 const [ moveDir , setMoveDir ] = useState < "in" | "out" > ( "out" ) ;
138157 const [ moveOther , setMoveOther ] = useState < string > ( "" ) ;
@@ -366,14 +385,25 @@ export default function BudgetPage() {
366385 if ( ! inspectorCat ) return ;
367386 const value = evalExpression ( targetDraft ) ;
368387 if ( value === null ) return ;
388+ // A by_date target is meaningless without a date — keep the editor open until one is given.
389+ if ( targetCadence === "by_date" && ! targetDate ) {
390+ console . warn ( "[budget] by_date target needs a target date" ) ;
391+ return ;
392+ }
369393 try {
370394 await fetch ( "/api/targets" , {
371395 method : "PUT" ,
372396 headers : { "Content-Type" : "application/json" } ,
373- body : JSON . stringify ( { category_id : inspectorCat . id , monthly_amount : value , cadence : targetCadence } ) ,
397+ body : JSON . stringify ( {
398+ category_id : inspectorCat . id ,
399+ monthly_amount : value ,
400+ cadence : targetCadence ,
401+ target_date : targetCadence === "by_date" ? targetDate : "" ,
402+ } ) ,
374403 } ) ;
375404 setTargetEditing ( false ) ;
376405 setTargetDraft ( "" ) ;
406+ setTargetDate ( "" ) ;
377407 load ( month ) ;
378408 } catch ( err ) {
379409 console . error ( "[budget] Save target error:" , err ) ;
@@ -846,6 +876,9 @@ export default function BudgetPage() {
846876 { bdrag ?. type === "group" && dropGroupAt === localGroups . length && < div className = "budget-group-drop-line" aria-hidden = "true" /> }
847877
848878 { ( ( ) => {
879+ // Hidden/snoozed categories belong only under the "Kaikki" (all) filter, not the
880+ // overspent / money-available views which should show active budget rows only.
881+ if ( filter !== "all" ) return null ;
849882 const snoozedCats = ( data ?. categories || [ ] ) . filter ( ( c ) => c . is_active && c . snoozed ) ;
850883 if ( snoozedCats . length === 0 ) return null ;
851884 return (
@@ -866,6 +899,7 @@ export default function BudgetPage() {
866899 } ) ( ) }
867900
868901 { ( ( ) => {
902+ if ( filter !== "all" ) return null ;
869903 const hiddenCats = ( data ?. categories || [ ] ) . filter ( ( c ) => ! c . is_active ) ;
870904 if ( hiddenCats . length === 0 ) return null ;
871905 return (
@@ -910,9 +944,12 @@ export default function BudgetPage() {
910944 { inspectorCat && ( ( ) => {
911945 const c = inspectorCat ;
912946 const availState = c . available > eps ? "is-positive" : c . available < - eps ? "is-negative" : "is-zero" ;
913- const hasTarget = c . target_monthly > 0 ;
947+ const isByDate = c . target_cadence === "by_date" ;
948+ // Use the configured amount, not the monthly need: a by_date goal that is already
949+ // met has a zero monthly need but should still show as a (completed) target.
950+ const hasTarget = c . target_amount > 0 ;
914951 const isSnoozed = hasTarget && c . snooze_until_month >= month ;
915- const progress = hasTarget ? Math . min ( 1 , c . budgeted / c . target_monthly ) : 0 ;
952+ const progress = c . target_monthly > 0 ? Math . min ( 1 , c . budgeted / c . target_monthly ) : ( hasTarget ? 1 : 0 ) ;
916953 return (
917954 < >
918955 < SheetHeader className = "insp-header" >
@@ -980,11 +1017,28 @@ export default function BudgetPage() {
9801017 { CADENCES . map ( ( cad ) => (
9811018 < SelectItem key = { cad } value = { cad } > { cadenceLabel ( cad , locale ) } </ SelectItem >
9821019 ) ) }
1020+ < SelectItem value = "by_date" > { cadenceLabel ( "by_date" , locale ) } </ SelectItem >
9831021 </ SelectContent >
9841022 </ Select >
9851023 </ div >
1024+ { targetCadence === "by_date" && (
1025+ < div className = "insp-target-date" >
1026+ < Label className = "insp-target-date-label" > { locale === "fi" ? "Mihin päivään mennessä" : "By which date" } </ Label >
1027+ < DateField value = { targetDate } onChange = { setTargetDate } />
1028+ </ div >
1029+ ) }
9861030 < p className = "settings-help" >
987- { targetCadence === "monthly"
1031+ { targetCadence === "by_date"
1032+ ? ( ( ) => {
1033+ const goal = evalExpression ( targetDraft ) || 0 ;
1034+ if ( ! targetDate ) return locale === "fi" ? "Säästä tämä summa valittuun päivään mennessä." : "Save this amount by the chosen date." ;
1035+ const n = monthsUntilInclusive ( month , targetDate ) ;
1036+ const need = Math . max ( 0 , ( goal - c . carryover ) / n ) ;
1037+ return locale === "fi"
1038+ ? `Tarvitaan noin ${ fmt ( Math . round ( need * 100 ) / 100 ) } € / kk, jotta ${ fmt ( goal ) } € on kasassa ${ formatIsoDate ( targetDate ) } .`
1039+ : `Needs about ${ fmt ( Math . round ( need * 100 ) / 100 ) } € / mo to reach ${ fmt ( goal ) } € by ${ formatIsoDate ( targetDate ) } .` ;
1040+ } ) ( )
1041+ : targetCadence === "monthly"
9881042 ? ( locale === "fi" ? "Summa, joka varataan tälle joka kuukausi." : "Amount assigned here every month." )
9891043 : ( ( ) => {
9901044 const amt = evalExpression ( targetDraft ) || 0 ;
@@ -995,22 +1049,29 @@ export default function BudgetPage() {
9951049 < div className = "insp-actions" >
9961050 < Button type = "button" size = "sm" onClick = { saveTarget } > { locale === "fi" ? "Tallenna" : "Save" } </ Button >
9971051 { hasTarget && < Button type = "button" variant = "destructive" size = "sm" onClick = { ( ) => clearTarget ( c . id ) } > { locale === "fi" ? "Poista" : "Clear" } </ Button > }
998- < Button type = "button" variant = "ghost" size = "sm" onClick = { ( ) => { setTargetEditing ( false ) ; setTargetDraft ( "" ) ; } } > { locale === "fi" ? "Peruuta" : "Cancel" } </ Button >
1052+ < Button type = "button" variant = "ghost" size = "sm" onClick = { ( ) => { setTargetEditing ( false ) ; setTargetDraft ( "" ) ; setTargetDate ( "" ) ; } } > { locale === "fi" ? "Peruuta" : "Cancel" } </ Button >
9991053 </ div >
10001054 </ div >
10011055 ) : hasTarget ? (
10021056 < div className = "insp-target" >
10031057 < div className = "budget-target-progress insp-target-bar" > < span className = "budget-target-progress-fill" style = { { width : `${ Math . round ( progress * 100 ) } %` } } /> </ div >
10041058 < p className = "insp-target-text" >
1005- { isSnoozed ? ( locale === "fi" ? "Tauolla tässä kuussa" : "Paused this month" ) : (
1059+ { isSnoozed ? ( locale === "fi" ? "Tauolla tässä kuussa" : "Paused this month" ) : isByDate ? (
1060+ < >
1061+ < F v = { c . target_amount } s = " €" /> { locale === "fi" ? "→" : "by" } { c . target_date ? formatIsoDate ( c . target_date ) : "" }
1062+ { c . target_monthly > 0
1063+ ? < span className = "text-muted" > · < F v = { c . target_monthly } s = " €" /> { cadenceSuffix ( "monthly" , locale ) } </ span >
1064+ : < span className = "text-positive" > · { locale === "fi" ? "valmis" : "funded" } </ span > }
1065+ </ >
1066+ ) : (
10061067 < >
10071068 < F v = { c . target_amount } s = " €" /> { cadenceSuffix ( c . target_cadence , locale ) }
10081069 { c . target_cadence !== "monthly" && < span className = "text-muted" > · < F v = { c . target_monthly } s = " €" /> { cadenceSuffix ( "monthly" , locale ) } </ span > }
10091070 </ >
10101071 ) }
10111072 </ p >
10121073 < div className = "insp-actions" >
1013- < Button type = "button" variant = "outline" size = "sm" onClick = { ( ) => { setTargetDraft ( c . target_amount ? fmt ( c . target_amount ) : "" ) ; setTargetCadence ( c . target_cadence || "monthly" ) ; setTargetEditing ( true ) ; } } > { locale === "fi" ? "Muokkaa" : "Edit" } </ Button >
1074+ < Button type = "button" variant = "outline" size = "sm" onClick = { ( ) => { setTargetDraft ( c . target_amount ? fmt ( c . target_amount ) : "" ) ; setTargetCadence ( c . target_cadence || "monthly" ) ; setTargetDate ( c . target_date || "" ) ; setTargetEditing ( true ) ; } } > { locale === "fi" ? "Muokkaa" : "Edit" } </ Button >
10141075 { isSnoozed ? (
10151076 < Button type = "button" variant = "outline" size = "sm" onClick = { ( ) => unsnoozeTarget ( c . id ) } > { locale === "fi" ? "Jatka" : "Resume" } </ Button >
10161077 ) : (
@@ -1019,7 +1080,7 @@ export default function BudgetPage() {
10191080 </ div >
10201081 </ div >
10211082 ) : (
1022- < Button type = "button" variant = "outline" size = "sm" onClick = { ( ) => { setTargetDraft ( "" ) ; setTargetCadence ( "monthly" ) ; setTargetEditing ( true ) ; } } > { locale === "fi" ? "Aseta tavoite" : "Set a target" } </ Button >
1083+ < Button type = "button" variant = "outline" size = "sm" onClick = { ( ) => { setTargetDraft ( "" ) ; setTargetCadence ( "monthly" ) ; setTargetDate ( "" ) ; setTargetEditing ( true ) ; } } > { locale === "fi" ? "Aseta tavoite" : "Set a target" } </ Button >
10231084 ) }
10241085 </ div >
10251086
0 commit comments