<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import {
api,
type GardenState,
type Task,
type XpEvent,
type XpSummary,
} from '../api'
import { formatDate } from '../taskui'
import GardenScene from '../components/GardenScene.vue'
defineOptions({ name: 'GardenView' })
// Сад (ТЗ 3.13): живая сцена — домик, растения (закрытые задачи), декорации.
// Принцип «контент открывается за опыт, покупается за монеты»: XP — уровень,
// монеты — валюта сада (рост растений, декорации, расширения карты).
const { t, locale, tm } = useI18n()
const tasks = ref<Task[]>([])
const events = ref<XpEvent[]>([])
const xp = ref<XpSummary | null>(null)
const garden = ref<GardenState | 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')
: []
})
const doneTasks = computed(() =>
tasks.value
.filter((task) => task.status === 'done' && task.done_at)
.sort((a, b) => (a.done_at! < b.done_at! ? 1 : -1)),
)
// Название уровня: 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 },
]
})
// --- Маркет: расширения, декорации, каталог видов ---
const shopOpen = ref(false)
const shopBusy = ref<string | null>(null) // item_key текущего запроса
// Состояние карточки магазина: замок / не хватает монет / можно купить
type ShopState = 'locked' | 'poor' | 'ok' | 'max'
function decorShopState(decor: { key: string; cost: number; unique: boolean }): ShopState {
if (decor.unique && (garden.value?.items ?? []).some((i) => i.item_key === decor.key)) {
return 'max'
}
return (garden.value?.balance ?? 0) >= decor.cost ? 'ok' : 'poor'
}
function expansionShopState(): ShopState {
const next = garden.value?.next_expansion
if (!next) return 'max'
if ((garden.value?.level ?? 1) < next.level) return 'locked'
return (garden.value?.balance ?? 0) >= next.cost ? 'ok' : 'poor'
}
async function buy(itemKey: string): Promise<void> {
shopBusy.value = itemKey
error.value = ''
try {
const res = await api.buyGardenItem(itemKey)
if (garden.value) garden.value.balance = res.balance
await load()
} catch (e) {
error.value = String(e)
} finally {
shopBusy.value = null
}
}
async function load() {
loading.value = true
error.value = ''
try {
const [taskList, xpSummary, eventList, gardenState] = await Promise.all([
api.listTasks(),
api.getXp(),
api.getXpEvents(),
api.getGarden(),
])
tasks.value = taskList
xp.value = xpSummary
events.value = eventList
garden.value = gardenState
} catch (e) {
error.value = String(e)
} finally {
loading.value = false
}
}
async function onMove(id: number, x: number, y: number): Promise<void> {
try {
await api.moveGardenItem(id, x, y)
} catch {
await load() // позиция не принята — вернуть из состояния бэка
}
}
async function onUpgrade(id: number): Promise<void> {
try {
await api.upgradePlant(id)
await load()
} catch (e) {
error.value = String(e)
}
}
onMounted(load)
</script>
<template>
<section>
<GnSkeleton v-if="loading && !garden" 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="warning" :title="t('garden.coinsHint')">
<i class="ph ph-coins" aria-hidden="true" />
{{ t('garden.coins', { n: garden?.balance ?? 0 }) }}
</GnBadge>
</template>
<template #actions>
<GnButton variant="secondary" icon="ph-storefront" @click="shopOpen = true">
{{ t('garden.shop') }}
</GnButton>
<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>
<!-- Маркет: расширения (первым блоком), декорации, каталог видов -->
<GnModal :open="shopOpen" :title="t('garden.shopTitle')" @update:open="shopOpen = $event">
<div class="shop-body">
<div class="shop-hint">{{ t('garden.speciesHint') }}</div>
<!-- 1. Расширение сада -->
<div class="shop-block">
<div class="shop-block-title">
<i class="ph ph-map-trifold" aria-hidden="true" />
<span>{{ t('garden.expansion') }}</span>
</div>
<p class="shop-block-hint">
{{ t('garden.expansionCurrent', { cols: garden?.grid.cols, rows: garden?.grid.rows }) }}
</p>
<div v-if="garden?.next_expansion" class="shop-card" :class="expansionShopState()">
<i class="ph ph-tree-palm" aria-hidden="true" />
<span class="shop-name">{{ t(`garden.expansion`) }} {{ garden.expansions_bought + 1 }}</span>
<span class="shop-meta">
<template v-if="expansionShopState() === 'locked'">
{{ t('garden.lockedUntil', { n: garden.next_expansion.level }) }}
</template>
<template v-else>{{ t('garden.expansionHint', { n: 8 }) }}</template>
</span>
<GnBadge variant="neutral">
<i class="ph ph-coins" aria-hidden="true" /> {{ garden.next_expansion.cost }}
</GnBadge>
<GnButton
size="sm"
variant="primary"
icon="ph-shopping-cart-simple"
:disabled="expansionShopState() !== 'ok' || shopBusy !== null"
:loading="shopBusy === garden.next_expansion.key"
@click="buy(garden.next_expansion.key)"
>
{{ t('garden.buy') }}
</GnButton>
</div>
<p v-else class="shop-max">{{ t('garden.expansionMax') }}</p>
</div>
<!-- 2. Декорации -->
<div class="shop-block">
<div class="shop-block-title">
<i class="ph ph-flowers" aria-hidden="true" />
<span>{{ t('garden.decorations') }}</span>
</div>
<p class="shop-block-hint">{{ t('garden.decorationsHint') }}</p>
<div
v-for="decor in garden?.decorations ?? []"
:key="decor.key"
class="shop-item"
:class="decorShopState(decor)"
>
<i class="ph ph-flower" aria-hidden="true" />
<span class="shop-name">
{{ t(`garden.decorNames.${decor.key}`) }}
<GnBadge v-if="decor.unique" variant="neutral">{{ t('garden.unique') }}</GnBadge>
</span>
<GnBadge variant="neutral" class="shop-price">
<i class="ph ph-coins" aria-hidden="true" /> {{ decor.cost }}
</GnBadge>
<span v-if="decorShopState(decor) === 'poor'" class="shop-poor">
{{ t('garden.notEnoughCoins', { n: decor.cost - (garden?.balance ?? 0) }) }}
</span>
<span v-else-if="decorShopState(decor) === 'max'" class="shop-owned">✓</span>
<GnButton
v-else
size="sm"
variant="secondary"
icon="ph-shopping-cart-simple"
:disabled="decorShopState(decor) !== 'ok'"
:loading="shopBusy === decor.key"
@click="buy(decor.key)"
>
{{ t('garden.buy') }}
</GnButton>
</div>
</div>
<!-- 3. Каталог видов растений (открываются уровнем, покупаются ростом) -->
<div class="shop-block">
<div class="shop-block-title">
<i class="ph ph-plant" aria-hidden="true" />
<span>{{ t('garden.species') }}</span>
</div>
<div
v-for="species in garden?.species ?? []"
:key="species.key"
class="shop-item"
:class="species.unlocked ? 'ok' : 'locked'"
>
<i :class="`ph ${species.key}`" aria-hidden="true" />
<span class="shop-name">
{{ t(`garden.speciesNames.${species.key}`) }}
<GnBadge v-if="species.premium" variant="warning">{{ t('garden.rareOnly') }}</GnBadge>
</span>
<span v-if="!species.unlocked" class="shop-meta">
{{ t('garden.lockedUntil', { n: species.level }) }}
</span>
<span v-else class="shop-meta">✓</span>
</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('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>
<!-- Сцена сада: домик, растения (drag&drop), декорации -->
<GardenScene
v-if="garden && garden.items.length"
:state="garden"
:balance="garden.balance"
@move="onMove"
@upgrade="onUpgrade"
/>
<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>
<!-- История: закрытых задач по месяцам (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 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;
}
/* маркет */
.shop-body {
display: flex;
flex-direction: column;
gap: 1.25rem;
font-size: 0.95em;
}
.shop-hint {
color: var(--text-muted, #888);
line-height: 1.45;
}
.shop-block {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.shop-block-title {
display: flex;
gap: 0.5rem;
align-items: center;
font-weight: 600;
font-size: 1rem;
}
.shop-block-title > i {
color: var(--accent, #7aa2f7);
}
.shop-block-hint {
margin: 0;
font-size: 0.88em;
color: var(--text-muted, #888);
}
.shop-item,
.shop-card {
display: flex;
gap: 0.6rem;
align-items: center;
padding: 0.6rem 0.75rem;
border: 1px solid var(--border, #2a2f45);
border-radius: 10px;
}
.shop-card > i:first-child {
font-size: 1.3rem;
color: var(--accent, #7aa2f7);
}
.shop-item > i:first-child {
font-size: 1.3rem;
color: var(--success, #9ece6a);
}
.shop-item.locked > i:first-child {
color: var(--text-muted, #888);
opacity: 0.5;
}
.shop-item.locked {
opacity: 0.6;
}
.shop-name {
flex: 1 1 auto;
display: flex;
gap: 0.4rem;
align-items: center;
font-weight: 600;
font-size: 0.92em;
}
.shop-meta {
font-size: 0.82em;
color: var(--text-muted, #888);
}
.shop-price {
white-space: nowrap;
}
.shop-poor {
font-size: 0.82em;
color: #f7768e;
white-space: nowrap;
}
.shop-owned {
color: var(--success, #9ece6a);
font-weight: 700;
}
.shop-max {
margin: 0;
font-size: 0.9em;
color: var(--text-muted, #888);
}
/* последний росток */
.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;
}
/* ачивки */
.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>