Skip to content
Open
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
35 changes: 35 additions & 0 deletions api/cards/streak-stats.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { changToNextGitHubToken } from "../utils/github-token-updater"
import { getErrorMsgCard } from "../utils/error-card"
import type { VercelRequest, VercelResponse } from "@vercel/node"
import {getStreakCardSVGWithThemeName} from "../../src/cards/streak-cards"

export default async (req: VercelRequest, res: VercelResponse) => {
const { username, theme = "default" } = req.query
if (typeof theme !== "string") {
res.status(400).send("theme must be a string")
return
}
if (typeof username !== "string") {
res.status(400).send("username must be a string")
return
}
try {
let tokenIndex = 0
while (true) {
try {
const cardSVG = await getStreakCardSVGWithThemeName(username, theme)
res.setHeader("Content-Type", "image/svg+xml")
res.send(cardSVG)
return
} catch (err: any) {
console.log(err.message)
// We update github token and try again, until getNextGitHubToken throw an Error
changToNextGitHubToken(tokenIndex)
tokenIndex += 1
}
}
Comment on lines +16 to +30
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

Avoid token rotation for non-auth errors (e.g., invalid theme).
Short-circuit when the error is not recoverable by changing tokens.

   try {
     let tokenIndex = 0
     while (true) {
       try {
         const cardSVG = await getStreakCardSVGWithThemeName(username, theme)
         res.setHeader("Content-Type", "image/svg+xml")
         res.send(cardSVG)
         return
       } catch (err: any) {
         console.log(err.message)
-        // We update github token and try again, until getNextGitHubToken throw an Error
+        // If theme is invalid or other non-auth errors, don't rotate tokens endlessly.
+        if (err?.message && /Theme does not exist/i.test(err.message)) {
+          throw err;
+        }
+        // We update GitHub token and try again, until the updater throws.
         changToNextGitHubToken(tokenIndex)
         tokenIndex += 1
       }
     }

🏁 Script executed:

#!/bin/bash
# Compare with other API routes to align headers and retry patterns
rg -nC2 --type=ts "export default async .*VercelRequest.*VercelResponse" api/cards
rg -nC2 --type=ts "Content-Type.*image/svg\\+xml" api/cards
rg -nC2 --type=ts "getErrorMsgCard\\(" api/cards

Length of output: 6175


I need to examine the full context of the streak-stats.ts file and verify the token rotation pattern across other routes.


---

🏁 Script executed:

```shell
#!/bin/bash
# Get full content of streak-stats.ts to see complete retry logic
cat api/cards/streak-stats.ts

# Search for token rotation patterns in other routes
rg -n "changToNextGitHubToken|getNextGitHubToken" api/cards/

# Check what happens on theme validation failure
rg -n "Theme does not exist|theme.*invalid|typeof theme" api/cards/ --type=ts

Length of output: 3415


I need to examine what errors getStreakCardSVGWithThemeName throws and verify if the review comment's suggestion applies universally or is specific to streak-stats.


---

🏁 Script executed:

```shell
#!/bin/bash
# Find and examine getStreakCardSVGWithThemeName implementation
rg -n "getStreakCardSVGWithThemeName|export.*getStreakCardSVG" src/cards/ --type=ts -A5

# Check if other routes have different error handling patterns
rg -n "if.*err.*message.*test\|if.*err.*code" api/cards/ --type=ts

# Verify the structure of error catching in other routes
rg -n "catch.*err" api/cards/stats.ts -A8 --type=ts

Length of output: 920


Token rotation for non-recoverable errors should be prevented across all card routes.

The review comment correctly identifies that getStreakCardSVGWithThemeName throws "Theme does not exist" (line 19 of src/cards/streak-cards.ts) for invalid themes—a non-recoverable validation error that token rotation cannot fix. The suggested diff is appropriate for streak-stats.ts.

However, this pattern exists identically in all card API routes (stats.ts, repos-per-language.ts, productive-time.ts, most-commit-language.ts, profile-details.ts). Each route calls a similar SVG generation function that validates the theme name and throws the same type of error. Apply the same fix to all routes to prevent infinite token rotation for invalid inputs.

🤖 Prompt for AI Agents
In api/cards/streak-stats.ts around lines 16–30, the catch currently always
rotates the GitHub token even when getStreakCardSVGWithThemeName throws a
non-recoverable validation error ("Theme does not exist"); change the catch to
inspect the error and if it indicates an invalid theme (e.g., message === "Theme
does not exist" or a validation error code) rethrow/send the error response
immediately without calling changeToNextGitHubToken, otherwise perform token
rotation as before; apply the identical change to the other card routes
(api/cards/stats.ts, repos-per-language.ts, productive-time.ts,
most-commit-language.ts, profile-details.ts) so invalid theme errors never
trigger token rotation across all card endpoints.

} catch (err: any) {
console.log(err)
res.send(getErrorMsgCard(err.message, theme))
}
Comment on lines +31 to +34
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Set SVG Content-Type on error responses.
Clients expect image/svg+xml even for error cards.

-  } catch (err: any) {
-    console.log(err)
-    res.send(getErrorMsgCard(err.message, theme))
-  }
+  } catch (err: any) {
+    console.log(err);
+    res.setHeader("Content-Type", "image/svg+xml");
+    res.send(getErrorMsgCard(err.message, theme));
+  }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
} catch (err: any) {
console.log(err)
res.send(getErrorMsgCard(err.message, theme))
}
} catch (err: any) {
console.log(err);
res.setHeader("Content-Type", "image/svg+xml");
res.send(getErrorMsgCard(err.message, theme));
}
🤖 Prompt for AI Agents
In api/cards/streak-stats.ts around lines 31 to 34, the catch block currently
logs the error and sends an error card without setting the response
Content-Type; update the catch to set the response header Content-Type to
"image/svg+xml" (e.g., res.type('image/svg+xml') or
res.setHeader('Content-Type', 'image/svg+xml')) before calling
res.send(getErrorMsgCard(...)) so clients always receive an SVG MIME type for
error cards.

}
16 changes: 16 additions & 0 deletions api/pages/demo.html
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,20 @@
</v-btn>
</v-col>
</v-row>
<!-- Added Streak Stats Card -->
<v-row justify="center" no-gutters>
<v-col cols="10">
<v-card outlined>
<v-img :src="streakStatsSource"></v-img>
<v-card-text class="text--primary">
<div>Markdown Usage</div>
<code>
![]({{ streakStatsSource }})
</code>
</v-card-text>
</v-card>
</v-col>
</v-row>
<v-row justify="center" no-gutters>
<v-col cols="10">
<v-card outlined>
Expand Down Expand Up @@ -240,6 +254,7 @@
themes: ['default'],
username: 'vn7n24fzkq',
theme: 'default',
streakStatsSource: '',
profileDetailSource: '',
repoLanguageSource: '',
commitLanguageSource: '',
Expand All @@ -265,6 +280,7 @@
this.theme = theme;
},
updateAllCards: function () {
this.streakStatsSource = `${this.baseURL}/api/cards/streak-stats?username=${this.username}&theme=${this.theme}`;
this.profileDetailSource = `${this.baseURL}/api/cards/profile-details?username=${this.username}&theme=${this.theme}`;
this.repoLanguageSource = `${this.baseURL}/api/cards/repos-per-language?username=${this.username}&theme=${this.theme}`;
this.commitLanguageSource = `${this.baseURL}/api/cards/most-commit-language?username=${this.username}&theme=${this.theme}`;
Expand Down
2 changes: 2 additions & 0 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {createProductiveTimeCard} from './cards/productive-time-card';
import {spawn} from 'child_process';
import {translateLanguage} from './utils/translator';
import {OUTPUT_PATH, generatePreviewMarkdown} from './utils/file-writer';
import { createStreakCardForUser } from './cards/streak-cards';

const execCmd = (cmd: string, args: string[] = []) =>
new Promise((resolve, reject) => {
Expand Down Expand Up @@ -128,6 +129,7 @@ const main = async (username: string, utcOffset: number, exclude: Array<string>)
await createCommitsPerLanguageCard(username, exclude);
await createStatsCard(username);
await createProductiveTimeCard(username, utcOffset);
await createStreakCardForUser(username)
generatePreviewMarkdown(false);
} catch (error: any) {
console.error(error);
Expand Down
61 changes: 61 additions & 0 deletions src/cards/streak-cards.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { ThemeMap } from "../const/theme"
import { createStreakCard, type StreakData } from "../templates/streak-card"

Check failure on line 2 in src/cards/streak-cards.ts

View workflow job for this annotation

GitHub Actions / lint

Parsing error: ',' expected
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Address the parsing error for type-only import syntax.

The static analysis tool reports a parsing error on the type-only import. This suggests the linter or TypeScript configuration may not support TypeScript 3.8+ syntax.

Option 1: Update TypeScript/ESLint configuration to support type-only imports (recommended if using TypeScript 3.8+):

Ensure your tsconfig.json has appropriate compiler options and your ESLint parser is up to date.

Option 2: Use separate import statement if you cannot update the configuration:

-import { createStreakCard, type StreakData } from "../templates/streak-card"
+import { createStreakCard } from "../templates/streak-card"
+import type { StreakData } from "../templates/streak-card"
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
import { createStreakCard, type StreakData } from "../templates/streak-card"
import { createStreakCard } from "../templates/streak-card"
import type { StreakData } from "../templates/streak-card"
🧰 Tools
🪛 GitHub Check: lint

[failure] 2-2:
Parsing error: ',' expected

🤖 Prompt for AI Agents
In src/cards/streak-cards.ts around line 2 the type-only import "import {
createStreakCard, type StreakData } ..." triggers a parser error because the
tooling doesn't support TypeScript 3.8+ "import type" syntax; either update the
project to a TS/ESLint parser that supports type-only imports (upgrade
TypeScript to >=3.8, set "moduleResolution"/"jsx"/target appropriately in
tsconfig.json and ensure @typescript-eslint/parser is current in ESLint) or
replace the single type-only syntax with compatible imports (split into two
lines or import StreakData as a normal import) so the file parses without
changing runtime behavior.

import { writeSVG } from "../utils/file-writer"
import { calculateStreaks } from "../github-api/streak-calculator"
import { getContributionByYear } from "../github-api/contributions-by-year"
import { getProfileDetails } from "../github-api/profile-details"

export const createStreakCardForUser = async (username: string) => {
const streakData = await getStreakCardData(username)
for (const themeName of ThemeMap.keys()) {
const title = ``
const svgString = getStreakCardSVG(title, streakData, themeName)
// output to folder, use 1- prefix for sort in preview
writeSVG(themeName, "1-streak-card", svgString)
}
}

export const getStreakCardSVGWithThemeName = async (username: string, themeName: string): Promise<string> => {
if (!ThemeMap.has(themeName)) throw new Error("Theme does not exist")
const streakData = await getStreakCardData(username)
const title = ``
return getStreakCardSVG(title, streakData, themeName)
}

const getStreakCardSVG = (title: string, streakData: StreakData, themeName: string): string => {
const svgString = createStreakCard(title, streakData, ThemeMap.get(themeName)!)
return svgString
}

const getStreakCardData = async (username: string): Promise<StreakData> => {
const profileDetails = await getProfileDetails(username)

// Calculate total contributions
let totalContributions = 0
if (process.env.VERCEL_I) {
profileDetails.contributionYears = profileDetails.contributionYears.slice(0, 1)
for (const year of profileDetails.contributionYears) {
totalContributions += (await getContributionByYear(username, year)).totalContributions
}
} else {
for (const year of profileDetails.contributionYears) {
totalContributions += (await getContributionByYear(username, year)).totalContributions
}
}
Comment on lines +35 to +44
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Critical: VERCEL_I logic produces incorrect total contributions.

When VERCEL_I is set, the code slices contributionYears to only the first year (line 36), which means totalContributions will only include contributions from the most recent year, not the user's entire history. This breaks the accuracy of the "Total Contributions" metric displayed on the streak card.

Additionally, the for loop logic is duplicated in both branches—the only difference is the slicing on line 36.

If the intent is to limit API calls on Vercel for performance/rate-limiting reasons, consider one of these approaches:

Option 1: Remove the VERCEL_I logic if accurate data is more important than performance:

-  let totalContributions = 0
-  if (process.env.VERCEL_I) {
-    profileDetails.contributionYears = profileDetails.contributionYears.slice(0, 1)
-    for (const year of profileDetails.contributionYears) {
-      totalContributions += (await getContributionByYear(username, year)).totalContributions
-    }
-  } else {
-    for (const year of profileDetails.contributionYears) {
-      totalContributions += (await getContributionByYear(username, year)).totalContributions
-    }
-  }
+  let totalContributions = 0
+  for (const year of profileDetails.contributionYears) {
+    totalContributions += (await getContributionByYear(username, year)).totalContributions
+  }

Option 2: Use caching to avoid recalculating on every request while maintaining accuracy.

Option 3: Clearly document the limitation and perhaps rename the metric to "Recent Contributions" when VERCEL_I is active, so users understand it's not the full history.

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In src/cards/streak-cards.ts around lines 35 to 44, the VERCEL_I branch slices
contributionYears to a single year causing totalContributions to only include
the most recent year and the loop is duplicated; fix by removing the slice so
the loop always iterates all contributionYears and deduplicate the loop (call
getContributionByYear for each year once), or if you must limit API calls under
VERCEL_I then explicitly choose a recent-years subset (e.g., last N years) and
also change the metric label to "Recent Contributions" or implement caching so
the full-history total can be returned without repeated API calls.


// Calculate streaks from contribution data
const streakInfo = calculateStreaks(profileDetails.contributions)

const streakData: StreakData = {
currentStreak: streakInfo.currentStreak,
currentStreakStart: streakInfo.currentStreakStart,
currentStreakEnd: streakInfo.currentStreakEnd,
maxStreak: streakInfo.maxStreak,
maxStreakStart: streakInfo.maxStreakStart,
maxStreakEnd: streakInfo.maxStreakEnd,
totalContributions: totalContributions,
joinedDate: new Date(profileDetails.createdAt),
}

return streakData
}
119 changes: 119 additions & 0 deletions src/github-api/streak-calculator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import moment from "moment"

Check failure on line 1 in src/github-api/streak-calculator.ts

View workflow job for this annotation

GitHub Actions / lint

Replace `"moment"` with `'moment';`
import type { ProfileContribution } from "./profile-details"

Check failure on line 2 in src/github-api/streak-calculator.ts

View workflow job for this annotation

GitHub Actions / lint

Replace `·ProfileContribution·}·from·"./profile-details"` with `ProfileContribution}·from·'./profile-details';`

export interface StreakInfo {
currentStreak: number

Check failure on line 5 in src/github-api/streak-calculator.ts

View workflow job for this annotation

GitHub Actions / lint

Replace `··currentStreak:·number` with `····currentStreak:·number;`
currentStreakStart: Date

Check failure on line 6 in src/github-api/streak-calculator.ts

View workflow job for this annotation

GitHub Actions / lint

Replace `··currentStreakStart:·Date` with `····currentStreakStart:·Date;`
currentStreakEnd: Date

Check failure on line 7 in src/github-api/streak-calculator.ts

View workflow job for this annotation

GitHub Actions / lint

Replace `currentStreakEnd:·Date` with `··currentStreakEnd:·Date;`
maxStreak: number

Check failure on line 8 in src/github-api/streak-calculator.ts

View workflow job for this annotation

GitHub Actions / lint

Replace `maxStreak:·number` with `··maxStreak:·number;`
maxStreakStart: Date

Check failure on line 9 in src/github-api/streak-calculator.ts

View workflow job for this annotation

GitHub Actions / lint

Replace `maxStreakStart:·Date` with `··maxStreakStart:·Date;`
maxStreakEnd: Date

Check failure on line 10 in src/github-api/streak-calculator.ts

View workflow job for this annotation

GitHub Actions / lint

Replace `maxStreakEnd:·Date` with `··maxStreakEnd:·Date;`
}

/**

Check failure on line 13 in src/github-api/streak-calculator.ts

View workflow job for this annotation

GitHub Actions / lint

Missing JSDoc return type
* Calculate current and max streak from contribution data
* @param contributions Array of contribution data sorted by date
* @returns StreakInfo object with current and max streak information
*/
export function calculateStreaks(contributions: ProfileContribution[]): StreakInfo {
if (contributions.length === 0) {
return {
currentStreak: 0,
currentStreakStart: new Date(),
currentStreakEnd: new Date(),
maxStreak: 0,
maxStreakStart: new Date(),
maxStreakEnd: new Date(),
}
}

// Sort contributions by date ascending
const sorted = [...contributions].sort((a, b) => a.date.getTime() - b.date.getTime())

let currentStreak = 0
let maxStreak = 0
let currentStreakStart = sorted[0].date
let currentStreakEnd = sorted[0].date
let maxStreakStart = sorted[0].date
let maxStreakEnd = sorted[0].date
let tempStreakStart = sorted[0].date

// Iterate through contributions to find streaks
Comment on lines +30 to +41
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

🧩 Analysis chain

Timezone consistency (UTC vs server local).
Using moment() without utc/offset can shift day boundaries by server TZ. Consider UTC or a supplied offset, consistent with ProductiveTime.

-const sorted = [...contributions].sort((a, b) => a.date.getTime() - b.date.getTime())
+const sorted = [...contributions].sort((a, b) => a.date.getTime() - b.date.getTime());
+// Option: accept utcOffset and use moment.utc().utcOffset(utcOffset) for day boundaries.

🌐 Web query:

Does GitHub contribution "date" represent UTC midnight or viewer-local day? Verify to align day-boundary calculations.

💡 Result:

Short answer: GitHub's contributions calendar uses UTC day boundaries. Commits are counted by the commit's timestamp (which includes a timezone) and the profile/calendar itself is evaluated in UTC; pull requests and issues opened on GitHub use the viewer/browser timezone. [1][2]

What this means for day-boundary calculations

  • The contributions calendar groups activity by UTC dates (midnight UTC → next midnight UTC). Use UTC when mapping an event to a calendar day. [1]
  • For commits, GitHub uses the commit's author timestamp (including its timezone) to determine which UTC day the commit falls on — so a commit with a local timestamp will be converted to UTC for the calendar. [2]
  • For PRs/issues opened on GitHub.com, GitHub uses the browser's timezone when computing their local timestamp (API calls use the API-supplied timestamp/timezone). [2]

Practical recommendation

  • To match GitHub's calendar: convert each event's timestamp to UTC and bucket by UTC date (for commits use the commit author date; for PRs/issues opened via the web, consider the viewer/browser TZ if you need exact parity). [1][2]

Sources:
[1] GitHub Docs — Contributions on your profile / contributions graph (timestamps use UTC).
[2] GitHub Docs — Viewing contributions on your profile / How contribution event times are calculated (commit uses commit timestamp; PRs/issues use browser timezone/API timestamp).


I'll examine the streak-calculator.ts file and related code to understand the actual timezone handling:


🏁 Script executed:

#!/bin/bash
# Examine the full streak-calculator.ts file
cat src/github-api/streak-calculator.ts

Length of output: 3706


🏁 Script executed:

#!/bin/bash
# Search for ProductiveTime references
rg -n "ProductiveTime" -C 3

Length of output: 291339


🏁 Script executed:

#!/bin/bash
# Search for timezone-related handling (moment, utc, offset, timezone)
rg -n "(utc|offset|timezone|getTimezoneOffset)" -C 2 --type=ts

Length of output: 8742


🏁 Script executed:

#!/bin/bash
# Check how this streak-calculator function is called and what data is passed
rg -n "streak-calculator\|StreakCalculator\|calculateStreak" -C 3 --type=ts

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Look for where contributions data comes from (likely GitHub API responses)
rg -n "contributions.*date\|date.*contribution" -C 2 --type=ts

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Check package.json to see if moment.js is a dependency
cat package.json | grep -A 20 -B 5 "moment\|dependencies"

Length of output: 1127


Use UTC for day-boundary arithmetic (match GitHub contributions calendar)

File: src/github-api/streak-calculator.ts — calculateStreaks

GitHub groups contributions by UTC days; calculateStreaks currently uses moment(...) / moment().startOf("day") (server-local). Change day-boundary math to UTC or accept a utcOffset and apply it consistently. Minimal actionable fixes:

  • Replace diffs like moment(current.date).diff(moment(previous.date), "days") with moment.utc(current.date).diff(moment.utc(previous.date), "days").
  • Compute "today" with moment.utc().startOf("day") (or with moment.utc().utcOffset(utcOffset).startOf("day") if you add an utcOffset param).
  • Update any .startOf('day') and comparisons to use the same UTC/utcOffset approach and adjust the calculateStreaks signature if needed.
🤖 Prompt for AI Agents
In src/github-api/streak-calculator.ts around lines 30 to 41, the function uses
local-time day-boundary math; change all day-based moment calls to use UTC (or a
supplied utcOffset) consistently. Replace moment(...) and
moment().startOf("day") usages with moment.utc(...) and
moment.utc().startOf("day") (or moment.utc().utcOffset(utcOffset).startOf("day")
if you add an utcOffset parameter), change
moment(current.date).diff(moment(previous.date), "days") to
moment.utc(current.date).diff(moment.utc(previous.date), "days"), and ensure any
.startOf('day') and "today" calculations use the same UTC/utcOffset approach; if
adding utcOffset, update calculateStreaks signature and propagate it to all
moment calls.

for (let i = 0; i < sorted.length; i++) {
const current = sorted[i]
const previous = i > 0 ? sorted[i - 1] : null

// Check if this is a continuation of streak (has contributions)
if (current.contributionCount > 0) {
if (previous && current.contributionCount > 0) {
const dayDiff = moment(current.date).diff(moment(previous.date), "days")

// If consecutive days, continue streak
if (dayDiff === 1) {
currentStreak++
currentStreakEnd = current.date
} else {
// Streak broken, check if it's the max
if (currentStreak > maxStreak) {
maxStreak = currentStreak
maxStreakStart = tempStreakStart
maxStreakEnd = sorted[i - 1].date
}
currentStreak = 1
tempStreakStart = current.date
currentStreakStart = current.date
currentStreakEnd = current.date
}
Comment on lines +46 to +66
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Consecutive-day detection is incorrect (normalization + prev-day check).

  • Not normalizing dates before diff can yield 0-day gaps across midnight.
  • You don’t check previous.contributionCount > 0, so streaks can span zero-contribution days.
-    if (current.contributionCount > 0) {
-      if (previous && current.contributionCount > 0) {
-        const dayDiff = moment(current.date).diff(moment(previous.date), "days")
+    if (current.contributionCount > 0) {
+      if (previous && previous.contributionCount > 0) {
+        const dayDiff = moment(current.date).startOf("day")
+          .diff(moment(previous.date).startOf("day"), "days");
         // If consecutive days, continue streak
         if (dayDiff === 1) {
           currentStreak++
           currentStreakEnd = current.date
         } else {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Check if this is a continuation of streak (has contributions)
if (current.contributionCount > 0) {
if (previous && current.contributionCount > 0) {
const dayDiff = moment(current.date).diff(moment(previous.date), "days")
// If consecutive days, continue streak
if (dayDiff === 1) {
currentStreak++
currentStreakEnd = current.date
} else {
// Streak broken, check if it's the max
if (currentStreak > maxStreak) {
maxStreak = currentStreak
maxStreakStart = tempStreakStart
maxStreakEnd = sorted[i - 1].date
}
currentStreak = 1
tempStreakStart = current.date
currentStreakStart = current.date
currentStreakEnd = current.date
}
// Check if this is a continuation of streak (has contributions)
if (current.contributionCount > 0) {
if (previous && previous.contributionCount > 0) {
const dayDiff = moment(current.date).startOf("day")
.diff(moment(previous.date).startOf("day"), "days");
// If consecutive days, continue streak
if (dayDiff === 1) {
currentStreak++
currentStreakEnd = current.date
} else {
// Streak broken, check if it's the max
if (currentStreak > maxStreak) {
maxStreak = currentStreak
maxStreakStart = tempStreakStart
maxStreakEnd = sorted[i - 1].date
}
currentStreak = 1
tempStreakStart = current.date
currentStreakStart = current.date
currentStreakEnd = current.date
}
🤖 Prompt for AI Agents
In src/github-api/streak-calculator.ts around lines 46 to 66, the
consecutive-day detection is flawed: dates must be normalized to day boundaries
before computing diffs and you must ensure previous had contributions before
treating it as part of a streak. Normalize both current.date and previous.date
to startOf('day') (or equivalent) before calling diff and only compute dayDiff
when previous && previous.contributionCount > 0; use the normalized dayDiff ===
1 to continue the streak, otherwise treat it as a break and update max/current
streak variables as currently done (ensure any references to sorted[i-1].date
use the normalized date where appropriate).

} else if (!previous) {
// First day
currentStreak = 1
tempStreakStart = current.date
currentStreakStart = current.date
currentStreakEnd = current.date
}
} else {
// No contribution on this day, streak broken
if (currentStreak > 0 && currentStreak > maxStreak) {
maxStreak = currentStreak
maxStreakStart = tempStreakStart
maxStreakEnd = sorted[i - 1].date
}
currentStreak = 0
}
}

// Check final streak
if (currentStreak > maxStreak) {
maxStreak = currentStreak
maxStreakStart = tempStreakStart
maxStreakEnd = sorted[sorted.length - 1].date
}

// Calculate current streak from today backwards
const today = moment().startOf("day")
let actualCurrentStreak = 0
let actualCurrentStreakStart = today.toDate()
const actualCurrentStreakEnd = today.toDate()

for (let i = sorted.length - 1; i >= 0; i--) {
const current = sorted[i]
const currentMoment = moment(current.date).startOf("day")
const daysDiff = today.diff(currentMoment, "days")

if (daysDiff === actualCurrentStreak && current.contributionCount > 0) {
actualCurrentStreak++
actualCurrentStreakStart = current.date
} else if (daysDiff > actualCurrentStreak) {
break
}
}
Comment on lines +92 to +109
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

Current streak should end at last contributed day (not always “today”).
This reports 0 if there’s no contribution today. Compute from the last contribution day backwards.

-  // Calculate current streak from today backwards
-  const today = moment().startOf("day")
-  let actualCurrentStreak = 0
-  let actualCurrentStreakStart = today.toDate()
-  const actualCurrentStreakEnd = today.toDate()
-
-  for (let i = sorted.length - 1; i >= 0; i--) {
-    const current = sorted[i]
-    const currentMoment = moment(current.date).startOf("day")
-    const daysDiff = today.diff(currentMoment, "days")
-
-    if (daysDiff === actualCurrentStreak && current.contributionCount > 0) {
-      actualCurrentStreak++
-      actualCurrentStreakStart = current.date
-    } else if (daysDiff > actualCurrentStreak) {
-      break
-    }
-  }
+  // Calculate current streak from the last day with contributions backwards
+  let actualCurrentStreak = 0;
+  let actualCurrentStreakStart = sorted[sorted.length - 1].date;
+  let actualCurrentStreakEnd = sorted[sorted.length - 1].date;
+  const lastIdx = [...sorted].reverse().findIndex(c => c.contributionCount > 0);
+  if (lastIdx !== -1) {
+    const endIdx = sorted.length - 1 - lastIdx;
+    const endMoment = moment(sorted[endIdx].date).startOf("day");
+    actualCurrentStreakEnd = endMoment.toDate();
+    for (let i = endIdx; i >= 0; i--) {
+      const m = moment(sorted[i].date).startOf("day");
+      const diff = endMoment.diff(m, "days");
+      if (diff === actualCurrentStreak && sorted[i].contributionCount > 0) {
+        actualCurrentStreak++;
+        actualCurrentStreakStart = sorted[i].date;
+      } else if (diff > actualCurrentStreak) {
+        break;
+      }
+    }
+  } else {
+    actualCurrentStreakStart = new Date();
+    actualCurrentStreakEnd = new Date();
+  }

🏁 Script executed:

#!/bin/bash
# Quick sanity on edge cases:
# 1) No contributions today but had yesterday -> currentStreak should be >=1
# 2) Gap day in between -> currentStreak should stop at gap
cat <<'EOF' | node -e '
const moment = require("moment");
function js(d){ return new Date(d); }
const sorted = [
  {date: js("2025-10-15T12:00:00Z"), contributionCount: 1},
  {date: js("2025-10-16T12:00:00Z"), contributionCount: 1},
  {date: js("2025-10-17T12:00:00Z"), contributionCount: 1},
  {date: js("2025-10-18T12:00:00Z"), contributionCount: 0},
];
// Simulate your revised logic here if desired.
'
EOF

Length of output: 833


I'll inspect the actual source file to verify the issue and proposed fix.


---

🏁 Script executed:

```shell
#!/bin/bash
# Find and inspect the streak calculator file
fd "streak-calculator.ts" --exec cat {}

Length of output: 3706


🏁 Script executed:

#!/bin/bash
# Look for related test files
fd -e test.ts -e spec.ts -e test.js -e spec.js | xargs grep -l "streak" 2>/dev/null || true

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Check the full context around lines 92-109 in the file
fd "streak-calculator.ts" --exec head -150 {}

Length of output: 3706


🏁 Script executed:

#!/bin/bash
# Check if moment is available in package.json
cat package.json | head -50

Length of output: 1752


🏁 Script executed:

#!/bin/bash
# Search for any existing tests or usage of this streak calculator
rg -n "actualCurrentStreak|calculateCurrentStreak" --type=ts --type=js

Length of output: 858


Fix current streak calculation to start from last contribution day (not today).

The current code assumes today has a contribution. When the last contribution was yesterday or earlier, the condition daysDiff === actualCurrentStreak never triggers (since today's diff won't equal 0), causing the function to report currentStreak: 0 incorrectly.

The proposed fix correctly identifies the last day with contributions and uses it as the reference point instead of today. This properly handles:

  • No contribution today but streak exists (e.g., last contributed Oct 17, today Oct 18)
  • Gap days (streak stops at the gap)
  • Empty data (no contributions)
🤖 Prompt for AI Agents
In src/github-api/streak-calculator.ts around lines 92 to 109, the loop uses
today as the reference which assumes a contribution exists today and thus yields
0 when the last contribution was earlier; instead find the last day in sorted
with contributionCount > 0 and use moment(lastContribution.date).startOf("day")
as the referenceMoment (handle empty sorted by returning streak 0), set
actualCurrentStreakEnd to referenceMoment.toDate(), then in the loop compute
daysDiff = referenceMoment.diff(currentMoment, "days") and compare daysDiff ===
actualCurrentStreak to increment actualCurrentStreak and update
actualCurrentStreakStart; break when daysDiff > actualCurrentStreak.


return {
currentStreak: actualCurrentStreak,
currentStreakStart: actualCurrentStreakStart,
currentStreakEnd: actualCurrentStreakEnd,
maxStreak: maxStreak,
maxStreakStart: maxStreakStart,
maxStreakEnd: maxStreakEnd,
}
}
Loading
Loading