<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>