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 && ( -
+ ⚡ This goal will auto-update from your GitHub commit count each period. +
+ )} +{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;