mirror of
https://codeberg.org/forgejo/forgejo
synced 2024-11-29 21:26:10 +01:00
444 lines
14 KiB
Vue
444 lines
14 KiB
Vue
|
<script>
|
||
|
import {SvgIcon} from '../svg.js';
|
||
|
import {
|
||
|
Chart,
|
||
|
Title,
|
||
|
Tooltip,
|
||
|
Legend,
|
||
|
BarElement,
|
||
|
CategoryScale,
|
||
|
LinearScale,
|
||
|
TimeScale,
|
||
|
PointElement,
|
||
|
LineElement,
|
||
|
Filler,
|
||
|
} from 'chart.js';
|
||
|
import {GET} from '../modules/fetch.js';
|
||
|
import zoomPlugin from 'chartjs-plugin-zoom';
|
||
|
import {Line as ChartLine} from 'vue-chartjs';
|
||
|
import {
|
||
|
startDaysBetween,
|
||
|
firstStartDateAfterDate,
|
||
|
fillEmptyStartDaysWithZeroes,
|
||
|
} from '../utils/time.js';
|
||
|
import 'chartjs-adapter-dayjs-4/dist/chartjs-adapter-dayjs-4.esm';
|
||
|
import $ from 'jquery';
|
||
|
|
||
|
const {pageData} = window.config;
|
||
|
|
||
|
const colors = {
|
||
|
text: '--color-text',
|
||
|
border: '--color-secondary-alpha-60',
|
||
|
commits: '--color-primary-alpha-60',
|
||
|
additions: '--color-green',
|
||
|
deletions: '--color-red',
|
||
|
title: '--color-secondary-dark-4',
|
||
|
};
|
||
|
|
||
|
const styles = window.getComputedStyle(document.documentElement);
|
||
|
const getColor = (name) => styles.getPropertyValue(name).trim();
|
||
|
|
||
|
for (const [key, value] of Object.entries(colors)) {
|
||
|
colors[key] = getColor(value);
|
||
|
}
|
||
|
|
||
|
const customEventListener = {
|
||
|
id: 'customEventListener',
|
||
|
afterEvent: (chart, args, opts) => {
|
||
|
// event will be replayed from chart.update when reset zoom,
|
||
|
// so we need to check whether args.replay is true to avoid call loops
|
||
|
if (args.event.type === 'dblclick' && opts.chartType === 'main' && !args.replay) {
|
||
|
chart.resetZoom();
|
||
|
opts.instance.updateOtherCharts(args.event, true);
|
||
|
}
|
||
|
}
|
||
|
};
|
||
|
|
||
|
Chart.defaults.color = colors.text;
|
||
|
Chart.defaults.borderColor = colors.border;
|
||
|
|
||
|
Chart.register(
|
||
|
TimeScale,
|
||
|
CategoryScale,
|
||
|
LinearScale,
|
||
|
BarElement,
|
||
|
Title,
|
||
|
Tooltip,
|
||
|
Legend,
|
||
|
PointElement,
|
||
|
LineElement,
|
||
|
Filler,
|
||
|
zoomPlugin,
|
||
|
customEventListener,
|
||
|
);
|
||
|
|
||
|
export default {
|
||
|
components: {ChartLine, SvgIcon},
|
||
|
props: {
|
||
|
locale: {
|
||
|
type: Object,
|
||
|
required: true,
|
||
|
},
|
||
|
},
|
||
|
data: () => ({
|
||
|
isLoading: false,
|
||
|
errorText: '',
|
||
|
totalStats: {},
|
||
|
sortedContributors: {},
|
||
|
repoLink: pageData.repoLink || [],
|
||
|
type: pageData.contributionType,
|
||
|
contributorsStats: [],
|
||
|
xAxisStart: null,
|
||
|
xAxisEnd: null,
|
||
|
xAxisMin: null,
|
||
|
xAxisMax: null,
|
||
|
}),
|
||
|
mounted() {
|
||
|
this.fetchGraphData();
|
||
|
|
||
|
$('#repo-contributors').dropdown({
|
||
|
onChange: (val) => {
|
||
|
this.xAxisMin = this.xAxisStart;
|
||
|
this.xAxisMax = this.xAxisEnd;
|
||
|
this.type = val;
|
||
|
this.sortContributors();
|
||
|
}
|
||
|
});
|
||
|
},
|
||
|
methods: {
|
||
|
sortContributors() {
|
||
|
const contributors = this.filterContributorWeeksByDateRange();
|
||
|
const criteria = `total_${this.type}`;
|
||
|
this.sortedContributors = Object.values(contributors)
|
||
|
.filter((contributor) => contributor[criteria] !== 0)
|
||
|
.sort((a, b) => a[criteria] > b[criteria] ? -1 : a[criteria] === b[criteria] ? 0 : 1)
|
||
|
.slice(0, 100);
|
||
|
},
|
||
|
|
||
|
async fetchGraphData() {
|
||
|
this.isLoading = true;
|
||
|
try {
|
||
|
let response;
|
||
|
do {
|
||
|
response = await GET(`${this.repoLink}/activity/contributors/data`);
|
||
|
if (response.status === 202) {
|
||
|
await new Promise((resolve) => setTimeout(resolve, 1000)); // wait for 1 second before retrying
|
||
|
}
|
||
|
} while (response.status === 202);
|
||
|
if (response.ok) {
|
||
|
const data = await response.json();
|
||
|
const {total, ...rest} = data;
|
||
|
// below line might be deleted if we are sure go produces map always sorted by keys
|
||
|
total.weeks = Object.fromEntries(Object.entries(total.weeks).sort());
|
||
|
|
||
|
const weekValues = Object.values(total.weeks);
|
||
|
this.xAxisStart = weekValues[0].week;
|
||
|
this.xAxisEnd = firstStartDateAfterDate(new Date());
|
||
|
const startDays = startDaysBetween(new Date(this.xAxisStart), new Date(this.xAxisEnd));
|
||
|
total.weeks = fillEmptyStartDaysWithZeroes(startDays, total.weeks);
|
||
|
this.xAxisMin = this.xAxisStart;
|
||
|
this.xAxisMax = this.xAxisEnd;
|
||
|
this.contributorsStats = {};
|
||
|
for (const [email, user] of Object.entries(rest)) {
|
||
|
user.weeks = fillEmptyStartDaysWithZeroes(startDays, user.weeks);
|
||
|
this.contributorsStats[email] = user;
|
||
|
}
|
||
|
this.sortContributors();
|
||
|
this.totalStats = total;
|
||
|
this.errorText = '';
|
||
|
} else {
|
||
|
this.errorText = response.statusText;
|
||
|
}
|
||
|
} catch (err) {
|
||
|
this.errorText = err.message;
|
||
|
} finally {
|
||
|
this.isLoading = false;
|
||
|
}
|
||
|
},
|
||
|
|
||
|
filterContributorWeeksByDateRange() {
|
||
|
const filteredData = {};
|
||
|
const data = this.contributorsStats;
|
||
|
for (const key of Object.keys(data)) {
|
||
|
const user = data[key];
|
||
|
user.total_commits = 0;
|
||
|
user.total_additions = 0;
|
||
|
user.total_deletions = 0;
|
||
|
user.max_contribution_type = 0;
|
||
|
const filteredWeeks = user.weeks.filter((week) => {
|
||
|
const oneWeek = 7 * 24 * 60 * 60 * 1000;
|
||
|
if (week.week >= this.xAxisMin - oneWeek && week.week <= this.xAxisMax + oneWeek) {
|
||
|
user.total_commits += week.commits;
|
||
|
user.total_additions += week.additions;
|
||
|
user.total_deletions += week.deletions;
|
||
|
if (week[this.type] > user.max_contribution_type) {
|
||
|
user.max_contribution_type = week[this.type];
|
||
|
}
|
||
|
return true;
|
||
|
}
|
||
|
return false;
|
||
|
});
|
||
|
// this line is required. See https://github.com/sahinakkaya/gitea/pull/3#discussion_r1396495722
|
||
|
// for details.
|
||
|
user.max_contribution_type += 1;
|
||
|
|
||
|
filteredData[key] = {...user, weeks: filteredWeeks};
|
||
|
}
|
||
|
|
||
|
return filteredData;
|
||
|
},
|
||
|
|
||
|
maxMainGraph() {
|
||
|
// This method calculates maximum value for Y value of the main graph. If the number
|
||
|
// of maximum contributions for selected contribution type is 15.955 it is probably
|
||
|
// better to round it up to 20.000.This method is responsible for doing that.
|
||
|
// Normally, chartjs handles this automatically, but it will resize the graph when you
|
||
|
// zoom, pan etc. I think resizing the graph makes it harder to compare things visually.
|
||
|
const maxValue = Math.max(
|
||
|
...this.totalStats.weeks.map((o) => o[this.type])
|
||
|
);
|
||
|
const [coefficient, exp] = maxValue.toExponential().split('e').map(Number);
|
||
|
if (coefficient % 1 === 0) return maxValue;
|
||
|
return (1 - (coefficient % 1)) * 10 ** exp + maxValue;
|
||
|
},
|
||
|
|
||
|
maxContributorGraph() {
|
||
|
// Similar to maxMainGraph method this method calculates maximum value for Y value
|
||
|
// for contributors' graph. If I let chartjs do this for me, it will choose different
|
||
|
// maxY value for each contributors' graph which again makes it harder to compare.
|
||
|
const maxValue = Math.max(
|
||
|
...this.sortedContributors.map((c) => c.max_contribution_type)
|
||
|
);
|
||
|
const [coefficient, exp] = maxValue.toExponential().split('e').map(Number);
|
||
|
if (coefficient % 1 === 0) return maxValue;
|
||
|
return (1 - (coefficient % 1)) * 10 ** exp + maxValue;
|
||
|
},
|
||
|
|
||
|
toGraphData(data) {
|
||
|
return {
|
||
|
datasets: [
|
||
|
{
|
||
|
data: data.map((i) => ({x: i.week, y: i[this.type]})),
|
||
|
pointRadius: 0,
|
||
|
pointHitRadius: 0,
|
||
|
fill: 'start',
|
||
|
backgroundColor: colors[this.type],
|
||
|
borderWidth: 0,
|
||
|
tension: 0.3,
|
||
|
},
|
||
|
],
|
||
|
};
|
||
|
},
|
||
|
|
||
|
updateOtherCharts(event, reset) {
|
||
|
const minVal = event.chart.options.scales.x.min;
|
||
|
const maxVal = event.chart.options.scales.x.max;
|
||
|
if (reset) {
|
||
|
this.xAxisMin = this.xAxisStart;
|
||
|
this.xAxisMax = this.xAxisEnd;
|
||
|
this.sortContributors();
|
||
|
} else if (minVal) {
|
||
|
this.xAxisMin = minVal;
|
||
|
this.xAxisMax = maxVal;
|
||
|
this.sortContributors();
|
||
|
}
|
||
|
},
|
||
|
|
||
|
getOptions(type) {
|
||
|
return {
|
||
|
responsive: true,
|
||
|
maintainAspectRatio: false,
|
||
|
animation: false,
|
||
|
events: ['mousemove', 'mouseout', 'click', 'touchstart', 'touchmove', 'dblclick'],
|
||
|
plugins: {
|
||
|
title: {
|
||
|
display: type === 'main',
|
||
|
text: 'drag: zoom, shift+drag: pan, double click: reset zoom',
|
||
|
color: colors.title,
|
||
|
position: 'top',
|
||
|
align: 'center',
|
||
|
},
|
||
|
customEventListener: {
|
||
|
chartType: type,
|
||
|
instance: this,
|
||
|
},
|
||
|
legend: {
|
||
|
display: false,
|
||
|
},
|
||
|
zoom: {
|
||
|
pan: {
|
||
|
enabled: true,
|
||
|
modifierKey: 'shift',
|
||
|
mode: 'x',
|
||
|
threshold: 20,
|
||
|
onPanComplete: this.updateOtherCharts,
|
||
|
},
|
||
|
limits: {
|
||
|
x: {
|
||
|
// Check https://www.chartjs.org/chartjs-plugin-zoom/latest/guide/options.html#scale-limits
|
||
|
// to know what each option means
|
||
|
min: 'original',
|
||
|
max: 'original',
|
||
|
|
||
|
// number of milliseconds in 2 weeks. Minimum x range will be 2 weeks when you zoom on the graph
|
||
|
minRange: 2 * 7 * 24 * 60 * 60 * 1000,
|
||
|
},
|
||
|
},
|
||
|
zoom: {
|
||
|
drag: {
|
||
|
enabled: type === 'main',
|
||
|
},
|
||
|
pinch: {
|
||
|
enabled: type === 'main',
|
||
|
},
|
||
|
mode: 'x',
|
||
|
onZoomComplete: this.updateOtherCharts,
|
||
|
},
|
||
|
},
|
||
|
},
|
||
|
scales: {
|
||
|
x: {
|
||
|
min: this.xAxisMin,
|
||
|
max: this.xAxisMax,
|
||
|
type: 'time',
|
||
|
grid: {
|
||
|
display: false,
|
||
|
},
|
||
|
time: {
|
||
|
minUnit: 'month',
|
||
|
},
|
||
|
ticks: {
|
||
|
maxRotation: 0,
|
||
|
maxTicksLimit: type === 'main' ? 12 : 6,
|
||
|
},
|
||
|
},
|
||
|
y: {
|
||
|
min: 0,
|
||
|
max: type === 'main' ? this.maxMainGraph() : this.maxContributorGraph(),
|
||
|
ticks: {
|
||
|
maxTicksLimit: type === 'main' ? 6 : 4,
|
||
|
},
|
||
|
},
|
||
|
},
|
||
|
};
|
||
|
},
|
||
|
},
|
||
|
};
|
||
|
</script>
|
||
|
<template>
|
||
|
<div>
|
||
|
<h2 class="ui header gt-df gt-ac gt-sb">
|
||
|
<div>
|
||
|
<relative-time
|
||
|
v-if="xAxisMin > 0"
|
||
|
format="datetime"
|
||
|
year="numeric"
|
||
|
month="short"
|
||
|
day="numeric"
|
||
|
weekday=""
|
||
|
:datetime="new Date(xAxisMin)"
|
||
|
>
|
||
|
{{ new Date(xAxisMin) }}
|
||
|
</relative-time>
|
||
|
{{ isLoading ? locale.loadingTitle : errorText ? locale.loadingTitleFailed: "-" }}
|
||
|
<relative-time
|
||
|
v-if="xAxisMax > 0"
|
||
|
format="datetime"
|
||
|
year="numeric"
|
||
|
month="short"
|
||
|
day="numeric"
|
||
|
weekday=""
|
||
|
:datetime="new Date(xAxisMax)"
|
||
|
>
|
||
|
{{ new Date(xAxisMax) }}
|
||
|
</relative-time>
|
||
|
</div>
|
||
|
<div>
|
||
|
<!-- Contribution type -->
|
||
|
<div class="ui dropdown jump" id="repo-contributors">
|
||
|
<div class="ui basic compact button">
|
||
|
<span class="text">
|
||
|
{{ locale.filterLabel }} <strong>{{ locale.contributionType[type] }}</strong>
|
||
|
<svg-icon name="octicon-triangle-down" :size="14"/>
|
||
|
</span>
|
||
|
</div>
|
||
|
<div class="menu">
|
||
|
<div :class="['item', {'active': type === 'commits'}]">
|
||
|
{{ locale.contributionType.commits }}
|
||
|
</div>
|
||
|
<div :class="['item', {'active': type === 'additions'}]">
|
||
|
{{ locale.contributionType.additions }}
|
||
|
</div>
|
||
|
<div :class="['item', {'active': type === 'deletions'}]">
|
||
|
{{ locale.contributionType.deletions }}
|
||
|
</div>
|
||
|
</div>
|
||
|
</div>
|
||
|
</div>
|
||
|
</h2>
|
||
|
<div class="gt-df ui segment main-graph">
|
||
|
<div v-if="isLoading || errorText !== ''" class="gt-tc gt-m-auto">
|
||
|
<div v-if="isLoading">
|
||
|
<SvgIcon name="octicon-sync" class="gt-mr-3 job-status-rotate"/>
|
||
|
{{ locale.loadingInfo }}
|
||
|
</div>
|
||
|
<div v-else class="text red">
|
||
|
<SvgIcon name="octicon-x-circle-fill"/>
|
||
|
{{ errorText }}
|
||
|
</div>
|
||
|
</div>
|
||
|
<ChartLine
|
||
|
v-memo="[totalStats.weeks, type]" v-if="Object.keys(totalStats).length !== 0"
|
||
|
:data="toGraphData(totalStats.weeks)" :options="getOptions('main')"
|
||
|
/>
|
||
|
</div>
|
||
|
<div class="contributor-grid">
|
||
|
<div
|
||
|
v-for="(contributor, index) in sortedContributors" :key="index" class="stats-table"
|
||
|
v-memo="[sortedContributors, type]"
|
||
|
>
|
||
|
<div class="ui top attached header gt-df gt-f1">
|
||
|
<b class="ui right">#{{ index + 1 }}</b>
|
||
|
<a :href="contributor.home_link">
|
||
|
<img class="ui avatar gt-vm" height="40" width="40" :src="contributor.avatar_link">
|
||
|
</a>
|
||
|
<div class="gt-ml-3">
|
||
|
<a v-if="contributor.home_link !== ''" :href="contributor.home_link"><h4>{{ contributor.name }}</h4></a>
|
||
|
<h4 v-else class="contributor-name">
|
||
|
{{ contributor.name }}
|
||
|
</h4>
|
||
|
<p class="gt-font-12 gt-df gt-gap-2">
|
||
|
<strong v-if="contributor.total_commits">{{ contributor.total_commits.toLocaleString() }} {{ locale.contributionType.commits }}</strong>
|
||
|
<strong v-if="contributor.total_additions" class="text green">{{ contributor.total_additions.toLocaleString() }}++ </strong>
|
||
|
<strong v-if="contributor.total_deletions" class="text red">
|
||
|
{{ contributor.total_deletions.toLocaleString() }}--</strong>
|
||
|
</p>
|
||
|
</div>
|
||
|
</div>
|
||
|
<div class="ui attached segment">
|
||
|
<div>
|
||
|
<ChartLine
|
||
|
:data="toGraphData(contributor.weeks)"
|
||
|
:options="getOptions('contributor')"
|
||
|
/>
|
||
|
</div>
|
||
|
</div>
|
||
|
</div>
|
||
|
</div>
|
||
|
</div>
|
||
|
</template>
|
||
|
<style scoped>
|
||
|
.main-graph {
|
||
|
height: 260px;
|
||
|
}
|
||
|
.contributor-grid {
|
||
|
display: grid;
|
||
|
grid-template-columns: repeat(2, 1fr);
|
||
|
gap: 1rem;
|
||
|
}
|
||
|
|
||
|
.contributor-name {
|
||
|
margin-bottom: 0;
|
||
|
}
|
||
|
</style>
|