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

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

const { t, locale } = useI18n()

type HeatRange = 'month' | 'half'
const range = ref<HeatRange>('month')

const rangeItems = computed(() => [
  { id: 'month', label: t('projectPage.heatMonth') },
  { id: 'half', label: t('projectPage.heatHalf') },
])

function setRange(id: string | number) {
  range.value = id as HeatRange
}

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

// Недель в сетке: месяц — пять недель (35 дней), полгода — 26 (182 дня)
const WEEKS: Record<HeatRange, number> = { month: 5, half: 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 weeks = WEEKS[range.value]
  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>
    <!-- Переключатель периода — в содержимом, а не в шапке карточки: шапка кита узкая
         и заглавная, два таба рядом с заголовком в 340px не читались бы -->
    <GnTabs
      :model-value="range"
      :items="rangeItems"
      compact
      class="heat-tabs"
      @update:model-value="setRange"
    />
    <div class="heat-body" :class="{ 'range-month': range === 'month' }">
      <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>
    </div>
  </GnCard>
</template>

<style scoped>
/* Переключатель периода: кит тянет табы на width:100% — в карточке они должны
   стоять по содержимому, а не растягиваться на всю полосу */
.heat-tabs {
  margin-bottom: 0.75rem;
}
.heat-tabs :deep(.tabs) {
  width: auto;
  max-width: 100%;
  margin-bottom: 0;
}
/* Сетка и легенда — одним потоком с переносом. В «полгода» сетка занимает всю
   ширину содержимого карточки (26 недель по 9px с зазором 2px = 284px), и легенда
   сама уезжает под неё. В «месяце» недель всего пять, и та же мелкая сетка терялась
   бы в широкой карточке: ячейки там крупнее (18px — 102px в ширину и 144px в
   высоту, по весу примерно как полугодовая сетка), а легенда встаёт с ней в одну
   строку. У статистики ячейки 12px, но там карточка во всю ширину */
.heat-body {
  --heat-size: 9px;
  --heat-gap: 2px;
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.5rem 1rem;
}
.heat-body.range-month {
  --heat-size: 18px;
  --heat-gap: 3px;
}
.heatmap {
  display: flex;
  gap: var(--heat-gap);
  width: max-content;
  max-width: 100%;
}
.heat-week {
  display: grid;
  grid-template-rows: repeat(7, var(--heat-size));
  gap: var(--heat-gap);
}
.heat-cell {
  display: block;
  width: var(--heat-size);
  height: var(--heat-size);
  background: color-mix(in srgb, var(--border, #2a2f45) 55%, transparent);
}
/* легенда — ключ шкалы: её квадратики мельче сетки, иначе строка ключа оказалась бы
   шире карточки и переносилась под сетку даже в месячном режиме */
.heat-legend .heat-cell {
  --heat-size: 9px;
}
/* шкала заливки — та же, что на странице статистики */
.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;
  font-size: 0.78em;
  color: var(--text-muted, #888);
}
.heat-legend > span {
  margin: 0 0.35rem;
}
</style>