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
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 }
);
}
}
75 changes: 75 additions & 0 deletions src/app/api/goals/suggestions/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
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 = [];

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 @@ -446,16 +446,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 @@ -466,13 +466,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");
});
});
Loading