<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { api, type Task, type XpEvent } from '../api'
defineOptions({ name: 'StatsView' })
// Статистика (ТЗ 3.13): закрываемость за выбранный день/неделю/месяц с
// анимированным графиком и сравнением с предыдущим аналогичным периодом.
// Только позитив: сравнение — просто факт, без «провалов» и наказаний.
const { t, locale } = useI18n()
const events = ref<XpEvent[]>([])
const tasks = ref<Task[]>([])
const error = ref('')
const loading = ref(false)
const loaded = ref(false)
type Period = 'day' | 'week' | 'month'
const period = ref<Period>('day')
// 0 — текущий период, −1 — предыдущий, −2 … (листание стрелками)
const offset = ref(0)
function setMode(id: string | number) {
period.value = id as Period
offset.value = 0
}
const modeItems = computed(() => [
{ id: 'day', label: t('stats.modeDay') },
{ id: 'week', label: t('stats.modeWeek') },
{ id: 'month', label: t('stats.modeMonth') },
])
// --- Границы периодов (локальное время, неделя с понедельника — ISO) ---
function startOfDay(d: Date): Date {
const x = new Date(d)
x.setHours(0, 0, 0, 0)
return x
}
function startOfWeek(d: Date): Date {
const x = startOfDay(d)
const shift = (x.getDay() + 6) % 7 // пн=0
x.setDate(x.getDate() - shift)
return x
}
function startOfMonth(d: Date): Date {
const x = startOfDay(d)
x.setDate(1)
return x
}
const bounds = computed(() => {
const now = new Date()
if (period.value === 'day') {
const start = startOfDay(now)
start.setDate(start.getDate() + offset.value)
return { start, end: new Date(start.getTime() + 24 * 3600e3) }
}
if (period.value === 'week') {
const start = startOfWeek(now)
start.setDate(start.getDate() + offset.value * 7)
return { start, end: new Date(start.getTime() + 7 * 24 * 3600_000) }
}
const start = startOfMonth(now)
start.setMonth(start.getMonth() + offset.value)
const end = startOfMonth(start)
end.setMonth(end.getMonth() + 1)
return { start, end }
})
const prevBounds = computed(() => {
const { start, end } = bounds.value
const span = end.getTime() - start.getTime()
return { start: new Date(start.getTime() - span), end: start }
})
// --- Бакеты графика: день → 24 часа, неделя → 7 дней, месяц → дни месяца ---
interface Bucket {
label: string
tooltip: string
start: Date
end: Date
count: number
prevCount: number // тот же бакет предыдущего аналогичного периода
}
// Построение бакетов по границам (переиспользуется для сравнения с прошлым периодом)
function buildBuckets(from: Date, to: Date): Bucket[] {
const fmtDay = new Intl.DateTimeFormat(locale.value, { weekday: 'short', day: 'numeric' })
const fmtFull = new Intl.DateTimeFormat(locale.value, {
day: 'numeric',
month: 'long',
hour: 'numeric',
minute: '2-digit',
})
const count = (a: Date, b: Date) =>
events.value.filter((e) => new Date(e.created_at) >= a && new Date(e.created_at) < b).length
const out: Bucket[] = []
if (period.value === 'day') {
const dayStart = startOfDay(from)
for (let h = 0; h < 24; h++) {
const start = new Date(dayStart.getTime() + h * 3600_000)
const end = new Date(start.getTime() + 3600_000)
out.push({
label: String(h).padStart(2, '0') + ':00',
tooltip: fmtFull.format(start),
start,
end,
count: count(start, end),
prevCount: 0,
})
}
return out
}
let cursor = new Date(from.getTime())
while (cursor < to) {
const start = new Date(cursor.getTime())
const end = new Date(start.getTime() + 24 * 3600_000)
out.push({
label: period.value === 'week' ? fmtDay.format(start) : String(start.getDate()),
tooltip: fmtFull.format(start),
start,
end,
count: count(start, end),
prevCount: 0,
})
cursor = end
}
return out
}
const buckets = computed<Bucket[]>(() => {
const { start, end } = bounds.value
const prev = buildBuckets(prevBounds.value.start, prevBounds.value.end)
// Сравнительный график: текущий и предыдущий период выровнены по позиции
// (час↔час, день недели↔день недели, число месяца↔число предыдущего месяца)
return buildBuckets(start, end).map((b, i) => ({ ...b, prevCount: prev[i]?.count ?? 0 }))
})
const bucketMax = computed(() =>
Math.max(1, ...buckets.value.flatMap((b) => [b.count, b.prevCount])),
)
// --- Метрики: значение за период + сравнение с предыдущим аналогичным ---
interface Metric {
key: string
icon: string
value: string
delta: string
negative: boolean
meta: string
}
function fmtDelta(cur: number, prev: number): { delta: string; negative: boolean } {
const diff = cur - prev
if (cur === 0 && prev === 0) return { delta: '—', negative: false }
if (prev === 0) return { delta: `+${cur}`, negative: false }
const pct = Math.round((diff / prev) * 100)
const sign = diff >= 0 ? '+' : ''
return { delta: `${sign}${diff} · ${sign}${pct}%`, negative: diff < 0 }
}
const inRangeEvents = (from: Date, to: Date) =>
events.value.filter((e) => new Date(e.created_at) >= from && new Date(e.created_at) < to)
const inRangeCreated = (from: Date, to: Date) =>
tasks.value.filter((task) => new Date(task.created_at) >= from && new Date(task.created_at) < to)
// Часы закрытых задач периода (факт, иначе оценка) — по событиям через task_id
function rangeMinutes(from: Date, to: Date, byId: Map<number, Task>): number {
return inRangeEvents(from, to).reduce((sum, e) => {
const task = e.task_id !== null ? byId.get(e.task_id) : undefined
return sum + (task?.actual_minutes ?? task?.estimated_minutes ?? 0)
}, 0)
}
const metrics = computed<Metric[]>(() => {
const { start, end } = bounds.value
const pb = prevBounds.value
const byId = new Map(tasks.value.map((task) => [task.id, task]))
const closed = inRangeEvents(start, end)
const prevClosed = inRangeEvents(pb.start, pb.end)
const created = inRangeCreated(start, end)
const prevCreated = inRangeCreated(pb.start, pb.end)
const fmtH = (minutes: number) =>
t('taskui.time.h', { h: Math.round(minutes / 60) })
// Дни с результатом: дни периода, где закрыта хотя бы одна задача
const dayKey = (d: Date) => `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`
const daysInPeriod =
period.value === 'day'
? 1
: period.value === 'week'
? 7
: Math.round((end.getTime() - start.getTime()) / (24 * 3600_000))
const activeDays = new Set(closed.map((e) => dayKey(new Date(e.created_at)))).size
const prevActive = new Set(prevClosed.map((e) => dayKey(new Date(e.created_at)))).size
const defs = [
{
key: 'closed',
icon: 'ph-check-circle',
cur: closed.length,
prev: prevClosed.length,
value: String(closed.length),
meta: '',
},
{
key: 'xp',
icon: 'ph-lightning',
cur: closed.reduce((s, e) => s + e.amount, 0),
prev: prevClosed.reduce((s, e) => s + e.amount, 0),
value: String(closed.reduce((s, e) => s + e.amount, 0)),
meta: '',
},
{
key: 'created',
icon: 'ph-plus-circle',
cur: created.length,
prev: prevCreated.length,
value: String(created.length),
meta: '',
},
{
key: 'hours',
icon: 'ph-clock',
cur: rangeMinutes(start, end, byId),
prev: rangeMinutes(pb.start, pb.end, byId),
value: fmtH(rangeMinutes(start, end, byId)),
meta: '',
},
{
key: 'activeDays',
icon: 'ph-sun',
cur: activeDays,
prev: prevActive,
value: `${activeDays}`,
meta: t('stats.ofDays', { n: daysInPeriod }),
},
]
return defs.map((d) => {
const { delta, negative } = fmtDelta(d.cur, d.prev)
return { key: d.key, icon: d.icon, value: d.value, delta, negative, meta: d.meta }
})
})
// --- Подпись периода ---
const periodLabel = computed(() => {
const { start, end } = bounds.value
const fmtDayMonth = new Intl.DateTimeFormat(locale.value, { day: 'numeric', month: 'long' })
const fmtMonthYear = new Intl.DateTimeFormat(locale.value, { month: 'long', year: 'numeric' })
if (period.value === 'day') {
const isToday = offset.value === 0
const label = fmtDayMonth.format(start)
return isToday ? t('stats.today', { date: label }) : label
}
if (period.value === 'week') {
const last = new Date(end.getTime() - 24 * 3600_000)
const a = fmtDayMonth.format(start)
const b = fmtDayMonth.format(last)
return a === b ? a : `${a} – ${b}`
}
return fmtMonthYear.format(start).replace(/^./, (c) => c.toUpperCase())
})
const totalLabel = computed(() => {
const cur = inRangeEvents(bounds.value.start, bounds.value.end).length
const prev = inRangeEvents(prevBounds.value.start, prevBounds.value.end).length
return t('stats.totalVs', { n: cur, m: prev })
})
// --- Сетка активности «как в гитхабе»: последние 26 недель, задачи или XP ---
type HeatMode = 'tasks' | 'xp'
const heatMode = ref<HeatMode>('tasks')
const HEAT_WEEKS = 26
const heatItems = computed(() => [
{ id: 'tasks', label: t('stats.heatTasks') },
{ id: 'xp', label: t('stats.heatXp') },
])
function setHeatMode(id: string | number) {
heatMode.value = id as HeatMode
}
// Уровень заливки 0–4 по интенсивности
function heatLevel(value: number, mode: HeatMode): number {
if (value <= 0) return 0
if (mode === 'xp') {
if (value < 10) return 1
if (value < 20) return 2
if (value < 50) return 3
return 4
}
if (value === 1) return 1
if (value <= 3) return 2
if (value <= 6) return 3
return 4
}
interface HeatCell {
level: number // −1 — будущее (не рисуем)
tooltip: string
}
const heatmap = computed<HeatCell[][]>(() => {
const fmt = new Intl.DateTimeFormat(locale.value, { day: 'numeric', month: 'short' })
const perDay = new Map<number, { count: number; xp: number }>()
events.value.forEach((e) => {
const key = startOfDay(new Date(e.created_at)).getTime()
const bucket = perDay.get(key) ?? { count: 0, xp: 0 }
bucket.count += 1
bucket.xp += e.amount
perDay.set(key, bucket)
})
const first = startOfWeek(new Date())
first.setDate(first.getDate() - (HEAT_WEEKS - 1) * 7)
const now = new Date()
const weeks: HeatCell[][] = []
for (let w = 0; w < HEAT_WEEKS; w++) {
const col: HeatCell[] = []
for (let d = 0; d < 7; d++) {
const date = new Date(first.getFullYear(), first.getMonth(), first.getDate() + w * 7 + d)
if (date > now) {
col.push({ level: -1, tooltip: '' })
continue
}
const b = perDay.get(date.getTime()) ?? { count: 0, xp: 0 }
const value = heatMode.value === 'tasks' ? b.count : b.xp
col.push({
level: heatLevel(value, heatMode.value),
tooltip: `${fmt.format(date)} — ${b.count} · ${b.xp} XP`,
})
}
weeks.push(col)
}
return weeks
})
// Пусто, если за оба периода вообще нет событий и задач
const isEmpty = computed(() => {
const { start, end } = bounds.value
const pb = prevBounds.value
return (
inRangeEvents(start, end).length +
inRangeEvents(pb.start, pb.end).length +
inRangeCreated(start, end).length +
inRangeCreated(pb.start, pb.end).length ===
0
)
})
function step(delta: number) {
offset.value += delta
}
async function load() {
loading.value = true
error.value = ''
try {
const [eventList, taskList] = await Promise.all([api.getXpEvents(), api.listTasks()])
events.value = eventList
tasks.value = taskList
loaded.value = true
} catch (e) {
error.value = String(e)
} finally {
loading.value = false
}
}
onMounted(load)
</script>
<template>
<section>
<GnSkeleton v-if="!loaded" type="block" stack :count="2" class="page-skeleton" />
<template v-else>
<GnPageHeader kicker="GNexus Tasks" :title="t('stats.title')">
<template #meta>
<GnBadge variant="neutral"><i class="ph ph-chart-bar" aria-hidden="true" />
{{ periodLabel }}
</GnBadge>
</template>
</GnPageHeader>
<GnAlert v-if="error" variant="error">{{ error }}</GnAlert>
<!-- Переключатель периода + листание назад/вперёд -->
<div class="stats-toolbar">
<div class="nav-arrows">
<GnIconButton icon="ph-caret-left" :label="t('stats.prevPeriod')" @click="step(-1)" />
<GnIconButton icon="ph-caret-right" :label="t('stats.nextPeriod')" @click="step(1)" :disabled="offset >= 0" />
</div>
<span class="period-label">{{ periodLabel }}</span>
<GnTabs
:model-value="period"
:items="modeItems"
compact
class="mode-tabs"
@update:model-value="setMode"
/>
</div>
<GnEmptyState
v-if="isEmpty"
icon="ph-chart-bar"
:title="t('stats.emptyTitle')"
:text="t('stats.emptyText')"
/>
<template v-else>
<!-- Метрики: каждая со сравнением с предыдущим аналогичным периодом -->
<div class="metrics-grid">
<GnMetricCard
v-for="m in metrics"
:key="m.key"
:label="t(`stats.metric.${m.key}`)"
:value="m.value"
:icon="m.icon"
:delta="m.delta"
:negative="m.negative"
:meta="m.meta"
/>
</div>
<!-- Анимированный сравнительный график: текущий и предыдущий период парами -->
<div class="chart-title">
<i class="ph ph-chart-bar" aria-hidden="true" />
<span>{{ t('stats.chartTitle') }}</span>
<span class="chart-legend">
<i class="legend-swatch now" aria-hidden="true" />{{ t('stats.legendNow') }}
<i class="legend-swatch prev" aria-hidden="true" />{{ t('stats.legendPrev') }}
</span>
</div>
<div :key="`${period}-${offset}`" class="chart">
<div v-for="(b, i) in buckets" :key="i" class="chart-col">
<div class="chart-pair">
<div
class="chart-bar prev"
:style="{ height: `${Math.max(3, (b.prevCount / bucketMax) * 110)}px`, animationDelay: `${i * 18}ms` }"
:title="`${t('stats.legendPrev')} — ${b.prevCount}`"
/>
<div
class="chart-bar"
:style="{ height: `${Math.max(3, (b.count / bucketMax) * 110)}px`, animationDelay: `${i * 18}ms` }"
:title="`${b.tooltip} — ${b.count}`"
/>
</div>
<span class="chart-label">{{ b.label }}</span>
</div>
</div>
<p class="chart-total">{{ totalLabel }}</p>
<!-- Сетка активности «как в гитхабе»: последние 26 недель -->
<div class="chart-title heat-title">
<i class="ph ph-calendar-check" aria-hidden="true" />
<span>{{ t('stats.heatmapTitle') }}</span>
<GnTabs
:model-value="heatMode"
:items="heatItems"
compact
class="heat-tabs"
@update:model-value="setHeatMode"
/>
</div>
<div class="heatmap-wrap">
<div class="heatmap">
<div v-for="(week, wi) in heatmap" :key="wi" class="heat-week">
<div
v-for="(cell, ci) in week"
:key="ci"
class="heat-cell"
:class="`level-${cell.level}`"
:title="cell.tooltip"
/>
</div>
</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>
</template>
</template>
</section>
</template>
<style scoped>
.page-skeleton {
margin-bottom: 1rem;
}
/* переключатель периода + стрелки */
.stats-toolbar {
display: flex;
gap: 0.75rem;
align-items: center;
flex-wrap: wrap;
margin-bottom: 1.5rem;
}
.nav-arrows {
display: flex;
gap: 0.35rem;
}
.period-label {
font-weight: 600;
min-width: 12ch;
}
.mode-tabs {
margin-left: auto;
}
/* метрики */
.metrics-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 0.75rem;
margin-bottom: 2rem;
}
/* график */
.chart-title {
display: flex;
gap: 0.6rem;
align-items: center;
margin: 0 0 0.5rem;
font-weight: 600;
font-size: 1.05rem;
}
.chart-title > i {
color: var(--accent, #7aa2f7);
}
/* легенда «сейчас / предыдущий период» */
.chart-legend {
display: flex;
gap: 0.4rem;
align-items: center;
margin-left: auto;
font-size: 0.8em;
font-weight: 400;
color: var(--text-muted, #888);
}
.legend-swatch {
width: 10px;
height: 10px;
border-radius: 3px;
display: inline-block;
margin-left: 0.5rem;
}
.legend-swatch.now {
background: var(--accent, #7aa2f7);
}
.legend-swatch.prev {
background: var(--border, #2a2f45);
}
.chart {
display: flex;
align-items: flex-end;
gap: 0.3rem;
padding: 0.75rem 0.5rem 0.25rem;
border: 1px solid var(--border, #2a2f45);
border-radius: 10px;
overflow-x: auto;
}
.chart-col {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
min-width: 26px;
flex: 1 1 auto;
}
/* пара столбиков: текущий период + предыдущий */
.chart-pair {
display: flex;
align-items: flex-end;
gap: 2px;
height: 113px;
}
/* анимация «волной»: scaleY от нуля, задержка — inline по индексу колонки */
.chart-bar {
width: 12px;
border-radius: 3px 3px 0 0;
background: var(--accent, #7aa2f7);
opacity: 0.85;
transform-origin: bottom;
animation: stats-grow 0.5s cubic-bezier(0.2, 0.8, 0.3, 1) backwards;
}
.chart-bar.prev {
background: var(--border, #2a2f45);
opacity: 1;
}
@keyframes stats-grow {
from {
transform: scaleY(0);
}
to {
transform: scaleY(1);
}
}
.chart-label {
font-size: 0.68em;
color: var(--text-muted, #888);
white-space: nowrap;
}
.chart-total {
margin: 0.5rem 0 0;
font-size: 0.88em;
color: var(--text-muted, #888);
}
/* сетка активности «как в гитхабе» */
.heat-title {
margin-top: 2rem;
}
.heat-tabs {
margin-left: auto;
}
.heatmap-wrap {
padding: 0.75rem;
border: 1px solid var(--border, #2a2f45);
border-radius: 10px;
overflow-x: auto;
}
.heatmap {
display: flex;
gap: 3px;
width: max-content;
}
.heat-week {
display: grid;
grid-template-rows: repeat(7, 12px);
gap: 3px;
}
.heat-cell {
width: 12px;
height: 12px;
border-radius: 3px;
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;
justify-content: flex-end;
margin-top: 0.5rem;
font-size: 0.78em;
color: var(--text-muted, #888);
}
.heat-legend > span {
margin: 0 0.35rem;
}
</style>