-
Notifications
You must be signed in to change notification settings - Fork 405
add streak card widget #224
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
add streak card widget #224
Conversation
WalkthroughA 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
Sequence DiagramsequenceDiagram
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
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
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
Pre-merge checks and finishing touches❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✨ Finishing touches
🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this 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 informationBased 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.5src/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.allto 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
📒 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.
| 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 | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 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/cardsLength 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)) | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
| } 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" | |||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
| 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.
| 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 | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
| // 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 |
There was a problem hiding this comment.
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.tsLength of output: 3706
🏁 Script executed:
#!/bin/bash
# Search for ProductiveTime references
rg -n "ProductiveTime" -C 3Length 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=tsLength 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=tsLength 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=tsLength 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.
| // 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 | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
| // 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).
| // 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 | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 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.
'
EOFLength 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 || trueLength 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 -50Length 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=jsLength 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.
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.

Summary by CodeRabbit
New Features