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