Newer
Older
gnexus-tasks / frontend / src / views / StatsView.vue
<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>