11'use client' ;
22
33import { motion , AnimatePresence } from 'framer-motion' ;
4- import { useState } from 'react' ;
4+ import { useState , useEffect , useRef } from 'react' ;
55import { Target , Edit2 , Check , X } from 'lucide-react' ;
66import { useLocalStorage } from '@/hooks/useLocalStorage' ;
77import { useTranslation } from '@/context/TranslationContext' ;
@@ -21,16 +21,58 @@ const DEFAULT_GOALS: UserGoals = {
2121 yearly : 1000 ,
2222} ;
2323
24+ const GOALS_API = '/api/user/goals' ;
25+
2426export default function GoalTracker ( { username, activity = [ ] } : GoalTrackerProps ) {
2527 const { t } = useTranslation ( ) ;
2628 const [ isEditing , setIsEditing ] = useState ( false ) ;
2729
28- // Load and save goals using standard local storage key keyed by username
30+ // localStorage acts as an instant cache so the UI renders immediately.
2931 const [ goals , setGoals ] = useLocalStorage < UserGoals > (
3032 `commitpulse:goals:${ username . toLowerCase ( ) } ` ,
3133 DEFAULT_GOALS
3234 ) ;
3335
36+ // Track whether the server has returned a value yet (to show a loading hint
37+ // only when localStorage had no prior value for this user).
38+ const [ serverSynced , setServerSynced ] = useState ( false ) ;
39+ const saveControllerRef = useRef < AbortController | null > ( null ) ;
40+
41+ // On mount, fetch goals from the server. The server value is authoritative
42+ // and overwrites localStorage so all devices stay in sync.
43+ useEffect ( ( ) => {
44+ let cancelled = false ;
45+
46+ async function fetchServerGoals ( ) {
47+ try {
48+ const res = await fetch (
49+ `${ GOALS_API } ?username=${ encodeURIComponent ( username . toLowerCase ( ) ) } ` ,
50+ { cache : 'no-store' }
51+ ) ;
52+ if ( ! res . ok || cancelled ) return ;
53+
54+ const data = await res . json ( ) ;
55+ if (
56+ data ?. goals &&
57+ typeof data . goals . monthly === 'number' &&
58+ typeof data . goals . yearly === 'number'
59+ ) {
60+ setGoals ( data . goals ) ;
61+ }
62+ } catch {
63+ // Network failure — silently keep local value
64+ } finally {
65+ if ( ! cancelled ) setServerSynced ( true ) ;
66+ }
67+ }
68+
69+ fetchServerGoals ( ) ;
70+ return ( ) => {
71+ cancelled = true ;
72+ } ;
73+ // eslint-disable-next-line react-hooks/exhaustive-deps
74+ } , [ username ] ) ;
75+
3476 const [ editMonthly , setEditMonthly ] = useState ( goals . monthly . toString ( ) ) ;
3577 const [ editYearly , setEditYearly ] = useState ( goals . yearly . toString ( ) ) ;
3678
@@ -65,18 +107,46 @@ export default function GoalTracker({ username, activity = [] }: GoalTrackerProp
65107 e . preventDefault ( ) ;
66108 const newMonthly = Math . max ( 1 , parseInt ( editMonthly , 10 ) || DEFAULT_GOALS . monthly ) ;
67109 const newYearly = Math . max ( 1 , parseInt ( editYearly , 10 ) || DEFAULT_GOALS . yearly ) ;
110+ const newGoals : UserGoals = { monthly : newMonthly , yearly : newYearly } ;
68111
69- setGoals ( {
70- monthly : newMonthly ,
71- yearly : newYearly ,
72- } ) ;
112+ // Optimistic local update — instant feedback regardless of network
113+ setGoals ( newGoals ) ;
73114 setIsEditing ( false ) ;
115+
116+ // Abort any in-flight previous save
117+ saveControllerRef . current ?. abort ( ) ;
118+ const controller = new AbortController ( ) ;
119+ saveControllerRef . current = controller ;
120+
121+ // Persist to server in the background
122+ fetch ( GOALS_API , {
123+ method : 'PATCH' ,
124+ headers : { 'Content-Type' : 'application/json' } ,
125+ body : JSON . stringify ( {
126+ username : username . toLowerCase ( ) ,
127+ monthly : newMonthly ,
128+ yearly : newYearly ,
129+ } ) ,
130+ signal : controller . signal ,
131+ } ) . catch ( ( err ) => {
132+ // AbortError is expected when navigating away — swallow silently
133+ if ( err ?. name !== 'AbortError' ) {
134+ console . warn ( '[GoalTracker] Failed to persist goals to server:' , err ) ;
135+ }
136+ } ) ;
74137 } ;
75138
76139 const handleCancel = ( ) => {
77140 setIsEditing ( false ) ;
78141 } ;
79142
143+ // Show a subtle loading state only when: (a) no localStorage value existed
144+ // AND (b) the server hasn't responded yet. This avoids any flash for returning users.
145+ const isInitialLoad =
146+ ! serverSynced &&
147+ goals . monthly === DEFAULT_GOALS . monthly &&
148+ goals . yearly === DEFAULT_GOALS . yearly ;
149+
80150 return (
81151 < motion . div
82152 initial = { { opacity : 0 , y : 12 } }
@@ -187,28 +257,38 @@ export default function GoalTracker({ username, activity = [] }: GoalTrackerProp
187257 { t ( 'dashboard.goals.monthly' ) || 'Monthly Target' }
188258 </ span >
189259 < span className = "text-xs font-bold text-zinc-900 dark:text-white" >
190- { monthlyContributions } / { goals . monthly }
260+ { isInitialLoad ? (
261+ < span className = "inline-block w-14 h-3 rounded bg-zinc-200 dark:bg-zinc-800 animate-pulse" />
262+ ) : (
263+ < >
264+ { monthlyContributions } / { goals . monthly }
265+ </ >
266+ ) }
191267 </ span >
192268 </ div >
193269 < div className = "w-full h-2 rounded-full bg-gray-100 dark:bg-zinc-900 overflow-hidden relative border border-black/5 dark:border-[rgba(255,255,255,0.03)]" >
194270 < motion . div
195271 initial = { { width : 0 } }
196- animate = { { width : `${ monthlyPercent } %` } }
272+ animate = { { width : isInitialLoad ? '0%' : `${ monthlyPercent } %` } }
197273 transition = { { duration : 0.8 , ease : 'easeOut' } }
198274 className = "h-full bg-gradient-to-r from-emerald-400 to-teal-500 rounded-full"
199275 />
200276 </ div >
201277 < div className = "flex justify-between items-center text-[10px]" >
202278 < span className = "text-zinc-400 dark:text-[#777]" >
203- { monthlyRemaining > 0
204- ? ( t ( 'dashboard.goals.remaining' ) || '{{count}} commits remaining' ) . replace (
205- '{{count}}' ,
206- monthlyRemaining . toString ( )
207- )
208- : t ( 'dashboard.goals.completed' ) || 'Goal Achieved! 🎉' }
279+ { isInitialLoad ? (
280+ < span className = "inline-block w-24 h-2.5 rounded bg-zinc-200 dark:bg-zinc-800 animate-pulse" />
281+ ) : monthlyRemaining > 0 ? (
282+ ( t ( 'dashboard.goals.remaining' ) || '{{count}} commits remaining' ) . replace (
283+ '{{count}}' ,
284+ monthlyRemaining . toString ( )
285+ )
286+ ) : (
287+ t ( 'dashboard.goals.completed' ) || 'Goal Achieved! 🎉'
288+ ) }
209289 </ span >
210290 < span className = "font-semibold text-emerald-500 dark:text-emerald-400" >
211- { monthlyPercent } %
291+ { isInitialLoad ? '—' : ` ${ monthlyPercent } %` }
212292 </ span >
213293 </ div >
214294 </ div >
@@ -220,28 +300,38 @@ export default function GoalTracker({ username, activity = [] }: GoalTrackerProp
220300 { t ( 'dashboard.goals.yearly' ) || 'Yearly Target' }
221301 </ span >
222302 < span className = "text-xs font-bold text-zinc-900 dark:text-white" >
223- { yearlyContributions } / { goals . yearly }
303+ { isInitialLoad ? (
304+ < span className = "inline-block w-14 h-3 rounded bg-zinc-200 dark:bg-zinc-800 animate-pulse" />
305+ ) : (
306+ < >
307+ { yearlyContributions } / { goals . yearly }
308+ </ >
309+ ) }
224310 </ span >
225311 </ div >
226312 < div className = "w-full h-2 rounded-full bg-gray-100 dark:bg-zinc-900 overflow-hidden relative border border-black/5 dark:border-[rgba(255,255,255,0.03)]" >
227313 < motion . div
228314 initial = { { width : 0 } }
229- animate = { { width : `${ yearlyPercent } %` } }
315+ animate = { { width : isInitialLoad ? '0%' : `${ yearlyPercent } %` } }
230316 transition = { { duration : 0.8 , ease : 'easeOut' } }
231317 className = "h-full bg-gradient-to-r from-emerald-400 to-teal-500 rounded-full"
232318 />
233319 </ div >
234320 < div className = "flex justify-between items-center text-[10px]" >
235321 < span className = "text-zinc-400 dark:text-[#777]" >
236- { yearlyRemaining > 0
237- ? ( t ( 'dashboard.goals.remaining' ) || '{{count}} commits remaining' ) . replace (
238- '{{count}}' ,
239- yearlyRemaining . toString ( )
240- )
241- : t ( 'dashboard.goals.completed' ) || 'Goal Achieved! 🎉' }
322+ { isInitialLoad ? (
323+ < span className = "inline-block w-24 h-2.5 rounded bg-zinc-200 dark:bg-zinc-800 animate-pulse" />
324+ ) : yearlyRemaining > 0 ? (
325+ ( t ( 'dashboard.goals.remaining' ) || '{{count}} commits remaining' ) . replace (
326+ '{{count}}' ,
327+ yearlyRemaining . toString ( )
328+ )
329+ ) : (
330+ t ( 'dashboard.goals.completed' ) || 'Goal Achieved! 🎉'
331+ ) }
242332 </ span >
243333 < span className = "font-semibold text-emerald-500 dark:text-emerald-400" >
244- { yearlyPercent } %
334+ { isInitialLoad ? '—' : ` ${ yearlyPercent } %` }
245335 </ span >
246336 </ div >
247337 </ div >
0 commit comments