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