dvorakmortonxd.dev/src/app/components/ContributionHeatmap.tsx

208 lines
7.4 KiB
TypeScript

"use client";
import React, { useEffect, useState } from "react";
interface HeatmapData {
timestamp: number;
contributions: number;
}
interface Activity {
op_type: string;
content?: string | null;
created: string;
repo?: {
full_name?: string;
};
}
interface ActivityPayload {
Commits?: Array<{
Message?: string;
}>;
HeadCommit?: {
Message?: string;
};
}
export default function ContributionHeatmap() {
const [weeks, setWeeks] = useState<{ date: string; contribs: number; level: number }[][]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
const [totalContribs, setTotalContribs] = useState(0);
const [activities, setActivities] = useState<Activity[]>([]);
const [hoveredCell, setHoveredCell] = useState<{ date: string; contribs: number } | null>(null);
useEffect(() => {
async function fetchHeatmap() {
try {
const res = await fetch("/api/heatmap");
if (!res.ok) {
throw new Error("Failed to fetch");
}
const data: HeatmapData[] = await res.json();
const map = new Map<string, number>();
let total = 0;
let maxContribs = 0;
data.forEach((item) => {
const d = new Date(item.timestamp * 1000);
const dateStr = d.getFullYear() + "-" + String(d.getMonth() + 1).padStart(2, "0") + "-" + String(d.getDate()).padStart(2, "0");
const currentDayTotal = (map.get(dateStr) || 0) + item.contributions;
map.set(dateStr, currentDayTotal);
if (currentDayTotal > maxContribs) maxContribs = currentDayTotal;
total += item.contributions;
});
const endDate = new Date();
endDate.setHours(0, 0, 0, 0);
const startDate = new Date(endDate);
startDate.setFullYear(startDate.getFullYear() - 1);
startDate.setDate(startDate.getDate() - startDate.getDay());
const newWeeks = [];
let currentWeek = [];
for (let d = new Date(startDate); d <= endDate; d.setDate(d.getDate() + 1)) {
const dateStr = d.getFullYear() + "-" + String(d.getMonth() + 1).padStart(2, "0") + "-" + String(d.getDate()).padStart(2, "0");
const contribs = map.get(dateStr) || 0;
let level = 0;
if (contribs > 0) {
level = 1;
if (contribs > maxContribs * 0.25) level = 2;
if (contribs > maxContribs * 0.5) level = 3;
if (contribs > maxContribs * 0.75) level = 4;
}
currentWeek.push({ date: dateStr, contribs, level });
if (currentWeek.length === 7) {
newWeeks.push(currentWeek);
currentWeek = [];
}
}
if (currentWeek.length > 0) {
newWeeks.push(currentWeek);
}
setWeeks(newWeeks);
setTotalContribs(total);
setLoading(false);
} catch (err) {
console.error("Failed to load heatmap:", err);
setError(true);
setLoading(false);
}
}
async function fetchActivities() {
try {
const res = await fetch("/api/activities");
if (res.ok) {
const data: Activity[] = await res.json();
// Filter to just standard push/commit events
const valid = data.filter((d) => d.op_type === "commit_repo" || d.op_type === "push_repo").slice(0, 3);
setActivities(valid);
}
} catch (err) {
console.error("Failed to load activities:", err);
}
}
fetchHeatmap();
fetchActivities();
}, []);
if (loading) {
return <div style={{ color: "var(--gray)", fontSize: "0.85rem" }}>Loading contributions...</div>;
}
if (error) {
return <div style={{ color: "var(--gray)", fontSize: "0.85rem" }}>Failed to load contributions.</div>;
}
return (
<div className="contribution-heatmap">
<div className="contribution-command-row">
<div className="contribution-command">
<span style={{ color: "var(--green)" }}>martin@git.dvorakmortonxd.dev</span>:<span style={{ color: "var(--gray)" }}>~</span>$ echo &#34;<span style={{ color: "var(--accent)" }}>{totalContribs}</span> contributions in the last year&#34;
</div>
</div>
<div className="contribution-total-title">
<span style={{ color: "var(--accent)" }}>{totalContribs}</span> contributions in the last year
</div>
<div className="heatmap-container">
{weeks.map((week, wIdx) => (
<div key={wIdx} className="heatmap-week">
{week.map((day, dIdx) => (
<div
key={dIdx}
className="heatmap-cell"
data-level={day.level >= 0 ? day.level : undefined}
title={day.level >= 0 ? `${day.contribs} contributions on ${day.date}` : undefined}
onMouseEnter={() => day.level >= 0 && setHoveredCell({ date: day.date, contribs: day.contribs })}
onMouseLeave={() => setHoveredCell(null)}
>
</div>
))}
</div>
))}
</div>
<div className="heatmap-status-row">
<div className="heatmap-status-text">
{hoveredCell ? (
<span><strong style={{ color: "var(--accent)" }}>{hoveredCell.contribs}</strong> contributions on {hoveredCell.date}</span>
) : (
<span>Hover over a day to see contributions</span>
)}
</div>
<div className="heatmap-legend">
<span>Less</span>
<div className="heatmap-legend-cell" data-level="0"></div>
<div className="heatmap-legend-cell" data-level="1"></div>
<div className="heatmap-legend-cell" data-level="2"></div>
<div className="heatmap-legend-cell" data-level="3"></div>
<div className="heatmap-legend-cell" data-level="4"></div>
<span>More</span>
</div>
</div>
{activities.length > 0 && (
<div className="contribution-log">
<div className="contribution-command contribution-log-command">
<span style={{ color: "var(--green)" }}>martin@git.dvorakmortonxd.dev</span>:<span style={{ color: "var(--gray)" }}>~</span>$ git log -n 3 <span style={{ color: "var(--gray)" }}>#displays my 3 latest contributions</span>
</div>
{activities.map((act, idx) => {
let msg = "No message";
try {
if (act.content) {
const parsed = JSON.parse(act.content) as ActivityPayload;
if (parsed.Commits && parsed.Commits.length > 0) {
msg = parsed.Commits[0].Message?.split("\n")[0] ?? msg;
} else if (parsed.HeadCommit?.Message) {
msg = parsed.HeadCommit.Message.split("\n")[0];
}
}
} catch {}
const created = new Date(act.created);
return (
<div key={idx} className="contribution-entry">
<div className="contribution-entry-header">
<span className="contribution-repo">repo: {act.repo?.full_name || "unknown"}</span>
<span className="contribution-date">
{created.toLocaleDateString()}-{created.toLocaleTimeString()}
</span>
</div>
<div className="contribution-message">
{msg}
</div>
</div>
);
})}
</div>
)}
</div>
);
}