1
1
mirror of https://github.com/go-gitea/gitea synced 2024-06-02 17:35:49 +00:00
gitea/web_src/js/components/RepoContributors.vue
Kemal Zebari 22c7b3a744
Have time.js use UTC-related getters/setters (#30857)
Before this patch, we were using `Date` getter/setter methods that
worked with local time to get a list of Sundays that are in the range of
some start date and end date. The problem with this was that the Sundays
are in Unix epoch time and when we changed the "startDate" argument that
was passed to make sure it is on a Sunday, this change would be
reflected when we convert it to Unix epoch time. More specifically, I
observed that we may get different Unix epochs depending on your
timezone when the returned list should rather be timezone-agnostic.

This led to issues in US timezones that caused the contributor, code
frequency, and recent commit charts to not show any chart data. This fix
resolves this by using getter/setter methods that work with UTC since it
isn't dependent on timezones.

Fixes #30851.

---------

Co-authored-by: Sam Fisher <fisher@3echelon.local>
2024-05-06 09:36:53 +08:00

433 lines
13 KiB
Vue

<script>
import {SvgIcon} from '../svg.js';
import {
Chart,
Title,
BarElement,
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 {chartJsColors} from '../utils/color.js';
import {sleep} from '../utils.js';
import 'chartjs-adapter-dayjs-4/dist/chartjs-adapter-dayjs-4.esm';
import $ from 'jquery';
const {pageData} = window.config;
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 = chartJsColors.text;
Chart.defaults.borderColor = chartJsColors.border;
Chart.register(
TimeScale,
LinearScale,
BarElement,
Title,
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 sleep(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(this.xAxisStart, 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: chartJsColors[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',
position: 'top',
align: 'center',
},
customEventListener: {
chartType: type,
instance: this,
},
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>
<div class="ui header tw-flex tw-items-center tw-justify-between">
<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">
<span class="not-mobile">{{ locale.filterLabel }}&nbsp;</span><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>
</div>
<div class="tw-flex ui segment main-graph">
<div v-if="isLoading || errorText !== ''" class="gt-tc tw-m-auto">
<div v-if="isLoading">
<SvgIcon name="octicon-sync" class="tw-mr-2 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"
v-memo="[sortedContributors, type]"
>
<div class="ui top attached header tw-flex tw-flex-1">
<b class="ui right">#{{ index + 1 }}</b>
<a :href="contributor.home_link">
<img class="ui avatar tw-align-middle" height="40" width="40" :src="contributor.avatar_link">
</a>
<div class="tw-ml-2">
<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="tw-text-12 tw-flex tw-gap-1">
<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;
padding-top: 2px;
}
.contributor-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
}
.contributor-grid > * {
min-width: 0;
}
@media (max-width: 991.98px) {
.contributor-grid {
grid-template-columns: repeat(1, 1fr);
}
}
.contributor-name {
margin-bottom: 0;
}
</style>