From 98ffaecb8a033c535e87c74c95b2ebdb31fb2727 Mon Sep 17 00:00:00 2001 From: IshitaSingh0822 Date: Sun, 7 Jun 2026 12:34:23 +0530 Subject: [PATCH 1/2] feat: add goal completion history & analytics chart (#2121) - Add GET /api/goals/history?weeks=8 route - Add GoalHistory.tsx with collapsible Recharts LineChart - Show weekly completion % per goal over last 8 weeks - Show average completion stat for last 4 weeks - Integrate GoalHistory into GoalTracker.tsx --- src/app/api/goals/history/route.ts | 46 +++++ src/components/GoalHistory.tsx | 232 ++++++++++++++++++++++++ src/components/GoalTracker.tsx | 276 ++++++++++++++++++----------- 3 files changed, 446 insertions(+), 108 deletions(-) create mode 100644 src/app/api/goals/history/route.ts create mode 100644 src/components/GoalHistory.tsx diff --git a/src/app/api/goals/history/route.ts b/src/app/api/goals/history/route.ts new file mode 100644 index 00000000..c7789b6d --- /dev/null +++ b/src/app/api/goals/history/route.ts @@ -0,0 +1,46 @@ +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(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 { searchParams } = new URL(req.url); + const weeksParam = searchParams.get("weeks"); + const weeks = Math.min( + Math.max(1, parseInt(weeksParam ?? "8", 10) || 8), + 52 + ); + + const since = new Date(); + since.setUTCDate(since.getUTCDate() - weeks * 7); + + const { data: histories, error } = await supabaseAdmin + .from("goal_history") + .select("goal_id, period_start, period_end, target, achieved, completed") + .eq("user_id", user.id) + .gte("period_end", since.toISOString()) + .order("period_end", { ascending: true }); + + if (error) { + console.error("Failed to fetch goal history:", error); + return Response.json({ error: "Failed to fetch history" }, { status: 500 }); + } + + // Also fetch active goals so we can label lines by goal title + const { data: goals } = await supabaseAdmin + .from("goals") + .select("id, title, unit") + .eq("user_id", user.id); + + return Response.json({ histories: histories ?? [], goals: goals ?? [] }); +} \ No newline at end of file diff --git a/src/components/GoalHistory.tsx b/src/components/GoalHistory.tsx new file mode 100644 index 00000000..3ffdc5e2 --- /dev/null +++ b/src/components/GoalHistory.tsx @@ -0,0 +1,232 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { + LineChart, + Line, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + Legend, + ResponsiveContainer, +} from "recharts"; + +interface HistoryEntry { + goal_id: string; + period_start: string; + period_end: string; + target: number; + achieved: number; + completed: boolean; +} + +interface GoalMeta { + id: string; + title: string; + unit: string; +} + +interface WeekRow { + weekLabel: string; + [goalTitle: string]: string | number; +} + +const LINE_COLORS = [ + "var(--accent)", + "#10B981", + "#F59E0B", + "#8B5CF6", + "#EC4899", + "#3B82F6", +]; + +function formatWeekLabel(periodEnd: string): string { + const d = new Date(periodEnd); + return d.toLocaleDateString(undefined, { month: "short", day: "numeric" }); +} + +export default function GoalHistory() { + const [open, setOpen] = useState(false); + const [loading, setLoading] = useState(false); + const [histories, setHistories] = useState([]); + const [goals, setGoals] = useState([]); + const [error, setError] = useState(null); + + useEffect(() => { + if (!open) return; + setLoading(true); + setError(null); + fetch("/api/goals/history?weeks=8") + .then((r) => r.json()) + .then((data) => { + setHistories(data.histories ?? []); + setGoals(data.goals ?? []); + }) + .catch(() => setError("Failed to load history.")) + .finally(() => setLoading(false)); + }, [open]); + + // Build chart data: one row per unique week label, columns per goal + const goalMap = new Map(goals.map((g) => [g.id, g])); + + const weekSet = new Set(); + for (const h of histories) { + weekSet.add(h.period_end.slice(0, 10)); + } + const sortedWeeks = Array.from(weekSet).sort(); + + const chartData: WeekRow[] = sortedWeeks.map((weekKey) => { + const row: WeekRow = { weekLabel: formatWeekLabel(weekKey) }; + for (const h of histories) { + if (h.period_end.slice(0, 10) !== weekKey) continue; + const meta = goalMap.get(h.goal_id); + const label = meta?.title ?? h.goal_id.slice(0, 8); + const pct = h.target > 0 ? Math.round((h.achieved / h.target) * 100) : 0; + row[label] = Math.min(pct, 100); + } + return row; + }); + + // Active goal titles that appear in history + const activeGoalTitles = Array.from( + new Set( + histories + .map((h) => goalMap.get(h.goal_id)?.title ?? h.goal_id.slice(0, 8)) + ) + ); + + // Average completion over last 4 weeks + const last4Weeks = sortedWeeks.slice(-4); + let totalPct = 0; + let count = 0; + for (const h of histories) { + if (!last4Weeks.includes(h.period_end.slice(0, 10))) continue; + totalPct += h.target > 0 ? (h.achieved / h.target) * 100 : 0; + count++; + } + const avgCompletion = count > 0 ? Math.round(totalPct / count) : null; + + const hasData = chartData.length >= 1 && activeGoalTitles.length > 0; + + return ( +
+ + + {open && ( +
+ {loading && ( +
+ Loading history… +
+ )} + + {error && !loading && ( +

{error}

+ )} + + {!loading && !error && !hasData && ( +

+ No history yet. Complete a recurring goal period to see trends here. +

+ )} + + {!loading && !error && hasData && ( + <> + {avgCompletion !== null && ( +

+ Average completion last 4 weeks:{" "} + + {avgCompletion}% + +

+ )} + + + + + + `${v}%`} + tick={{ fontSize: 11, fill: "var(--muted-foreground)" }} + tickLine={false} + axisLine={false} + /> + [`${value}%`, ""]} + contentStyle={{ + background: "var(--card)", + border: "1px solid var(--border)", + borderRadius: "8px", + fontSize: "12px", + }} + labelStyle={{ color: "var(--card-foreground)" }} + /> + + {activeGoalTitles.map((title, i) => ( + + ))} + + + + )} +
+ )} +
+ ); +} \ No newline at end of file diff --git a/src/components/GoalTracker.tsx b/src/components/GoalTracker.tsx index 166266fc..6cef4136 100644 --- a/src/components/GoalTracker.tsx +++ b/src/components/GoalTracker.tsx @@ -3,8 +3,9 @@ import { useCallback, useEffect, useState, useRef } from "react"; import { useSession } from "next-auth/react"; import { submitGoalWithRefresh } from "@/lib/goal-tracker"; -import ConfirmModal from "@/components/ConfirmModal"; // 🎯 Imported the native project confirmation layout +import ConfirmModal from "@/components/ConfirmModal"; import { buildPublicGoalShareUrl } from "@/lib/goals/share"; +import GoalHistory from "@/components/GoalHistory"; type Recurrence = "none" | "weekly" | "monthly"; @@ -56,7 +57,6 @@ export function useGoalTracker() { const prevGoalsRef = useRef>(new Map()); const initialLoadDoneRef = useRef(false); - // Find the goal title that matches the confirmingId to display inside the modal confirmation dialog const activeConfirmingGoal = goals.find((g) => g.id === confirmingId); const loadGoals = useCallback(async () => { @@ -67,14 +67,12 @@ export function useGoalTracker() { return fetchedGoals; }, []); - /** Sync commit-based goals from GitHub, then reload */ const handleSync = useCallback(async () => { setSyncing(true); setSyncError(null); try { const res = await fetch("/api/goals/sync", { method: "POST" }); if (!res.ok) { - // Read the body once β€” the Fetch API only allows a single read per response. let errData: { error?: string } = {}; try { errData = await res.json(); @@ -101,7 +99,6 @@ export function useGoalTracker() { } }, [loadGoals]); - // On mount: load goals then auto-sync if stale useEffect(() => { loadGoals() .then(async (fetchedGoals) => { @@ -109,7 +106,7 @@ export function useGoalTracker() { if (g.unit !== "commits") return false; if (!g.last_synced_at) return true; const syncedAt = new Date(g.last_synced_at).getTime(); - return Date.now() - syncedAt > 15 * 60 * 1000; // > 15 mins + return Date.now() - syncedAt > 15 * 60 * 1000; }); if (needsSync) { await handleSync(); @@ -159,16 +156,15 @@ export function useGoalTracker() { setRecurrence("none"); setDeadline(""); - // Immediately sync if it was a commit-based goal or prs if (unit === "commits" || unit === "prs") { await handleSync(); } else { - await loadGoals().catch(() => { }); + await loadGoals().catch(() => {}); } } catch (e) { setCreateError("Failed to create goal. Please try again."); } finally { - setCreating(false); + setCreating(false); } } @@ -199,7 +195,7 @@ export function useGoalTracker() { if (goal.recurrence === "monthly") return "Completed this month βœ“"; return "Completed βœ“"; } - + if (goal.deadline) { const msLeft = new Date(goal.deadline).getTime() - Date.now(); const daysLeft = Math.ceil(msLeft / (1000 * 60 * 60 * 24)); @@ -207,7 +203,7 @@ export function useGoalTracker() { if (daysLeft === 0) return "Due today ⏳"; return `${daysLeft}d left`; } - + return ""; } @@ -229,7 +225,11 @@ export function useGoalTracker() { const wasCompleted = prevGoalsRef.current.get(g.id); if (wasCompleted === false && isCompleted) { - if (typeof window !== "undefined" && typeof window.matchMedia === "function" && !window.matchMedia("(prefers-reduced-motion: reduce)").matches) { + if ( + typeof window !== "undefined" && + typeof window.matchMedia === "function" && + !window.matchMedia("(prefers-reduced-motion: reduce)").matches + ) { setActiveConfettiGoalId(g.id); setTimeout(() => { setActiveConfettiGoalId((curr) => (curr === g.id ? null : curr)); @@ -278,6 +278,7 @@ export function useGoalTracker() { deleteError, setDeleteError, activeConfettiGoalId, + activeConfirmingGoal, handleSync, handleCreate, handleDelete, @@ -313,6 +314,7 @@ export default function GoalTracker() { deleteError, setDeleteError, activeConfettiGoalId, + activeConfirmingGoal, handleSync, handleCreate, handleDelete, @@ -325,12 +327,11 @@ export default function GoalTracker() { typeof (session as { githubLogin?: unknown } | null)?.githubLogin === "string" ? (session as { githubLogin: string }).githubLogin : null; - + const [copiedGoalId, setCopiedGoalId] = useState(null); const [sharingGoalId, setSharingGoalId] = useState(null); const [shareError, setShareError] = useState(null); - const toggleGoalSharing = async (goalId: string, nextValue: boolean) => { setSharingGoalId(goalId); setShareError(null); @@ -360,31 +361,29 @@ export default function GoalTracker() { }; const copyGoalShareLink = async (goalId: string) => { - if (!githubLogin) { - setShareError("Unable to build share link for this account."); - return; - } - - const shareUrl = buildPublicGoalShareUrl( - window.location.origin, - githubLogin, - goalId - ); + if (!githubLogin) { + setShareError("Unable to build share link for this account."); + return; + } - try { - await navigator.clipboard.writeText(shareUrl); - setCopiedGoalId(goalId); - window.setTimeout(() => { - setCopiedGoalId((currentGoalId) => - currentGoalId === goalId ? null : currentGoalId - ); - }, 2000); - } catch { - setShareError("Failed to copy share link. Please copy it manually."); - } -}; + const shareUrl = buildPublicGoalShareUrl( + window.location.origin, + githubLogin, + goalId + ); - const activeConfirmingGoal = goals.find((g) => g.id === confirmingId); + try { + await navigator.clipboard.writeText(shareUrl); + setCopiedGoalId(goalId); + window.setTimeout(() => { + setCopiedGoalId((currentGoalId) => + currentGoalId === goalId ? null : currentGoalId + ); + }, 2000); + } catch { + setShareError("Failed to copy share link. Please copy it manually."); + } + }; if (loading) { return ( @@ -454,23 +453,30 @@ export default function GoalTracker() { {deleteError && (

{deleteError}

- +
)} + {/* Share Error */} {shareError && ( -
-

{shareError}

- -
-)} +
+

{shareError}

+ +
+ )} {goals.length === 0 ? (

@@ -479,7 +485,10 @@ export default function GoalTracker() { ) : (

    {goals.map((goal) => { - const pct = goal.current > 0 ? Math.max(1, Math.min(Math.round((goal.current / goal.target) * 100), 100)) : 0; + const pct = + goal.current > 0 + ? Math.max(1, Math.min(Math.round((goal.current / goal.target) * 100), 100)) + : 0; const isDeleting = deletingId === goal.id; const completed = goal.current >= goal.target; const completionLabel = getCompletionLabel(goal); @@ -493,11 +502,13 @@ export default function GoalTracker() {
    {goal.title} {goal.recurrence !== "none" && ( - + {RECURRENCE_LABELS[goal.recurrence]} )} @@ -528,16 +539,24 @@ export default function GoalTracker() { {completionLabel} ) : completionLabel ? ( - + {completionLabel} ) : null} {goal.last_period && ( Last period: {goal.last_period.completed ? "βœ“" : "β—‹"}{" "} {goal.last_period.achieved}/{goal.last_period.target} {goal.unit} @@ -550,7 +569,6 @@ export default function GoalTracker() { {goal.current}/{goal.target} {goal.unit} - {/* Manual +1 only for non-auto-synced goals */} {!isAutoSynced && ( )} - {/* 🎯 Clean interception: Clicking trash icon sets confirmingId instead of trigger-deleting */}
    @@ -604,41 +631,42 @@ export default function GoalTracker() { style={{ width: `${Math.max(0, Math.min(pct, 100))}%` }} /> -
    -
    -
    -

    - Share this goal -

    -

    - Make this goal visible on a public share page. -

    -
    - -
    - - {goal.is_public && ( - - )} -
    +
    +
    +
    +

    + Share this goal +

    +

    + Make this goal visible on a public share page. +

    +
    + + +
    + + {goal.is_public && ( + + )} +
    ); })} @@ -654,7 +682,10 @@ export default function GoalTracker() { {/* Goal Creation Form */}
    -