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
+ + ![]({{ streakStatsSource }}) + +
+
+
+
@@ -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() +}