myCRM/assets/js/components/GitContributionChart.vue
olli 3c36fdd9a1 feat: Add Document and GitRepository entities with repositories and services
- Created Document entity with properties for file management, including filename, originalFilename, mimeType, size, uploadedAt, uploadedBy, relatedEntity, relatedEntityId, and description.
- Implemented DocumentRepository for querying documents by related entity and user.
- Added GitRepository entity with properties for managing Git repositories, including URL, localPath, branch, provider, accessToken, project, lastSync, name, description, createdAt, and updatedAt.
- Implemented GitRepositoryRepository for querying repositories by project.
- Developed GitHubService for interacting with GitHub API, including methods for fetching commits, contributions, branches, and repository info.
- Developed GitService for local Git repository interactions, including methods for fetching commits, contributions, branches, and repository info.
- Developed GiteaService for interacting with Gitea API, including methods for fetching commits, contributions, branches, repository info, and testing connection.
2025-11-12 16:14:18 +01:00

292 lines
6.1 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 im letzten Jahr</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="contributionData.length === 0" class="p-4 border-round surface-border border-1 text-center">
<i class="pi pi-info-circle text-blue-500 text-3xl mb-2"></i>
<p class="text-500">Keine Contributions im letzten Jahr</p>
</div>
<div v-else>
<!-- Heatmap-style visualization (similar to GitHub) -->
<div class="contribution-heatmap">
<div class="heatmap-grid">
<div
v-for="week in contributionData"
:key="week.week"
class="heatmap-cell"
:class="getIntensityClass(week.count)"
:title="`KW ${week.weekNumber} ${week.year}: ${week.count} Commits`"
>
<span class="cell-count">{{ week.count }}</span>
</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 level-0"></div>
<div class="legend-cell level-1"></div>
<div class="legend-cell level-2"></div>
<div class="legend-cell level-3"></div>
<div class="legend-cell level-4"></div>
<span class="text-sm text-500">Mehr</span>
</div>
</div>
<!-- Bar Chart alternative -->
<div class="mt-4">
<Chart type="bar" :data="chartData" :options="chartOptions" class="h-20rem" />
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import Chart from 'primevue/chart'
import { useToast } from 'primevue/usetoast'
const props = defineProps({
repositoryId: {
type: Number,
required: true
},
branch: {
type: String,
default: 'main'
},
author: {
type: String,
default: null
}
})
const toast = useToast()
const contributionData = ref([])
const loading = ref(false)
const error = ref(null)
const totalContributions = computed(() => {
return contributionData.value.reduce((sum, week) => sum + week.count, 0)
})
const chartData = computed(() => {
const labels = contributionData.value.map(w => `KW ${w.weekNumber}`)
const data = contributionData.value.map(w => w.count)
return {
labels,
datasets: [
{
label: 'Commits pro Woche',
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], () => {
loadContributions()
})
async function loadContributions() {
loading.value = true
error.value = null
try {
let url = `/api/git-repos/${props.repositoryId}/contributions?branch=${props.branch}`
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 || []
} 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 'level-0'
if (count <= 2) return 'level-1'
if (count <= 5) return 'level-2'
if (count <= 10) return 'level-3'
return 'level-4'
}
defineExpose({
loadContributions
})
</script>
<style scoped>
.git-contribution-chart {
width: 100%;
}
.contribution-heatmap {
width: 100%;
}
.heatmap-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(16px, 1fr));
gap: 3px;
max-width: 100%;
}
.heatmap-cell {
position: relative;
aspect-ratio: 1;
border-radius: 3px;
cursor: pointer;
transition: all 0.2s;
}
.heatmap-cell:hover {
transform: scale(1.2);
z-index: 10;
}
.cell-count {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 10px;
font-weight: 600;
color: white;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
opacity: 0;
transition: opacity 0.2s;
}
.heatmap-cell:hover .cell-count {
opacity: 1;
}
.level-0 {
background-color: var(--surface-200);
}
.level-1 {
background-color: #9be9a8;
}
.level-2 {
background-color: #40c463;
}
.level-3 {
background-color: #30a14e;
}
.level-4 {
background-color: #216e39;
}
.legend {
display: flex;
align-items: center;
}
.legend-cell {
width: 14px;
height: 14px;
border-radius: 2px;
}
.legend-cell.level-0 {
background-color: var(--surface-200);
}
.legend-cell.level-1 {
background-color: #9be9a8;
}
.legend-cell.level-2 {
background-color: #40c463;
}
.legend-cell.level-3 {
background-color: #30a14e;
}
.legend-cell.level-4 {
background-color: #216e39;
}
</style>