gitea源码

RepoContributors.vue 15KB

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