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
}

const buckets = computed<Bucket[]>(() => {
  const { start, end } = bounds.value
  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 out: Bucket[] = []
  const inRange = (e: XpEvent, from: Date, to: Date) =>
    new Date(e.created_at) >= from && new Date(e.created_at) < to
  if (period.value === 'day') {
    for (let h = 0; h < 24; h++) {
      const from = new Date(start.getTime() + h * 3600_000)
      const to = new Date(from.getTime() + 3600_000)
      out.push({
        label: String(h).padStart(2, '0') + ':00',
        tooltip: fmtFull.format(from),
        start: from,
        end: to,
        count: events.value.filter((e) => inRange(e, from, to)).length,
      })
    }
    return out
  }
  let cursor = new Date(start.getTime())
  while (cursor < end) {
    const from = new Date(cursor.getTime())
    const to = new Date(from.getTime() + 24 * 3600_000)
    out.push({
      label: period.value === 'week' ? fmtDay.format(from) : String(from.getDate()),
      tooltip: fmtFull.format(from),
      start: from,
      end: to,
      count: events.value.filter((e) => inRange(e, from, to)).length,
    })
    cursor = to
  }
  return out
})

const bucketMax = computed(() => Math.max(1, ...buckets.value.map((b) => b.count)))

// --- Метрики: значение за период + сравнение с предыдущим аналогичным ---

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

// Пусто, если за оба периода вообще нет событий и задач
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>
        </div>
        <div :key="`${period}-${offset}`" class="chart">
          <div v-for="(b, i) in buckets" :key="i" class="chart-col">
            <div
              class="chart-bar"
              :style="{ height: `${Math.max(3, (b.count / bucketMax) * 110)}px`, animationDelay: `${i * 18}ms` }"
              :title="`${b.tooltip} — ${b.count}`"
            />
            <span class="chart-label">{{ b.label }}</span>
          </div>
        </div>
        <p class="chart-total">{{ totalLabel }}</p>
      </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 {
  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;
}
/* анимация «волной»: scaleY от нуля, задержка — inline по индексу колонки */
.chart-bar {
  width: 100%;
  max-width: 30px;
  border-radius: 4px 4px 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;
}
@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);
}
</style>