Newer
Older
gnexus-tasks / frontend / src / views / GardenView.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 { formatDate } from '../taskui'

defineOptions({ name: 'GardenView' })

// Сад (ТЗ 3.13): каждая закрытая задача — растение на грядке. Только позитив:
// сад никогда не уменьшается, стриков нет, уровень не падает.
// Редкость растения и признак «из 3 вариантов» приходят из событий XP.

const { t, locale, 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('garden.about')
  return Array.isArray(messages)
    ? (messages as unknown[]).filter((p): p is string => typeof p === 'string')
    : []
})

// Растение детерминировано по id задачи — при каждом визите то же самое
const PLANTS = ['ph-flower', 'ph-flower-lotus', 'ph-tree-evergreen', 'ph-tree', 'ph-leaf', 'ph-cactus']

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

function plantIcon(task: Task): string {
  if (task.task_type === 'recurring') return 'ph-tree'
  return PLANTS[task.id % PLANTS.length]
}

// Редкость по событию XP (решается на бэке в момент закрытия по весу задачи)
const rarityByTask = computed(() => {
  const map = new Map<number, string>()
  events.value.forEach((e) => {
    if (e.task_id !== null) map.set(e.task_id, e.rarity)
  })
  return map
})

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

// Последнее закрытие — для строки «последний росток»
const lastPlant = computed(() => doneTasks.value[0] ?? null)

// --- История: закрытых задач по месяцам за последние 12 месяцев ---

interface MonthBucket {
  label: string
  count: number
}

const history = computed<MonthBucket[]>(() => {
  const fmt = new Intl.DateTimeFormat(locale.value, { month: 'short' })
  const buckets = new Map<string, MonthBucket>()
  const now = new Date()
  for (let i = 11; i >= 0; i--) {
    const d = new Date(now.getFullYear(), now.getMonth() - i, 1)
    const key = `${d.getFullYear()}-${d.getMonth()}`
    buckets.set(key, { label: fmt.format(d), count: 0 })
  }
  events.value.forEach((e) => {
    const d = new Date(e.created_at)
    const bucket = buckets.get(`${d.getFullYear()}-${d.getMonth()}`)
    if (bucket) bucket.count += 1
  })
  return [...buckets.values()]
})

const historyMax = computed(() => Math.max(1, ...history.value.map((b) => b.count)))

// --- Ачивки с тирами: ступени I/II/III внутри каждой, без «провала» ---

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 ? `${Math.round(n / 60)} ч` : `${n}`)
  if (idx === -1) return `${ROMANS[a.tiers.length - 1]} · ${fmt(a.tiers[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,
  )
  // Дедлайн-босс: закрытые со строгим дедлайном-датой
  const strictDone = done.filter((task) => task.deadline_date).length
  // «Выбрал и сделал»: закрыто через режим «3 вариантов»
  const viaOptions = events.value.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 }>()
  tasks.value.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: xp.value?.level ?? 1 },
    { 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 },
  ]
})

async function load() {
  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)
</script>

<template>
  <section>
    <GnSkeleton v-if="loading && !xp" type="block" stack :count="2" class="page-skeleton" />

    <template v-else>
      <GnPageHeader kicker="GNexus Tasks" :title="t('garden.title')">
        <template #meta>
          <GnBadge variant="primary"><i class="ph ph-trophy" aria-hidden="true" />
            {{ t('garden.level', { n: xp?.level ?? 1 }) }} · {{ levelName(xp?.level ?? 1) }}
          </GnBadge>
          <GnBadge variant="neutral"><i class="ph ph-lightning" aria-hidden="true" />
            {{ t('garden.xpTotal', { n: xp?.total_xp ?? 0 }) }}
          </GnBadge>
          <GnBadge variant="success"><i class="ph ph-leaf" aria-hidden="true" />
            {{ t('garden.plants', { n: doneTasks.length }) }}
          </GnBadge>
        </template>
        <template #actions>
          <GnIconButton icon="ph-question" :label="t('garden.aboutOpen')" :title="t('garden.aboutOpen')" @click="aboutOpen = true" />
        </template>
      </GnPageHeader>

      <!-- Справка: что происходит в саду и как это работает -->
      <GnModal :open="aboutOpen" :title="t('garden.aboutTitle')" @update:open="aboutOpen = $event">
        <div class="about-body">
          <p v-for="(p, i) in aboutParagraphs" :key="i">{{ p }}</p>
        </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('garden.levelProgress', { xp: xp.level_xp, span: xp.level_span })"
      />

      <!-- Последний росток: что и когда закрыто последним -->
      <div v-if="lastPlant" class="last-plant">
        <i class="ph ph-sparkle" aria-hidden="true" />
        <span>{{ t('garden.lastPlant', { title: lastPlant.title, date: formatDate(lastPlant.done_at!) }) }}</span>
      </div>

      <!-- История: закрытых задач по месяцам (12 месяцев) -->
      <div class="history-title">
        <i class="ph ph-chart-bar" aria-hidden="true" />
        <span>{{ t('garden.history') }}</span>
      </div>
      <div class="history">
        <div v-for="(bucket, i) in history" :key="i" class="history-month">
          <div
            class="history-bar"
            :style="{ height: `${Math.max(4, (bucket.count / historyMax) * 64)}px` }"
            :title="`${bucket.label}: ${bucket.count}`"
          />
          <span class="history-label">{{ bucket.label }}</span>
        </div>
      </div>

      <!-- Грядка: растение за каждую закрытую задачу -->
      <div v-if="doneTasks.length" class="garden-grid">
        <RouterLink
          v-for="task in doneTasks"
          :key="task.id"
          class="plant"
          :class="rarityByTask.get(task.id)"
          :to="`/tasks/${task.id}`"
        >
          <i :class="`ph ${plantIcon(task)}`" aria-hidden="true" />
          <span v-if="rarityByTask.get(task.id) === 'epic'" class="rarity-badge">
            <i class="ph ph-crown-simple" aria-hidden="true" />
          </span>
          <span v-else-if="rarityByTask.get(task.id) === 'rare'" class="rarity-badge">
            <i class="ph ph-sparkle" aria-hidden="true" />
          </span>
          <span class="plant-title">{{ task.title }}</span>
          <span class="plant-date">{{ formatDate(task.done_at!) }}</span>
        </RouterLink>
      </div>
      <GnEmptyState
        v-else
        icon="ph-flower-lotus"
        :title="t('garden.emptyTitle')"
        :text="t('garden.emptyText')"
      >
        <GnButton variant="secondary" icon="ph-question" @click="aboutOpen = true">
          {{ t('garden.aboutOpen') }}
        </GnButton>
      </GnEmptyState>

      <!-- Ачивки с тирами: прогресс к следующей ступени виден всегда -->
      <div class="achievements-title">
        <i class="ph ph-trophy" aria-hidden="true" />
        <span>{{ t('garden.achievements') }}</span>
      </div>
      <div class="achievements-grid">
        <div
          v-for="a in achievements"
          :key="a.key"
          class="achievement"
          :class="{ unlocked: currentTier(a) === -1 }"
        >
          <i :class="`ph ${a.icon}`" aria-hidden="true" />
          <span class="achievement-name">{{ t(`garden.ach.${a.key}`) }}</span>
          <GnBadge :variant="currentTier(a) === -1 ? 'success' : 'neutral'">
            {{ tierLabel(a) }}
          </GnBadge>
        </div>
      </div>
    </template>
  </section>
</template>

<style scoped>
.page-skeleton {
  margin-bottom: 1rem;
}
.level-progress {
  margin-bottom: 1rem;
}
/* справка о саде */
.about-body {
  display: flex;
  flex-direction: column;
  gap: 0.75rem;
  font-size: 0.95em;
  line-height: 1.5;
}
/* последний росток */
.last-plant {
  display: flex;
  gap: 0.5rem;
  align-items: center;
  margin-bottom: 1.5rem;
  font-size: 0.92em;
  color: var(--text-muted, #888);
}
.last-plant > i {
  color: var(--accent, #7aa2f7);
}
/* история по месяцам: простые столбики, подписи — Intl */
.history-title {
  display: flex;
  gap: 0.6rem;
  align-items: center;
  margin: 0 0 0.5rem;
  font-weight: 600;
  font-size: 1.05rem;
}
.history-title > i {
  color: var(--accent, #7aa2f7);
}
.history {
  display: flex;
  align-items: flex-end;
  gap: 0.4rem;
  margin-bottom: 2rem;
  padding: 0.75rem 0.5rem 0.25rem;
  border: 1px solid var(--border, #2a2f45);
  border-radius: 10px;
  overflow-x: auto;
}
.history-month {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 0.25rem;
  min-width: 34px;
  flex: 1 1 auto;
}
.history-bar {
  width: 100%;
  max-width: 26px;
  border-radius: 4px 4px 0 0;
  background: var(--success, #9ece6a);
  opacity: 0.85;
}
.history-label {
  font-size: 0.68em;
  color: var(--text-muted, #888);
  white-space: nowrap;
}
/* грядка: карточки-растения ровной сеткой, на узком экране — уже */
.garden-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));
  gap: 0.75rem;
  margin-bottom: 2rem;
}
.plant {
  position: relative;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 0.3rem;
  padding: 1rem 0.75rem;
  border: 1px solid var(--border, #2a2f45);
  border-radius: 10px;
  text-decoration: none;
  color: inherit;
  background: color-mix(in srgb, var(--success, #9ece6a) 6%, transparent);
}
.plant > i {
  font-size: 1.9rem;
  color: var(--success, #9ece6a);
}
/* редкие растения: золото + бейдж редкости, эпик — ещё и свечение */
.plant.rare > i {
  color: #d4a017;
}
.plant.epic > i {
  color: #d4a017;
  text-shadow:
    0 0 8px color-mix(in srgb, #f1c40f 70%, transparent),
    0 0 18px color-mix(in srgb, #f1c40f 40%, transparent);
}
.rarity-badge {
  position: absolute;
  top: 0.4rem;
  right: 0.4rem;
  font-size: 0.9rem;
  color: #d4a017;
  line-height: 1;
}
.plant-title {
  font-size: 0.85em;
  font-weight: 600;
  text-align: center;
  overflow: hidden;
  text-overflow: ellipsis;
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
}
.plant-date {
  font-size: 0.75em;
  color: var(--text-muted, #888);
}
.plant:hover {
  border-color: var(--success, #9ece6a);
}
/* ачивки */
.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;
}
.achievement {
  display: flex;
  gap: 0.6rem;
  align-items: center;
  padding: 0.75rem;
  border: 1px solid var(--border, #2a2f45);
  border-radius: 10px;
  opacity: 0.55;
}
.achievement.unlocked {
  opacity: 1;
  background: color-mix(in srgb, var(--success, #9ece6a) 8%, transparent);
}
.achievement > i {
  font-size: 1.3rem;
  color: var(--success, #9ece6a);
}
.achievement-name {
  flex: 1 1 auto;
  font-size: 0.92em;
  font-weight: 600;
}
</style>