Newer
Older
gnexus-tasks / frontend / src / components / ProjectHeatmap.vue
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { Task } from '../api'

// Активность проекта (0.83) — та же сетка «как в GitHub», что на странице статистики
// (StatsView), но по задачам одного проекта: карточка живёт в сайдбаре страницы
// проекта (340px), поэтому ячейки 9px — при китовых 12px полугодовая сетка уехала бы
// в горизонтальную прокрутку. Период один — полгода (26 недель): он в содержимое
// карточки влезает целиком, а месяц в этой же сетке занимал бы пятую часть ширины
// и читался бы недоделкой (0.84, переключатель периодов убран). Шкала заливки —
// общая со статистикой.
const props = defineProps<{ tasks: Task[] }>()

const { t, locale } = useI18n()

interface HeatCell {
  level: number // −1 — будущее (не рисуем)
  tooltip: string
}

// Недель в сетке — полгода, 182 дня
const WEEKS = 26

function startOfDay(d: Date): Date {
  const x = new Date(d)
  x.setHours(0, 0, 0, 0)
  return x
}

// Неделя с понедельника (ISO): столбец сетки — неделя, строка — день недели
function startOfWeek(d: Date): Date {
  const x = startOfDay(d)
  x.setDate(x.getDate() - ((x.getDay() + 6) % 7))
  return x
}

// Заливка по числу закрытых за день — та же шкала, что в статистике
function heatLevel(value: number): number {
  if (value <= 0) return 0
  if (value === 1) return 1
  if (value <= 3) return 2
  if (value <= 6) return 3
  return 4
}

// Задачи проекта приходят целиком (включая завершённые), поэтому история считается
// без запросов к серверу: закрытия — по done_at, появления — по created_at.
// Заливка — по закрытиям (это и есть «работа по проекту»), появления видны в подсказке
const heatmap = computed<HeatCell[][]>(() => {
  const fmt = new Intl.DateTimeFormat(locale.value, { day: 'numeric', month: 'short' })
  const closed = new Map<number, number>()
  const created = new Map<number, number>()
  for (const task of props.tasks) {
    if (task.done_at) {
      const key = startOfDay(new Date(task.done_at)).getTime()
      closed.set(key, (closed.get(key) ?? 0) + 1)
    }
    const born = startOfDay(new Date(task.created_at)).getTime()
    created.set(born, (created.get(born) ?? 0) + 1)
  }
  const first = startOfWeek(new Date())
  first.setDate(first.getDate() - (WEEKS - 1) * 7)
  const now = new Date()
  const out: HeatCell[][] = []
  for (let w = 0; w < WEEKS; w++) {
    const column: HeatCell[] = []
    for (let d = 0; d < 7; d++) {
      const date = new Date(first.getFullYear(), first.getMonth(), first.getDate() + w * 7 + d)
      if (date > now) {
        column.push({ level: -1, tooltip: '' })
        continue
      }
      const done = closed.get(date.getTime()) ?? 0
      column.push({
        level: heatLevel(done),
        tooltip: t('projectPage.heatCell', {
          date: fmt.format(date),
          done,
          created: created.get(date.getTime()) ?? 0,
        }),
      })
    }
    out.push(column)
  }
  return out
})
</script>

<template>
  <GnCard class="side-card heat-card">
    <template #title>
      <span class="card-title-icon"><i class="ph ph-calendar-check" aria-hidden="true" /></span>
      {{ t('projectPage.activity') }}
    </template>
    <div class="heatmap">
      <div v-for="(week, wi) in heatmap" :key="wi" class="heat-week">
        <span
          v-for="(cell, ci) in week"
          :key="ci"
          class="heat-cell"
          :class="`level-${cell.level}`"
          :title="cell.tooltip"
        />
      </div>
    </div>
    <div class="heat-legend">
      <span>{{ t('stats.heatLess') }}</span>
      <i class="heat-cell level-0" aria-hidden="true" />
      <i class="heat-cell level-1" aria-hidden="true" />
      <i class="heat-cell level-2" aria-hidden="true" />
      <i class="heat-cell level-3" aria-hidden="true" />
      <i class="heat-cell level-4" aria-hidden="true" />
      <span>{{ t('stats.heatMore') }}</span>
    </div>
  </GnCard>
</template>

<style scoped>
/* Сетка: 26 недель по 9px с зазором 2px — 284px, в содержимое сайдбара влезает без
   горизонтальной прокрутки; у статистики ячейки 12px, но там карточка во всю ширину */
.heatmap {
  display: flex;
  gap: 2px;
  width: max-content;
  max-width: 100%;
}
.heat-week {
  display: grid;
  grid-template-rows: repeat(7, 9px);
  gap: 2px;
}
.heat-cell {
  display: block;
  width: 9px;
  height: 9px;
  background: color-mix(in srgb, var(--border, #2a2f45) 55%, transparent);
}
/* шкала заливки — та же, что на странице статистики */
.heat-cell.level-1 {
  background: color-mix(in srgb, var(--accent, #7aa2f7) 30%, transparent);
}
.heat-cell.level-2 {
  background: color-mix(in srgb, var(--accent, #7aa2f7) 55%, transparent);
}
.heat-cell.level-3 {
  background: color-mix(in srgb, var(--accent, #7aa2f7) 80%, transparent);
}
.heat-cell.level-4 {
  background: var(--accent, #7aa2f7);
}
/* будущие дни (у последней недели) и легенда */
.heat-cell.level--1 {
  opacity: 0.08;
}
.heat-legend {
  display: flex;
  gap: 4px;
  align-items: center;
  margin-top: 0.6rem;
  font-size: 0.78em;
  color: var(--text-muted, #888);
}
.heat-legend > span {
  margin: 0 0.35rem;
}
</style>