diff --git a/src/app/api/goals/route.ts b/src/app/api/goals/route.ts index 1717629..592a64d 100644 --- a/src/app/api/goals/route.ts +++ b/src/app/api/goals/route.ts @@ -9,15 +9,21 @@ type Recurrence = "none" | "weekly" | "monthly"; function getPeriodStart(recurrence: Recurrence): string { const now = new Date(); if (recurrence === "weekly") { - const day = now.getDay(); + // Use UTC methods so the Monday boundary is the same regardless of the + // server's local timezone. getDay() / setDate() / setHours() all operate + // in local time, which can push the reset boundary a day early or late + // on servers that are not running in UTC. + const day = now.getUTCDay(); const diff = day === 0 ? -6 : 1 - day; // Monday const monday = new Date(now); - monday.setDate(now.getDate() + diff); - monday.setHours(0, 0, 0, 0); + monday.setUTCDate(now.getUTCDate() + diff); + monday.setUTCHours(0, 0, 0, 0); return monday.toISOString(); } if (recurrence === "monthly") { - return new Date(now.getFullYear(), now.getMonth(), 1, 0, 0, 0, 0).toISOString(); + // Date.UTC avoids the local-timezone offset that the Date constructor + // applies when month/day/hour arguments are passed directly. + return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)).toISOString(); } return new Date(0).toISOString(); // 'none' never resets } @@ -53,13 +59,30 @@ export async function GET() { : new Date(0); if (storedPeriodStart < periodStart) { + // Use a conditional update that only succeeds when the DB row still + // has the old period_start. If two concurrent GET requests both see + // a stale period_start and race to reset the goal, only one update + // will match the lt() filter — the second finds no row and returns + // null, after which we re-fetch the already-reset row to avoid + // silently zeroing out any progress written between the two reads. const { data: updated } = await supabaseAdmin .from("goals") .update({ current: 0, period_start: periodStart.toISOString() }) .eq("id", goal.id) + .lt("period_start", periodStart.toISOString()) .select() .single(); - return updated ?? { ...goal, current: 0, period_start: periodStart.toISOString() }; + + if (updated) return updated; + + // Another concurrent request already reset this goal — re-fetch + // the current state so we return accurate data without clobbering it. + const { data: current } = await supabaseAdmin + .from("goals") + .select("*") + .eq("id", goal.id) + .single(); + return current ?? goal; } return goal; diff --git a/src/app/api/metrics/streak/route.ts b/src/app/api/metrics/streak/route.ts index ae35df7..fe01d11 100644 --- a/src/app/api/metrics/streak/route.ts +++ b/src/app/api/metrics/streak/route.ts @@ -25,28 +25,42 @@ async function fetchActiveDates( since.setDate(since.getDate() - 90); const sinceStr = since.toISOString().slice(0, 10); - const searchRes = await fetch( - `${GITHUB_API}/search/commits?q=author:${githubLogin}+author-date:>=${sinceStr}&per_page=100&sort=author-date&order=desc`, - { - headers: { - Authorization: `Bearer ${token}`, - Accept: "application/vnd.github+json", - }, - cache: "no-store", + const activeDates = new Set(); + // Paginate through all results. GitHub Search API caps responses at 100 + // items per page and 1000 items total (10 pages). Without pagination, + // active users with more than 100 commits in the window have their oldest + // commits silently dropped, introducing phantom gaps that shorten the + // calculated streak. + let page = 1; + while (true) { + const searchRes = await fetch( + `${GITHUB_API}/search/commits?q=author:${githubLogin}+author-date:>=${sinceStr}&per_page=100&page=${page}&sort=author-date&order=desc`, + { + headers: { + Authorization: `Bearer ${token}`, + Accept: "application/vnd.github+json", + }, + cache: "no-store", + } + ); + + if (!searchRes.ok) { + throw new Error("GitHub API error"); } - ); - if (!searchRes.ok) { - throw new Error("GitHub API error"); - } + const data = (await searchRes.json()) as { + items: Array<{ commit: { author: { date: string } } }>; + }; - const data = (await searchRes.json()) as { - items: Array<{ commit: { author: { date: string } } }>; - }; + for (const item of data.items) { + activeDates.add(item.commit.author.date.slice(0, 10)); + } - const activeDates = new Set(); - for (const item of data.items) { - activeDates.add(item.commit.author.date.slice(0, 10)); + // Stop when the page is not full (last page) or we reach GitHub's + // 1000-item hard cap (page 10). Since we only need unique dates, + // 90 days is the theoretical maximum we will ever collect. + if (data.items.length < 100 || page >= 10) break; + page++; } return activeDates;