diff --git a/api/cards/streak-stats.ts b/api/cards/streak-stats.ts
new file mode 100644
index 0000000000..847335384a
--- /dev/null
+++ b/api/cards/streak-stats.ts
@@ -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
+ }
+ }
+ } catch (err: any) {
+ console.log(err)
+ res.send(getErrorMsgCard(err.message, theme))
+ }
+}
diff --git a/api/pages/demo.html b/api/pages/demo.html
index 0cf4a5097b..224344c107 100644
--- a/api/pages/demo.html
+++ b/api/pages/demo.html
@@ -160,6 +160,20 @@
+
+
+
+
+
+
+ Markdown Usage
+
+ 
+
+
+
+
+
@@ -240,6 +254,7 @@
themes: ['default'],
username: 'vn7n24fzkq',
theme: 'default',
+ streakStatsSource: '',
profileDetailSource: '',
repoLanguageSource: '',
commitLanguageSource: '',
@@ -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}`;
diff --git a/src/app.ts b/src/app.ts
index ba6a7d8c53..cba85d3f0b 100644
--- a/src/app.ts
+++ b/src/app.ts
@@ -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) => {
@@ -128,6 +129,7 @@ const main = async (username: string, utcOffset: number, exclude: Array)
await createCommitsPerLanguageCard(username, exclude);
await createStatsCard(username);
await createProductiveTimeCard(username, utcOffset);
+ await createStreakCardForUser(username)
generatePreviewMarkdown(false);
} catch (error: any) {
console.error(error);
diff --git a/src/cards/streak-cards.ts b/src/cards/streak-cards.ts
new file mode 100644
index 0000000000..0a41ebb01f
--- /dev/null
+++ b/src/cards/streak-cards.ts
@@ -0,0 +1,61 @@
+import { ThemeMap } from "../const/theme"
+import { createStreakCard, type StreakData } from "../templates/streak-card"
+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 => {
+ 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 => {
+ 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
+ }
+ }
+
+ // 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
+}
diff --git a/src/github-api/streak-calculator.ts b/src/github-api/streak-calculator.ts
new file mode 100644
index 0000000000..8ef495cfc6
--- /dev/null
+++ b/src/github-api/streak-calculator.ts
@@ -0,0 +1,119 @@
+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
+}
+
+/**
+ * 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
+ 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
+ }
+ } 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
+ }
+ }
+
+ return {
+ currentStreak: actualCurrentStreak,
+ currentStreakStart: actualCurrentStreakStart,
+ currentStreakEnd: actualCurrentStreakEnd,
+ maxStreak: maxStreak,
+ maxStreakStart: maxStreakStart,
+ maxStreakEnd: maxStreakEnd,
+ }
+}
diff --git a/src/templates/streak-card.ts b/src/templates/streak-card.ts
new file mode 100644
index 0000000000..e098ed4f53
--- /dev/null
+++ b/src/templates/streak-card.ts
@@ -0,0 +1,211 @@
+import { Card } from "./card"
+import moment from "moment"
+import type { Theme } from "../const/theme"
+
+export interface StreakData {
+ currentStreak: number
+ currentStreakStart: Date
+ currentStreakEnd: Date
+ maxStreak: number
+ maxStreakStart: Date
+ maxStreakEnd: Date
+ totalContributions: number
+ joinedDate: Date
+}
+
+export function createStreakCard(title: string, streakData: StreakData, theme: Theme): string {
+ const width = 550
+ const height = 200
+ const card = new Card(title, width, height, theme)
+ const svg = card.getSVG()
+
+ // layout math for three equal panels, centered content
+ const padding = 30
+ const panelWidth = (width - padding * 2) / 3
+ const leftCenterX = padding + panelWidth / 2 - 20
+ const centerCenterX = padding + panelWidth * 1.5
+ const rightCenterX = padding + panelWidth * 2.5 + 10
+
+ const leftTopY = 50
+ const centerGroupY = 40
+ const rightTopY = 50
+
+ // Left section - Total Contributions (centered)
+ const leftPanel = svg.append("g").attr("transform", `translate(${leftCenterX}, ${leftTopY})`)
+
+ leftPanel
+ .append("text")
+ .text(streakData.totalContributions.toString())
+ .attr("x", 0)
+ .attr("y", 0)
+ .attr("text-anchor", "middle")
+ .style("font-size", "34px")
+ .style("font-weight", "800")
+ .style("fill", theme.title)
+
+ leftPanel
+ .append("text")
+ .text("Total Contributions")
+ .attr("x", 0)
+ .attr("y", 36)
+ .attr("text-anchor", "middle")
+ .style("font-size", "14px")
+ .style("font-weight", "500")
+ .style("fill", theme.title)
+
+ const joinedDateStr = moment(streakData.joinedDate).format("MMM DD, YYYY")
+ leftPanel
+ .append("text")
+ .text(`${joinedDateStr} - Present`)
+ .attr("x", 0)
+ .attr("y", 62)
+ .attr("text-anchor", "middle")
+ .style("font-size", "12px")
+ .style("fill", theme.text)
+ .attr("opacity", 1)
+
+ // vertical separators (between panels) - positioned using panel widths
+ const leftSepX = Math.round(padding + panelWidth)
+ const rightSepX = Math.round(padding + panelWidth * 2)
+
+ svg
+ .append("line")
+ .attr("x1", leftSepX)
+ .attr("y1", -10)
+ .attr("x2", leftSepX)
+ .attr("y2", 136)
+ .attr("stroke", theme.text)
+ .attr("stroke-width", 1)
+ .attr("opacity", 1)
+
+ svg
+ .append("line")
+ .attr("x1", rightSepX)
+ .attr("y1", -10)
+ .attr("x2", rightSepX)
+ .attr("y2", 136)
+ .attr("stroke", theme.text)
+ .attr("stroke-width", 1)
+ .attr("opacity", 1)
+
+ // Center section - Current Streak with circular progress (fully centered)
+ const centerPanel = svg.append("g").attr("transform", `translate(${centerCenterX}, ${centerGroupY})`)
+
+ const circleRadius = 40
+ const circleX = 0
+ const circleY = 0
+
+ // Background circle (subtle)
+ centerPanel
+ .append("circle")
+ .attr("cx", circleX)
+ .attr("cy", circleY)
+ .attr("r", circleRadius)
+ .attr("fill", "none")
+ .attr("stroke", theme.text)
+ .attr("stroke-width", 2.5)
+ .attr("opacity", 0.12)
+
+ // Progress circle (orange for streak) - draw full circle then offset
+ const circumference = 2 * Math.PI * circleRadius
+ const strokeDashoffset = 0
+
+ centerPanel
+ .append("circle")
+ .attr("cx", circleX)
+ .attr("cy", circleY)
+ .attr("r", circleRadius)
+ .attr("fill", "none")
+ .attr("stroke", theme.title)
+ .attr("stroke-width", 4)
+ .attr("stroke-dasharray", circumference)
+ .attr("stroke-dashoffset", strokeDashoffset)
+ .attr("stroke-linecap", "round")
+ // rotate so stroke starts at 12 o'clock
+ .attr("transform", `rotate(-90 ${circleX} ${circleY})`)
+
+ // Flame emoji at 12 o'clock
+ centerPanel
+ .append("text")
+ .text(" 🔥 ")
+ .attr("x", 0)
+ .attr("y", -circleRadius)
+ .attr("text-anchor", "middle")
+ .attr("dominant-baseline", "middle")
+ .style("font-size", "22px")
+ .style("stroke", theme.title)
+ .style("pointer-events", "none")
+
+ // Current streak number (centered)
+ centerPanel
+ .append("text")
+ .text(streakData.currentStreak.toString())
+ .attr("x", 0)
+ .attr("y", 5.5)
+ .attr("text-anchor", "middle")
+ .attr("dominant-baseline", "middle")
+ .style("font-size", "32px")
+ .style("font-weight", "800")
+ .style("fill", theme.text)
+
+ // Current Streak label (below)
+ centerPanel
+ .append("text")
+ .text("Current Streak")
+ .attr("x", 0)
+ .attr("y", circleRadius + 28)
+ .attr("text-anchor", "middle")
+ .style("font-size", "14px")
+ .style("font-weight", "600")
+ .style("fill", "#FFA500")
+
+ // Current streak date range (below the label)
+ const currentStreakStart = moment(streakData.currentStreakStart).format("MMM D")
+ const currentStreakEnd = moment(streakData.currentStreakEnd).format("MMM D")
+ centerPanel
+ .append("text")
+ .text(`${currentStreakStart} - ${currentStreakEnd}`)
+ .attr("x", 0)
+ .attr("y", circleRadius + 50)
+ .attr("text-anchor", "middle")
+ .style("font-size", "12px")
+ .style("fill", theme.text)
+ .attr("opacity", 1)
+
+ // Right section - Max Streak (centered)
+ const rightPanel = svg.append("g").attr("transform", `translate(${rightCenterX}, ${rightTopY})`)
+
+ rightPanel
+ .append("text")
+ .text(streakData.maxStreak.toString())
+ .attr("x", 0)
+ .attr("y", 0)
+ .attr("text-anchor", "middle")
+ .style("font-size", "34px")
+ .style("font-weight", "800")
+ .style("fill", theme.title)
+
+ rightPanel
+ .append("text")
+ .text("Longest Streak")
+ .attr("x", 0)
+ .attr("y", 36)
+ .attr("text-anchor", "middle")
+ .style("font-size", "14px")
+ .style("font-weight", "500")
+ .style("fill", theme.title)
+
+ const maxStreakStart = moment(streakData.maxStreakStart).format("MMM DD, YYYY")
+ const maxStreakEnd = moment(streakData.maxStreakEnd).format("MMM DD, YYYY")
+ rightPanel
+ .append("text")
+ .text(`${maxStreakStart} - ${maxStreakEnd}`)
+ .attr("x", 0)
+ .attr("y", 62)
+ .attr("text-anchor", "middle")
+ .style("font-size", "12px")
+ .style("fill", theme.text)
+ .attr("opacity", 1)
+
+ return card.toString()
+}