483 lines
12 KiB
Vue
483 lines
12 KiB
Vue
<template>
|
|
<div class="git-contribution-chart">
|
|
<div class="flex justify-between align-items-center mb-4">
|
|
<h3 class="text-lg font-semibold m-0">Contributions {{ currentYear }}</h3>
|
|
<div v-if="!loading && totalContributions > 0" class="text-500">
|
|
<i class="pi pi-calendar mr-2"></i>
|
|
{{ totalContributions }} Commits
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="loading" class="text-center py-8">
|
|
<i class="pi pi-spin pi-spinner text-4xl text-primary"></i>
|
|
</div>
|
|
|
|
<div v-else-if="error" class="p-4 border-round surface-border border-1 text-center">
|
|
<i class="pi pi-exclamation-triangle text-orange-500 text-3xl mb-2"></i>
|
|
<p class="text-500">{{ error }}</p>
|
|
</div>
|
|
|
|
<div v-else-if="contributionGrid.length > 0" class="contribution-heatmap">
|
|
<div class="heatmap-wrapper">
|
|
<!-- Month labels -->
|
|
<div class="month-labels-row">
|
|
<div class="weekday-spacer"></div>
|
|
<div class="month-labels">
|
|
<div
|
|
v-for="(month, index) in monthLabels"
|
|
:key="index"
|
|
:style="{ gridColumn: `${month.startCol} / span ${month.colSpan}` }"
|
|
class="month-label"
|
|
>
|
|
{{ month.label }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Grid with weekday labels -->
|
|
<div class="grid-container">
|
|
<!-- Weekday labels on the left -->
|
|
<div class="weekday-labels">
|
|
<div class="weekday-label"></div>
|
|
<div class="weekday-label">Mo</div>
|
|
<div class="weekday-label"></div>
|
|
<div class="weekday-label">Mi</div>
|
|
<div class="weekday-label"></div>
|
|
<div class="weekday-label">Fr</div>
|
|
<div class="weekday-label"></div>
|
|
</div>
|
|
|
|
<!-- Heatmap grid -->
|
|
<div class="heatmap-grid">
|
|
<div
|
|
v-for="(day, index) in contributionGrid"
|
|
:key="index"
|
|
:class="['contribution-cell', getIntensityClass(day.count)]"
|
|
:style="{ gridRow: day.row, gridColumn: day.col }"
|
|
:title="day.tooltip"
|
|
>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="legend mt-3 flex gap-2 align-items-center justify-end">
|
|
<span class="text-sm text-500">Weniger</span>
|
|
<div class="legend-cell intensity-0"></div>
|
|
<div class="legend-cell intensity-1"></div>
|
|
<div class="legend-cell intensity-2"></div>
|
|
<div class="legend-cell intensity-3"></div>
|
|
<div class="legend-cell intensity-4"></div>
|
|
<span class="text-sm text-500">Mehr</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-else class="p-4 border-round surface-border border-1 text-center text-500">
|
|
<i class="pi pi-info-circle text-3xl mb-2"></i>
|
|
<p>Keine Contributions für {{ currentYear }} gefunden</p>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, computed, onMounted, watch } from 'vue'
|
|
import { useToast } from 'primevue/usetoast'
|
|
|
|
const props = defineProps({
|
|
repositoryId: {
|
|
type: Number,
|
|
required: true
|
|
},
|
|
branch: {
|
|
type: String,
|
|
default: 'main'
|
|
},
|
|
author: {
|
|
type: String,
|
|
default: null
|
|
},
|
|
year: {
|
|
type: Number,
|
|
default: null
|
|
}
|
|
})
|
|
|
|
const toast = useToast()
|
|
const contributionData = ref([])
|
|
const loading = ref(false)
|
|
const error = ref(null)
|
|
const currentYear = computed(() => props.year || new Date().getFullYear())
|
|
|
|
const totalContributions = computed(() => {
|
|
return contributionData.value.reduce((sum, day) => sum + day.count, 0)
|
|
})
|
|
|
|
const contributionGrid = computed(() => {
|
|
if (contributionData.value.length === 0) return []
|
|
|
|
const grid = []
|
|
const firstDay = new Date(currentYear.value, 0, 1)
|
|
|
|
// Find the first Sunday on or before January 1st to start the grid
|
|
const firstSunday = new Date(firstDay)
|
|
while (firstSunday.getDay() !== 0) {
|
|
firstSunday.setDate(firstSunday.getDate() - 1)
|
|
}
|
|
|
|
// Create a map of dates to contribution counts
|
|
const contributionMap = {}
|
|
contributionData.value.forEach(day => {
|
|
contributionMap[day.date] = day.count
|
|
})
|
|
|
|
// Debug: Log contribution map
|
|
console.log('Contribution map sample:', Object.entries(contributionMap).slice(0, 10))
|
|
|
|
let col = 1
|
|
let currentDate = new Date(firstSunday)
|
|
const lastDay = new Date(currentYear.value, 11, 31)
|
|
|
|
while (currentDate <= lastDay || currentDate.getDay() !== 0) {
|
|
const dateStr = currentDate.toISOString().split('T')[0]
|
|
const weekday = currentDate.getDay() // 0 = Sunday, 6 = Saturday
|
|
const row = weekday + 1 // Grid row: 1-7 (Sunday-Saturday)
|
|
|
|
// Only add cells for the current year
|
|
if (currentDate.getFullYear() === currentYear.value) {
|
|
const count = contributionMap[dateStr] || 0
|
|
const date = new Date(currentDate)
|
|
|
|
grid.push({
|
|
date: dateStr,
|
|
count: count,
|
|
row: row,
|
|
col: col,
|
|
tooltip: `${count} ${count === 1 ? 'Commit' : 'Commits'} am ${formatDateShort(date)}`
|
|
})
|
|
} else if (currentDate < firstDay) {
|
|
// Placeholder for days before the year starts
|
|
grid.push({
|
|
date: dateStr,
|
|
count: -1, // Special value for empty placeholder
|
|
row: row,
|
|
col: col,
|
|
tooltip: ''
|
|
})
|
|
}
|
|
|
|
// Move to next column on Saturday
|
|
if (weekday === 6) {
|
|
col++
|
|
}
|
|
|
|
currentDate.setDate(currentDate.getDate() + 1)
|
|
}
|
|
|
|
// Debug: Log grid sample
|
|
const gridWithCommits = grid.filter(g => g.count > 0)
|
|
console.log('Grid cells with commits:', gridWithCommits.length)
|
|
console.log('Sample cells with commits:', gridWithCommits.slice(0, 5))
|
|
|
|
return grid
|
|
})
|
|
|
|
const monthLabels = computed(() => {
|
|
if (contributionGrid.value.length === 0) return []
|
|
|
|
const labels = []
|
|
const months = [
|
|
{ name: 'Jan', index: 0 },
|
|
{ name: 'Feb', index: 1 },
|
|
{ name: 'Mär', index: 2 },
|
|
{ name: 'Apr', index: 3 },
|
|
{ name: 'Mai', index: 4 },
|
|
{ name: 'Jun', index: 5 },
|
|
{ name: 'Jul', index: 6 },
|
|
{ name: 'Aug', index: 7 },
|
|
{ name: 'Sep', index: 8 },
|
|
{ name: 'Okt', index: 9 },
|
|
{ name: 'Nov', index: 10 },
|
|
{ name: 'Dez', index: 11 }
|
|
]
|
|
|
|
const firstDayOfYear = new Date(currentYear.value, 0, 1)
|
|
const firstSunday = new Date(firstDayOfYear)
|
|
while (firstSunday.getDay() !== 0) {
|
|
firstSunday.setDate(firstSunday.getDate() - 1)
|
|
}
|
|
|
|
months.forEach(month => {
|
|
const firstDayOfMonth = new Date(currentYear.value, month.index, 1)
|
|
const daysSinceFirstSunday = Math.floor((firstDayOfMonth - firstSunday) / (1000 * 60 * 60 * 24))
|
|
const col = Math.floor(daysSinceFirstSunday / 7) + 1
|
|
|
|
// Calculate how many weeks this month spans
|
|
const lastDayOfMonth = new Date(currentYear.value, month.index + 1, 0)
|
|
const daysSinceFirstSundayEnd = Math.floor((lastDayOfMonth - firstSunday) / (1000 * 60 * 60 * 24))
|
|
const endCol = Math.floor(daysSinceFirstSundayEnd / 7) + 1
|
|
const colSpan = Math.max(1, endCol - col + 1)
|
|
|
|
// Only show month label if there's enough space (at least 2 columns)
|
|
if (colSpan >= 2) {
|
|
labels.push({
|
|
label: month.name,
|
|
startCol: col,
|
|
colSpan: colSpan
|
|
})
|
|
}
|
|
})
|
|
|
|
return labels
|
|
})
|
|
|
|
const chartData = computed(() => {
|
|
const labels = contributionData.value.map(d => d.date)
|
|
const data = contributionData.value.map(d => d.count)
|
|
|
|
return {
|
|
labels,
|
|
datasets: [
|
|
{
|
|
label: 'Commits pro Tag',
|
|
data,
|
|
backgroundColor: '#10b981',
|
|
borderColor: '#059669',
|
|
borderWidth: 1
|
|
}
|
|
]
|
|
}
|
|
})
|
|
|
|
const chartOptions = ref({
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
plugins: {
|
|
legend: {
|
|
display: false
|
|
},
|
|
tooltip: {
|
|
callbacks: {
|
|
label: (context) => {
|
|
return `${context.parsed.y} Commits`
|
|
}
|
|
}
|
|
}
|
|
},
|
|
scales: {
|
|
x: {
|
|
grid: {
|
|
display: false
|
|
},
|
|
ticks: {
|
|
maxRotation: 45,
|
|
minRotation: 45
|
|
}
|
|
},
|
|
y: {
|
|
beginAtZero: true,
|
|
ticks: {
|
|
stepSize: 1
|
|
}
|
|
}
|
|
}
|
|
})
|
|
|
|
onMounted(() => {
|
|
loadContributions()
|
|
})
|
|
|
|
watch(() => [props.repositoryId, props.branch, props.author, props.year], () => {
|
|
loadContributions()
|
|
})
|
|
|
|
async function loadContributions() {
|
|
loading.value = true
|
|
error.value = null
|
|
|
|
try {
|
|
let url = `/api/git-repos/${props.repositoryId}/contributions?branch=${props.branch}&year=${currentYear.value}`
|
|
if (props.author) {
|
|
url += `&author=${encodeURIComponent(props.author)}`
|
|
}
|
|
|
|
const response = await fetch(url)
|
|
|
|
if (!response.ok) {
|
|
const errorData = await response.json()
|
|
throw new Error(errorData.error || 'Fehler beim Laden der Contributions')
|
|
}
|
|
|
|
const data = await response.json()
|
|
contributionData.value = data.contributions || []
|
|
|
|
// Debug: Log the data to check format
|
|
console.log('Contribution data received:', contributionData.value.slice(0, 5))
|
|
console.log('Total days:', contributionData.value.length)
|
|
console.log('Days with commits:', contributionData.value.filter(d => d.count > 0).length)
|
|
} catch (err) {
|
|
console.error('Error loading contributions:', err)
|
|
error.value = err.message
|
|
toast.add({
|
|
severity: 'error',
|
|
summary: 'Fehler',
|
|
detail: err.message || 'Contributions konnten nicht geladen werden',
|
|
life: 3000
|
|
})
|
|
contributionData.value = []
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
function getIntensityClass(count) {
|
|
if (count < 0) return 'intensity-empty' // Placeholder cells
|
|
if (count === 0) return 'intensity-0'
|
|
if (count <= 2) return 'intensity-1'
|
|
if (count <= 5) return 'intensity-2'
|
|
if (count <= 10) return 'intensity-3'
|
|
return 'intensity-4'
|
|
}
|
|
|
|
function formatDate(dateString) {
|
|
if (!dateString) return 'Invalid Date'
|
|
const date = new Date(dateString)
|
|
if (isNaN(date.getTime())) return 'Invalid Date'
|
|
return date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })
|
|
}
|
|
|
|
function formatDateShort(date) {
|
|
return date.toLocaleDateString('de-DE', {
|
|
day: 'numeric',
|
|
month: 'short',
|
|
year: 'numeric'
|
|
})
|
|
}
|
|
|
|
defineExpose({
|
|
loadContributions
|
|
})
|
|
</script>
|
|
|
|
<style scoped>
|
|
.git-contribution-chart {
|
|
width: 100%;
|
|
}
|
|
|
|
.contribution-heatmap {
|
|
margin-top: 1rem;
|
|
}
|
|
|
|
.heatmap-wrapper {
|
|
overflow-x: auto;
|
|
padding: 0.5rem 0;
|
|
}
|
|
|
|
.month-labels-row {
|
|
display: flex;
|
|
margin-bottom: 0.5rem;
|
|
}
|
|
|
|
.weekday-spacer {
|
|
width: 30px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.month-labels {
|
|
display: grid;
|
|
grid-auto-flow: column;
|
|
grid-auto-columns: 13px;
|
|
gap: 3px;
|
|
flex: 1;
|
|
margin-left: 3px;
|
|
}
|
|
|
|
.month-label {
|
|
font-size: 0.75rem;
|
|
color: var(--text-color-secondary);
|
|
text-align: left;
|
|
}
|
|
|
|
.grid-container {
|
|
display: flex;
|
|
gap: 3px;
|
|
}
|
|
|
|
.weekday-labels {
|
|
display: grid;
|
|
grid-template-rows: repeat(7, 13px);
|
|
gap: 3px;
|
|
width: 30px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.weekday-label {
|
|
font-size: 0.65rem;
|
|
color: var(--text-color-secondary);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: flex-start;
|
|
height: 13px;
|
|
}
|
|
|
|
.heatmap-grid {
|
|
display: grid;
|
|
grid-template-rows: repeat(7, 13px);
|
|
grid-auto-flow: column;
|
|
grid-auto-columns: 13px;
|
|
gap: 3px;
|
|
}
|
|
|
|
.contribution-cell {
|
|
border-radius: 2px;
|
|
cursor: pointer;
|
|
transition: all 0.15s ease;
|
|
min-height: 13px;
|
|
min-width: 13px;
|
|
}
|
|
|
|
.contribution-cell:not(.intensity-empty):hover {
|
|
outline: 2px solid rgba(0, 0, 0, 0.3);
|
|
outline-offset: 0;
|
|
}
|
|
|
|
.intensity-empty {
|
|
background-color: transparent;
|
|
cursor: default;
|
|
}
|
|
|
|
.intensity-0 {
|
|
background-color: var(--surface-200);
|
|
}
|
|
|
|
.intensity-1 {
|
|
background-color: #9be9a8;
|
|
}
|
|
|
|
.intensity-2 {
|
|
background-color: #40c463;
|
|
}
|
|
|
|
.intensity-3 {
|
|
background-color: #30a14e;
|
|
}
|
|
|
|
.intensity-4 {
|
|
background-color: #216e39;
|
|
}
|
|
|
|
:global(.dark) .intensity-0 {
|
|
background-color: var(--surface-700);
|
|
}
|
|
|
|
.legend {
|
|
display: flex;
|
|
align-items: center;
|
|
}
|
|
|
|
.legend-cell {
|
|
width: 14px;
|
|
height: 14px;
|
|
border-radius: 2px;
|
|
}
|
|
</style>
|