diff options
Diffstat (limited to '')
-rw-r--r-- | web_src/js/components/RepoContributors.vue | 431 |
1 files changed, 431 insertions, 0 deletions
diff --git a/web_src/js/components/RepoContributors.vue b/web_src/js/components/RepoContributors.vue new file mode 100644 index 0000000..dec2599 --- /dev/null +++ b/web_src/js/components/RepoContributors.vue @@ -0,0 +1,431 @@ +<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 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, + }, + repoLink: { + type: String, + required: true, + }, + }, + data: () => ({ + isLoading: false, + errorText: '', + totalStats: {}, + sortedContributors: {}, + type: 'commits', + 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="not-mobile">{{ locale.filterLabel }}</span> <strong>{{ locale.contributionType[type] }}</strong> + <svg-icon name="octicon-triangle-down" :size="14"/> + </div> + <div class="menu"> + <div :class="['item', {'selected': type === 'commits'}]" data-value="commits"> + {{ locale.contributionType.commits }} + </div> + <div :class="['item', {'selected': type === 'additions'}]" data-value="additions"> + {{ locale.contributionType.additions }} + </div> + <div :class="['item', {'selected': type === 'deletions'}]" data-value="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> |