From 523a13559786d71d66ebc7b764a19ae8a360a597 Mon Sep 17 00:00:00 2001 From: anshul23102 Date: Tue, 19 May 2026 07:50:36 +0530 Subject: [PATCH] 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;