diff --git a/e2e/dashboard-widgets.spec.js b/e2e/dashboard-widgets.spec.js index a8d8ac8ac..40ab65620 100644 --- a/e2e/dashboard-widgets.spec.js +++ b/e2e/dashboard-widgets.spec.js @@ -124,7 +124,7 @@ test("dashboard widgets render with mocked metrics", async ({ page }) => { await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible({ timeout: 30000 }); await expect(page.getByRole("heading", { name: "Your Commits" })).toBeVisible({ timeout: 10000 }); await expect(page.getByRole("heading", { name: "PR Analytics" })).toBeVisible({ timeout: 10000 }); - await expect(page.getByRole("heading", { name: "Weekly Goals" })).toBeVisible({ timeout: 10000 }); + await expect(page.getByRole("heading", { name: "Goals" })).toBeVisible({ timeout: 10000 }); await expect(page.getByText("Make 10 commits")).toBeVisible({ timeout: 10000 }); }); @@ -155,14 +155,14 @@ test("goal form posts a new goal", async ({ page }) => { await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible({ timeout: 30000 }); await page.getByLabel("Goal title").fill("Ship one PR"); await page.getByLabel("Target").fill("1"); - await page.getByLabel("Unit").fill("PR"); + await page.getByLabel("Unit").selectOption("prs"); await page.getByRole("button", { name: "Add goal" }).click(); await expect.poll(() => goalPosts, { timeout: 15000 }).toHaveLength(1); expect(goalPosts[0]).toMatchObject({ title: "Ship one PR", target: 1, - unit: "PR", + unit: "prs", }); }); diff --git a/src/app/api/goals/route.ts b/src/app/api/goals/route.ts index 469e6dd35..6396b30a4 100644 --- a/src/app/api/goals/route.ts +++ b/src/app/api/goals/route.ts @@ -179,4 +179,4 @@ try { if (error) return Response.json({ error: error.message }, { status: 500 }); return Response.json({ goal }, { status: 201 }); -} \ No newline at end of file +} diff --git a/src/app/api/goals/sync/route.ts b/src/app/api/goals/sync/route.ts new file mode 100644 index 000000000..7a2586253 --- /dev/null +++ b/src/app/api/goals/sync/route.ts @@ -0,0 +1,109 @@ +import { getServerSession } from "next-auth"; +import { authOptions } from "@/lib/auth"; +import { supabaseAdmin } from "@/lib/supabase"; + +export const dynamic = "force-dynamic"; + +/** + * Returns Monday 00:00:00 local time of the current week as a full ISO string. + * + * Fix for Bug 2 (Sunday + timezone): + * - Uses `const diff = day === 0 ? -6 : 1 - day` so Sunday correctly resolves + * to the *previous* Monday, not the *next* one. + * - Returns a full ISO timestamp instead of `.slice(0, 10)` to avoid the UTC + * date-shift bug (matching the approach already used in `getPeriodStart()`). + */ +function currentWeekStart(): string { + const now = new Date(); + const day = now.getUTCDay(); + const diff = day === 0 ? -6 : 1 - day; // Monday = 0 offset; Sunday = -6 + const monday = new Date(now); + monday.setUTCDate(now.getUTCDate() + diff); + monday.setUTCHours(0, 0, 0, 0); + return monday.toISOString(); +} + +/** Returns Sunday 23:59:59.999 of the current week as a full ISO string. */ +function currentWeekEnd(): string { + const now = new Date(); + const day = now.getUTCDay(); + const diff = day === 0 ? 0 : 7 - day; // Sunday of this week + const sunday = new Date(now); + sunday.setUTCDate(now.getUTCDate() + diff); + sunday.setUTCHours(23, 59, 59, 999); + return sunday.toISOString(); +} + +const GITHUB_API = "https://api.github.com"; + +export async function POST() { + const session = await getServerSession(authOptions); + if (!session?.accessToken || !session.githubId || !session.githubLogin) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + + // ── 1. Fetch user from DB ───────────────────────────────────────────────── + const { data: user } = await supabaseAdmin + .from("users") + .select("id") + .eq("github_id", session.githubId) + .single(); + + if (!user) return Response.json({ error: "User not found" }, { status: 404 }); + + const weekStart = currentWeekStart(); + const weekEnd = currentWeekEnd(); + + // ── 2. Fetch all commit-based goals for this week ───────────────────────── + // Fix for Bug 1: column is `period_start` (full ISO timestamp), not `week_start`. + // Use a range filter (.gte / .lte) instead of string equality. + const { data: commitGoals, error: goalsError } = await supabaseAdmin + .from("goals") + .select("id") + .eq("user_id", user.id) + .eq("unit", "commits") + .gte("period_start", weekStart) + .lte("period_start", weekEnd); + + if (goalsError) { + return Response.json({ error: "Failed to fetch goals" }, { status: 500 }); + } + + if (!commitGoals || commitGoals.length === 0) { + return Response.json({ updated: 0, commitCount: 0 }); + } + + // ── 3. Count commits for the current week from GitHub ──────────────────── + const ghRes = await fetch( + `${GITHUB_API}/search/commits?q=author:${session.githubLogin}+author-date:${weekStart}..${weekEnd}&per_page=100`, + { + headers: { + Authorization: `Bearer ${session.accessToken}`, + Accept: "application/vnd.github+json", + }, + cache: "no-store", + } + ); + + if (!ghRes.ok) { + return Response.json({ error: "GitHub API error" }, { status: 502 }); + } + + const ghData = (await ghRes.json()) as { total_count: number }; + const commitCount = ghData.total_count; + + // ── 4. Update all commit-based goals with the real commit count ─────────── + const now = new Date().toISOString(); + const ids = commitGoals.map((g) => g.id); + + const { error: updateError } = await supabaseAdmin + .from("goals") + .update({ current: commitCount, last_synced_at: now }) + .in("id", ids); + + if (updateError) { + return Response.json({ error: "Failed to update goals" }, { status: 500 }); + } + + return Response.json({ updated: ids.length, commitCount }); +} diff --git a/src/app/api/metrics/contributions/route.ts b/src/app/api/metrics/contributions/route.ts index 7b451d5c7..c88d2ae24 100644 --- a/src/app/api/metrics/contributions/route.ts +++ b/src/app/api/metrics/contributions/route.ts @@ -80,12 +80,7 @@ async function fetchContributionsForAccount( since.setDate(since.getDate() - days); const sinceStr = fromDate ?? toLocalDateStr(since); - let allItems: Array<{ - sha: string; - html_url: string; - repository?: { full_name: string }; - commit: { author: { date: string }; message: string }; - }> = []; + let allItems: GitHubCommitSearchItem[] = []; const commitItems: CommitItem[] = []; let totalCount = 0; let page = 1; @@ -122,12 +117,7 @@ async function fetchContributionsForAccount( const data = (await searchRes.json()) as { total_count: number; - items: Array<{ - sha: string; - html_url: string; - repository?: { full_name: string }; - commit: { author: { date: string }; message: string }; - }>; + items: GitHubCommitSearchItem[]; }; if (page === 1) { diff --git a/src/app/dashboard/settings/page.tsx b/src/app/dashboard/settings/page.tsx index 0f50cb255..2c88f7ae1 100644 --- a/src/app/dashboard/settings/page.tsx +++ b/src/app/dashboard/settings/page.tsx @@ -313,7 +313,7 @@ function SettingsPageContent() { className={`mb-6 rounded-xl border p-4 text-sm ${ statusMessage.kind === "success" ? "border-green-500/30 bg-green-500/10 text-green-400" - : "border-red-500/30 bg-red-500/10 text-red-400" + : "border-[var(--destructive-muted-border)] bg-[var(--destructive-muted)] text-[var(--destructive)]" }`} > {statusMessage.message} @@ -492,7 +492,7 @@ function SettingsPageContent() { {removeError && ( -
+
{removeError}
)} @@ -532,7 +532,7 @@ function SettingsPageContent() { onClick={() => handleRemoveAccount(account.githubId)} aria-label={`Remove ${account.githubLogin}`} disabled={removingAccountId === account.githubId} - className="rounded-lg border border-[var(--border)] px-4 py-2 text-sm font-medium text-[var(--card-foreground)] transition-colors hover:bg-red-500/10 hover:text-red-400 disabled:opacity-60" + className="rounded-lg border border-[var(--border)] px-4 py-2 text-sm font-medium text-[var(--card-foreground)] transition-colors hover:bg-[var(--destructive-muted)] hover:text-[var(--destructive)] disabled:opacity-60" > {removingAccountId === account.githubId ? "Removing..." diff --git a/src/app/globals.css b/src/app/globals.css index ced876421..31a717656 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -21,6 +21,10 @@ --control-hover: #cbd5e1; --tooltip: #ffffff; --tooltip-foreground: #0f172a; + --destructive: #ef4444; + --destructive-muted: rgba(239, 68, 68, 0.1); + --destructive-muted-border: rgba(239, 68, 68, 0.3); + --destructive-foreground: #ffffff; } .dark { @@ -42,6 +46,10 @@ --control-hover: #475569; --tooltip: #1e293b; --tooltip-foreground: #f8fafc; + --destructive: #f87171; + --destructive-muted: rgba(248, 113, 113, 0.1); + --destructive-muted-border: rgba(248, 113, 113, 0.3); + --destructive-foreground: #ffffff; } body { diff --git a/src/components/GoalTracker.tsx b/src/components/GoalTracker.tsx index 36633792d..da1f32323 100644 --- a/src/components/GoalTracker.tsx +++ b/src/components/GoalTracker.tsx @@ -12,6 +12,7 @@ interface Goal { unit: string; recurrence: Recurrence; period_start: string; + last_synced_at: string | null; } const RECURRENCE_LABELS: Record = { @@ -23,6 +24,8 @@ const RECURRENCE_LABELS: Record = { export default function GoalTracker() { const [goals, setGoals] = useState([]); const [loading, setLoading] = useState(true); + const [syncing, setSyncing] = useState(false); + const [syncError, setSyncError] = useState(null); const [lastUpdated, setLastUpdated] = useState(null); const [minutesAgo, setMinutesAgo] = useState(0); const [title, setTitle] = useState(""); @@ -42,18 +45,64 @@ export default function GoalTracker() { const loadGoals = useCallback(async () => { const response = await fetch("/api/goals"); const data: { goals: Goal[] } = await response.json(); - setGoals(data.goals ?? []); + 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" }); + if (!res.ok) { + let msg = "Sync failed. Please try again."; + try { + 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) { + msg = "GitHub sync failed: Expired token or missing repo scope."; + } + setSyncError(msg); + return; + } + await loadGoals(); + setLastUpdated(new Date()); + setMinutesAgo(0); + } 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) => { + 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 + }); + if (needsSync) { + await handleSync(); + } + }) .catch(() => {}) .finally(() => { setLoading(false); setLastUpdated(new Date()); setMinutesAgo(0); }); - }, [loadGoals]); + }, [loadGoals, handleSync]); async function handleCreate(e: React.FormEvent) { e.preventDefault(); @@ -80,7 +129,13 @@ export default function GoalTracker() { setTarget(7); setUnit("commits"); setRecurrence("none"); - await loadGoals().catch(() => {}); + + // Immediately sync if it was a commit-based goal + if (unit === "commits") { + await handleSync(); + } else { + await loadGoals().catch(() => {}); + } setCreating(false); } @@ -181,8 +236,49 @@ export default function GoalTracker() { return (
-

Weekly Goals

+ {/* ── Header ── */} +
+

Goals

+ +
+ + {/* Sync Error */} + {syncError && ( +
+ ⚠️ {syncError} + +
+ )} + {/* Delete Error */} {deleteError && (
= goal.target; const completionLabel = getCompletionLabel(goal); + const isAutoSynced = goal.unit === "commits"; return (
  • {activeConfettiGoalId === goal.id && }
    -
    +
    {goal.title} {goal.recurrence !== "none" && ( )} + {isAutoSynced && ( + + + Auto-synced + + )}
    {completed && ( @@ -245,37 +363,31 @@ export default function GoalTracker() { {goal.current}/{goal.target} {goal.unit} - + {/* Manual +1 only for non-auto-synced goals */} + {!isAutoSynced && ( + + )} {isConfirming ? ( @@ -305,7 +417,7 @@ export default function GoalTracker() { aria-label={`Delete goal: ${goal.title}`} title="Delete goal" > - + @@ -369,15 +481,17 @@ export default function GoalTracker() { -
    @@ -410,6 +524,12 @@ export default function GoalTracker() { )}
    + {unit === "commits" && ( +

    + ⚡ This goal will auto-update from your GitHub commit count each period. +

    + )} +
  • - ) : ( + ) : activeTab === "authored" ? (
    {/* Stat grid */}
    @@ -249,9 +249,7 @@ export default function PRMetrics() {
    )}
    - )} - {/* Reviews Given Tab */} - {!loading && !error && activeTab === "reviews" && ( + ) : (
    {[ diff --git a/src/components/PinnedRepos.tsx b/src/components/PinnedRepos.tsx index 8e936e18f..e42d678eb 100644 --- a/src/components/PinnedRepos.tsx +++ b/src/components/PinnedRepos.tsx @@ -60,12 +60,12 @@ export default function PinnedRepos() { ))}
    ) : error ? ( -
    +

    {error}

    diff --git a/supabase/migrations/20260519000000_add_goal_unit_and_sync.sql b/supabase/migrations/20260519000000_add_goal_unit_and_sync.sql new file mode 100644 index 000000000..5be464636 --- /dev/null +++ b/supabase/migrations/20260519000000_add_goal_unit_and_sync.sql @@ -0,0 +1,7 @@ +-- Migration: add unit and last_synced_at to goals +-- unit: 'commits' | 'prs' | 'hours' — only 'commits' triggers auto-progress +-- last_synced_at: set whenever the sync route updates this goal's current value + +alter table goals + add column if not exists unit text not null default 'commits', + add column if not exists last_synced_at timestamptz;