code.gitea.io/gitea@v1.22.3/web_src/js/components/RepoContributors.vue (about) 1 <script> 2 import {SvgIcon} from '../svg.js'; 3 import { 4 Chart, 5 Title, 6 BarElement, 7 LinearScale, 8 TimeScale, 9 PointElement, 10 LineElement, 11 Filler, 12 } from 'chart.js'; 13 import {GET} from '../modules/fetch.js'; 14 import zoomPlugin from 'chartjs-plugin-zoom'; 15 import {Line as ChartLine} from 'vue-chartjs'; 16 import { 17 startDaysBetween, 18 firstStartDateAfterDate, 19 fillEmptyStartDaysWithZeroes, 20 } from '../utils/time.js'; 21 import {chartJsColors} from '../utils/color.js'; 22 import {sleep} from '../utils.js'; 23 import 'chartjs-adapter-dayjs-4/dist/chartjs-adapter-dayjs-4.esm'; 24 import $ from 'jquery'; 25 26 const customEventListener = { 27 id: 'customEventListener', 28 afterEvent: (chart, args, opts) => { 29 // event will be replayed from chart.update when reset zoom, 30 // so we need to check whether args.replay is true to avoid call loops 31 if (args.event.type === 'dblclick' && opts.chartType === 'main' && !args.replay) { 32 chart.resetZoom(); 33 opts.instance.updateOtherCharts(args.event, true); 34 } 35 }, 36 }; 37 38 Chart.defaults.color = chartJsColors.text; 39 Chart.defaults.borderColor = chartJsColors.border; 40 41 Chart.register( 42 TimeScale, 43 LinearScale, 44 BarElement, 45 Title, 46 PointElement, 47 LineElement, 48 Filler, 49 zoomPlugin, 50 customEventListener, 51 ); 52 53 export default { 54 components: {ChartLine, SvgIcon}, 55 props: { 56 locale: { 57 type: Object, 58 required: true, 59 }, 60 repoLink: { 61 type: String, 62 required: true, 63 }, 64 }, 65 data: () => ({ 66 isLoading: false, 67 errorText: '', 68 totalStats: {}, 69 sortedContributors: {}, 70 type: 'commits', 71 contributorsStats: [], 72 xAxisStart: null, 73 xAxisEnd: null, 74 xAxisMin: null, 75 xAxisMax: null, 76 }), 77 mounted() { 78 this.fetchGraphData(); 79 80 $('#repo-contributors').dropdown({ 81 onChange: (val) => { 82 this.xAxisMin = this.xAxisStart; 83 this.xAxisMax = this.xAxisEnd; 84 this.type = val; 85 this.sortContributors(); 86 }, 87 }); 88 }, 89 methods: { 90 sortContributors() { 91 const contributors = this.filterContributorWeeksByDateRange(); 92 const criteria = `total_${this.type}`; 93 this.sortedContributors = Object.values(contributors) 94 .filter((contributor) => contributor[criteria] !== 0) 95 .sort((a, b) => a[criteria] > b[criteria] ? -1 : a[criteria] === b[criteria] ? 0 : 1) 96 .slice(0, 100); 97 }, 98 99 async fetchGraphData() { 100 this.isLoading = true; 101 try { 102 let response; 103 do { 104 response = await GET(`${this.repoLink}/activity/contributors/data`); 105 if (response.status === 202) { 106 await sleep(1000); // wait for 1 second before retrying 107 } 108 } while (response.status === 202); 109 if (response.ok) { 110 const data = await response.json(); 111 const {total, ...rest} = data; 112 // below line might be deleted if we are sure go produces map always sorted by keys 113 total.weeks = Object.fromEntries(Object.entries(total.weeks).sort()); 114 115 const weekValues = Object.values(total.weeks); 116 this.xAxisStart = weekValues[0].week; 117 this.xAxisEnd = firstStartDateAfterDate(new Date()); 118 const startDays = startDaysBetween(this.xAxisStart, this.xAxisEnd); 119 total.weeks = fillEmptyStartDaysWithZeroes(startDays, total.weeks); 120 this.xAxisMin = this.xAxisStart; 121 this.xAxisMax = this.xAxisEnd; 122 this.contributorsStats = {}; 123 for (const [email, user] of Object.entries(rest)) { 124 user.weeks = fillEmptyStartDaysWithZeroes(startDays, user.weeks); 125 this.contributorsStats[email] = user; 126 } 127 this.sortContributors(); 128 this.totalStats = total; 129 this.errorText = ''; 130 } else { 131 this.errorText = response.statusText; 132 } 133 } catch (err) { 134 this.errorText = err.message; 135 } finally { 136 this.isLoading = false; 137 } 138 }, 139 140 filterContributorWeeksByDateRange() { 141 const filteredData = {}; 142 const data = this.contributorsStats; 143 for (const key of Object.keys(data)) { 144 const user = data[key]; 145 user.total_commits = 0; 146 user.total_additions = 0; 147 user.total_deletions = 0; 148 user.max_contribution_type = 0; 149 const filteredWeeks = user.weeks.filter((week) => { 150 const oneWeek = 7 * 24 * 60 * 60 * 1000; 151 if (week.week >= this.xAxisMin - oneWeek && week.week <= this.xAxisMax + oneWeek) { 152 user.total_commits += week.commits; 153 user.total_additions += week.additions; 154 user.total_deletions += week.deletions; 155 if (week[this.type] > user.max_contribution_type) { 156 user.max_contribution_type = week[this.type]; 157 } 158 return true; 159 } 160 return false; 161 }); 162 // this line is required. See https://github.com/sahinakkaya/gitea/pull/3#discussion_r1396495722 163 // for details. 164 user.max_contribution_type += 1; 165 166 filteredData[key] = {...user, weeks: filteredWeeks}; 167 } 168 169 return filteredData; 170 }, 171 172 maxMainGraph() { 173 // This method calculates maximum value for Y value of the main graph. If the number 174 // of maximum contributions for selected contribution type is 15.955 it is probably 175 // better to round it up to 20.000.This method is responsible for doing that. 176 // Normally, chartjs handles this automatically, but it will resize the graph when you 177 // zoom, pan etc. I think resizing the graph makes it harder to compare things visually. 178 const maxValue = Math.max( 179 ...this.totalStats.weeks.map((o) => o[this.type]), 180 ); 181 const [coefficient, exp] = maxValue.toExponential().split('e').map(Number); 182 if (coefficient % 1 === 0) return maxValue; 183 return (1 - (coefficient % 1)) * 10 ** exp + maxValue; 184 }, 185 186 maxContributorGraph() { 187 // Similar to maxMainGraph method this method calculates maximum value for Y value 188 // for contributors' graph. If I let chartjs do this for me, it will choose different 189 // maxY value for each contributors' graph which again makes it harder to compare. 190 const maxValue = Math.max( 191 ...this.sortedContributors.map((c) => c.max_contribution_type), 192 ); 193 const [coefficient, exp] = maxValue.toExponential().split('e').map(Number); 194 if (coefficient % 1 === 0) return maxValue; 195 return (1 - (coefficient % 1)) * 10 ** exp + maxValue; 196 }, 197 198 toGraphData(data) { 199 return { 200 datasets: [ 201 { 202 data: data.map((i) => ({x: i.week, y: i[this.type]})), 203 pointRadius: 0, 204 pointHitRadius: 0, 205 fill: 'start', 206 backgroundColor: chartJsColors[this.type], 207 borderWidth: 0, 208 tension: 0.3, 209 }, 210 ], 211 }; 212 }, 213 214 updateOtherCharts(event, reset) { 215 const minVal = event.chart.options.scales.x.min; 216 const maxVal = event.chart.options.scales.x.max; 217 if (reset) { 218 this.xAxisMin = this.xAxisStart; 219 this.xAxisMax = this.xAxisEnd; 220 this.sortContributors(); 221 } else if (minVal) { 222 this.xAxisMin = minVal; 223 this.xAxisMax = maxVal; 224 this.sortContributors(); 225 } 226 }, 227 228 getOptions(type) { 229 return { 230 responsive: true, 231 maintainAspectRatio: false, 232 animation: false, 233 events: ['mousemove', 'mouseout', 'click', 'touchstart', 'touchmove', 'dblclick'], 234 plugins: { 235 title: { 236 display: type === 'main', 237 text: 'drag: zoom, shift+drag: pan, double click: reset zoom', 238 position: 'top', 239 align: 'center', 240 }, 241 customEventListener: { 242 chartType: type, 243 instance: this, 244 }, 245 zoom: { 246 pan: { 247 enabled: true, 248 modifierKey: 'shift', 249 mode: 'x', 250 threshold: 20, 251 onPanComplete: this.updateOtherCharts, 252 }, 253 limits: { 254 x: { 255 // Check https://www.chartjs.org/chartjs-plugin-zoom/latest/guide/options.html#scale-limits 256 // to know what each option means 257 min: 'original', 258 max: 'original', 259 260 // number of milliseconds in 2 weeks. Minimum x range will be 2 weeks when you zoom on the graph 261 minRange: 2 * 7 * 24 * 60 * 60 * 1000, 262 }, 263 }, 264 zoom: { 265 drag: { 266 enabled: type === 'main', 267 }, 268 pinch: { 269 enabled: type === 'main', 270 }, 271 mode: 'x', 272 onZoomComplete: this.updateOtherCharts, 273 }, 274 }, 275 }, 276 scales: { 277 x: { 278 min: this.xAxisMin, 279 max: this.xAxisMax, 280 type: 'time', 281 grid: { 282 display: false, 283 }, 284 time: { 285 minUnit: 'month', 286 }, 287 ticks: { 288 maxRotation: 0, 289 maxTicksLimit: type === 'main' ? 12 : 6, 290 }, 291 }, 292 y: { 293 min: 0, 294 max: type === 'main' ? this.maxMainGraph() : this.maxContributorGraph(), 295 ticks: { 296 maxTicksLimit: type === 'main' ? 6 : 4, 297 }, 298 }, 299 }, 300 }; 301 }, 302 }, 303 }; 304 </script> 305 <template> 306 <div> 307 <div class="ui header tw-flex tw-items-center tw-justify-between"> 308 <div> 309 <relative-time 310 v-if="xAxisMin > 0" 311 format="datetime" 312 year="numeric" 313 month="short" 314 day="numeric" 315 weekday="" 316 :datetime="new Date(xAxisMin)" 317 > 318 {{ new Date(xAxisMin) }} 319 </relative-time> 320 {{ isLoading ? locale.loadingTitle : errorText ? locale.loadingTitleFailed: "-" }} 321 <relative-time 322 v-if="xAxisMax > 0" 323 format="datetime" 324 year="numeric" 325 month="short" 326 day="numeric" 327 weekday="" 328 :datetime="new Date(xAxisMax)" 329 > 330 {{ new Date(xAxisMax) }} 331 </relative-time> 332 </div> 333 <div> 334 <!-- Contribution type --> 335 <div class="ui dropdown jump" id="repo-contributors"> 336 <div class="ui basic compact button"> 337 <span class="not-mobile">{{ locale.filterLabel }}</span> <strong>{{ locale.contributionType[type] }}</strong> 338 <svg-icon name="octicon-triangle-down" :size="14"/> 339 </div> 340 <div class="menu"> 341 <div :class="['item', {'selected': type === 'commits'}]" data-value="commits"> 342 {{ locale.contributionType.commits }} 343 </div> 344 <div :class="['item', {'selected': type === 'additions'}]" data-value="additions"> 345 {{ locale.contributionType.additions }} 346 </div> 347 <div :class="['item', {'selected': type === 'deletions'}]" data-value="deletions"> 348 {{ locale.contributionType.deletions }} 349 </div> 350 </div> 351 </div> 352 </div> 353 </div> 354 <div class="tw-flex ui segment main-graph"> 355 <div v-if="isLoading || errorText !== ''" class="gt-tc tw-m-auto"> 356 <div v-if="isLoading"> 357 <SvgIcon name="octicon-sync" class="tw-mr-2 job-status-rotate"/> 358 {{ locale.loadingInfo }} 359 </div> 360 <div v-else class="text red"> 361 <SvgIcon name="octicon-x-circle-fill"/> 362 {{ errorText }} 363 </div> 364 </div> 365 <ChartLine 366 v-memo="[totalStats.weeks, type]" v-if="Object.keys(totalStats).length !== 0" 367 :data="toGraphData(totalStats.weeks)" :options="getOptions('main')" 368 /> 369 </div> 370 <div class="contributor-grid"> 371 <div 372 v-for="(contributor, index) in sortedContributors" 373 :key="index" 374 v-memo="[sortedContributors, type]" 375 > 376 <div class="ui top attached header tw-flex tw-flex-1"> 377 <b class="ui right">#{{ index + 1 }}</b> 378 <a :href="contributor.home_link"> 379 <img class="ui avatar tw-align-middle" height="40" width="40" :src="contributor.avatar_link"> 380 </a> 381 <div class="tw-ml-2"> 382 <a v-if="contributor.home_link !== ''" :href="contributor.home_link"><h4>{{ contributor.name }}</h4></a> 383 <h4 v-else class="contributor-name"> 384 {{ contributor.name }} 385 </h4> 386 <p class="tw-text-12 tw-flex tw-gap-1"> 387 <strong v-if="contributor.total_commits">{{ contributor.total_commits.toLocaleString() }} {{ locale.contributionType.commits }}</strong> 388 <strong v-if="contributor.total_additions" class="text green">{{ contributor.total_additions.toLocaleString() }}++ </strong> 389 <strong v-if="contributor.total_deletions" class="text red"> 390 {{ contributor.total_deletions.toLocaleString() }}--</strong> 391 </p> 392 </div> 393 </div> 394 <div class="ui attached segment"> 395 <div> 396 <ChartLine 397 :data="toGraphData(contributor.weeks)" 398 :options="getOptions('contributor')" 399 /> 400 </div> 401 </div> 402 </div> 403 </div> 404 </div> 405 </template> 406 <style scoped> 407 .main-graph { 408 height: 260px; 409 padding-top: 2px; 410 } 411 412 .contributor-grid { 413 display: grid; 414 grid-template-columns: repeat(2, 1fr); 415 gap: 1rem; 416 } 417 418 .contributor-grid > * { 419 min-width: 0; 420 } 421 422 @media (max-width: 991.98px) { 423 .contributor-grid { 424 grid-template-columns: repeat(1, 1fr); 425 } 426 } 427 428 .contributor-name { 429 margin-bottom: 0; 430 } 431 </style>