diff --git a/src/components/GoalTracker.tsx b/src/components/GoalTracker.tsx index d2eecbe22..4ea361737 100644 --- a/src/components/GoalTracker.tsx +++ b/src/components/GoalTracker.tsx @@ -1,18 +1,8 @@ "use client"; -import { useCallback, useEffect, useState, useRef } from "react"; -import { useSession } from "next-auth/react"; -import { useDashboardWidgetA11y } from "@/components/dashboard/DashboardWidgetA11yContext"; -import { submitGoalWithRefresh } from "@/lib/goal-tracker"; -import ConfirmModal from "@/components/ConfirmModal"; -import { buildPublicGoalShareUrl } from "@/lib/goals/share"; -import GoalHistory from "@/components/GoalHistory"; -import EmptyState from "@/components/EmptyState"; -import WidgetSkeleton, { SkeletonBlock } from "./WidgetSkeleton"; - +import { useCallback, useEffect, useRef, useState } from "react"; type Recurrence = "none" | "weekly" | "monthly"; -type GoalCategory = "side-project" | "work" | "dsa" | "open-source"; interface Goal { id: string; @@ -21,19 +11,8 @@ interface Goal { current: number; unit: string; recurrence: Recurrence; - deadline: string | null; - is_public: boolean; period_start: string; last_synced_at: string | null; - week_start: string | null; - category?: GoalCategory | null; - last_period: { - period_start: string; - period_end: string; - target: number; - achieved: number; - completed: boolean; - } | null; } const RECURRENCE_LABELS: Record = { @@ -42,23 +21,7 @@ const RECURRENCE_LABELS: Record = { monthly: "Monthly", }; -const CATEGORY_LABELS: Record = { - "side-project": "Side Project", - work: "Work", - dsa: "DSA", - "open-source": "Open Source", -}; - -const CATEGORY_BADGE_CLASSES: Record = { - "side-project": "bg-purple-500/10 text-purple-400 border-purple-500/30", - work: "bg-blue-500/10 text-blue-400 border-blue-500/30", - dsa: "bg-amber-500/10 text-amber-400 border-amber-500/30", - "open-source": "bg-emerald-500/10 text-emerald-400 border-emerald-500/30", -}; - -const CATEGORY_OPTIONS: GoalCategory[] = ["side-project", "work", "dsa", "open-source"]; - -export function useGoalTracker() { +export default function GoalTracker() { const [goals, setGoals] = useState([]); const [loading, setLoading] = useState(true); const [syncing, setSyncing] = useState(false); @@ -69,84 +32,96 @@ export function useGoalTracker() { const [target, setTarget] = useState(7); const [unit, setUnit] = useState("commits"); const [recurrence, setRecurrence] = useState("none"); - const [deadline, setDeadline] = useState(""); - const [category, setCategory] = useState(""); - const [activeCategoryFilter, setActiveCategoryFilter] = useState(null); const [creating, setCreating] = useState(false); const [createError, setCreateError] = useState(null); const [confirmingId, setConfirmingId] = useState(null); const [deletingId, setDeletingId] = useState(null); const [deleteError, setDeleteError] = useState(null); - const [activeConfettiGoalId, setActiveConfettiGoalId] = useState(null); + const [activeConfettiGoalId, setActiveConfettiGoalId] = + useState(null); + const prevGoalsRef = useRef>(new Map()); const initialLoadDoneRef = useRef(false); const loadGoals = useCallback(async () => { const response = await fetch("/api/goals"); - if (!response.ok) { - throw new Error(`Failed to load goals (HTTP ${response.status})`); - } const data: { goals: Goal[] } = await response.json(); + const fetchedGoals = data.goals ?? []; + setGoals(fetchedGoals); + 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" }); + const res = await fetch("/api/goals/sync", { + method: "POST", + }); + if (!res.ok) { - // Read the body exactly once β€” the Fetch API body stream can only be - // consumed once, so we must store the result before branching on status. - let errData: { error?: string } = {}; + let msg = "Sync failed. Please try again."; + try { - errData = await res.json(); - } catch (_) {} - - if (res.status === 429) { - setSyncError( - errData.error ?? "GitHub rate limit reached. Please try again later." - ); - } else if (res.status === 401) { - setSyncError("Unauthorized. Please log in again."); + const errData = await res.json(); + + if (errData && errData.error) { + msg = errData.error; + } + } catch {} + + if (res.status === 401) { + msg = "Unauthorized. Please log in again."; } else if (res.status === 502) { - setSyncError( - "GitHub sync failed: Expired token or missing repo scope." - ); - } else { - setSyncError(errData.error ?? "Sync failed. Please try again."); + msg = "GitHub sync failed: Expired token or missing repo scope."; } + + setSyncError(msg); return; } + await loadGoals(); + setLastUpdated(new Date()); setMinutesAgo(0); - } catch (e) { + } catch { setSyncError("Network error. Failed to sync goals."); } finally { setSyncing(false); } }, [loadGoals]); + // On mount: load goals then auto-sync if stale. useEffect(() => { loadGoals() .then(async (fetchedGoals) => { const needsSync = fetchedGoals.some((g: Goal) => { - // auto-sync applies to all goal types - if (!g.last_synced_at) return true; + 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; }); + if (needsSync) { await handleSync(); } }) - .catch(() => { - setSyncError("Failed to load goals. Please try again."); - }) + .catch(() => {}) .finally(() => { setLoading(false); setLastUpdated(new Date()); @@ -154,86 +129,68 @@ export function useGoalTracker() { }); }, [loadGoals, handleSync]); - useEffect(() => { - const handleSyncEvent = () => { - loadGoals() - .then(() => { - setLastUpdated(new Date()); - setMinutesAgo(0); - }) - .catch(() => { - setSyncError("Failed to sync goals. Please try again."); - }); - }; - window.addEventListener("devtrack:sync", handleSyncEvent); - return () => window.removeEventListener("devtrack:sync", handleSyncEvent); - }, [loadGoals]); + async function handleCreate(e: React.FormEvent) { + e.preventDefault(); - async function handleCreate(e?: React.FormEvent) { - if (e) e.preventDefault(); setCreating(true); setCreateError(null); - if (target <= 0) { - setCreateError("Target must be greater than 0."); - setCreating(false); - return; - } - try { - const result = await submitGoalWithRefresh({ - payload: { + const response = await fetch("/api/goals", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ title, target, unit, recurrence, - deadline: deadline || null, - category: category || null, - }, - handleSync, - loadGoals, + }), }); - if (!result.created) { - setCreateError(result.error); - return; - } - - setTitle(""); - setTarget(7); - setUnit("commits"); - setRecurrence("none"); - setDeadline(""); - setCategory(""); - - if (unit === "commits" || unit === "prs") { - await handleSync(); - } else { - await loadGoals().catch(() => { - setSyncError("Failed to refresh goals after creation."); - }); + if (!response.ok) { + throw new Error("Failed to create goal"); } - } catch (e) { + } catch { setCreateError("Failed to create goal. Please try again."); - } finally { setCreating(false); + return; + } + + setTitle(""); + setTarget(7); + setUnit("commits"); + setRecurrence("none"); + + // Immediately sync if it was a commit-based goal. + if (unit === "commits") { + await handleSync(); + } else { + await loadGoals().catch(() => {}); } + + setCreating(false); } async function handleDelete(id: string) { const previousGoals = goals; + setGoals((prev) => prev.filter((g) => g.id !== id)); setConfirmingId(null); setDeletingId(id); setDeleteError(null); try { - const res = await fetch(`/api/goals/${id}`, { method: "DELETE" }); + const res = await fetch(`/api/goals/${id}`, { + method: "DELETE", + }); + if (!res.ok) { setGoals(previousGoals); setDeleteError("Failed to delete goal. Please try again."); } - } catch (e) { + } catch { setGoals(previousGoals); setDeleteError("Failed to delete goal. Please check your connection."); } finally { @@ -243,33 +200,35 @@ export function useGoalTracker() { function getCompletionLabel(goal: Goal): string { if (goal.current >= goal.target) { - if (goal.current > goal.target) return "Goal surpassed! 🎯"; - if (goal.recurrence === "weekly") return "Completed this week βœ“"; - if (goal.recurrence === "monthly") return "Completed this month βœ“"; - return "Completed βœ“"; - } + if (goal.recurrence === "weekly") { + return "Completed this week βœ“"; + } - if (goal.deadline) { - const msLeft = new Date(goal.deadline).getTime() - Date.now(); - const daysLeft = Math.ceil(msLeft / (1000 * 60 * 60 * 24)); - if (daysLeft < 0) return "Overdue ⚠️"; - if (daysLeft === 0) return "Due today ⏳"; - return `${daysLeft}d left`; + if (goal.recurrence === "monthly") { + return "Completed this month βœ“"; + } + + return "Completed βœ“"; } return ""; } useEffect(() => { - if (goals.length === 0) return; + if (goals.length === 0) { + return; + } if (!initialLoadDoneRef.current) { const map = new Map(); + for (const g of goals) { map.set(g.id, g.current >= g.target); } + prevGoalsRef.current = map; initialLoadDoneRef.current = true; + return; } @@ -279,13 +238,14 @@ export function useGoalTracker() { if (wasCompleted === false && isCompleted) { 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)); + setActiveConfettiGoalId((curr) => + curr === g.id ? null : curr + ); }, 2500); } } @@ -295,198 +255,60 @@ export function useGoalTracker() { }, [goals]); useEffect(() => { - if (!lastUpdated) return; + if (!lastUpdated) { + return; + } + const interval = setInterval(() => { - const diff = Math.floor((Date.now() - lastUpdated.getTime()) / 60000); + const diff = Math.floor( + (Date.now() - lastUpdated.getTime()) / 60000 + ); + setMinutesAgo(diff); }, 60000); + return () => clearInterval(interval); }, [lastUpdated]); - return { - goals, - setGoals, - loading, - setLoading, - syncing, - syncError, - setSyncError, - lastUpdated, - minutesAgo, - title, - setTitle, - target, - setTarget, - unit, - setUnit, - recurrence, - setRecurrence, - deadline, - setDeadline, - category, - setCategory, - activeCategoryFilter, - setActiveCategoryFilter, - creating, - createError, - confirmingId, - setConfirmingId, - deletingId, - deleteError, - setDeleteError, - activeConfettiGoalId, - handleSync, - handleCreate, - handleDelete, - getCompletionLabel, - }; -} - -export default function GoalTracker() { - - - const { - - goals, - setGoals, - loading, - syncing, - syncError, - setSyncError, - lastUpdated, - minutesAgo, - title, - setTitle, - target, - setTarget, - unit, - setUnit, - recurrence, - setRecurrence, - deadline, - setDeadline, - category, - setCategory, - activeCategoryFilter, - setActiveCategoryFilter, - creating, - createError, - confirmingId, - setConfirmingId, - deletingId, - deleteError, - setDeleteError, - activeConfettiGoalId, - handleSync, - handleCreate, - handleDelete, - getCompletionLabel, - } = useGoalTracker(); - - const { setSummary, setIsUpdating } = useDashboardWidgetA11y("goal-tracker"); - - useEffect(() => { - setIsUpdating(loading); - }, [loading, setIsUpdating]); - - useEffect(() => { - const activeCount = goals.filter((goal) => goal.current < goal.target).length; - const completedCount = goals.filter( - (goal) => goal.current >= goal.target, - ).length; - setSummary( - `${activeCount} active goal${activeCount === 1 ? "" : "s"}. ${completedCount} completed.`, - ); - }, [goals, setSummary]); - - const { data: session } = useSession(); - - const githubLogin = - 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); - - try { - const response = await fetch(`/api/goals/${goalId}`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ is_public: nextValue }), - }); - - if (!response.ok) { - setShareError("Failed to update goal sharing. Please try again."); - return; - } - - const data: { goal: Goal } = await response.json(); - - setGoals((currentGoals) => - currentGoals.map((goal) => (goal.id === data.goal.id ? data.goal : goal)) - ); - } catch { - setShareError("Failed to update goal sharing. Please check your connection."); - } finally { - setSharingGoalId(null); - } - }; - - 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 (loading) { + return ( +
+
+ Loading weekly goals - 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."); - } - }; +