efecelik's picture
feat: add GitHub-style activity section showing daily contributions
62f661f
"""
Hugging Face Contributions Graph
A GitHub-style contribution calendar for Hugging Face users.
Shows daily commit activity across models, datasets, and spaces.
"""
import gradio as gr
from datetime import datetime, timedelta
from hf_contributions import fetch_user_contributions, ContributionStats
from typing import Optional
import json
# GitHub-style color palette
COLORS = {
"light": {
0: "#ebedf0", # No contributions
1: "#9be9a8", # Level 1 (1-3)
2: "#40c463", # Level 2 (4-6)
3: "#30a14e", # Level 3 (7-9)
4: "#216e39", # Level 4 (10+)
},
"dark": {
0: "#161b22",
1: "#0e4429",
2: "#006d32",
3: "#26a641",
4: "#39d353",
}
}
def get_contribution_level(count: int) -> int:
"""Determine the contribution level (0-4) based on count."""
if count == 0:
return 0
elif count <= 3:
return 1
elif count <= 6:
return 2
elif count <= 9:
return 3
else:
return 4
def generate_calendar_data(contributions: dict[str, int], days: int = 365) -> list[dict]:
"""Generate calendar data for the past N days."""
today = datetime.now().date()
start_date = today - timedelta(days=days - 1)
# Adjust to start from Sunday
days_since_sunday = start_date.weekday() + 1
if days_since_sunday == 7:
days_since_sunday = 0
start_date = start_date - timedelta(days=days_since_sunday)
calendar_data = []
current_date = start_date
while current_date <= today:
date_str = current_date.strftime("%Y-%m-%d")
count = contributions.get(date_str, 0)
level = get_contribution_level(count)
calendar_data.append({
"date": date_str,
"count": count,
"level": level,
"weekday": current_date.weekday(),
"month": current_date.month,
"day": current_date.day
})
current_date += timedelta(days=1)
return calendar_data
def build_daily_activity_data(contributions_by_repo: dict) -> dict[str, list[dict]]:
"""Build a mapping of date -> list of contributions for that date."""
daily_activities = {}
for repo_id, commits in contributions_by_repo.items():
for commit in commits:
date = commit["date"]
if date not in daily_activities:
daily_activities[date] = []
daily_activities[date].append({
"repo_id": repo_id,
"repo_type": commit["repo_type"],
"message": commit.get("message", "No message")[:80]
})
return daily_activities
def generate_contribution_graph_html(
stats: Optional[ContributionStats],
theme: str = "light",
username: str = ""
) -> str:
"""Generate the HTML/CSS for the GitHub-style contribution graph."""
if stats is None:
return """
<div style="text-align: center; padding: 40px; color: #666;">
<p>Enter a Hugging Face username to see their contribution graph</p>
</div>
"""
colors = COLORS[theme]
calendar_data = generate_calendar_data(stats.contributions_by_date)
# Build daily activity data for the detail view
daily_activities = build_daily_activity_data(stats.contributions_by_repo)
daily_activities_json = json.dumps(daily_activities)
# Group by weeks
weeks = []
current_week = []
for day in calendar_data:
if len(current_week) == 7:
weeks.append(current_week)
current_week = []
current_week.append(day)
if current_week:
weeks.append(current_week)
# Generate month labels
months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
month_positions = {}
for week_idx, week in enumerate(weeks):
for day in week:
if day["day"] <= 7: # First week of month
month_positions[week_idx] = months[day["month"] - 1]
break
# Build HTML
squares_html = ""
for week_idx, week in enumerate(weeks):
for day in week:
color = colors[day["level"]]
tooltip = f'{day["count"]} contributions on {day["date"]}'
squares_html += f'''
<div class="day"
data-date="{day["date"]}"
data-count="{day["count"]}"
data-level="{day["level"]}"
style="background-color: {color};"
title="{tooltip}"
onclick="window.selectDay('{day["date"]}', {day["count"]})">
</div>
'''
# Month labels HTML
months_html = ""
last_month_week = -4
for week_idx, month_name in sorted(month_positions.items()):
if week_idx - last_month_week >= 4: # At least 4 weeks apart
left_pos = week_idx * 15 # 11px square + 4px gap
months_html += f'<span style="position: absolute; left: {left_pos}px;">{month_name}</span>'
last_month_week = week_idx
bg_color = "#ffffff" if theme == "light" else "#0d1117"
text_color = "#24292f" if theme == "light" else "#c9d1d9"
border_color = "#d0d7de" if theme == "light" else "#30363d"
html = f'''
<style>
.contribution-graph {{
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
background: {bg_color};
padding: 16px;
border-radius: 6px;
border: 1px solid {border_color};
overflow-x: auto;
}}
.graph-header {{
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
color: {text_color};
}}
.graph-title {{
font-size: 16px;
font-weight: 600;
}}
.graph-container {{
display: flex;
gap: 4px;
}}
.days-labels {{
display: flex;
flex-direction: column;
justify-content: space-around;
font-size: 10px;
color: {text_color};
padding-right: 8px;
height: 91px;
}}
.days-labels span {{
height: 11px;
line-height: 11px;
}}
.calendar-wrapper {{
position: relative;
}}
.months-row {{
position: relative;
height: 15px;
font-size: 10px;
color: {text_color};
margin-bottom: 4px;
}}
.calendar {{
display: grid;
grid-template-rows: repeat(7, 11px);
grid-auto-flow: column;
grid-auto-columns: 11px;
gap: 4px;
}}
.day {{
width: 11px;
height: 11px;
border-radius: 2px;
cursor: pointer;
transition: transform 0.1s ease;
}}
.day:hover {{
transform: scale(1.3);
outline: 1px solid {text_color};
outline-offset: 1px;
}}
.legend {{
display: flex;
align-items: center;
justify-content: flex-end;
gap: 4px;
margin-top: 8px;
font-size: 11px;
color: {text_color};
}}
.legend-item {{
width: 11px;
height: 11px;
border-radius: 2px;
}}
.stats-grid {{
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 16px;
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid {border_color};
}}
.stat-card {{
text-align: center;
padding: 12px;
background: {colors[0]};
border-radius: 6px;
}}
.stat-value {{
font-size: 24px;
font-weight: 600;
color: {text_color};
}}
.stat-label {{
font-size: 12px;
color: {text_color};
opacity: 0.8;
}}
.activity-section {{
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid {border_color};
display: none;
}}
.activity-section.active {{
display: block;
}}
.activity-header {{
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
color: {text_color};
}}
.activity-date {{
font-size: 16px;
font-weight: 600;
}}
.activity-count {{
font-size: 14px;
color: {text_color};
opacity: 0.7;
}}
.activity-list {{
display: flex;
flex-direction: column;
gap: 8px;
}}
.activity-item {{
display: flex;
align-items: flex-start;
gap: 12px;
padding: 12px;
background: {colors[0]};
border-radius: 6px;
border-left: 3px solid #30a14e;
}}
.activity-item.model {{
border-left-color: #9b59b6;
}}
.activity-item.dataset {{
border-left-color: #3498db;
}}
.activity-item.space {{
border-left-color: #e67e22;
}}
.activity-icon {{
width: 32px;
height: 32px;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
flex-shrink: 0;
}}
.activity-icon.model {{
background: rgba(155, 89, 182, 0.15);
}}
.activity-icon.dataset {{
background: rgba(52, 152, 219, 0.15);
}}
.activity-icon.space {{
background: rgba(230, 126, 34, 0.15);
}}
.activity-content {{
flex: 1;
min-width: 0;
}}
.activity-repo {{
font-weight: 600;
color: #0969da;
text-decoration: none;
font-size: 14px;
}}
.activity-repo:hover {{
text-decoration: underline;
}}
.activity-type {{
font-size: 12px;
color: {text_color};
opacity: 0.7;
margin-left: 8px;
}}
.activity-message {{
font-size: 13px;
color: {text_color};
opacity: 0.8;
margin-top: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}}
.no-activity {{
text-align: center;
padding: 20px;
color: {text_color};
opacity: 0.6;
}}
</style>
<div class="contribution-graph">
<div class="graph-header">
<span class="graph-title">{stats.total_commits} contributions in the last year</span>
</div>
<div class="graph-container">
<div class="days-labels">
<span></span>
<span>Mon</span>
<span></span>
<span>Wed</span>
<span></span>
<span>Fri</span>
<span></span>
</div>
<div class="calendar-wrapper">
<div class="months-row">
{months_html}
</div>
<div class="calendar">
{squares_html}
</div>
</div>
</div>
<div class="legend">
<span>Less</span>
<div class="legend-item" style="background-color: {colors[0]};"></div>
<div class="legend-item" style="background-color: {colors[1]};"></div>
<div class="legend-item" style="background-color: {colors[2]};"></div>
<div class="legend-item" style="background-color: {colors[3]};"></div>
<div class="legend-item" style="background-color: {colors[4]};"></div>
<span>More</span>
</div>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-value">{stats.total_commits}</div>
<div class="stat-label">Total Contributions</div>
</div>
<div class="stat-card">
<div class="stat-value">{stats.models_count}</div>
<div class="stat-label">Models</div>
</div>
<div class="stat-card">
<div class="stat-value">{stats.datasets_count}</div>
<div class="stat-label">Datasets</div>
</div>
<div class="stat-card">
<div class="stat-value">{stats.spaces_count}</div>
<div class="stat-label">Spaces</div>
</div>
<div class="stat-card">
<div class="stat-value">{stats.longest_streak}</div>
<div class="stat-label">Longest Streak</div>
</div>
<div class="stat-card">
<div class="stat-value">{stats.current_streak}</div>
<div class="stat-label">Current Streak</div>
</div>
</div>
<div id="activity-section" class="activity-section">
<div class="activity-header">
<span class="activity-date" id="activity-date"></span>
<span class="activity-count" id="activity-count"></span>
</div>
<div class="activity-list" id="activity-list">
</div>
</div>
</div>
<script>
const dailyActivities = {daily_activities_json};
const typeIcons = {{
'model': '🤖',
'dataset': '📊',
'space': '🚀'
}};
const typeLabels = {{
'model': 'Model',
'dataset': 'Dataset',
'space': 'Space'
}};
function getHfUrl(repoId, repoType) {{
if (repoType === 'model') return 'https://huggingface.co/' + repoId;
if (repoType === 'dataset') return 'https://huggingface.co/datasets/' + repoId;
if (repoType === 'space') return 'https://huggingface.co/spaces/' + repoId;
return 'https://huggingface.co/' + repoId;
}}
function escapeHtml(text) {{
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}}
window.selectDay = function(date, count) {{
const section = document.getElementById('activity-section');
const dateEl = document.getElementById('activity-date');
const countEl = document.getElementById('activity-count');
const listEl = document.getElementById('activity-list');
// Format date nicely
const dateObj = new Date(date + 'T00:00:00');
const options = {{ weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }};
dateEl.textContent = dateObj.toLocaleDateString('en-US', options);
countEl.textContent = count + ' contribution' + (count !== 1 ? 's' : '');
// Build activity list
const activities = dailyActivities[date] || [];
// Clear list using safe method
while (listEl.firstChild) {{
listEl.removeChild(listEl.firstChild);
}}
if (activities.length === 0) {{
const noActivity = document.createElement('div');
noActivity.className = 'no-activity';
noActivity.textContent = 'No contributions on this day';
listEl.appendChild(noActivity);
}} else {{
// Group by repo to avoid duplicates
const repoMap = {{}};
activities.forEach(a => {{
if (!repoMap[a.repo_id]) {{
repoMap[a.repo_id] = {{ ...a, count: 1 }};
}} else {{
repoMap[a.repo_id].count++;
}}
}});
Object.values(repoMap).forEach(activity => {{
const url = getHfUrl(activity.repo_id, activity.repo_type);
const icon = typeIcons[activity.repo_type] || '📁';
const label = typeLabels[activity.repo_type] || 'Repository';
const commitText = activity.count > 1 ? activity.count + ' commits' : '1 commit';
// Build DOM safely
const item = document.createElement('div');
item.className = 'activity-item ' + activity.repo_type;
const iconDiv = document.createElement('div');
iconDiv.className = 'activity-icon ' + activity.repo_type;
iconDiv.textContent = icon;
const contentDiv = document.createElement('div');
contentDiv.className = 'activity-content';
const link = document.createElement('a');
link.href = url;
link.target = '_blank';
link.className = 'activity-repo';
link.textContent = activity.repo_id;
const typeSpan = document.createElement('span');
typeSpan.className = 'activity-type';
typeSpan.textContent = label + ' · ' + commitText;
const messageDiv = document.createElement('div');
messageDiv.className = 'activity-message';
messageDiv.textContent = activity.message;
contentDiv.appendChild(link);
contentDiv.appendChild(typeSpan);
contentDiv.appendChild(messageDiv);
item.appendChild(iconDiv);
item.appendChild(contentDiv);
listEl.appendChild(item);
}});
}}
section.classList.add('active');
// Highlight selected day
document.querySelectorAll('.day').forEach(d => d.style.outline = '');
event.target.style.outline = '2px solid #0969da';
event.target.style.outlineOffset = '1px';
// Scroll to activity section
section.scrollIntoView({{ behavior: 'smooth', block: 'nearest' }});
}}
</script>
'''
return html
# Store the current stats globally for the detail view
current_stats: Optional[ContributionStats] = None
def fetch_and_display(username: str, theme: str) -> tuple[str, str]:
"""Fetch contributions and generate the display."""
global current_stats
if not username or not username.strip():
return (
generate_contribution_graph_html(None, theme),
"Please enter a username"
)
username = username.strip()
try:
# Fetch the data
current_stats = fetch_user_contributions(username)
# Generate the graph HTML
graph_html = generate_contribution_graph_html(current_stats, theme, username)
# Generate summary
summary = f"""
### {username}'s Hugging Face Activity
- **Total Contributions:** {current_stats.total_commits}
- **Repositories:** {current_stats.total_repos} ({current_stats.models_count} models, {current_stats.datasets_count} datasets, {current_stats.spaces_count} spaces)
- **Longest Streak:** {current_stats.longest_streak} days
- **Current Streak:** {current_stats.current_streak} days
"""
if current_stats.most_active_day:
summary += f"- **Most Active Day:** {current_stats.most_active_day} ({current_stats.most_active_count} contributions)"
return graph_html, summary
except Exception as e:
return (
f'<div style="color: red; padding: 20px;">Error: {str(e)}</div>',
f"Error fetching data for {username}: {str(e)}"
)
# Create the Gradio interface
with gr.Blocks(
title="HF Contributions Graph",
theme=gr.themes.Soft(),
css="""
.gradio-container { max-width: 1200px !important; }
footer { display: none !important; }
"""
) as demo:
gr.Markdown("""
# 🤗 Hugging Face Contributions Graph
GitHub-style contribution calendar for Hugging Face users.
See your daily activity across models, datasets, and spaces!
""")
with gr.Row():
with gr.Column(scale=4):
username_input = gr.Textbox(
label="Hugging Face Username",
placeholder="e.g., huggingface, Qwen, meta-llama",
lines=1
)
with gr.Column(scale=1):
theme_dropdown = gr.Dropdown(
choices=["light", "dark"],
value="light",
label="Theme"
)
with gr.Column(scale=1):
fetch_btn = gr.Button("Fetch Contributions", variant="primary")
graph_output = gr.HTML(
value=generate_contribution_graph_html(None, "light"),
label="Contribution Graph"
)
summary_output = gr.Markdown(
value="Enter a username and click 'Fetch Contributions' to see their activity."
)
# Event handlers
fetch_btn.click(
fn=fetch_and_display,
inputs=[username_input, theme_dropdown],
outputs=[graph_output, summary_output]
)
username_input.submit(
fn=fetch_and_display,
inputs=[username_input, theme_dropdown],
outputs=[graph_output, summary_output]
)
theme_dropdown.change(
fn=lambda u, t: fetch_and_display(u, t) if u else (generate_contribution_graph_html(None, t), ""),
inputs=[username_input, theme_dropdown],
outputs=[graph_output, summary_output]
)
gr.Markdown("""
---
**How it works:** This app fetches your public repositories (models, datasets, spaces)
from the Hugging Face Hub API and counts commits to generate a contribution calendar.
**Note:** Only public repositories are counted. Private repos require authentication.
""")
if __name__ == "__main__":
demo.launch()