GitHubの草生やすやつを作ってみた

[一人もくもく会](https://mokumoku.alphabrend.com/)の自分の投稿が増えてきたので、GitHubの草を生やすやつを作ってみると気分がいいのではないかと思い作成してみた。

↓*こういうの*

![Screenshot from 2017-12-28 22-33-46.png](https://qiita-image-store.s3.amazonaws.com/0/136506/02c0de4a-80f3-d09f-3a07-ebaef2cd0393.png)

Vueで。
本家はsvgで描画しているらしい。とりあえず適当にテーブルで作った。

```html
<template>
  <div class="panel panel-default">
    <div class="panel-body">
      <table>
        <tr v-for="(row, index) in contributionRows" :key="index">
          <th v-if="index === 0"></th>
          <th v-if="weekdayName(index)" rowspan="2">{{weekdayName(index)}}</th>
          <td
            v-for="(cell, cellIndex) in row"
            :key="cellIndex"
            v-bind:class="cell.getClass()"
            data-toggle="tooltip"
            data-animation="false"
            data-container="body"
            data-placement="top"
            data-html="true"
            title=""
            :data-original-title="cell.getMessage()"
          ></td>
        </tr>
      </table>
      <div class="note">
        <span class="string">Less</span>
        <span class="color bg-grey-200"></span>
        <span class="color bg-green-200"></span>
        <span class="color bg-green-400"></span>
        <span class="color bg-green-600"></span>
        <span class="color bg-green-800"></span>
        <span class="color bg-green-900"></span>
        <span class="string">More</span>
      </div>
    </div>
  </div>
</template>

<style scoped>
div.panel {
  margin-top: 40px;
}

table {
  border: 0;
  border-collapse: separate;
  border-spacing: 2px;
}

th {
  font-weight: lighter;
  font-size: 10px;
  transform: scale(0.9);
  transform-origin: 0 0;
  padding-right: 0.5rem;
  vertical-align: top;
}

td {
  width: 10px;
  height: 10px;
}

.note {
  margin-top: 1rem;
  text-align: right;
}

.note span.string {
  margin-left: 1rem;
  margin-right: 1rem;
  font-size: 9px;
}

.note span.color {
  display: inline-block;
  width: 10px;
  height: 10px;
}
</style>

<script>
import moment from 'moment'

class Cell {
  constructor(date, count) {
    this.date = date.clone();
    this.count = count;
  }

  getClass() {
    if (this.count === 0) {
      return 'bg-grey-200';
    } else if (this.count <= 2) {
      return 'bg-green-200';
    } else if (this.count <= 4) {
      return 'bg-green-400';
    } else if (this.count <= 6) {
      return 'bg-green-600';
    } else if (this.count <= 8) {
      return 'bg-green-800';
    }
    return 'bg-green-900';
  }

  getMessage() {
    return '<b>' + this.getCountMessage() + '</b> on ' + this.date.format('YYYY-MM-DD');
  }

  getCountMessage() {
    if (this.count === 0) {
      return 'No contributions';
    }
    return `${this.count} contributions`;
  }
}

export default {
  props: ['contributions'],

  data() {
    let contributionRows = [];
    for (let i = 0; i < 7; i++) {
      contributionRows[i] = [];
    }

    const today = moment().format('YYYY-MM-DD');
    const baseDate = moment().add(-364, 'days');
    const startDate = baseDate.add(-baseDate.weekday(), 'days');
    for (let date = startDate.clone(); date.format('YYYY-MM-DD') <= today; date = date.add(1, 'days')) {
      contributionRows[date.weekday()].push(new Cell(date, this.getCountForDate(date)));
    }

    return {contributionRows}
  },

  mounted() {
    $('[data-toggle="tooltip"]').tooltip();
  },

  methods: {
    getCountForDate(date) {
      const formatDate = date.format('YYYY-MM-DD');
      if (this.contributions[formatDate] !== undefined) {
        return this.contributions[formatDate];
      }
      return 0;
    },

    weekdayName(index) {
      if (index == 1) {
        return 'Mon';
      } else if (index == 3) {
        return 'Wed';
      } else if (index == 5) {
        return 'Fri';
      }
    }
  }
}
</script>
```

tooltip有効化のためだけにVueコンポーネントにするのはなんとなく微妙ではある…。

呼び出し。

```html
<contribution-map :contributions="<?= h(json_encode($contributionMap)) ?>"></contribution-map>
```

集計処理。

Cake\Chronos\Dateで時間を考慮しない日付を扱える。時間が関わってくると意外に厄介だったりするので。（これくらいでは関係ないが）

```php:UsersTable.php
    public function createContributionMap($id)
    {
        $end = Date::today();
        $start = Date::today()->subYear(1)->addDay(1);
        $start = $start->subDay($start->dayOfWeek);
        $summary = $this->Meetings->getDateSummary($id, $start, $end);

        return $summary;
    }
```

実際の集計。Meetingをクローズする際に必ずMeetingPostが追加されるので、MeetingPostがあればその数、なければ1を加えていく。

* `$postCount`は通常のfind。CakePHP3はQueryになるので、そのまま集計クエリのサブクエリとして使うことができる。
* `$date`はMySQLのDATE_FORMATの処理。マニュアルにはちゃんとした説明がなかったのだが使ってみたら使えた。

```php:MeetingsTable.php
    public function getDateSummary($userId, Date $start, Date $end)
    {
        $query = $this->find();
        $postCount = $this->MeetingPosts->find()
            ->select([$query->func()->count('*')])
            ->where([
                'MeetingPosts.meeting_id = Meetings.id',
                'MeetingPosts.deleted' => false,
            ]);
        $date = $query->func()->date_format([
            'created' => 'identifier',
            "'%Y-%m-%d'" => 'literal'
        ]);
        $summary = $query->select([
                'date' => $date,
                'post_count' => $postCount,
            ])
            ->where([
                'Meetings.user_id' => $userId,
                'Meetings.deleted' => false,
                function ($exp, $q) use ($date, $start, $end) {
                    return $exp->between($date, $start, $end);
                }
            ])
            ->all();
        $result = [];
        foreach ($summary as $row) {
            if (!isset($result[$row->date])) {
                $result[$row->date] = 0;
            }
            $result[$row->date] += $row->post_count ?: 1;
        }
        return $result;
    }
```

できた。

![contributionmap.png](https://qiita-image-store.s3.amazonaws.com/0/136506/e80f2e30-aca9-dd50-2f75-f425408e6c33.png)

日付処理がPHPとJSで２重になっているので集計処理の際にテーブル型の配列にしてしまっても良いかもしれない。

[一人もくもく会](https://mokumoku.alphabrend.com/)