Skip to content

Commit 20e430d

Browse files
bug- goaltracker , new route for user added
1 parent 1a6f2b3 commit 20e430d

3 files changed

Lines changed: 276 additions & 24 deletions

File tree

app/api/user/goals/route.ts

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import { NextResponse } from 'next/server';
2+
import { z } from 'zod';
3+
import { validateCSRF } from '@/lib/security/csrf';
4+
import dbConnect from '@/lib/mongodb';
5+
import { User } from '@/models/User';
6+
import { getClientIp } from '@/utils/getClientIp';
7+
import { getRateLimitHeaders, RateLimiter } from '@/lib/rate-limit';
8+
import { githubUsernameSchema } from '@/lib/validations';
9+
import { sanitizeMongoPayload } from '@/utils/sanitize';
10+
import logger from '@/lib/logger';
11+
12+
// GET — 20 req / min per IP (read-heavy, cheap)
13+
const goalsReadLimiter = new RateLimiter(20, 60_000, 1);
14+
15+
// PATCH — 10 req / min per IP (write-side, slightly stricter)
16+
const goalsWriteLimiter = new RateLimiter(10, 60_000, 1);
17+
18+
const DEFAULT_GOALS = { monthly: 100, yearly: 1000 };
19+
20+
const patchBodySchema = z.object({
21+
username: githubUsernameSchema,
22+
monthly: z.number().int().min(1).max(99_999),
23+
yearly: z.number().int().min(1).max(9_999_999),
24+
});
25+
26+
// ---------------------------------------------------------------------------
27+
// GET /api/user/goals?username=<user>
28+
// Returns the stored goals for the user, or defaults when none are saved yet.
29+
// ---------------------------------------------------------------------------
30+
export async function GET(request: Request) {
31+
const ip = getClientIp(request);
32+
const rateLimitKey =
33+
ip && ip !== 'unknown' ? ip : `unknown:${request.headers.get('user-agent') ?? 'no-agent'}`;
34+
35+
const rateLimitResult = await goalsReadLimiter.checkWithResult(rateLimitKey);
36+
if (!rateLimitResult.success) {
37+
return NextResponse.json(
38+
{ error: 'Too many requests. Please try again later.' },
39+
{ status: 429, headers: getRateLimitHeaders(rateLimitResult) }
40+
);
41+
}
42+
43+
const { searchParams } = new URL(request.url);
44+
const rawUsername = searchParams.get('username')?.trim();
45+
46+
if (!rawUsername) {
47+
return NextResponse.json({ error: 'Username is required' }, { status: 400 });
48+
}
49+
50+
const parsed = githubUsernameSchema.safeParse(rawUsername);
51+
if (!parsed.success) {
52+
return NextResponse.json({ error: 'Invalid GitHub username' }, { status: 400 });
53+
}
54+
55+
const username = parsed.data.toLowerCase();
56+
57+
// When DB is not configured, return defaults so the client degrades gracefully.
58+
if (!process.env.MONGODB_URI) {
59+
return NextResponse.json({ goals: DEFAULT_GOALS, source: 'default' });
60+
}
61+
62+
try {
63+
await dbConnect();
64+
const user = await User.findOne({ username }).select('goals').lean();
65+
66+
if (!user || !user.goals) {
67+
return NextResponse.json({ goals: DEFAULT_GOALS, source: 'default' });
68+
}
69+
70+
return NextResponse.json({
71+
goals: {
72+
monthly: user.goals.monthly ?? DEFAULT_GOALS.monthly,
73+
yearly: user.goals.yearly ?? DEFAULT_GOALS.yearly,
74+
},
75+
source: 'db',
76+
});
77+
} catch (error) {
78+
logger.error('Failed to fetch user goals', { route: '/api/user/goals', error });
79+
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
80+
}
81+
}
82+
83+
// ---------------------------------------------------------------------------
84+
// PATCH /api/user/goals
85+
// Body: { username, monthly, yearly }
86+
// Upserts the goals sub-document on the user record.
87+
// ---------------------------------------------------------------------------
88+
export async function PATCH(request: Request) {
89+
const ip = getClientIp(request);
90+
const rateLimitKey =
91+
ip && ip !== 'unknown' ? ip : `unknown:${request.headers.get('user-agent') ?? 'no-agent'}`;
92+
93+
const rateLimitResult = await goalsWriteLimiter.checkWithResult(rateLimitKey);
94+
if (!rateLimitResult.success) {
95+
return NextResponse.json(
96+
{ error: 'Too many requests. Please try again later.' },
97+
{ status: 429, headers: getRateLimitHeaders(rateLimitResult) }
98+
);
99+
}
100+
101+
// CSRF validation — mirrors track-user route
102+
const csrfError = validateCSRF(request);
103+
if (csrfError) {
104+
return NextResponse.json({ error: 'Origin not allowed' }, { status: 403 });
105+
}
106+
107+
let body: unknown;
108+
try {
109+
body = await request.json();
110+
} catch {
111+
return NextResponse.json({ error: 'Malformed JSON request body' }, { status: 400 });
112+
}
113+
114+
// Sanitize MongoDB operators from body
115+
sanitizeMongoPayload(body);
116+
117+
const parsed = patchBodySchema.safeParse(body);
118+
if (!parsed.success) {
119+
return NextResponse.json(
120+
{ error: 'Invalid request body', details: parsed.error.flatten().fieldErrors },
121+
{ status: 400 }
122+
);
123+
}
124+
125+
const { username, monthly, yearly } = parsed.data;
126+
const trimmedUsername = username.toLowerCase();
127+
128+
// Graceful bypass when DB is not configured (dev / CI without MongoDB)
129+
if (!process.env.MONGODB_URI) {
130+
logger.warn('Goals save bypassed: MONGODB_URI is not set', {
131+
environment: process.env.NODE_ENV,
132+
});
133+
return NextResponse.json({ success: true, bypassed: true });
134+
}
135+
136+
try {
137+
await dbConnect();
138+
await User.updateOne(
139+
{ username: trimmedUsername },
140+
{
141+
$setOnInsert: { username: trimmedUsername },
142+
$set: { 'goals.monthly': monthly, 'goals.yearly': yearly },
143+
},
144+
{ upsert: true }
145+
);
146+
147+
return NextResponse.json({ success: true });
148+
} catch (error) {
149+
logger.error('Failed to save user goals', { route: '/api/user/goals', error });
150+
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
151+
}
152+
}

components/dashboard/GoalTracker.tsx

Lines changed: 114 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
'use client';
22

33
import { motion, AnimatePresence } from 'framer-motion';
4-
import { useState } from 'react';
4+
import { useState, useEffect, useRef } from 'react';
55
import { Target, Edit2, Check, X } from 'lucide-react';
66
import { useLocalStorage } from '@/hooks/useLocalStorage';
77
import { 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+
2426
export 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>

models/User.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,17 @@
11
import mongoose, { Document, Model, Schema } from 'mongoose';
22

3+
export interface IUserGoals {
4+
monthly: number;
5+
yearly: number;
6+
}
7+
38
export interface IUser extends Document {
49
username: string;
510
githubToken?: string;
611
createdAt: Date;
712
lastSeen?: Date;
813
visitCount: number;
14+
goals?: IUserGoals;
915
}
1016

1117
const UserSchema: Schema<IUser> = new Schema<IUser>({
@@ -33,6 +39,10 @@ const UserSchema: Schema<IUser> = new Schema<IUser>({
3339
type: Number,
3440
default: 0,
3541
},
42+
goals: {
43+
monthly: { type: Number, min: 1 },
44+
yearly: { type: Number, min: 1 },
45+
},
3646
});
3747

3848
export const User: Model<IUser> = mongoose.models.User || mongoose.model<IUser>('User', UserSchema);

0 commit comments

Comments
 (0)