Newer
Older
gnexus-tasks / frontend / src / components / AchievementsPanel.vue
<script setup lang="ts">
// Ачивки с тирами (ТЗ 3.13): общий блок страницы «Достижения» и сада.
// Компонент только считает по переданным данным — ничего не грузит сам,
// поэтому переживает отключение сада (0.58) и вернётся вместе с ним.
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { Task, XpEvent } from '../api'

const props = defineProps<{
  tasks: Task[]
  events: XpEvent[]
  level: number
}>()

const { t } = useI18n()

const doneTasks = computed(() =>
  props.tasks
    .filter((task) => task.status === 'done' && task.done_at)
    .sort((a, b) => (a.done_at! < b.done_at! ? 1 : -1)),
)

interface Achievement {
  key: string
  icon: string
  tiers: number[]
  progress: number
}

const ROMANS = ['I', 'II', 'III', 'IV']

// Текущая ступень: первая незакрытая; -1 — все пройдены
function currentTier(a: Achievement): number {
  return a.tiers.findIndex((goal) => a.progress < goal)
}

function tierLabel(a: Achievement): string {
  const idx = currentTier(a)
  const inHours = a.key === 'marathon'
  const fmt = (n: number) => (inHours ? t('taskui.time.h', { h: Math.round(n / 60) }) : `${n}`)
  // Пройдено: галочка + высшая ступень («✓ III»); в процессе: ступень и прогресс
  if (idx === -1) return `✓ ${ROMANS[a.tiers.length - 1]}`
  return `${ROMANS[idx]} · ${fmt(a.progress)}/${fmt(a.tiers[idx])}`
}

const achievements = computed<Achievement[]>(() => {
  const done = doneTasks.value
  const recurringDone = done.some((task) => task.task_type === 'recurring')
  // Спринтер: максимум закрытых за один день
  const perDay = new Map<string, number>()
  done.forEach((task) => {
    const day = task.done_at!.slice(0, 10)
    perDay.set(day, (perDay.get(day) ?? 0) + 1)
  })
  const bestDay = Math.max(0, ...perDay.values())
  // Разносторонний: проекты, в которых есть закрытые задачи
  const projectsWithDone = new Set(done.map((task) => task.project?.id).filter((id) => id != null))
  // Марафонец: суммарные минуты (факт, иначе оценка)
  const totalMinutes = done.reduce(
    (sum, task) => sum + (task.actual_minutes ?? task.estimated_minutes ?? 0),
    0,
  )
  // Дедлайн-босс: закрытые со строгим дедлайном-датой. Регулярные не считаем
  // (0.91): с 3.5 у экземпляра есть дата следующего раза, и привычка «зарядка
  // ежедневно» сама накручивала бы ачивку — это не работа в срок
  const strictDone = done.filter(
    (task) => task.deadline_date && task.task_type !== 'recurring',
  ).length
  // «Выбрал и сделал»: закрыто через режим «3 вариантов»
  const viaOptions = props.events.filter((e) => e.via_options).length
  // Преодоление: закрытые «ментально сложные» (метка «не хочется делать»)
  const willpowerDone = done.filter((task) => task.mentally_hard).length
  // проект закрыт: есть проект (от 3 задач), все задачи которого завершены
  const projects = new Map<number, { total: number; done: number }>()
  props.tasks.forEach((task) => {
    if (!task.project) return
    const bucket = projects.get(task.project.id) ?? { total: 0, done: 0 }
    bucket.total += 1
    if (task.status === 'done') bucket.done += 1
    projects.set(task.project.id, bucket)
  })
  const projectDone = [...projects.values()].some((p) => p.total >= 3 && p.done === p.total)
  return [
    { key: 'firstDone', icon: 'ph-flower-lotus', tiers: [1], progress: Math.min(done.length, 1) },
    { key: 'done10', icon: 'ph-leaf', tiers: [10, 50, 100], progress: done.length },
    { key: 'level5', icon: 'ph-crown-simple', tiers: [5, 10, 15], progress: props.level },
    { key: 'sprinter', icon: 'ph-lightning', tiers: [3, 10, 25], progress: bestDay },
    { key: 'explorer', icon: 'ph-stack', tiers: [3, 5, 8], progress: projectsWithDone.size },
    { key: 'marathon', icon: 'ph-mountains', tiers: [600, 3000, 6000], progress: totalMinutes },
    { key: 'deadlineBoss', icon: 'ph-medal', tiers: [5, 15, 30], progress: strictDone },
    { key: 'chooser', icon: 'ph-shuffle', tiers: [5, 25, 50], progress: viaOptions },
    { key: 'willpower', icon: 'ph-brain', tiers: [5, 25, 50], progress: willpowerDone },
    { key: 'recurring', icon: 'ph-repeat', tiers: [1], progress: recurringDone ? 1 : 0 },
    { key: 'projectDone', icon: 'ph-flag-checkered', tiers: [1], progress: projectDone ? 1 : 0 },
  ]
})
</script>

<template>
  <!-- Ачивки с тирами: прогресс к следующей ступени виден всегда -->
  <div class="achievements-title">
    <i class="ph ph-trophy" aria-hidden="true" />
    <span>{{ t('progress.achievements') }}</span>
  </div>
  <div class="achievements-grid">
    <!-- карточка-ачивка = триггер GnPopover: клик открывает панель с описанием
         (китовые .popover/.popover-panel; работает на тач-экранах) -->
    <GnPopover
      v-for="a in achievements"
      :key="a.key"
      :title="t(`progress.ach.${a.key}`)"
      :text="t(`progress.achHint.${a.key}`)"
    >
      <template #trigger="{ toggle }">
        <button
          type="button"
          class="achievement"
          :class="{ unlocked: currentTier(a) === -1 }"
          :aria-label="t(`progress.ach.${a.key}`)"
          @click="toggle"
        >
          <i :class="`ph ${a.icon}`" aria-hidden="true" />
          <span class="achievement-name">{{ t(`progress.ach.${a.key}`) }}</span>
          <GnBadge
            :variant="currentTier(a) === -1 ? 'success' : 'neutral'"
            :title="
              currentTier(a) === -1
                ? t('progress.achDone', { r: ROMANS[a.tiers.length - 1] })
                : t('progress.achStep')
            "
          >
            {{ tierLabel(a) }}
          </GnBadge>
        </button>
      </template>
    </GnPopover>
  </div>
</template>

<style scoped>
.achievements-title {
  display: flex;
  gap: 0.6rem;
  align-items: center;
  margin: 0 0 0.75rem;
  font-weight: 600;
  font-size: 1.05rem;
}
.achievements-title > i {
  color: var(--accent, #7aa2f7);
}
.achievements-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
  gap: 0.75rem;
}
/* на десктопе — ровно 4 карточки в ряд */
@media (min-width: 1024px) {
  .achievements-grid {
    grid-template-columns: repeat(4, 1fr);
  }
}
/* карточка-ачивка живёт в триггере GnPopover: китовый .popover — inline-flex,
   в гриде карточка должна тянуться на всю колонку */
.achievements-grid .popover {
  display: flex;
  width: 100%;
}
.achievement {
  display: flex;
  gap: 0.6rem;
  align-items: center;
  width: 100%;
  padding: 0.75rem;
  border: 2px solid var(--gn-border-color-muted, rgba(192, 202, 245, 0.24));
  border-radius: 6px;
  background: none;
  color: inherit;
  font: inherit;
  text-align: left;
  cursor: pointer;
  opacity: 0.55;
}
.achievement.unlocked {
  opacity: 1;
  background: color-mix(in srgb, var(--success, #9ece6a) 8%, var(--gn-surface-panel, #1f2335));
  border-color: color-mix(
    in srgb,
    var(--success, #9ece6a) 45%,
    var(--gn-border-color-muted, rgba(192, 202, 245, 0.24))
  );
}
.achievement > i {
  font-size: 1.3rem;
  color: var(--success, #9ece6a);
}
.achievement-name {
  flex: 1 1 auto;
  font-size: 0.92em;
  font-weight: 600;
}
</style>