From 523a13559786d71d66ebc7b764a19ae8a360a597 Mon Sep 17 00:00:00 2001 From: anshul23102 Date: Tue, 19 May 2026 07:50:36 +0530 Subject: [PATCH 1/2] fix(goals): prevent race condition on period reset and fix UTC boundary Two bugs in the GET /api/goals handler caused goal progress to be lost incorrectly at period boundaries. Bug 1 - Race condition on period reset: Promise.all fires all goal-reset DB writes concurrently. When two requests arrive simultaneously (two browser tabs, mobile + desktop), both read the same stale period_start, both decide a reset is needed, and both issue UPDATE current=0. Any progress written between the two reads is silently zeroed by the second update. Fixed by adding .lt("period_start", periodStart) to the update so only one concurrent write wins. The losing request re-fetches the current row instead of returning a stale or double-zeroed value. Bug 2 - Local timezone used for period boundary calculation: getPeriodStart used getDay()/setDate()/setHours() which all operate in the server's local timezone. On servers not running in UTC the weekly Monday boundary and monthly first-of-month boundary shift by the UTC offset, resetting goals a day early or late for affected users. Fixed by switching to getUTCDay()/setUTCDate()/setUTCHours() and Date.UTC() so the boundary is always calculated in UTC regardless of server timezone. --- src/app/api/goals/route.ts | 33 ++++++++++++++++++++++++++++----- 1 file changed, 28 insertions(+), 5 deletions(-) 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; From 394b06906b35522d14a41c1d8b467b315bd10807 Mon Sep 17 00:00:00 2001 From: anshul23102 Date: Tue, 19 May 2026 08:33:24 +0530 Subject: [PATCH 2/2] fix(streak): paginate GitHub commit search to avoid 100-result truncation fetchActiveDates fetched only the first page (per_page=100) of the GitHub Search API. Users with more than 100 commits in the 90-day window had their oldest commits silently dropped, introducing phantom gaps in the active-date set and causing the streak calculator to report a shorter streak than the user actually has. Fix: loop through pages until the response has fewer than 100 items (last page) or page 10 is reached (GitHub Search API hard cap of 1000 results). The early-exit on a partial page avoids an extra round-trip in the common case. totalActiveDays and longestStreak are also corrected as a side-effect. --- src/app/api/metrics/streak/route.ts | 50 ++++++++++++++++++----------- 1 file changed, 32 insertions(+), 18 deletions(-) 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;