Skip to content
74 changes: 74 additions & 0 deletions src/app/api/goals/achievements/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { supabaseAdmin } from "@/lib/supabase";
import { resolveAppUser } from "@/lib/resolve-user";

export const dynamic = "force-dynamic";

interface Badge {
id: string;
name: string;
description: string;
icon: string;
unlockedAt: string | null;
}

export async function GET() {
const session = await getServerSession(authOptions);
if (!session?.githubId) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}

const user = await resolveAppUser(session.githubId, session.githubLogin);
if (!user) return Response.json({ error: "User not found" }, { status: 404 });

try {
// Get goal statistics
const { data: goalHistory } = await supabaseAdmin
.from("goal_history")
.select("completed")
.eq("user_id", user.id);

const completedCount = (goalHistory ?? []).filter((h) => h.completed).length;

// Define badges
const badges: Badge[] = [
{
id: "first-goal",
name: "Goal Setter",
description: "Create your first goal",
icon: "🎯",
unlockedAt: completedCount > 0 ? new Date().toISOString() : null,
},
{
id: "goal-master",
name: "Goal Master",
description: "Complete 5 goals",
icon: "πŸ†",
unlockedAt: completedCount >= 5 ? new Date().toISOString() : null,
},
{
id: "consistency-king",
name: "Consistency King",
description: "Maintain weekly streak for 4 weeks",
icon: "πŸ‘‘",
unlockedAt: null, // Dynamic tracking needed
},
{
id: "overachiever",
name: "Overachiever",
description: "Exceed goal target by 50%",
icon: "⚑",
unlockedAt: null, // Dynamic tracking needed
},
];

return Response.json({ badges });
} catch (error) {
console.error("Failed to get achievements:", error);
return Response.json(
{ error: "Failed to fetch achievements" },
{ status: 500 }
);
}
}
54 changes: 54 additions & 0 deletions src/app/api/goals/notify/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { supabaseAdmin } from "@/lib/supabase";
import { resolveAppUser } from "@/lib/resolve-user";

export const dynamic = "force-dynamic";

export async function POST(req: Request) {
const session = await getServerSession(authOptions);
if (!session?.githubId) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}

const user = await resolveAppUser(session.githubId, session.githubLogin);
if (!user) return Response.json({ error: "User not found" }, { status: 404 });

const { goalId } = await req.json();

if (!goalId) {
return Response.json({ error: "goalId required" }, { status: 400 });
}

try {
const { data: goal } = await supabaseAdmin
.from("goals")
.select("*")
.eq("id", goalId)
.eq("user_id", user.id)
.single();

if (!goal) {
return Response.json({ error: "Goal not found" }, { status: 404 });
}

const { data: notification } = await supabaseAdmin
.from("notifications")
.insert({
user_id: user.id,
type: "goal_achievement",
message: `πŸŽ‰ Congrats! You completed "${goal.title}" goal!`,
read: false,
})
.select()
.single();

return Response.json({ notification });
} catch (error) {
console.error("Failed to create notification:", error);
return Response.json(
{ error: "Failed to create notification" },
{ status: 500 }
);
}
}
81 changes: 81 additions & 0 deletions src/app/api/goals/suggestions/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { supabaseAdmin } from "@/lib/supabase";
import { resolveAppUser } from "@/lib/resolve-user";

export const dynamic = "force-dynamic";

export async function GET() {
const session = await getServerSession(authOptions);
if (!session?.githubId) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}

const user = await resolveAppUser(session.githubId, session.githubLogin);
if (!user) return Response.json({ error: "User not found" }, { status: 404 });

try {
// Get user metrics
const { data: metrics } = await supabaseAdmin
.from("metric_snapshots")
.select("commits, prs_merged")
.eq("user_id", user.id)
.order("snapshot_at", { ascending: false })
.limit(1)
.single();

// Get existing goals
const { data: existingGoals } = await supabaseAdmin
.from("goals")
.select("unit")
.eq("user_id", user.id);

const existingUnits = new Set((existingGoals ?? []).map((g) => g.unit));

const suggestions: Array<{
title: string;
target: number;
unit: string;
recurrence: string;
reason: string;
}> = [];

if (!existingUnits.has("commits")) {
suggestions.push({
title: "Weekly Commits",
target: 5,
unit: "commits",
recurrence: "weekly",
reason: "Based on typical developer activity",
});
}

if (!existingUnits.has("prs")) {
suggestions.push({
title: "Monthly Pull Requests",
target: 8,
unit: "prs",
recurrence: "monthly",
reason: "Build review expertise",
});
}

if (!existingUnits.has("streak")) {
suggestions.push({
title: "7-Day Contribution Streak",
target: 7,
unit: "streak",
recurrence: "none",
reason: "Stay consistent with your coding",
});
}

return Response.json({ suggestions });
} catch (error) {
console.error("Failed to get suggestions:", error);
return Response.json(
{ error: "Failed to generate suggestions" },
{ status: 500 }
);
}
}
46 changes: 46 additions & 0 deletions src/components/GoalSuggestions.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
"use client";

import { useEffect, useState } from "react";

interface Suggestion {
title: string;
target: number;
unit: string;
recurrence: string;
reason: string;
}

export default function GoalSuggestions({
onSelect,
}: {
onSelect: (suggestion: Suggestion) => void;
}) {
const [suggestions, setSuggestions] = useState<Suggestion[]>([]);
const [loading, setLoading] = useState(true);

useEffect(() => {
fetch("/api/goals/suggestions")
.then((r) => r.json())
.then((data) => setSuggestions(data.suggestions || []))
.finally(() => setLoading(false));
}, []);

if (loading) return <div className="text-xs text-[var(--muted-foreground)]">Loading suggestions...</div>;
if (suggestions.length === 0) return null;

return (
<div className="mt-4 space-y-2">
<p className="text-xs font-medium text-[var(--muted-foreground)]">πŸ’‘ Suggested Goals:</p>
{suggestions.map((s, i) => (
<button
key={i}
onClick={() => onSelect(s)}
className="w-full text-left rounded-lg border border-[var(--border)] bg-[var(--control)] p-2 text-xs hover:border-[var(--accent)] transition"
>
<div className="font-medium">{s.title}</div>
<div className="text-[var(--muted-foreground)]">{s.reason}</div>
</button>
))}
</div>
);
}
15 changes: 9 additions & 6 deletions src/components/GoalTracker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -482,16 +482,16 @@ export default function GoalTracker() {
}

return (
<div className="h-full rounded-xl border border-[var(--border)] bg-[var(--card)] p-4 sm:p-6 shadow-sm">
<div className="h-full rounded-xl border border-[var(--border)] bg-[var(--card)] p-3 sm:p-4 md:p-6 shadow-sm overflow-y-auto">
{/* ── Header ── */}
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-[var(--card-foreground)]">Goals</h2>
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 mb-4">
<h2 className="text-base sm:text-lg font-semibold text-[var(--card-foreground)]">Goals</h2>
<button
onClick={handleSync}
disabled={syncing}
title="Refresh commit-based goals from GitHub"
aria-label="Refresh commit goals"
className="flex items-center gap-1.5 rounded-lg border border-[var(--border)] bg-[var(--background)] px-2.5 py-1 text-xs text-[var(--muted-foreground)] transition hover:text-[var(--card-foreground)] hover:border-[var(--accent)] disabled:opacity-50 disabled:cursor-not-allowed"
className="w-full sm:w-auto flex items-center justify-center gap-1.5 rounded-lg border border-[var(--border)] bg-[var(--background)] px-2.5 py-1.5 sm:py-1 text-xs text-[var(--muted-foreground)] transition hover:text-[var(--card-foreground)]"
>
<svg
xmlns="http://www.w3.org/2000/svg"
Expand All @@ -502,13 +502,16 @@ export default function GoalTracker() {
>
<path
fillRule="evenodd"
d="M15.312 3.312a.75.75 0 011.06 1.06l-1.43 1.43A8 8 0 1118 10a.75.75 0 01-1.5 0 6.5 6.5 0 10-1.923 4.596l-1.43-1.43a.75.75 0 011.06-1.06l2.75 2.75a.75.75 0 010 1.06l-2.75 2.75a.75.75 0 01-1.06-1.06l1.43-1.43A8 8 0 012 10 8 8 0 0115.312 3.312z"
d="M15.312 3.312a.75.75 0 011.06 1.06l-1.43 1.43A8 8 0 1118 10a.75.75 0 01-1.5 0 6.5 6.5 0 10-1.923 4.596l-1.43-1.43a.75.75 0 011.06-1.06l2.75 2.75a.75.75 0 010 1.06l-2.75 2.75a.75.75 0 01-1.06-1.06l1.43-1.43A8 8 0 015.5 10a.75.75 0 01-1.5 0 9.5 9.5 0 0110.312-9.688z"
clipRule="evenodd"
/>
</svg>
{syncing ? "Syncing…" : "Refresh"}
<span className="hidden xs:inline">{syncing ? "Syncing…" : "Refresh"}</span>
<span className="inline xs:hidden">{syncing ? "…" : "↻"}</span>
</button>
</div>

{/* Rest of component... */}

{/* Sync Error */}
{syncError && (
Expand Down
29 changes: 29 additions & 0 deletions src/components/__tests__/GoalTracker.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { describe, it, expect, vi } from "vitest";

describe("GoalTracker", () => {
it("should validate goal title", () => {
expect(() => {
const title = "";
if (title.trim().length === 0) throw new Error("Title required");
}).toThrow();
});

it("should validate target range", () => {
const MIN_TARGET = 1;
const MAX_TARGET = 10000;

expect(MIN_TARGET).toBeLessThanOrEqual(5);
expect(MAX_TARGET).toBeGreaterThanOrEqual(5);
});

it("should calculate progress percentage", () => {
const goal = { current: 5, target: 10 };
const pct = Math.round((goal.current / goal.target) * 100);
expect(pct).toBe(50);
});

it("should validate recurrence values", () => {
const VALID_RECURRENCES = ["none", "weekly", "monthly"];
expect(VALID_RECURRENCES).toContain("weekly");
});
});
2 changes: 1 addition & 1 deletion test/GoalTracker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ describe('GoalTracker - useGoalTracker Hook', () => {
});

expect(result.current.syncing).toBe(false);
expect(result.current.syncError).toBe('Failed to sync goals. Please try again.');
expect(result.current.syncError).toBe('Sync failed. Please try again.');
});

it('handles creating a non-auto-synced goal successfully', async () => {
Expand Down
Loading