Клавиша / esc

GitHub-плитка коммитов за год

Отображаем активность коммитов в GitHub-репозиторий.

Время чтения: 15 мин

Задача

Скопировано

«GitHub-плитка» — легко узнаваемый компонент на странице профиля пользователя GitHub.

Создадим подобный компонент для отображения активности коммитов в репозиторий.

Готовое решение

Скопировано

В ходе воплощения идеи мы будем использовать:

  • GitHub-API;
  • манипуляции с датами и цветами;
  • рендеринг html-элементов с помощью JavaScript.
Пример GitHub-плитки

Готовое решение

Скопировано

Создадим основу html-разметки:

        
          
          <div id="mainContainer" class="main-container">  <div class="description">    <span class="loading">      Загрузка...    </span>  </div>  <div class="total-row hidden">    <span class="total-label">      Общее количество коммитов за год:    </span>  </div>  <div    class="tooltip hidden"    role="tooltip"    id="tooltip"    data-position="top"  /></div>
          <div id="mainContainer" class="main-container">
  <div class="description">
    <span class="loading">
      Загрузка...
    </span>
  </div>

  <div class="total-row hidden">
    <span class="total-label">
      Общее количество коммитов за год:
    </span>
  </div>

  <div
    class="tooltip hidden"
    role="tooltip"
    id="tooltip"
    data-position="top"
  />
</div>

        
        
          
        
      

Добавим стили. Большинство из них потребуются в дальнейшем:

        
          
            html {    color-scheme: dark;  }  body {    min-height: 100vh;    margin: 0;    padding: 50px;    display: flex;    align-items: center;    justify-content: center;    box-sizing: border-box;    background-color: #18191C;    color: #FFFFFF;    font-family: "Roboto", sans-serif;  }  .main-container {    display: flex;    flex-direction: column;    gap: .5rem;    min-width: 200px;    padding: 1rem;    position: relative;    background-color: #18191C;  }  .main-container div {    box-sizing: border-box;  }  .description {    display: flex;    flex-direction: row;    gap: .5rem;    font-size: 1.5rem;  }  .repo-name,  .total-row,  .tooltip,  .error-value {    color: #FFF;  }  .description,  .loading,  .total-label {    color: #C56FFF;  }  .loading {    font-size: 1.5rem;    margin: auto;  }  .year {    flex: 1 1 ;    display: flex;    max-width: 100%;    min-width: 100px;    overflow-y: hidden;    overflow-x: auto;    padding: 2rem 1rem 1rem 1rem;    background-color: #0C0C0E6B;    border: 1px solid #363636;    border-radius: 5px;  }  .weekday-label,  .month-label {    font-size: .75rem;    color: #FFF;  }  .weekday-label {    margin: 10px 4px 0 0;  }  .month-label {    width: 0;    margin-top: -1rem;    overflow-x: visible;  }  .weekday-labels,  .week {    display: flex;    flex-direction: column;    flex: 0 0 auto;  }  .day {    width: 10px;    height: 10px;    margin: 1px;    border: 1px solid #88888818;    border-radius: 2px;  }  .day:hover {    border-color: #8885;  }  .day.has-commits:hover {    border-color: #888a;  }  .cell {    background-color: #28e628;  }  .tooltip {    position: absolute;    width: max-content;    max-width: 400px;    padding: 8px 16px;    border-radius: 4px;    background-color: #555;  }  .hidden {    visibility: hidden;  }
            html {
    color-scheme: dark;
  }

  body {
    min-height: 100vh;
    margin: 0;
    padding: 50px;
    display: flex;
    align-items: center;
    justify-content: center;
    box-sizing: border-box;
    background-color: #18191C;
    color: #FFFFFF;
    font-family: "Roboto", sans-serif;
  }

  .main-container {
    display: flex;
    flex-direction: column;
    gap: .5rem;
    min-width: 200px;
    padding: 1rem;
    position: relative;
    background-color: #18191C;
  }

  .main-container div {
    box-sizing: border-box;
  }

  .description {
    display: flex;
    flex-direction: row;
    gap: .5rem;
    font-size: 1.5rem;
  }

  .repo-name,
  .total-row,
  .tooltip,
  .error-value {
    color: #FFF;
  }

  .description,
  .loading,
  .total-label {
    color: #C56FFF;
  }

  .loading {
    font-size: 1.5rem;
    margin: auto;
  }

  .year {
    flex: 1 1 ;
    display: flex;
    max-width: 100%;
    min-width: 100px;
    overflow-y: hidden;
    overflow-x: auto;
    padding: 2rem 1rem 1rem 1rem;
    background-color: #0C0C0E6B;
    border: 1px solid #363636;
    border-radius: 5px;
  }

  .weekday-label,
  .month-label {
    font-size: .75rem;
    color: #FFF;
  }

  .weekday-label {
    margin: 10px 4px 0 0;
  }

  .month-label {
    width: 0;
    margin-top: -1rem;
    overflow-x: visible;
  }

  .weekday-labels,
  .week {
    display: flex;
    flex-direction: column;
    flex: 0 0 auto;
  }

  .day {
    width: 10px;
    height: 10px;
    margin: 1px;
    border: 1px solid #88888818;
    border-radius: 2px;
  }
  .day:hover {
    border-color: #8885;
  }
  .day.has-commits:hover {
    border-color: #888a;
  }

  .cell {
    background-color: #28e628;
  }

  .tooltip {
    position: absolute;
    width: max-content;
    max-width: 400px;
    padding: 8px 16px;
    border-radius: 4px;
    background-color: #555;
  }

  .hidden {
    visibility: hidden;
  }

        
        
          
        
      

Основную работу по созданию всего компонента будет выполнять JS. Рассмотрим его далее.

Итоговый результат выглядит так:

Открыть демо в новой вкладке

Разбор решения

Скопировано

Нашу задачу можно разделить на несколько частей:

  1. получение данных с помощью запроса к GitHub API;
  2. преобразование полученных данных;
  3. определение цветов;
  4. отображение плитки.

Получение данных с помощью запроса к GitHub API

Скопировано

GitHub предоставляет удобное API и документацию по его использованию. Мы будем использовать запрос для получения данных об активности коммитов за последний (прошедший начиная с сегодняшнего дня) год.

Вот как выглядит запрос:

GET https://api.github.com/repos/{owner}/{repo}/stats/commit_activity

где:
{owner} - имя владельца репозитория
{repo} - имя репозитория

Мы будем получать данные об активности репозитория с контентом Доки.

Напишем функцию формирования пути запроса. Мы используем функцию вместо константы, чтобы иметь возможность проще расширять функционал в дальнейшем:

        
          
          // имя в формате owner/repoconst REPO = 'doka-guide/content'function createURL(repo = REPO) {  return `https://api.github.com/repos/${repo}/stats/commit_activity`}
          // имя в формате owner/repo
const REPO = 'doka-guide/content'

function createURL(repo = REPO) {
  return `https://api.github.com/repos/${repo}/stats/commit_activity`
}

        
        
          
        
      

Документация описывает необходимые заголовки (headers) API-запрса:

  • 'X-GitHub-Api-Version': '2022-11-28' — версия API. Этот параметр гарантирует получение задукоментированной структуры ответа, даже при изменении этой структуры в других версиях API;
  • 'Accept': 'application/vnd.github+json' — формат файла ответа. Документация рекомендует добавлять заголовок Accept для уточнения желаемого формата данных.

Напишем функцию выполнения запроса. Функция принимает в качестве параметра путь запроса и возвращает промис:

        
          
          function requestGitHubData(url) {  try {    return fetch(url, {      method: 'GET',      headers: {        'Accept': 'application/vnd.github+json',        'X-GitHub-Api-Version': '2022-11-28'      }    })  } catch (error) {    console.error(error)    return []  }}
          function requestGitHubData(url) {
  try {
    return fetch(url, {
      method: 'GET',
      headers: {
        'Accept': 'application/vnd.github+json',
        'X-GitHub-Api-Version': '2022-11-28'
      }
    })
  } catch (error) {
    console.error(error)
    return []
  }
}

        
        
          
        
      

Документация предупреждает о возможности получить пустой ответ со статусом 202. Чтобы корректно обработать такую ситуацию, предусмотрим возможность выполнения повторных запросов с задержкой.

Для этого нам понадобится функция, проверяющая статус ответа и выполняющая повторные запросы. Функция принимает объект параметров выполнения запроса:

        
          
          // количество повторных попытокconst REQ_MAX_ATTEMPTS = 3// пауза между попытками, мсconst REQ_ATTEMPT_TIMEOUT = 20000// сообщение об ошибке в случае получения ответа со статусом 202const ERR_202 = 'Данные не готовы'async function fetchCommitActivity(requestParams = {}) {  try {    const {      url,      requestAttempts = REQ_MAX_ATTEMPTS,      requestAttemptTimeout = REQ_ATTEMPT_TIMEOUT    } = requestParams    const response = await requestGitHubData(url)    if (response.status === 202) {      if (requestAttempts > 0) {        // пауза перед повторным запросом        await new Promise(r => setTimeout(r, requestAttemptTimeout));        // рекурсивно вызываем функцию, уменьшая счётчик повторов        return fetchCommitActivity({          url,          requestAttempts: requestAttempts - 1        })      }      // кидаем ошибку если все попытки были безуспешны      throw new Error(ERR_202)    }    if (response.ok) {      return await response.json()    }  } catch (error) {    console.error(error)    return Promise.reject(error)  }}
          // количество повторных попыток
const REQ_MAX_ATTEMPTS = 3
// пауза между попытками, мс
const REQ_ATTEMPT_TIMEOUT = 20000
// сообщение об ошибке в случае получения ответа со статусом 202
const ERR_202 = 'Данные не готовы'

async function fetchCommitActivity(requestParams = {}) {
  try {
    const {
      url,
      requestAttempts = REQ_MAX_ATTEMPTS,
      requestAttemptTimeout = REQ_ATTEMPT_TIMEOUT
    } = requestParams

    const response = await requestGitHubData(url)

    if (response.status === 202) {
      if (requestAttempts > 0) {
        // пауза перед повторным запросом
        await new Promise(r => setTimeout(r, requestAttemptTimeout));

        // рекурсивно вызываем функцию, уменьшая счётчик повторов
        return fetchCommitActivity({
          url,
          requestAttempts: requestAttempts - 1
        })
      }

      // кидаем ошибку если все попытки были безуспешны
      throw new Error(ERR_202)
    }

    if (response.ok) {
      return await response.json()
    }
  } catch (error) {
    console.error(error)
    return Promise.reject(error)
  }
}

        
        
          
        
      

Преобразование полученных данных

Скопировано

Успешный ответ запроса будет содержит массив объектов, например:

        
          
          [  {    "days": [ 0, 3, 26, 20, 39, 1, 0 ],    "total": 89,    "week": 1336280400  },  ...]
          [
  {
    "days": [ 0, 3, 26, 20, 39, 1, 0 ],
    "total": 89,
    "week": 1336280400
  },
  ...
]

        
        
          
        
      

где каждый элемент массива, это объект с информацией о неделе:
days — массив значений количества коммитов за каждый день недели (начиная с воскресения);
total — общее количество коммитов за неделю;
week — дата первого дня недели в виде Unix timestamp

Преобразуем полученные данные для удобства отображения названий дней недели, месяцев и даты дня в формат:

        
          
          [  {    "total": number,    "weekDate": Date // Date-объект для даты начала недели    "days": [      {        count: number, // количество коммитов в вс.        dateFormated: string // дата в формате `ГГГГ.MM.ДД`      },      {        count: number // количество коммитов в пн.        dateFormated: string      },      ...    ],    month: string // сокращённое название месяца (для первой недели месяца)  },  ...]
          [
  {
    "total": number,
    "weekDate": Date // Date-объект для даты начала недели
    "days": [
      {
        count: number, // количество коммитов в вс.
        dateFormated: string // дата в формате `ГГГГ.MM.ДД`
      },
      {
        count: number // количество коммитов в пн.
        dateFormated: string
      },
      ...
    ],
    month: string // сокращённое название месяца (для первой недели месяца)
  },
  ...
]

        
        
          
        
      

Нам понадобятся функции форматирования дат:

        
          
          // формат `ГГГГ.MM.ДД`const DATE_FORMATTER = new Intl.DateTimeFormat('ru', {  year: 'numeric',  month: 'numeric',  day: 'numeric',})// сокращённое имя месяцаconst DATE_MONTH_FORMATTER = new Intl.DateTimeFormat('ru', {  month: 'short',})// Функция преобразования timestamp в экземпляр Datefunction getWeekDate(weekTimestamp) {  return new Date(weekTimestamp * 1000)}// Функция преобразования экземпляра Date в строку формата `ГГГГ.MM.ДД`function getDateFormat(date) {  return DATE_FORMATTER.format(date)}// Функция преобразования экземпляра Date в строку с сокращённым именем месяцаfunction getMonthName(date) {  return DATE_MONTH_FORMATTER.format(date)}
          // формат `ГГГГ.MM.ДД`
const DATE_FORMATTER = new Intl.DateTimeFormat('ru', {
  year: 'numeric',
  month: 'numeric',
  day: 'numeric',
})
// сокращённое имя месяца
const DATE_MONTH_FORMATTER = new Intl.DateTimeFormat('ru', {
  month: 'short',
})

// Функция преобразования timestamp в экземпляр Date
function getWeekDate(weekTimestamp) {
  return new Date(weekTimestamp * 1000)
}

// Функция преобразования экземпляра Date в строку формата `ГГГГ.MM.ДД`
function getDateFormat(date) {
  return DATE_FORMATTER.format(date)
}

// Функция преобразования экземпляра Date в строку с сокращённым именем месяца
function getMonthName(date) {
  return DATE_MONTH_FORMATTER.format(date)
}

        
        
          
        
      

Создадим функцию преобразования полученных данных:

        
          
          function parseCommitActivity(responseData = []) {  if (!Array.isArray(responseData)) {    throw new Error('Данные не найдены')  }  // текущая дата  const currDate = new Date()  let isFirstWeekOfMonth  // массив данных о неделях  return responseData.map((weekItem, weekIndex) => {    const { total, days: commitsPerDay, week: weekTimestamp } = weekItem    // Date-объект первого дня недели    const weekDate = getWeekDate(weekTimestamp)    // число месяца первого дня недели    const firstWeekDay = weekDate.getDate()    let dayDate    // массив данных по дням недели    const days = commitsPerDay.map((count, dayIndex) => {      // Date-объект для дня недели      dayDate = new Date(weekDate)      dayDate.setDate(firstWeekDay + dayIndex)      // если этот день ещё не наступил, возвращяем пустой объект      if (dayDate > currDate) {        return {}      }      // дата дня в формате `ГГГГ.MM.ДД`      const dateFormated = getDateFormat(dayDate)      return {        count,        dateFormated      }    })    // число месяца последнего дня недели    const lastDay = dayDate.getDate()    // для этой недели требуется отображать название месяца?    showMonthName = (weekIndex === 0 && firstWeekDay < 10) || lastDay <= 7    return {      total,      weekDate,      days,      month: showMonthName        ? getMonthName(dayDate)        : ''    }  })}
          function parseCommitActivity(responseData = []) {
  if (!Array.isArray(responseData)) {
    throw new Error('Данные не найдены')
  }

  // текущая дата
  const currDate = new Date()

  let isFirstWeekOfMonth

  // массив данных о неделях
  return responseData.map((weekItem, weekIndex) => {
    const { total, days: commitsPerDay, week: weekTimestamp } = weekItem

    // Date-объект первого дня недели
    const weekDate = getWeekDate(weekTimestamp)

    // число месяца первого дня недели
    const firstWeekDay = weekDate.getDate()
    let dayDate

    // массив данных по дням недели
    const days = commitsPerDay.map((count, dayIndex) => {
      // Date-объект для дня недели
      dayDate = new Date(weekDate)
      dayDate.setDate(firstWeekDay + dayIndex)

      // если этот день ещё не наступил, возвращяем пустой объект
      if (dayDate > currDate) {
        return {}
      }

      // дата дня в формате `ГГГГ.MM.ДД`
      const dateFormated = getDateFormat(dayDate)

      return {
        count,
        dateFormated
      }
    })

    // число месяца последнего дня недели
    const lastDay = dayDate.getDate()
    // для этой недели требуется отображать название месяца?
    showMonthName = (weekIndex === 0 && firstWeekDay < 10) || lastDay <= 7

    return {
      total,
      weekDate,
      days,
      month: showMonthName
        ? getMonthName(dayDate)
        : ''
    }
  })
}

        
        
          
        
      

Мы хотим отображать общее количество коммитов за год.
Кроме этого определим минимальное (min) и максимальное (max) число коммитов за день. Добавим функцию получения этих данных из массива сформированного ранее:

        
          
          function analyzeCommits(commitsData = []) {  return commitsData.reduce((acc, weekData) => {    const counts = weekData.days.map(item => item.count ?? 0)    acc.min = Math.min(...counts, acc.min)    acc.max = Math.max(...counts, acc.max)    acc.total += weekData.total    return acc  }, {min: Number.POSITIVE_INFINITY, max: 0, total: 0})}
          function analyzeCommits(commitsData = []) {
  return commitsData.reduce((acc, weekData) => {
    const counts = weekData.days.map(item => item.count ?? 0)
    acc.min = Math.min(...counts, acc.min)
    acc.max = Math.max(...counts, acc.max)
    acc.total += weekData.total
    return acc
  }, {min: Number.POSITIVE_INFINITY, max: 0, total: 0})
}

        
        
          
        
      

TODO! описать функцию создания массива дней недели

        
          
          function createWeekDays(commitsData = []) {  const [firstWeek] = commitsData  if (!firstWeek) return  const {days, weekDate} = firstWeek  const firstWeekDay = weekDate.getDate()  const weekDays = days.reduce((acc, dayData, dayIndex) => {    if (dayIndex % 2) {      let dayDate = new Date(weekDate)      dayDate.setDate(firstWeekDay + dayIndex)      acc.push(getWeekDayFormat(dayDate))    }    return acc  }, [])  return weekDays}
          function createWeekDays(commitsData = []) {
  const [firstWeek] = commitsData
  if (!firstWeek) return

  const {days, weekDate} = firstWeek

  const firstWeekDay = weekDate.getDate()

  const weekDays = days.reduce((acc, dayData, dayIndex) => {
    if (dayIndex % 2) {
      let dayDate = new Date(weekDate)
      dayDate.setDate(firstWeekDay + dayIndex)
      acc.push(getWeekDayFormat(dayDate))
    }
    return acc
  }, [])

  return weekDays
}

        
        
          
        
      

Определение цветов

Скопировано

Как отмечалось ранее, идея тепловой карты состоит в отображении данных с помощью цвета. В нашем случае данные — это число коммитов за день. Чем больше это число, тем ярче цвет плитки.

Будем для краткости использовать термин «палитра» — набор цветов (оттенков), созданных на основе базового цвета. Цвета в нашей палитре располагаются от тёмного ( цвет для значения 0) к светлому (соответствует максимальному значению в диапазоне).

При формировании палитры следует учесть зависимость необходимого количества цветов от диапазона чисел. Так как размер диапазона чисел заранее неизвестен, возникает вопрос: как правильно подобрать палитру? Чем больший диапазон чисел, тем больше цветов потребуется. При этом может ухудшится наглядность, так как будет меньше заметна разница оттенков. С другой стороны, если диапазон значений слишком мал, в палитре окажется всего несколько цветов. В этом случае контраст оттенков не будет соответствовать незначительной разнице значений. Чтобы избежать этих крайностей, добавим константы, определяющие минимальное и максимальное количество цветов.

        
          
          const COLOR_MAX_STEPS = 15const COLOR_MIN_STEPS = 5
          const COLOR_MAX_STEPS = 15
const COLOR_MIN_STEPS = 5

        
        
          
        
      

Определим базовый цвет палитры (цвет соответствующий максимальному количеству коммитов). Для цветов мы будем использовать цветовую модель HSL. Эта модель подходит для нашей задачи, так как позволяет получать новый оттенок изменяя параметр светлоты (Lightness) базового цвета.

        
          
          const BASE_HSL = [112, 100, 80] // ярко зелёный
          const BASE_HSL = [112, 100, 80] // ярко зелёный

        
        
          
        
      

Вынос этих параметров в константы позволит в дальнейшем упростить изменение палитры цветов.

Определим требуемое количество цветов (steps) и сформируем палитру на основе полученных ранее значений min и max:

        
          
          function makeColors(commitCounts, baseColor = BASE_HSL) {  const {min, max} = commitCounts  const steps = Math.max(Math.min(max - min, COLOR_MAX_STEPS), COLOR_MIN_STEPS)  return generatePalette(baseColor, steps)}
          function makeColors(commitCounts, baseColor = BASE_HSL) {
  const {min, max} = commitCounts
  const steps = Math.max(Math.min(max - min, COLOR_MAX_STEPS), COLOR_MIN_STEPS)

  return generatePalette(baseColor, steps)
}

        
        
          
        
      
        
          
          function generatePalette(hslColor, steps) {  const [h, s, lBase] = hslColor  const palette = []  let lStep  for (let i = 0; i < steps - 1; i++) {    // определяем светлоту цвета    lStep = lBase - (lBase * i) / steps    lStep = Math.max(0, lStep)    // добавляем в палитру цвет на основе базового    palette.push(`hsl(${h}, ${s}%, ${Math.round(lStep)}%)`)  }  // добавим в палитру цвет, соответствующий отсутствию коммитов  const lEmpty = Math.min(9, Math.max(9, Math.round(lStep/2)))  palette.push(`hsl(${Math.round(h/1.05)}, ${Math.round(s/4)}%, ${lEmpty}%)`) //  return palette.reverse()}
          function generatePalette(hslColor, steps) {
  const [h, s, lBase] = hslColor
  const palette = []
  let lStep

  for (let i = 0; i < steps - 1; i++) {
    // определяем светлоту цвета
    lStep = lBase - (lBase * i) / steps
    lStep = Math.max(0, lStep)

    // добавляем в палитру цвет на основе базового
    palette.push(`hsl(${h}, ${s}%, ${Math.round(lStep)}%)`)
  }

  // добавим в палитру цвет, соответствующий отсутствию коммитов
  const lEmpty = Math.min(9, Math.max(9, Math.round(lStep/2)))

  palette.push(`hsl(${Math.round(h/1.05)}, ${Math.round(s/4)}%, ${lEmpty}%)`) //

  return palette.reverse()
}