Skip to content

Conversation

@SehajDeepSingh26
Copy link

@SehajDeepSingh26 SehajDeepSingh26 commented Oct 18, 2025

add streak card int the profile summary which displays current streak, max streak and total contributions in a standardized frame of card. Also added this in the demo.html.
image

Summary by CodeRabbit

New Features

  • Introduced Streak Stats Card displaying user contribution streaks, including current streak duration, longest streak, and total contributions statistics
  • Card features theme customization and visual progress indicators with circular visualization
  • Added resilient API endpoint for streak card generation with automatic failure recovery
  • Card preview now available in demo interface with usage documentation

@coderabbitai
Copy link

coderabbitai bot commented Oct 18, 2025

Walkthrough

A new streak stats card feature is implemented across backend calculation, SVG template rendering, file output, API endpoint, and demo UI. The feature displays GitHub contribution streaks with current and maximum streak metrics, validates themes, rotates GitHub tokens on API failures, and generates themed SVG cards for both programmatic output and dynamic API consumption.

Changes

Cohort / File(s) Summary
Streak Calculation Logic
src/github-api/streak-calculator.ts
Introduces StreakInfo interface and calculateStreaks() function to compute current and maximum consecutive-day contribution streaks with date ranges. Sorts contributions by date and iterates to identify streaks up to today.
Streak Card Rendering
src/templates/streak-card.ts
Adds StreakData interface and createStreakCard() template function. Generates three-panel SVG card layout displaying total contributions, current streak with circular progress visualization, and longest streak. Uses moment for date formatting and includes flame emoji.
Card Generation Orchestration
src/cards/streak-cards.ts
Exports createStreakCardForUser() and getStreakCardSVGWithThemeName() functions. Fetches profile and contribution data, calculates streaks, renders SVG per theme, and writes files to disk. Conditionally adjusts total contributions based on VERCEL_I environment variable.
CLI Integration
src/app.ts
Imports and calls createStreakCardForUser(username) after card generation to extend workflow and output themed streak cards.
API Endpoint
api/cards/streak-stats.ts
Implements default export API route handler accepting username and theme query parameters. Fetches SVG via getStreakCardSVGWithThemeName() with retry logic, rotates GitHub tokens on per-try failures via changToNextGitHubToken(), and returns error SVG on outer exception.
Demo UI
api/pages/demo.html
Adds new Streak Stats Card section to demo, introduces streakStatsSource data property, and extends updateAllCards() to populate the source URL from baseURL, username, and theme.

Sequence Diagram

sequenceDiagram
    participant User
    participant API as API Endpoint
    participant CardGen as Card Generation
    participant GH as GitHub API
    participant FS as File System
    
    User->>API: GET /api/cards/streak-stats?username=X&theme=Y
    API->>CardGen: getStreakCardSVGWithThemeName(username, theme)
    CardGen->>GH: Fetch profile & contributions
    GH-->>CardGen: Profile data
    CardGen->>CardGen: calculateStreaks()
    CardGen->>CardGen: createStreakCard() → SVG
    CardGen-->>API: SVG string
    API-->>User: 200 image/svg+xml
    
    alt API Failure
        GH--xCardGen: Error
        CardGen-->>API: Error caught
        API->>API: Token rotation (changToNextGitHubToken)
        API->>API: Retry loop
        alt Retry Success
            API-->>User: SVG
        else Retry Exhausted
            API->>CardGen: getErrorMsgCard()
            API-->>User: 200 Error SVG
        end
    end
Loading
sequenceDiagram
    participant CLI
    participant CardOrch as Orchestration
    participant Calc as Streak Calculator
    participant Theme as Theme System
    participant CardTmpl as Card Template
    participant FS as File System
    
    CLI->>CardOrch: createStreakCardForUser(username)
    CardOrch->>CardOrch: getStreakCardData(username)
    CardOrch->>Calc: calculateStreaks(contributions)
    Calc-->>CardOrch: StreakInfo
    loop For each theme in ThemeMap
        CardOrch->>Theme: Resolve theme
        CardOrch->>CardTmpl: createStreakCard(title, streakData, theme)
        CardTmpl-->>CardOrch: SVG string
        CardOrch->>FS: writeSVG(filename, svg)
    end
    CardOrch-->>CLI: Complete
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~40 minutes

The diff introduces heterogeneous logic across multiple files (streak calculation, SVG rendering, orchestration, API retry handling, UI integration). While individual files are manageable, understanding the interaction between streak calculation, template rendering with circular progress visualization, theme handling, GitHub token rotation, and environment-conditional behavior requires careful examination. The density of SVG rendering logic and conditional logic paths adds to complexity.

Poem

🐰 A streak card hops to life today,
With circular flames that dance and sway,
Through themes and tokens, retries flow,
While GitHub streaks in glory glow!
✨🔥

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title Check ✅ Passed The title "add streak card widget" directly and clearly describes the primary change in the pull request. The changeset is entirely focused on introducing a new streak card feature across multiple layers: a new API endpoint (streak-stats.ts), streak calculation logic (streak-calculator.ts), card template rendering (streak-card.ts), integration into the main application workflow (app.ts), and display in the demo UI (demo.html). The title accurately captures this unified objective with specific terminology ("streak card") rather than vague language, and "widget" is an appropriate term for a UI component in this context.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 7

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/app.ts (1)

125-133: Add missing StreakCard creation to GitHub Action path.

The action() function (GitHub Action execution path) is missing the createStreakCardForUser call that exists in main() (local path). Users running via GitHub Actions won't get streak cards. Apply the diff to action() after the ProductiveTimeCard block:

         // ProductiveTimeCard
         try {
             core.info(`Creating ProductiveTimeCard...`);
             await createProductiveTimeCard(username, utcOffset);
         } catch (error: any) {
             core.error(`Error when creating ProductiveTimeCard \n${error.stack}`);
         }
+
+        // StreakCard
+        try {
+            core.info(`Creating StreakCard...`);
+            await createStreakCardForUser(username);
+        } catch (error: any) {
+            core.error(`Error when creating StreakCard \n${(error as any).stack}`);
+        }
🧹 Nitpick comments (7)
api/pages/demo.html (2)

163-176: Add alt text and optional lazy placeholder for accessibility/UX.
v-img supports alt and lazy-src; add alt at minimum.

-<v-img :src="streakStatsSource"></v-img>
+<v-img :src="streakStatsSource" alt="Streak stats card"></v-img>

283-289: Encode query parameters to avoid malformed URLs and edge cases.
Usernames/themes with special characters or spaces will break without encoding.

-this.streakStatsSource = `${this.baseURL}/api/cards/streak-stats?username=${this.username}&theme=${this.theme}`;
+this.streakStatsSource = `${this.baseURL}/api/cards/streak-stats?username=${encodeURIComponent(this.username)}&theme=${encodeURIComponent(this.theme)}`;
 
-this.profileDetailSource = `${this.baseURL}/api/cards/profile-details?username=${this.username}&theme=${this.theme}`;
+this.profileDetailSource = `${this.baseURL}/api/cards/profile-details?username=${encodeURIComponent(this.username)}&theme=${encodeURIComponent(this.theme)}`;
 
-this.repoLanguageSource = `${this.baseURL}/api/cards/repos-per-language?username=${this.username}&theme=${this.theme}`;
+this.repoLanguageSource = `${this.baseURL}/api/cards/repos-per-language?username=${encodeURIComponent(this.username)}&theme=${encodeURIComponent(this.theme)}`;
 
-this.commitLanguageSource = `${this.baseURL}/api/cards/most-commit-language?username=${this.username}&theme=${this.theme}`;
+this.commitLanguageSource = `${this.baseURL}/api/cards/most-commit-language?username=${encodeURIComponent(this.username)}&theme=${encodeURIComponent(this.theme)}`;
 
-this.statsSource = `${this.baseURL}/api/cards/stats?username=${this.username}&theme=${this.theme}`;
+this.statsSource = `${this.baseURL}/api/cards/stats?username=${encodeURIComponent(this.username)}&theme=${encodeURIComponent(this.theme)}`;
 
-this.productiveTimeSource = `${this.baseURL}/api/cards/productive-time?username=${this.username}&theme=${this.theme}&utcOffset=8`;
+this.productiveTimeSource = `${this.baseURL}/api/cards/productive-time?username=${encodeURIComponent(this.username)}&theme=${encodeURIComponent(this.theme)}&utcOffset=8`;
src/github-api/streak-calculator.ts (1)

1-11: Style/JSDoc nits to satisfy lint.
Use single quotes, semicolons in interface, and add @returns type.

-import moment from "moment"
-import type { ProfileContribution } from "./profile-details"
+import moment from 'moment';
+import type { ProfileContribution } from './profile-details';
@@
-export interface StreakInfo {
-  currentStreak: number
-  currentStreakStart: Date
-  currentStreakEnd: Date
-  maxStreak: number
-  maxStreakStart: Date
-  maxStreakEnd: Date
-}
+export interface StreakInfo {
+  currentStreak: number;
+  currentStreakStart: Date;
+  currentStreakEnd: Date;
+  maxStreak: number;
+  maxStreakStart: Date;
+  maxStreakEnd: Date;
+}
@@
- * @returns StreakInfo object with current and max streak information
+ * @returns {StreakInfo} StreakInfo object with current and max streak information

Based on learnings

src/templates/streak-card.ts (3)

113-126: Progress ring is static; consider reflecting the streak.
If desired, show e.g. current/max ratio (capped) as progress.

-  const strokeDashoffset = 0
+  const progress = Math.max(0, Math.min(1, streakData.maxStreak ? streakData.currentStreak / streakData.maxStreak : 0));
+  const strokeDashoffset = circumference * (1 - progress);

159-161: Avoid hard-coded label color; use theme for consistency.
Hard-coded #FFA500 may clash with themes.

-    .style("fill", "#FFA500")
+    .style("fill", theme.title)

23-31: Magic offsets (-20/+10) risk misalignment across widths/fonts.
Prefer deriving centers from panel bounds without manual nudges.

-  const leftCenterX = padding + panelWidth / 2 - 20
-  const centerCenterX = padding + panelWidth * 1.5
-  const rightCenterX = padding + panelWidth * 2.5 + 10
+  const leftCenterX = padding + panelWidth * 0.5
+  const centerCenterX = padding + panelWidth * 1.5
+  const rightCenterX = padding + panelWidth * 2.5
src/cards/streak-cards.ts (1)

37-43: Consider parallelizing API calls for better performance.

The current implementation makes sequential API calls for each contribution year. For users with many years of contributions, this could be slow.

Consider using Promise.all to fetch contributions for all years in parallel:

-  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
-    }
-  }
+  const yearsToFetch = process.env.VERCEL_I 
+    ? profileDetails.contributionYears.slice(0, 1)
+    : profileDetails.contributionYears
+  
+  const contributionsByYear = await Promise.all(
+    yearsToFetch.map(year => getContributionByYear(username, year))
+  )
+  
+  const totalContributions = contributionsByYear.reduce(
+    (sum, yearData) => sum + yearData.totalContributions, 
+    0
+  )

This approach also eliminates the code duplication between the two branches.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between c2fa12c and 29032a9.

📒 Files selected for processing (6)
  • api/cards/streak-stats.ts (1 hunks)
  • api/pages/demo.html (3 hunks)
  • src/app.ts (2 hunks)
  • src/cards/streak-cards.ts (1 hunks)
  • src/github-api/streak-calculator.ts (1 hunks)
  • src/templates/streak-card.ts (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (5)
src/github-api/streak-calculator.ts (1)
src/github-api/profile-details.ts (1)
  • ProfileContribution (27-34)
src/templates/streak-card.ts (2)
src/const/theme.ts (1)
  • Theme (3-28)
src/templates/card.ts (1)
  • Card (4-70)
src/app.ts (1)
src/cards/streak-cards.ts (1)
  • createStreakCardForUser (8-16)
src/cards/streak-cards.ts (6)
src/const/theme.ts (1)
  • ThemeMap (1-1)
src/utils/file-writer.ts (1)
  • writeSVG (14-18)
src/templates/streak-card.ts (2)
  • StreakData (5-14)
  • createStreakCard (16-211)
src/github-api/profile-details.ts (1)
  • getProfileDetails (92-127)
src/github-api/contributions-by-year.ts (1)
  • getContributionByYear (41-62)
src/github-api/streak-calculator.ts (1)
  • calculateStreaks (18-119)
api/cards/streak-stats.ts (3)
src/cards/streak-cards.ts (1)
  • getStreakCardSVGWithThemeName (18-23)
api/utils/github-token-updater.ts (1)
  • changToNextGitHubToken (1-8)
api/utils/error-card.ts (1)
  • getErrorMsgCard (4-14)
🪛 GitHub Check: lint
src/github-api/streak-calculator.ts

[failure] 13-13:
Missing JSDoc return type


[failure] 10-10:
Replace maxStreakEnd:·Date with ··maxStreakEnd:·Date;


[failure] 9-9:
Replace maxStreakStart:·Date with ··maxStreakStart:·Date;


[failure] 8-8:
Replace maxStreak:·number with ··maxStreak:·number;


[failure] 7-7:
Replace currentStreakEnd:·Date with ··currentStreakEnd:·Date;


[failure] 6-6:
Replace ··currentStreakStart:·Date with ····currentStreakStart:·Date;


[failure] 5-5:
Replace ··currentStreak:·number with ····currentStreak:·number;


[failure] 2-2:
Replace ·ProfileContribution·}·from·"./profile-details" with ProfileContribution}·from·'./profile-details';


[failure] 1-1:
Replace "moment" with 'moment';

src/cards/streak-cards.ts

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

🔇 Additional comments (4)
src/app.ts (1)

10-10: Import looks good.
No issues with the new import path/name.

api/pages/demo.html (1)

257-257: LGTM: state wiring for streak card.
Data property streakStatsSource initialized correctly.

api/cards/streak-stats.ts (1)

6-15: Caching is already configured at the framework level via vercel.json.

All /api/cards/* endpoints, including streak-stats.ts, already have Cache-Control headers applied through Vercel's configuration: "public, max-age=21600, s-maxage=21600, stale-while-revalidate=14400". Adding Cache-Control headers directly to the endpoint code would be redundant and could create conflicts with the framework-level settings.

Likely an incorrect or invalid review comment.

src/cards/streak-cards.ts (1)

11-11: Verify the empty title is intentional.

The title is set to an empty string. Please confirm this is the intended design for the streak card.

Comment on lines +16 to +30
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
}
}
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.

Comment on lines +31 to +34
} catch (err: any) {
console.log(err)
res.send(getErrorMsgCard(err.message, theme))
}
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.

@@ -0,0 +1,61 @@
import { ThemeMap } from "../const/theme"
import { createStreakCard, type StreakData } from "../templates/streak-card"
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.

Comment on lines +35 to +44
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
}
}
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.

Comment on lines +30 to +41
// 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
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.

Comment on lines +46 to +66
// 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
}
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).

Comment on lines +92 to +109
// 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
}
}
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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant