Newer
Older
gnexus-tasks / frontend / src / views / AchievementsView.vue
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { api, type Task, type XpEvent, type XpSummary } from '../api'
import { useRealtimeRefresh } from '../realtime'
import AchievementsPanel from '../components/AchievementsPanel.vue'

defineOptions({ name: 'AchievementsView' })

// Достижения (ТЗ 3.13, 0.58): XP, уровни, звания, монеты и ачивки —
// без сада. Страница намеренно не зовёт GET /api/garden: растения рождаются
// лениво именно в этом эндпоинте, так что отказ от него и есть отключение
// выдачи растений (сад вернётся позже — см. docs/TZ.md).

const { t, tm } = useI18n()

const tasks = ref<Task[]>([])
const events = ref<XpEvent[]>([])
const xp = ref<XpSummary | null>(null)
const error = ref('')
const loading = ref(false)

// Справка «как это работает»: модалка с абзацами из локали
const aboutOpen = ref(false)
const aboutParagraphs = computed(() => {
  const messages = tm('progress.about')
  return Array.isArray(messages)
    ? (messages as unknown[]).filter((p): p is string => typeof p === 'string')
    : []
})

// Званий столько же, сколько уровней сада (progress.levels)
const LEVEL_COUNT = 13

// Название уровня: 13 званий (progress.levels), дальше — последнее
function levelName(level: number): string {
  const idx = Math.min(level, LEVEL_COUNT) - 1
  return t(`progress.levels.${idx}`)
}

// --- Дерево званий: только звания и пороги XP (что открывается — в саду) ---

const levelsOpen = ref(false)

const levelsTree = computed(() =>
  Array.from({ length: LEVEL_COUNT }, (_, i) => {
    const level = i + 1
    return {
      level,
      name: levelName(level),
      threshold: (100 * level * (level - 1)) / 2, // порог XP: 100·L·(L−1)/2
    }
  }),
)

async function load(): Promise<void> {
  loading.value = true
  error.value = ''
  try {
    const [taskList, xpSummary, eventList] = await Promise.all([
      api.listTasks(),
      api.getXp(),
      api.getXpEvents(),
    ])
    tasks.value = taskList
    xp.value = xpSummary
    events.value = eventList
  } catch (e) {
    error.value = String(e)
  } finally {
    loading.value = false
  }
}

onMounted(load)

// --- Реактивность (ТЗ 3.14) ---
useRealtimeRefresh(load, ['xp.changed', 'task.changed'])
</script>

<template>
  <section>
    <!-- Скелетон: бейджи прогресса + сетка ачивок -->
    <div v-if="loading && !xp" class="page-skeleton" aria-hidden="true">
      <div class="page-skeleton-lines">
        <span class="skeleton skeleton-line" style="width: 38%" />
        <span class="skeleton skeleton-line" style="width: 24%" />
      </div>
      <span class="skeleton skeleton-block" style="height: 120px" />
    </div>

    <template v-else>
      <GnPageHeader kicker="GNexus Tasks" :title="t('progress.title')">
        <template #meta>
          <GnBadge variant="primary"><i class="ph ph-trophy" aria-hidden="true" />
            {{ t('progress.level', { n: xp?.level ?? 1 }) }} · {{ levelName(xp?.level ?? 1) }}
          </GnBadge>
          <GnBadge variant="neutral"><i class="ph ph-lightning" aria-hidden="true" />
            {{ t('progress.xpTotal', { n: xp?.total_xp ?? 0 }) }}
          </GnBadge>
          <GnBadge variant="warning" :title="t('progress.coinsHint')">
            <i class="ph ph-coins" aria-hidden="true" />
            {{ t('progress.coins', { n: xp?.coins ?? 0 }) }}
          </GnBadge>
        </template>
        <template #actions>
          <!-- Спиннер обновления: данные уже есть, сводка дотягивается -->
          <GnLoader
            v-if="loading"
            circle
            label=""
            :aria-label="t('common.loading')"
            class="refresh-loader"
          />
          <GnButton variant="secondary" icon="ph-list-numbers" @click="levelsOpen = true">
            {{ t('progress.levelsTitle') }}
          </GnButton>
          <GnIconButton
            icon="ph-question"
            :label="t('progress.aboutOpen')"
            :title="t('progress.aboutOpen')"
            @click="aboutOpen = true"
          />
        </template>
      </GnPageHeader>

      <!-- Справка: как считается опыт, монеты и что отмечают достижения -->
      <GnModal :open="aboutOpen" :title="t('progress.aboutTitle')" @update:open="aboutOpen = $event">
        <div class="about-body">
          <p v-for="(p, i) in aboutParagraphs" :key="i">{{ p }}</p>
        </div>
      </GnModal>

      <!-- Дерево званий: звание и порог XP каждого уровня -->
      <GnModal
        :open="levelsOpen"
        :title="t('progress.levelsTitle')"
        @update:open="levelsOpen = $event"
      >
        <p class="levels-hint">{{ t('progress.levelsHint') }}</p>
        <div class="levels-tree">
          <div
            v-for="node in levelsTree"
            :key="node.level"
            class="level-node"
            :class="{ current: node.level === xp?.level, past: node.level < (xp?.level ?? 1) }"
          >
            <div class="level-node-head">
              <i
                :class="node.level < (xp?.level ?? 1) ? 'ph ph-check-circle' : 'ph ph-lock-simple'"
                aria-hidden="true"
              />
              <span class="level-node-title">
                {{ t('progress.level', { n: node.level }) }} · {{ node.name }}
              </span>
              <GnBadge variant="neutral" class="level-threshold">
                <i class="ph ph-lightning" aria-hidden="true" /> {{ node.threshold }}
              </GnBadge>
              <GnBadge v-if="node.level === xp?.level" variant="success">
                {{ t('progress.levelCurrent') }}
              </GnBadge>
            </div>
          </div>
        </div>
      </GnModal>

      <GnAlert v-if="error" variant="error">{{ error }}</GnAlert>

      <!-- Прогресс уровня: XP только растёт, уровень не падает -->
      <GnProgress
        v-if="xp"
        class="level-progress"
        :value="xp.level_xp"
        :max="xp.level_span"
        :label="t('progress.levelProgress', { xp: xp.level_xp, span: xp.level_span })"
      />

      <!-- Ачивки с тирами: считаются по задачам и событиям XP -->
      <AchievementsPanel :tasks="tasks" :events="events" :level="xp?.level ?? 1" />
    </template>
  </section>
</template>

<style scoped>
.page-skeleton {
  margin-bottom: 1rem;
  display: flex;
  flex-direction: column;
  gap: 12px;
}
.page-skeleton-lines {
  display: flex;
  flex-direction: column;
  gap: 10px;
}
.level-progress {
  margin-bottom: 1rem;
  /* на всю ширину контейнера (в ките .progress упёрт в max-width: 640px) */
  max-width: 100%;
}
/* справка */
.about-body {
  display: flex;
  flex-direction: column;
  gap: 0.75rem;
  font-size: 0.95em;
  line-height: 1.5;
}
/* дерево званий */
.levels-hint {
  margin: 0 0 0.75rem;
  font-size: 0.88em;
  color: var(--text-muted, #888);
  line-height: 1.45;
}
.levels-tree {
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
}
.level-node {
  padding: 0.6rem 0.75rem;
  border: 2px solid var(--gn-border-color-muted, rgba(192, 202, 245, 0.24));
  border-radius: 6px;
  opacity: 0.6;
}
.level-node.past {
  opacity: 0.85;
  /* фон как у пройденных достижений: зелёная подложка */
  background: color-mix(in srgb, var(--success, #9ece6a) 8%, var(--gn-surface-panel, #1f2335));
}
.level-node.current {
  opacity: 1;
  background: color-mix(in srgb, var(--accent, #7aa2f7) 8%, var(--gn-surface-panel, #1f2335));
  border-color: color-mix(
    in srgb,
    var(--accent, #7aa2f7) 45%,
    var(--gn-border-color-muted, rgba(192, 202, 245, 0.24))
  );
}
.level-node-head {
  display: flex;
  gap: 0.5rem;
  align-items: center;
  font-weight: 600;
  font-size: 0.95em;
}
.level-node-head > i {
  font-size: 1.1rem;
  color: var(--text-muted, #888);
}
.level-node.past > .level-node-head > i,
.level-node.current > .level-node-head > i {
  color: var(--success, #9ece6a);
}
.level-node-title {
  flex: 1 1 auto;
}
.level-threshold {
  white-space: nowrap;
}
</style>