208 lines
7.4 KiB
TypeScript
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 "<span style={{ color: "var(--accent)" }}>{totalContribs}</span> contributions in the last year"
|
|
</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>
|
|
);
|
|
}
|