Skip to content

Commit aa1a319

Browse files
committed
feat: Added git heatmap with aggregated contributions
1 parent c0d826f commit aa1a319

6 files changed

Lines changed: 303 additions & 8 deletions

File tree

components/Day.tsx

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import React from 'react';
2+
import style from '../styles/git.module.scss';
3+
4+
type DayProps = {
5+
date: string;
6+
count: number;
7+
sources: { [source: string]: number };
8+
};
9+
10+
const Day: React.FC<DayProps> = ({ date, count, sources }) => {
11+
const getColor = (count: number) => {
12+
if (count === 0) return '#2b2c3d';
13+
if (count === 1) return '#6b57a1';
14+
if (count === 2) return '#936cc9';
15+
if (count === 3) return '#ab83e3';
16+
if (count === 4) return '#bb9af7';
17+
if (count === 5) return '#a07ce0';
18+
if (count === 6) return '#8566c2';
19+
return '#6a4ca3';
20+
};
21+
22+
const tooltipContent = Object.entries(sources)
23+
.map(([source, value]) => `${source}: ${value}`)
24+
.join('\n');
25+
26+
return (
27+
<div className={style.dayWrapper}>
28+
<div
29+
className={style.day}
30+
style={{ backgroundColor: getColor(count) }}
31+
title={`${date}: ${count} contributions\n${tooltipContent}`}
32+
/>
33+
</div>
34+
);
35+
};
36+
37+
export default Day;

components/GitHeatMap.tsx

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
// GitHeatMap.tsx
2+
import { useEffect, useState } from 'react';
3+
import Day from './Day';
4+
import style from '../styles/git.module.scss';
5+
6+
type ByDate = {
7+
[date: string]: { [source: string]: number };
8+
};
9+
10+
type BySourceTotal = {
11+
[source: string]: { commits: number; issues: number; merge_requests: number };
12+
};
13+
14+
type ContributionsResponse = {
15+
by_date: ByDate;
16+
by_source_total: BySourceTotal;
17+
};
18+
19+
type SumUpDay = {
20+
date: string;
21+
count: number;
22+
sources: { [source: string]: number };
23+
};
24+
25+
const colors = [
26+
'#2b2c3d', // 0
27+
'#6b57a1', // 1
28+
'#936cc9', // 2
29+
'#ab83e3', // 3
30+
'#bb9af7', // 4
31+
'#a07ce0', // 5
32+
'#8566c2', // 6+
33+
];
34+
35+
const GitHeatMap = () => {
36+
const [data, setData] = useState<ContributionsResponse | null>(null);
37+
38+
useEffect(() => {
39+
fetch('https://git-contrib.mathis-burger.de/api/activity', {
40+
mode: 'cors',
41+
})
42+
.then((res) => res.json())
43+
.then((json: ContributionsResponse) => setData(json))
44+
.catch((error) => console.error('Error fetching data:', error));
45+
}, []);
46+
47+
if (!data) return null;
48+
49+
const year = new Date().getFullYear();
50+
const firstDay = new Date(year, 0, 1);
51+
const lastDay = new Date(year, 11, 31);
52+
53+
const days: SumUpDay[] = [];
54+
for (let d = new Date(firstDay); d <= lastDay; d.setDate(d.getDate() + 1)) {
55+
const iso = d.toISOString().slice(0, 10);
56+
const sources = data.by_date[iso] || {};
57+
const total = Object.values(sources).reduce((a, b) => a + b, 0);
58+
days.push({ date: iso, count: total, sources });
59+
}
60+
61+
const total_criteria = ['commits', 'issues', 'merge_requests'] as const;
62+
63+
const getTotal = (criterion: keyof BySourceTotal[string]) =>
64+
Object.values(data.by_source_total).reduce(
65+
(acc, curr) => acc + curr[criterion],
66+
0,
67+
);
68+
69+
return (
70+
<div className={style.overallContainer}>
71+
<div className={style.box}>
72+
{total_criteria.map((criterion) => (
73+
<div key={criterion} className={style.elementBox}>
74+
<p>{criterion.replaceAll('_', ' ')}</p>
75+
<h2>{getTotal(criterion)}</h2>
76+
77+
<ul
78+
style={{
79+
listStyle: 'none',
80+
padding: 0,
81+
marginTop: '1em',
82+
fontSize: '0.9em',
83+
}}
84+
>
85+
{Object.entries(data.by_source_total).map(([source, values]) => (
86+
<li key={source}>
87+
{source}: {values[criterion]}
88+
</li>
89+
))}
90+
</ul>
91+
</div>
92+
))}
93+
</div>
94+
<div className={style.chart}>
95+
{days.map((day) => (
96+
<Day
97+
key={day.date}
98+
date={day.date}
99+
count={day.count}
100+
sources={day.sources}
101+
/>
102+
))}
103+
</div>
104+
<div className={style.legend}>
105+
{colors.map((color, idx) => (
106+
<div key={idx} className={style.legendItem}>
107+
<div
108+
className={style.legendColor}
109+
style={{ backgroundColor: color }}
110+
/>
111+
<span>{idx === 6 ? '6+' : idx}</span>
112+
</div>
113+
))}
114+
</div>
115+
</div>
116+
);
117+
};
118+
119+
export default GitHeatMap;

package.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,9 @@
1111
"format:fix": "prettier --write --ignore-path .gitignore ."
1212
},
1313
"dependencies": {
14-
"@fortawesome/fontawesome-svg-core": "^1.2.35",
15-
"@fortawesome/free-solid-svg-icons": "^5.15.3",
16-
"@fortawesome/react-fontawesome": "^0.1.14",
14+
"@fortawesome/fontawesome-svg-core": "1.2.35",
15+
"@fortawesome/free-solid-svg-icons": "5.15.3",
16+
"@fortawesome/react-fontawesome": "0.1.14",
1717
"framer-motion": "^12.23.24",
1818
"lucide-react": "^0.553.0",
1919
"next": "14.1.0",

pages/index.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import Footer from '../components/Footer';
66
import { getCookie, setCookie } from 'typescript-cookie';
77
import IntImg from '../components/IntImg';
88
import JourneyPath from '../components/journey';
9+
import GitHeatMap from '../components/GitHeatmap';
910

1011
/**
1112
* The index page that sows some general content of the page.
@@ -59,6 +60,10 @@ const Home = () => {
5960
</p>
6061
</div>
6162
</section>
63+
<section className={style.section} style={{ alignItems: 'center' }}>
64+
<h2>Git contribution (last year)</h2>
65+
<GitHeatMap />
66+
</section>
6267
<section className={style.section}>
6368
<h2>My journey</h2>
6469
<div className={`${style.split} ${style.reverse}`}>

styles/Home.module.scss

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -147,11 +147,7 @@
147147
justify-content: center;
148148
flex-direction: column;
149149
background: #0f1019;
150-
padding-bottom: 1em;
151-
}
152-
153-
.section:last-child {
154-
padding-bottom: 85px;
150+
padding-bottom: 48px;
155151
}
156152

157153
.section .split {
@@ -170,6 +166,7 @@
170166
.section .split img {
171167
max-width: 15vw;
172168
object-fit: contain;
169+
height: 75%;
173170
}
174171

175172
.section .split p {
@@ -205,6 +202,7 @@
205202
.section p {
206203
width: 100%;
207204
text-align: left;
205+
padding-bottom: 0;
208206
}
209207

210208
@media only screen and (max-width: 770px) {

styles/git.module.scss

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
.overallContainer {
2+
display: flex;
3+
flex-direction: column;
4+
align-items: center;
5+
width: fit-content;
6+
width: 70%;
7+
}
8+
9+
@media only screen and (max-width: 930px) {
10+
.overallContainer {
11+
width: 90%;
12+
}
13+
}
14+
15+
.chart {
16+
display: grid;
17+
justify-content: center;
18+
align-items: center;
19+
grid-template-columns: repeat(53, 1fr); /* 53 weeks */
20+
gap: 2px;
21+
width: 100%;
22+
}
23+
.dayWrapper {
24+
position: relative;
25+
26+
.day {
27+
border-radius: 2px;
28+
transition: background-color 0.2s;
29+
cursor: pointer;
30+
width: inherit;
31+
height: auto;
32+
aspect-ratio: 1/1;
33+
}
34+
35+
.day:hover {
36+
outline: 1px solid #333;
37+
}
38+
39+
// optional: fancier tooltip
40+
.day:hover::after {
41+
content: attr(title);
42+
position: absolute;
43+
left: 50%;
44+
bottom: 100%;
45+
transform: translateX(-50%);
46+
white-space: pre; // preserves line breaks
47+
background: #1a1a2e;
48+
color: #fff;
49+
padding: 4px 8px;
50+
border-radius: 4px;
51+
font-size: 12px;
52+
pointer-events: none;
53+
z-index: 10;
54+
}
55+
}
56+
57+
.legend {
58+
display: flex;
59+
justify-content: center; // center the legend
60+
align-items: center;
61+
align-self: flex-start;
62+
margin-top: 16px;
63+
64+
.legendItem {
65+
display: flex;
66+
flex-direction: column;
67+
align-items: center;
68+
margin: 0 4px;
69+
70+
.legendColor {
71+
width: 14px;
72+
height: 14px;
73+
border-radius: 2px;
74+
margin-bottom: 2px;
75+
}
76+
77+
span {
78+
font-size: 12px;
79+
}
80+
}
81+
}
82+
83+
.box {
84+
width: 90%;
85+
display: flex;
86+
gap: 10px;
87+
flex-direction: row;
88+
flex-wrap: wrap;
89+
justify-content: center;
90+
align-items: center;
91+
margin-bottom: 48px;
92+
}
93+
94+
.elementBox {
95+
width: 200px;
96+
min-height: 200px;
97+
border-radius: 5px;
98+
border: 1px solid #49519a;
99+
box-shadow: 1px 1px 1px rgba(0, 0, 0, 0.4);
100+
display: grid;
101+
place-items: center;
102+
transition: 0.3s;
103+
background-color: #1a1b2b; // subtle background for better contrast
104+
}
105+
106+
.elementBox p {
107+
font-size: 1.2em;
108+
text-align: center;
109+
text-transform: capitalize;
110+
padding: 0;
111+
margin-top: 2px;
112+
}
113+
114+
.elementBox h2 {
115+
font-size: 3em;
116+
margin: 0;
117+
}
118+
119+
.elementBox ul {
120+
list-style: none;
121+
padding: 0;
122+
margin-top: 1em;
123+
font-size: 0.9em;
124+
text-transform: capitalize;
125+
}
126+
127+
.elementBox:hover {
128+
box-shadow: 16px 16px 16px rgba(0, 0, 0, 0.6);
129+
transform: scale(1.02);
130+
}
131+
132+
.elementBox ul {
133+
margin-top: 0.5em;
134+
padding-left: 0;
135+
list-style: none;
136+
}

0 commit comments

Comments
 (0)