Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
152 changes: 152 additions & 0 deletions app/api/user/goals/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { validateCSRF } from '@/lib/security/csrf';
import dbConnect from '@/lib/mongodb';
import { User } from '@/models/User';
import { getClientIp } from '@/utils/getClientIp';
import { getRateLimitHeaders, RateLimiter } from '@/lib/rate-limit';
import { githubUsernameSchema } from '@/lib/validations';
import { sanitizeMongoPayload } from '@/utils/sanitize';
import logger from '@/lib/logger';

// GET — 20 req / min per IP (read-heavy, cheap)
const goalsReadLimiter = new RateLimiter(20, 60_000, 1);

// PATCH — 10 req / min per IP (write-side, slightly stricter)
const goalsWriteLimiter = new RateLimiter(10, 60_000, 1);

const DEFAULT_GOALS = { monthly: 100, yearly: 1000 };

const patchBodySchema = z.object({
username: githubUsernameSchema,
monthly: z.number().int().min(1).max(99_999),
yearly: z.number().int().min(1).max(9_999_999),
});

// ---------------------------------------------------------------------------
// GET /api/user/goals?username=<user>
// Returns the stored goals for the user, or defaults when none are saved yet.
// ---------------------------------------------------------------------------
export async function GET(request: Request) {
const ip = getClientIp(request);
const rateLimitKey =
ip && ip !== 'unknown' ? ip : `unknown:${request.headers.get('user-agent') ?? 'no-agent'}`;

const rateLimitResult = await goalsReadLimiter.checkWithResult(rateLimitKey);
if (!rateLimitResult.success) {
return NextResponse.json(
{ error: 'Too many requests. Please try again later.' },
{ status: 429, headers: getRateLimitHeaders(rateLimitResult) }
);
}

const { searchParams } = new URL(request.url);
const rawUsername = searchParams.get('username')?.trim();

if (!rawUsername) {
return NextResponse.json({ error: 'Username is required' }, { status: 400 });
}

const parsed = githubUsernameSchema.safeParse(rawUsername);
if (!parsed.success) {
return NextResponse.json({ error: 'Invalid GitHub username' }, { status: 400 });
}

const username = parsed.data.toLowerCase();

// When DB is not configured, return defaults so the client degrades gracefully.
if (!process.env.MONGODB_URI) {
return NextResponse.json({ goals: DEFAULT_GOALS, source: 'default' });
}

try {
await dbConnect();
const user = await User.findOne({ username }).select('goals').lean();

if (!user || !user.goals) {
return NextResponse.json({ goals: DEFAULT_GOALS, source: 'default' });
}

return NextResponse.json({
goals: {
monthly: user.goals.monthly ?? DEFAULT_GOALS.monthly,
yearly: user.goals.yearly ?? DEFAULT_GOALS.yearly,
},
source: 'db',
});
} catch (error) {
logger.error('Failed to fetch user goals', { route: '/api/user/goals', error });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}

// ---------------------------------------------------------------------------
// PATCH /api/user/goals
// Body: { username, monthly, yearly }
// Upserts the goals sub-document on the user record.
// ---------------------------------------------------------------------------
export async function PATCH(request: Request) {
const ip = getClientIp(request);
const rateLimitKey =
ip && ip !== 'unknown' ? ip : `unknown:${request.headers.get('user-agent') ?? 'no-agent'}`;

const rateLimitResult = await goalsWriteLimiter.checkWithResult(rateLimitKey);
if (!rateLimitResult.success) {
return NextResponse.json(
{ error: 'Too many requests. Please try again later.' },
{ status: 429, headers: getRateLimitHeaders(rateLimitResult) }
);
}

// CSRF validation — mirrors track-user route
const csrfError = validateCSRF(request);
if (csrfError) {
return NextResponse.json({ error: 'Origin not allowed' }, { status: 403 });
}

let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: 'Malformed JSON request body' }, { status: 400 });
}

// Sanitize MongoDB operators from body
sanitizeMongoPayload(body);

const parsed = patchBodySchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: 'Invalid request body', details: parsed.error.flatten().fieldErrors },
{ status: 400 }
);
}

const { username, monthly, yearly } = parsed.data;
const trimmedUsername = username.toLowerCase();

// Graceful bypass when DB is not configured (dev / CI without MongoDB)
if (!process.env.MONGODB_URI) {
logger.warn('Goals save bypassed: MONGODB_URI is not set', {
environment: process.env.NODE_ENV,
});
return NextResponse.json({ success: true, bypassed: true });
}

try {
await dbConnect();
await User.updateOne(
{ username: trimmedUsername },
{
$setOnInsert: { username: trimmedUsername },
$set: { 'goals.monthly': monthly, 'goals.yearly': yearly },
},
{ upsert: true }
);

return NextResponse.json({ success: true });
} catch (error) {
logger.error('Failed to save user goals', { route: '/api/user/goals', error });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
79 changes: 42 additions & 37 deletions components/InteractiveViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,20 +25,23 @@ interface ParallaxParticle {
* Deterministic math prevents random values from causing SSR/CSR mismatches. */
function buildParticles(): ParallaxParticle[] {
const colors = ['#10b981', '#8b5cf6', '#06b6d4', '#3b82f6', '#f59e0b'];
return Array.from({ length: PARALLAX_PARTICLE_COUNT }, (_, i): ParallaxParticle => ({
id: i,
// Spread particles across the container using prime-number strides
x: (i * 17 + 11) % 100,
y: (i * 23 + 7) % 100,
size: 4 + (i % 5) * 2, // range: 4–12 px
// Keep opacity low so particles never obscure the badge
opacity: 0.05 + (i % 4) * 0.025, // range: 0.05–0.125
// Vary depth so each "layer" of particles shifts by a different amount,
// creating the illusion of 3-D depth. depth 0.1 = farthest; 0.7 = nearest.
depth: 0.1 + (i % 6) * 0.1, // range: 0.1–0.6
color: colors[i % colors.length],
isCircle: i % 4 === 0,
}));
return Array.from(
{ length: PARALLAX_PARTICLE_COUNT },
(_, i): ParallaxParticle => ({
id: i,
// Spread particles across the container using prime-number strides
x: (i * 17 + 11) % 100,
y: (i * 23 + 7) % 100,
size: 4 + (i % 5) * 2, // range: 4–12 px
// Keep opacity low so particles never obscure the badge
opacity: 0.05 + (i % 4) * 0.025, // range: 0.05–0.125
// Vary depth so each "layer" of particles shifts by a different amount,
// creating the illusion of 3-D depth. depth 0.1 = farthest; 0.7 = nearest.
depth: 0.1 + (i % 6) * 0.1, // range: 0.1–0.6
color: colors[i % colors.length],
isCircle: i % 4 === 0,
})
);
}

// How many pixels a depth-1.0 particle shifts when the cursor is at the
Expand Down Expand Up @@ -399,29 +402,31 @@ export default function InteractiveViewer({
Each particle shifts by (parallaxX * depth, parallaxY * depth) px relative
to its base position, so "closer" particles (higher depth) shift more —
creating the impression of a multi-layered isometric space. */}
{particles.map((particle): ReactElement => (
<div
key={particle.id}
style={{
position: 'absolute',
left: `${particle.x}%`,
top: `${particle.y}%`,
width: particle.size,
height: particle.size,
backgroundColor: particle.color,
borderRadius: particle.isCircle ? '50%' : '2px',
boxShadow: `0 0 ${particle.size * 2}px ${particle.color}55`,
opacity: isHovering ? particle.opacity * 1.8 : particle.opacity,
// Particles shift in the SAME direction as the cursor offset to create
// a realistic parallax: near objects (depth ~0.6) move more than far ones.
transform: `translate(${parallaxX * particle.depth}px, ${parallaxY * particle.depth}px)`,
// Smooth lerp toward the new position; opacity fades independently
transition: `transform 0.35s cubic-bezier(0.25, 0.46, 0.45, 0.94), opacity 0.5s ease`,
pointerEvents: 'none',
willChange: 'transform',
}}
/>
))}
{particles.map(
(particle): ReactElement => (
<div
key={particle.id}
style={{
position: 'absolute',
left: `${particle.x}%`,
top: `${particle.y}%`,
width: particle.size,
height: particle.size,
backgroundColor: particle.color,
borderRadius: particle.isCircle ? '50%' : '2px',
boxShadow: `0 0 ${particle.size * 2}px ${particle.color}55`,
opacity: isHovering ? particle.opacity * 1.8 : particle.opacity,
// Particles shift in the SAME direction as the cursor offset to create
// a realistic parallax: near objects (depth ~0.6) move more than far ones.
transform: `translate(${parallaxX * particle.depth}px, ${parallaxY * particle.depth}px)`,
// Smooth lerp toward the new position; opacity fades independently
transition: `transform 0.35s cubic-bezier(0.25, 0.46, 0.45, 0.94), opacity 0.5s ease`,
pointerEvents: 'none',
willChange: 'transform',
}}
/>
)
)}
</div>

{/* ── Card content ──────────────────────────────────────────────────────
Expand Down
13 changes: 13 additions & 0 deletions components/dashboard/GoalTracker.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,19 @@ describe('GoalTracker Component', () => {

beforeEach(() => {
window.localStorage.clear();

// Stub fetch so the background server-sync resolves immediately with
// "no goals saved yet" (source: 'default'). This prevents the skeleton
// loading state from blocking assertions in tests that don't pre-populate
// localStorage — the component will immediately set serverSynced = true
// and render actual values instead of placeholders.
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ goals: { monthly: 100, yearly: 1000 }, source: 'default' }),
})
);
});

it('renders title and defaults correctly when localStorage is empty', () => {
Expand Down
70 changes: 64 additions & 6 deletions components/dashboard/GoalTracker.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
'use client';

import { motion, AnimatePresence } from 'framer-motion';
import { useState } from 'react';
import { useState, useEffect, useRef } from 'react';
import { Target, Edit2, Check, X } from 'lucide-react';
import { useLocalStorage } from '@/hooks/useLocalStorage';
import { useTranslation } from '@/context/TranslationContext';
Expand All @@ -21,16 +21,53 @@ const DEFAULT_GOALS: UserGoals = {
yearly: 1000,
};

const GOALS_API = '/api/user/goals';

export default function GoalTracker({ username, activity = [] }: GoalTrackerProps) {
const { t } = useTranslation();
const [isEditing, setIsEditing] = useState(false);

// Load and save goals using standard local storage key keyed by username
// localStorage acts as an instant cache so the UI renders immediately.
const [goals, setGoals] = useLocalStorage<UserGoals>(
`commitpulse:goals:${username.toLowerCase()}`,
DEFAULT_GOALS
);

const saveControllerRef = useRef<AbortController | null>(null);

// On mount, fetch goals from the server. The server value is authoritative
// and overwrites localStorage so all devices stay in sync.
useEffect(() => {
let cancelled = false;

async function fetchServerGoals() {
try {
const res = await fetch(
`${GOALS_API}?username=${encodeURIComponent(username.toLowerCase())}`,
{ cache: 'no-store' }
);
if (!res.ok || cancelled) return;

const data = await res.json();
if (
data?.goals &&
typeof data.goals.monthly === 'number' &&
typeof data.goals.yearly === 'number'
) {
setGoals(data.goals);
}
} catch {
// Network failure — silently keep local value
}
}

fetchServerGoals();
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [username]);

const [editMonthly, setEditMonthly] = useState(goals.monthly.toString());
const [editYearly, setEditYearly] = useState(goals.yearly.toString());

Expand Down Expand Up @@ -65,12 +102,33 @@ export default function GoalTracker({ username, activity = [] }: GoalTrackerProp
e.preventDefault();
const newMonthly = Math.max(1, parseInt(editMonthly, 10) || DEFAULT_GOALS.monthly);
const newYearly = Math.max(1, parseInt(editYearly, 10) || DEFAULT_GOALS.yearly);
const newGoals: UserGoals = { monthly: newMonthly, yearly: newYearly };

setGoals({
monthly: newMonthly,
yearly: newYearly,
});
// Optimistic local update — instant feedback regardless of network
setGoals(newGoals);
setIsEditing(false);

// Abort any in-flight previous save
saveControllerRef.current?.abort();
const controller = new AbortController();
saveControllerRef.current = controller;

// Persist to server in the background
fetch(GOALS_API, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: username.toLowerCase(),
monthly: newMonthly,
yearly: newYearly,
}),
signal: controller.signal,
}).catch((err) => {
// AbortError is expected when navigating away — swallow silently
if (err?.name !== 'AbortError') {
console.warn('[GoalTracker] Failed to persist goals to server:', err);
}
});
};

const handleCancel = () => {
Expand Down
10 changes: 10 additions & 0 deletions models/User.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
import mongoose, { Document, Model, Schema } from 'mongoose';

export interface IUserGoals {
monthly: number;
yearly: number;
}

export interface IUser extends Document {
username: string;
githubToken?: string;
createdAt: Date;
lastSeen?: Date;
visitCount: number;
goals?: IUserGoals;
}

const UserSchema: Schema<IUser> = new Schema<IUser>({
Expand Down Expand Up @@ -33,6 +39,10 @@ const UserSchema: Schema<IUser> = new Schema<IUser>({
type: Number,
default: 0,
},
goals: {
monthly: { type: Number, min: 1 },
yearly: { type: Number, min: 1 },
},
});

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