Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 28 additions & 5 deletions src/app/api/goals/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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;
Expand Down
50 changes: 32 additions & 18 deletions src/app/api/metrics/streak/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>();
// 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<string>();
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;
Expand Down
Loading