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>