feat: Enhance contribution tracking with yearly granularity and improved data visualization
This commit is contained in:
parent
3c36fdd9a1
commit
404dc2c73a
@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="git-contribution-chart">
|
<div class="git-contribution-chart">
|
||||||
<div class="flex justify-between align-items-center mb-4">
|
<div class="flex justify-between align-items-center mb-4">
|
||||||
<h3 class="text-lg font-semibold m-0">Contributions im letzten Jahr</h3>
|
<h3 class="text-lg font-semibold m-0">Contributions {{ currentYear }}</h3>
|
||||||
<div v-if="!loading && totalContributions > 0" class="text-500">
|
<div v-if="!loading && totalContributions > 0" class="text-500">
|
||||||
<i class="pi pi-calendar mr-2"></i>
|
<i class="pi pi-calendar mr-2"></i>
|
||||||
{{ totalContributions }} Commits
|
{{ totalContributions }} Commits
|
||||||
@ -17,48 +17,70 @@
|
|||||||
<p class="text-500">{{ error }}</p>
|
<p class="text-500">{{ error }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="contributionData.length === 0" class="p-4 border-round surface-border border-1 text-center">
|
<div v-else-if="contributionGrid.length > 0" class="contribution-heatmap">
|
||||||
<i class="pi pi-info-circle text-blue-500 text-3xl mb-2"></i>
|
<div class="heatmap-wrapper">
|
||||||
<p class="text-500">Keine Contributions im letzten Jahr</p>
|
<!-- Month labels -->
|
||||||
</div>
|
<div class="month-labels-row">
|
||||||
|
<div class="weekday-spacer"></div>
|
||||||
<div v-else>
|
<div class="month-labels">
|
||||||
<!-- Heatmap-style visualization (similar to GitHub) -->
|
<div
|
||||||
<div class="contribution-heatmap">
|
v-for="(month, index) in monthLabels"
|
||||||
<div class="heatmap-grid">
|
:key="index"
|
||||||
<div
|
:style="{ gridColumn: `${month.startCol} / span ${month.colSpan}` }"
|
||||||
v-for="week in contributionData"
|
class="month-label"
|
||||||
:key="week.week"
|
>
|
||||||
class="heatmap-cell"
|
{{ month.label }}
|
||||||
:class="getIntensityClass(week.count)"
|
</div>
|
||||||
:title="`KW ${week.weekNumber} ${week.year}: ${week.count} Commits`"
|
|
||||||
>
|
|
||||||
<span class="cell-count">{{ week.count }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="legend mt-3 flex gap-2 align-items-center justify-end">
|
<!-- Grid with weekday labels -->
|
||||||
<span class="text-sm text-500">Weniger</span>
|
<div class="grid-container">
|
||||||
<div class="legend-cell level-0"></div>
|
<!-- Weekday labels on the left -->
|
||||||
<div class="legend-cell level-1"></div>
|
<div class="weekday-labels">
|
||||||
<div class="legend-cell level-2"></div>
|
<div class="weekday-label"></div>
|
||||||
<div class="legend-cell level-3"></div>
|
<div class="weekday-label">Mo</div>
|
||||||
<div class="legend-cell level-4"></div>
|
<div class="weekday-label"></div>
|
||||||
<span class="text-sm text-500">Mehr</span>
|
<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>
|
</div>
|
||||||
|
|
||||||
<!-- Bar Chart alternative -->
|
<div class="legend mt-3 flex gap-2 align-items-center justify-end">
|
||||||
<div class="mt-4">
|
<span class="text-sm text-500">Weniger</span>
|
||||||
<Chart type="bar" :data="chartData" :options="chartOptions" class="h-20rem" />
|
<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>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted, watch } from 'vue'
|
import { ref, computed, onMounted, watch } from 'vue'
|
||||||
import Chart from 'primevue/chart'
|
|
||||||
import { useToast } from 'primevue/usetoast'
|
import { useToast } from 'primevue/usetoast'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@ -73,6 +95,10 @@ const props = defineProps({
|
|||||||
author: {
|
author: {
|
||||||
type: String,
|
type: String,
|
||||||
default: null
|
default: null
|
||||||
|
},
|
||||||
|
year: {
|
||||||
|
type: Number,
|
||||||
|
default: null
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -80,20 +106,139 @@ const toast = useToast()
|
|||||||
const contributionData = ref([])
|
const contributionData = ref([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const error = ref(null)
|
const error = ref(null)
|
||||||
|
const currentYear = computed(() => props.year || new Date().getFullYear())
|
||||||
|
|
||||||
const totalContributions = computed(() => {
|
const totalContributions = computed(() => {
|
||||||
return contributionData.value.reduce((sum, week) => sum + week.count, 0)
|
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 chartData = computed(() => {
|
||||||
const labels = contributionData.value.map(w => `KW ${w.weekNumber}`)
|
const labels = contributionData.value.map(d => d.date)
|
||||||
const data = contributionData.value.map(w => w.count)
|
const data = contributionData.value.map(d => d.count)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
labels,
|
labels,
|
||||||
datasets: [
|
datasets: [
|
||||||
{
|
{
|
||||||
label: 'Commits pro Woche',
|
label: 'Commits pro Tag',
|
||||||
data,
|
data,
|
||||||
backgroundColor: '#10b981',
|
backgroundColor: '#10b981',
|
||||||
borderColor: '#059669',
|
borderColor: '#059669',
|
||||||
@ -141,7 +286,7 @@ onMounted(() => {
|
|||||||
loadContributions()
|
loadContributions()
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(() => [props.repositoryId, props.branch, props.author], () => {
|
watch(() => [props.repositoryId, props.branch, props.author, props.year], () => {
|
||||||
loadContributions()
|
loadContributions()
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -150,7 +295,7 @@ async function loadContributions() {
|
|||||||
error.value = null
|
error.value = null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let url = `/api/git-repos/${props.repositoryId}/contributions?branch=${props.branch}`
|
let url = `/api/git-repos/${props.repositoryId}/contributions?branch=${props.branch}&year=${currentYear.value}`
|
||||||
if (props.author) {
|
if (props.author) {
|
||||||
url += `&author=${encodeURIComponent(props.author)}`
|
url += `&author=${encodeURIComponent(props.author)}`
|
||||||
}
|
}
|
||||||
@ -164,6 +309,11 @@ async function loadContributions() {
|
|||||||
|
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
contributionData.value = data.contributions || []
|
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) {
|
} catch (err) {
|
||||||
console.error('Error loading contributions:', err)
|
console.error('Error loading contributions:', err)
|
||||||
error.value = err.message
|
error.value = err.message
|
||||||
@ -180,11 +330,27 @@ async function loadContributions() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getIntensityClass(count) {
|
function getIntensityClass(count) {
|
||||||
if (count === 0) return 'level-0'
|
if (count < 0) return 'intensity-empty' // Placeholder cells
|
||||||
if (count <= 2) return 'level-1'
|
if (count === 0) return 'intensity-0'
|
||||||
if (count <= 5) return 'level-2'
|
if (count <= 2) return 'intensity-1'
|
||||||
if (count <= 10) return 'level-3'
|
if (count <= 5) return 'intensity-2'
|
||||||
return 'level-4'
|
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({
|
defineExpose({
|
||||||
@ -198,66 +364,111 @@ defineExpose({
|
|||||||
}
|
}
|
||||||
|
|
||||||
.contribution-heatmap {
|
.contribution-heatmap {
|
||||||
width: 100%;
|
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 {
|
.heatmap-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(16px, 1fr));
|
grid-template-rows: repeat(7, 13px);
|
||||||
|
grid-auto-flow: column;
|
||||||
|
grid-auto-columns: 13px;
|
||||||
gap: 3px;
|
gap: 3px;
|
||||||
max-width: 100%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.heatmap-cell {
|
.contribution-cell {
|
||||||
position: relative;
|
border-radius: 2px;
|
||||||
aspect-ratio: 1;
|
|
||||||
border-radius: 3px;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
transition: all 0.15s ease;
|
||||||
|
min-height: 13px;
|
||||||
|
min-width: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.heatmap-cell:hover {
|
.contribution-cell:not(.intensity-empty):hover {
|
||||||
transform: scale(1.2);
|
outline: 2px solid rgba(0, 0, 0, 0.3);
|
||||||
z-index: 10;
|
outline-offset: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cell-count {
|
.intensity-empty {
|
||||||
position: absolute;
|
background-color: transparent;
|
||||||
top: 50%;
|
cursor: default;
|
||||||
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 {
|
.intensity-0 {
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.level-0 {
|
|
||||||
background-color: var(--surface-200);
|
background-color: var(--surface-200);
|
||||||
}
|
}
|
||||||
|
|
||||||
.level-1 {
|
.intensity-1 {
|
||||||
background-color: #9be9a8;
|
background-color: #9be9a8;
|
||||||
}
|
}
|
||||||
|
|
||||||
.level-2 {
|
.intensity-2 {
|
||||||
background-color: #40c463;
|
background-color: #40c463;
|
||||||
}
|
}
|
||||||
|
|
||||||
.level-3 {
|
.intensity-3 {
|
||||||
background-color: #30a14e;
|
background-color: #30a14e;
|
||||||
}
|
}
|
||||||
|
|
||||||
.level-4 {
|
.intensity-4 {
|
||||||
background-color: #216e39;
|
background-color: #216e39;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:global(.dark) .intensity-0 {
|
||||||
|
background-color: var(--surface-700);
|
||||||
|
}
|
||||||
|
|
||||||
.legend {
|
.legend {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -268,24 +479,4 @@ defineExpose({
|
|||||||
height: 14px;
|
height: 14px;
|
||||||
border-radius: 2px;
|
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>
|
</style>
|
||||||
|
|||||||
@ -686,7 +686,7 @@
|
|||||||
|
|
||||||
<div v-else class="flex flex-col gap-4">
|
<div v-else class="flex flex-col gap-4">
|
||||||
<!-- Repository Selector -->
|
<!-- Repository Selector -->
|
||||||
<div class="flex gap-3 align-items-center">
|
<div class="flex gap-3 align-items-center flex-wrap">
|
||||||
<label class="font-medium">Repository:</label>
|
<label class="font-medium">Repository:</label>
|
||||||
<Select
|
<Select
|
||||||
v-model="selectedRepository"
|
v-model="selectedRepository"
|
||||||
@ -702,6 +702,15 @@
|
|||||||
placeholder="Branch (z.B. main)"
|
placeholder="Branch (z.B. main)"
|
||||||
class="w-15rem"
|
class="w-15rem"
|
||||||
/>
|
/>
|
||||||
|
<label v-if="selectedRepository" class="font-medium">Jahr:</label>
|
||||||
|
<InputNumber
|
||||||
|
v-if="selectedRepository"
|
||||||
|
v-model="selectedYear"
|
||||||
|
:min="2000"
|
||||||
|
:max="2100"
|
||||||
|
:use-grouping="false"
|
||||||
|
class="w-10rem"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Contribution Chart -->
|
<!-- Contribution Chart -->
|
||||||
@ -709,6 +718,7 @@
|
|||||||
<GitContributionChart
|
<GitContributionChart
|
||||||
:repository-id="selectedRepository.id"
|
:repository-id="selectedRepository.id"
|
||||||
:branch="selectedBranch"
|
:branch="selectedBranch"
|
||||||
|
:year="selectedYear"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -771,6 +781,7 @@ const statuses = ref([])
|
|||||||
const gitRepositories = ref([])
|
const gitRepositories = ref([])
|
||||||
const selectedRepository = ref(null)
|
const selectedRepository = ref(null)
|
||||||
const selectedBranch = ref('main')
|
const selectedBranch = ref('main')
|
||||||
|
const selectedYear = ref(new Date().getFullYear())
|
||||||
|
|
||||||
// Git Repository Management
|
// Git Repository Management
|
||||||
const editGitRepositories = ref([])
|
const editGitRepositories = ref([])
|
||||||
|
|||||||
@ -87,17 +87,19 @@ class GitRepositoryController extends AbstractController
|
|||||||
try {
|
try {
|
||||||
$branch = $request->query->get('branch', $gitRepo->getBranch() ?? 'main');
|
$branch = $request->query->get('branch', $gitRepo->getBranch() ?? 'main');
|
||||||
$author = $request->query->get('author');
|
$author = $request->query->get('author');
|
||||||
|
$year = (int)$request->query->get('year', date('Y'));
|
||||||
|
|
||||||
// Choose service based on provider
|
// Choose service based on provider
|
||||||
if ($gitRepo->getProvider() === 'github') {
|
if ($gitRepo->getProvider() === 'github') {
|
||||||
$contributions = $this->getContributionsFromGitHub($gitRepo);
|
$contributions = $this->getContributionsFromGitHub($gitRepo, $year);
|
||||||
} elseif ($gitRepo->getProvider() === 'gitea') {
|
} elseif ($gitRepo->getProvider() === 'gitea') {
|
||||||
$contributions = $this->getContributionsFromGitea($gitRepo, $branch, $author);
|
$contributions = $this->getContributionsFromGitea($gitRepo, $branch, $author, $year);
|
||||||
} elseif ($gitRepo->getLocalPath()) {
|
} elseif ($gitRepo->getLocalPath()) {
|
||||||
$contributions = $this->gitService->getContributions(
|
$contributions = $this->gitService->getContributions(
|
||||||
$gitRepo->getLocalPath(),
|
$gitRepo->getLocalPath(),
|
||||||
$branch,
|
$branch,
|
||||||
$author
|
$author,
|
||||||
|
$year
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
return $this->json([
|
return $this->json([
|
||||||
@ -117,6 +119,7 @@ class GitRepositoryController extends AbstractController
|
|||||||
'provider' => $gitRepo->getProvider()
|
'provider' => $gitRepo->getProvider()
|
||||||
],
|
],
|
||||||
'contributions' => $contributions,
|
'contributions' => $contributions,
|
||||||
|
'year' => $year,
|
||||||
'total' => array_sum(array_column($contributions, 'count'))
|
'total' => array_sum(array_column($contributions, 'count'))
|
||||||
]);
|
]);
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
@ -126,16 +129,16 @@ class GitRepositoryController extends AbstractController
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private function getContributionsFromGitHub(GitRepository $gitRepo): array
|
private function getContributionsFromGitHub(GitRepository $gitRepo, int $year): array
|
||||||
{
|
{
|
||||||
$parsed = GitHubService::parseGitHubUrl($gitRepo->getUrl());
|
$parsed = GitHubService::parseGitHubUrl($gitRepo->getUrl());
|
||||||
return $this->githubService->getContributions($parsed['owner'], $parsed['repo']);
|
return $this->githubService->getContributions($parsed['owner'], $parsed['repo'], $year);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function getContributionsFromGitea(GitRepository $gitRepo, string $branch, ?string $author): array
|
private function getContributionsFromGitea(GitRepository $gitRepo, string $branch, ?string $author, int $year): array
|
||||||
{
|
{
|
||||||
$parsed = GiteaService::parseGiteaUrl($gitRepo->getUrl());
|
$parsed = GiteaService::parseGiteaUrl($gitRepo->getUrl());
|
||||||
return $this->giteaService->getContributions($parsed['baseUrl'], $parsed['owner'], $parsed['repo'], $branch, $author);
|
return $this->giteaService->getContributions($parsed['baseUrl'], $parsed['owner'], $parsed['repo'], $branch, $author, $year);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Route('/{id}/branches', name: 'api_git_repo_branches', methods: ['GET'])]
|
#[Route('/{id}/branches', name: 'api_git_repo_branches', methods: ['GET'])]
|
||||||
|
|||||||
@ -68,9 +68,16 @@ class GitHubService
|
|||||||
* @param string $repo Repository name
|
* @param string $repo Repository name
|
||||||
* @return array Weekly contribution data
|
* @return array Weekly contribution data
|
||||||
*/
|
*/
|
||||||
public function getContributions(string $owner, string $repo): array
|
public function getContributions(string $owner, string $repo, ?int $year = null): array
|
||||||
{
|
{
|
||||||
$url = sprintf('%s/repos/%s/%s/stats/commit_activity', self::GITHUB_API, $owner, $repo);
|
// GitHub's stats API only provides weekly data, so we need to use commits API
|
||||||
|
// to get daily granularity
|
||||||
|
$targetYear = $year ?? (int)date('Y');
|
||||||
|
$since = sprintf('%d-01-01T00:00:00Z', $targetYear);
|
||||||
|
$until = sprintf('%d-12-31T23:59:59Z', $targetYear);
|
||||||
|
|
||||||
|
$url = sprintf('%s/repos/%s/%s/commits?since=%s&until=%s&per_page=100',
|
||||||
|
self::GITHUB_API, $owner, $repo, $since, $until);
|
||||||
|
|
||||||
$options = [];
|
$options = [];
|
||||||
if ($this->githubToken) {
|
if ($this->githubToken) {
|
||||||
@ -80,19 +87,49 @@ class GitHubService
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
$dailyContributions = [];
|
||||||
|
|
||||||
|
// Fetch commits (may need pagination for repositories with many commits)
|
||||||
$response = $this->httpClient->request('GET', $url, $options);
|
$response = $this->httpClient->request('GET', $url, $options);
|
||||||
$weeklyStats = $response->toArray();
|
$commits = $response->toArray();
|
||||||
|
|
||||||
return array_map(function ($week) {
|
// Group by day
|
||||||
$date = new \DateTime('@' . $week['week']);
|
foreach ($commits as $commit) {
|
||||||
return [
|
if (isset($commit['commit']['author']['date'])) {
|
||||||
'week' => $date->format('Y-W'),
|
$date = new \DateTime($commit['commit']['author']['date']);
|
||||||
'year' => (int)$date->format('Y'),
|
$dayKey = $date->format('Y-m-d');
|
||||||
'weekNumber' => (int)$date->format('W'),
|
|
||||||
'startDate' => $date->format('Y-m-d'),
|
if (!isset($dailyContributions[$dayKey])) {
|
||||||
'count' => $week['total']
|
$dailyContributions[$dayKey] = [
|
||||||
];
|
'date' => $dayKey,
|
||||||
}, $weeklyStats);
|
'count' => 0,
|
||||||
|
'weekday' => (int)$date->format('N')
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$dailyContributions[$dayKey]['count']++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fill in missing days with 0 commits
|
||||||
|
$startDate = new \DateTime($since);
|
||||||
|
$endDate = new \DateTime($until);
|
||||||
|
|
||||||
|
for ($date = clone $startDate; $date <= $endDate; $date->modify('+1 day')) {
|
||||||
|
$dayKey = $date->format('Y-m-d');
|
||||||
|
|
||||||
|
if (!isset($dailyContributions[$dayKey])) {
|
||||||
|
$dailyContributions[$dayKey] = [
|
||||||
|
'date' => $dayKey,
|
||||||
|
'count' => 0,
|
||||||
|
'weekday' => (int)$date->format('N')
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ksort($dailyContributions);
|
||||||
|
return array_values($dailyContributions);
|
||||||
|
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
throw new \RuntimeException('Failed to fetch contribution stats from GitHub: ' . $e->getMessage());
|
throw new \RuntimeException('Failed to fetch contribution stats from GitHub: ' . $e->getMessage());
|
||||||
}
|
}
|
||||||
|
|||||||
@ -74,14 +74,16 @@ class GitService
|
|||||||
* @param string|null $author Filter by author email (optional)
|
* @param string|null $author Filter by author email (optional)
|
||||||
* @return array Array of weekly contribution data
|
* @return array Array of weekly contribution data
|
||||||
*/
|
*/
|
||||||
public function getContributions(string $repositoryPath, string $branch = 'main', ?string $author = null): array
|
public function getContributions(string $repositoryPath, string $branch = 'main', ?string $author = null, ?int $year = null): array
|
||||||
{
|
{
|
||||||
if (!is_dir($repositoryPath) || !is_dir($repositoryPath . '/.git')) {
|
if (!is_dir($repositoryPath) || !is_dir($repositoryPath . '/.git')) {
|
||||||
throw new \InvalidArgumentException('Invalid Git repository path');
|
throw new \InvalidArgumentException('Invalid Git repository path');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get commits from the last year
|
// Use specified year or current year
|
||||||
$since = (new \DateTime())->modify('-1 year')->format('Y-m-d');
|
$targetYear = $year ?? (int)date('Y');
|
||||||
|
$since = sprintf('%d-01-01', $targetYear);
|
||||||
|
$until = sprintf('%d-12-31', $targetYear);
|
||||||
|
|
||||||
$command = [
|
$command = [
|
||||||
'git',
|
'git',
|
||||||
@ -90,7 +92,8 @@ class GitService
|
|||||||
'log',
|
'log',
|
||||||
$branch,
|
$branch,
|
||||||
'--format=%aI',
|
'--format=%aI',
|
||||||
'--since=' . $since
|
'--since=' . $since,
|
||||||
|
'--until=' . $until
|
||||||
];
|
];
|
||||||
|
|
||||||
if ($author) {
|
if ($author) {
|
||||||
@ -107,30 +110,45 @@ class GitService
|
|||||||
$output = $process->getOutput();
|
$output = $process->getOutput();
|
||||||
$dates = array_filter(explode("\n", trim($output)));
|
$dates = array_filter(explode("\n", trim($output)));
|
||||||
|
|
||||||
// Group commits by week
|
// Group commits by day
|
||||||
$weeklyContributions = [];
|
$dailyContributions = [];
|
||||||
|
|
||||||
foreach ($dates as $dateString) {
|
foreach ($dates as $dateString) {
|
||||||
$date = new \DateTime($dateString);
|
$date = new \DateTime($dateString);
|
||||||
$weekKey = $date->format('Y-W'); // Year-Week format
|
// Use Y-m-d format to match frontend expectation
|
||||||
|
$dayKey = $date->format('Y-m-d');
|
||||||
|
|
||||||
if (!isset($weeklyContributions[$weekKey])) {
|
if (!isset($dailyContributions[$dayKey])) {
|
||||||
$weeklyContributions[$weekKey] = [
|
$dailyContributions[$dayKey] = [
|
||||||
'week' => $weekKey,
|
'date' => $dayKey,
|
||||||
'year' => (int)$date->format('Y'),
|
'count' => 0,
|
||||||
'weekNumber' => (int)$date->format('W'),
|
'weekday' => (int)$date->format('N') // 1 = Monday, 7 = Sunday
|
||||||
'startDate' => $date->modify('monday this week')->format('Y-m-d'),
|
|
||||||
'count' => 0
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
$weeklyContributions[$weekKey]['count']++;
|
$dailyContributions[$dayKey]['count']++;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort by week
|
// Fill in missing days with 0 commits to create complete year grid
|
||||||
ksort($weeklyContributions);
|
$startDate = new \DateTime($since);
|
||||||
|
$endDate = new \DateTime($until);
|
||||||
|
|
||||||
|
for ($date = clone $startDate; $date <= $endDate; $date->modify('+1 day')) {
|
||||||
|
$dayKey = $date->format('Y-m-d');
|
||||||
|
|
||||||
|
if (!isset($dailyContributions[$dayKey])) {
|
||||||
|
$dailyContributions[$dayKey] = [
|
||||||
|
'date' => $dayKey,
|
||||||
|
'count' => 0,
|
||||||
|
'weekday' => (int)$date->format('N')
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return array_values($weeklyContributions);
|
// Sort by date
|
||||||
|
ksort($dailyContributions);
|
||||||
|
|
||||||
|
return array_values($dailyContributions);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -72,46 +72,60 @@ class GiteaService
|
|||||||
* @param string|null $author Filter by author email
|
* @param string|null $author Filter by author email
|
||||||
* @return array Weekly contribution data
|
* @return array Weekly contribution data
|
||||||
*/
|
*/
|
||||||
public function getContributions(string $baseUrl, string $owner, string $repo, string $branch = 'main', ?string $author = null): array
|
public function getContributions(string $baseUrl, string $owner, string $repo, string $branch = 'main', ?string $author = null, ?int $year = null): array
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
|
$targetYear = $year ?? (int)date('Y');
|
||||||
|
|
||||||
// Fetch commits (Gitea limits to 50 per request, may need pagination for full year)
|
// Fetch commits (Gitea limits to 50 per request, may need pagination for full year)
|
||||||
$commits = $this->getCommits($baseUrl, $owner, $repo, $branch, 50);
|
$commits = $this->getCommits($baseUrl, $owner, $repo, $branch, 100);
|
||||||
|
|
||||||
// Filter by author if specified
|
// Filter by author if specified
|
||||||
if ($author) {
|
if ($author) {
|
||||||
$commits = array_filter($commits, fn($commit) => stripos($commit['email'], $author) !== false);
|
$commits = array_filter($commits, fn($commit) => stripos($commit['email'], $author) !== false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Group by week
|
// Group by day
|
||||||
$weeklyContributions = [];
|
$dailyContributions = [];
|
||||||
$oneYearAgo = new \DateTime('-1 year');
|
$yearStart = new \DateTime(sprintf('%d-01-01', $targetYear));
|
||||||
|
$yearEnd = new \DateTime(sprintf('%d-12-31', $targetYear));
|
||||||
|
|
||||||
foreach ($commits as $commit) {
|
foreach ($commits as $commit) {
|
||||||
$date = new \DateTime($commit['date']);
|
$date = new \DateTime($commit['date']);
|
||||||
|
|
||||||
// Only include commits from last year
|
// Only include commits from target year
|
||||||
if ($date < $oneYearAgo) {
|
if ($date->format('Y') != $targetYear) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$weekKey = $date->format('Y-W');
|
$dayKey = $date->format('Y-m-d');
|
||||||
|
|
||||||
if (!isset($weeklyContributions[$weekKey])) {
|
if (!isset($dailyContributions[$dayKey])) {
|
||||||
$weeklyContributions[$weekKey] = [
|
$dailyContributions[$dayKey] = [
|
||||||
'week' => $weekKey,
|
'date' => $dayKey,
|
||||||
'year' => (int)$date->format('Y'),
|
'count' => 0,
|
||||||
'weekNumber' => (int)$date->format('W'),
|
'weekday' => (int)$date->format('N')
|
||||||
'startDate' => (clone $date)->modify('monday this week')->format('Y-m-d'),
|
|
||||||
'count' => 0
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
$weeklyContributions[$weekKey]['count']++;
|
$dailyContributions[$dayKey]['count']++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fill in missing days with 0 commits
|
||||||
|
for ($date = clone $yearStart; $date <= $yearEnd; $date->modify('+1 day')) {
|
||||||
|
$dayKey = $date->format('Y-m-d');
|
||||||
|
|
||||||
|
if (!isset($dailyContributions[$dayKey])) {
|
||||||
|
$dailyContributions[$dayKey] = [
|
||||||
|
'date' => $dayKey,
|
||||||
|
'count' => 0,
|
||||||
|
'weekday' => (int)$date->format('N')
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ksort($weeklyContributions);
|
ksort($dailyContributions);
|
||||||
return array_values($weeklyContributions);
|
return array_values($dailyContributions);
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
throw new \RuntimeException('Failed to fetch contribution stats from Gitea: ' . $e->getMessage());
|
throw new \RuntimeException('Failed to fetch contribution stats from Gitea: ' . $e->getMessage());
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user