feat(#10): достижения — milestones по reps/streaks с прогрессом

This commit is contained in:
AnRil
2026-05-22 13:41:51 +07:00
parent 68998607e8
commit 9e59be9cba
4 changed files with 328 additions and 1 deletions

View File

@@ -0,0 +1,128 @@
import { useMemo } from 'react'
import { Award, Activity, Flame, Sparkles, TrendingUp, Lock } from 'lucide-react'
import type { Exercise, HistoryEntry } from '@shared/types'
import {
computeAchievements,
type AchievementProgress
} from '../lib/achievements'
import { useT } from '../i18n'
const ICON_BY_NAME = {
Activity,
Flame,
Sparkles,
TrendingUp
} as const
type Props = {
history: HistoryEntry[]
exercises: Exercise[]
}
/**
* Сетка достижений. Показывает: (1) все unlocked прямо, (2) первое
* unlocked-в-прогрессе (ближайшее по %% — мотивация), (3) остальные
* как блёклые «locked». В компактной grid 4-в-ряд.
*/
export function AchievementsCard({ history, exercises }: Props): JSX.Element {
const { t } = useT()
const achievements = useMemo(
() => computeAchievements(history, exercises),
[history, exercises]
)
const unlocked = achievements.filter((a) => a.unlocked)
const locked = achievements.filter((a) => !a.unlocked)
// Сортируем locked по близости к unlock'у — чтобы «осталось 12»
// оказалось вверху, а «осталось 9999» внизу.
const nearestLocked = [...locked].sort((a, b) => {
const ap = a.current / a.target
const bp = b.current / b.target
return bp - ap
})
// Показываем: все unlocked + first 2 nearest locked (preview-мотивация).
const visible = [...unlocked, ...nearestLocked.slice(0, 2)]
if (visible.length === 0) return <></>
return (
<div className="bg-surface rounded-2xl p-5 shadow-card dark:ring-0.5 dark:ring-hairline/30">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<div className="w-7 h-7 rounded-lg bg-accent text-white grid place-items-center">
<Award size={14} strokeWidth={2.6} />
</div>
<div className="text-[14px] text-text/75 font-semibold">
{t('achievements.title')}
</div>
</div>
<div className="text-[12px] text-text/55 font-mono-num font-medium">
{t('achievements.unlocked_of', {
n: unlocked.length,
total: achievements.length
})}
</div>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-2">
{visible.map((a) => (
<Badge key={a.def.id} a={a} />
))}
</div>
</div>
)
}
function Badge({ a }: { a: AchievementProgress }): JSX.Element {
const { t } = useT()
const IconCmp = ICON_BY_NAME[a.def.icon as keyof typeof ICON_BY_NAME] ?? Award
const pct = Math.min(100, Math.round((a.current / a.target) * 100))
const toneBg = {
accent: 'bg-accent',
warning: 'bg-warning',
success: 'bg-success',
info: 'bg-info'
}[a.def.tone]
return (
<div
className={[
'rounded-xl p-2.5 transition-opacity',
a.unlocked ? 'bg-surface-2' : 'bg-surface-2 opacity-55'
].join(' ')}
title={t(a.def.descKey, { target: a.target })}
>
<div className="flex items-center gap-2 mb-1.5">
<div
className={[
'w-7 h-7 rounded-lg grid place-items-center text-white shrink-0',
a.unlocked ? toneBg : 'bg-text/30'
].join(' ')}
>
{a.unlocked ? (
<IconCmp size={14} strokeWidth={2.4} />
) : (
<Lock size={12} strokeWidth={2.4} />
)}
</div>
<div className="text-[12px] font-semibold truncate">
{t(a.def.titleKey)}
</div>
</div>
{!a.unlocked && (
<>
<div className="h-1 rounded-full bg-text/10 overflow-hidden">
<div
className={['h-full', toneBg].join(' ')}
style={{ width: `${pct}%` }}
/>
</div>
<div className="text-[10px] text-text/55 mt-1 font-mono-num font-medium">
{t('achievements.progress', { n: a.target - a.current })}
</div>
</>
)}
</div>
)
}

View File

@@ -219,6 +219,27 @@ export const ru: Dict = {
'updater.idle.title': 'Проверить обновления',
'updater.idle.subtitle': 'Авто-проверка раз в час',
// Achievements
'achievements.title': 'Достижения',
'achievements.unlocked_of': '{n} из {total}',
'achievements.progress': 'осталось {n}',
'achievement.reps.desc': 'Сделай {target} повторений всего',
'achievement.reps_100.title': 'Сотня',
'achievement.reps_500.title': 'Пятьсот',
'achievement.reps_1000.title': 'Тысяча',
'achievement.reps_5000.title': 'Пять тысяч',
'achievement.reps_10000.title': 'Десять тысяч',
'achievement.streak.desc': '{target} дней подряд',
'achievement.streak_3.title': 'Три дня',
'achievement.streak_7.title': 'Неделя',
'achievement.streak_14.title': 'Две недели',
'achievement.streak_30.title': 'Месяц',
'achievement.streak_100.title': 'Сто дней',
'achievement.first_day.title': 'Первый шаг',
'achievement.first_day.desc': 'Закрой первое напоминание',
'achievement.today_quad.title': 'Ударный день',
'achievement.today_quad.desc': '40+ повторений за день',
// Categories
'category.exercise': 'Упражнение',
'category.hydration': 'Гидратация',
@@ -495,6 +516,27 @@ export const en: Dict = {
'updater.idle.title': 'Check for updates',
'updater.idle.subtitle': 'Auto-check every hour',
// Achievements
'achievements.title': 'Achievements',
'achievements.unlocked_of': '{n} of {total}',
'achievements.progress': '{n} to go',
'achievement.reps.desc': '{target} reps total',
'achievement.reps_100.title': 'Century',
'achievement.reps_500.title': 'Five hundred',
'achievement.reps_1000.title': 'Thousand',
'achievement.reps_5000.title': 'Five thousand',
'achievement.reps_10000.title': 'Ten thousand',
'achievement.streak.desc': '{target} days in a row',
'achievement.streak_3.title': 'Three days',
'achievement.streak_7.title': 'Week',
'achievement.streak_14.title': 'Two weeks',
'achievement.streak_30.title': 'Month',
'achievement.streak_100.title': 'Hundred days',
'achievement.first_day.title': 'First step',
'achievement.first_day.desc': 'Close your first reminder',
'achievement.today_quad.title': 'Strong day',
'achievement.today_quad.desc': '40+ reps in one day',
// Categories
'category.exercise': 'Exercise',
'category.hydration': 'Hydration',

View File

@@ -0,0 +1,155 @@
/**
* Достижения = derived data из истории. Не persisting'ятся: при каждом
* прокладывании Dashboard'а пересчитываются из истории.
*
* Определение достижения: id, человеческий label/description, иконка
* (Lucide name), функция-проверка `progress(history, exercises) →
* { current, target }` где `current >= target` означает «получено».
*
* Этим UI получает не только список «полученных», но и прогресс по «почти-
* полученным», что важно мотивационно: «осталось 12 повторов до значка
* Сотня».
*/
import type { Exercise, HistoryEntry } from '@shared/types'
import { currentStreak, dailyReps, dayKey } from './history'
export type AchievementDef = {
id: string
/** Lucide icon name (whitelisted ICON_CHOICES не обязательно — иконки
* достижений отдельный набор). */
icon: string
tone: 'accent' | 'warning' | 'success' | 'info'
/** i18n-ключ для названия. */
titleKey: string
/** i18n-ключ описания. Принимает {target}. */
descKey: string
}
export type AchievementProgress = {
def: AchievementDef
current: number
target: number
/** Получено = current >= target. */
unlocked: boolean
}
/** Сумма всех done-повторений за всё время (учитывая actualReps). */
function totalDoneReps(
history: HistoryEntry[],
exercises: Exercise[]
): number {
const byId = new Map(exercises.map((e) => [e.id, e]))
let sum = 0
for (const e of history) {
if (e.action !== 'done') continue
sum += e.actualReps ?? byId.get(e.exerciseId)?.reps ?? 0
}
return sum
}
/** Сколько уникальных done-дней за всё время. */
function totalDoneDays(history: HistoryEntry[]): number {
const days = new Set<string>()
for (const e of history) {
if (e.action === 'done') days.add(dayKey(e.ts))
}
return days.size
}
/** Самый длинный завершённый streak (для historic-достижений). */
function longestStreak(history: HistoryEntry[]): number {
const days = new Set<string>()
for (const e of history) {
if (e.action === 'done') days.add(dayKey(e.ts))
}
const sorted = Array.from(days).sort()
let max = 0
let cur = 0
let prev = ''
for (const d of sorted) {
if (prev) {
const prevDate = new Date(prev + 'T00:00:00')
const curDate = new Date(d + 'T00:00:00')
const diffDays = Math.round(
(curDate.getTime() - prevDate.getTime()) / (24 * 60 * 60 * 1000)
)
cur = diffDays === 1 ? cur + 1 : 1
} else {
cur = 1
}
if (cur > max) max = cur
prev = d
}
return max
}
const REPS_MILESTONES = [100, 500, 1000, 5000, 10000] as const
const STREAK_MILESTONES = [3, 7, 14, 30, 100] as const
const DEFINITIONS: AchievementDef[] = [
...REPS_MILESTONES.map<AchievementDef>((n) => ({
id: `reps_${n}`,
icon: 'Activity',
tone: 'accent' as const,
titleKey: `achievement.reps_${n}.title`,
descKey: 'achievement.reps.desc'
})),
...STREAK_MILESTONES.map<AchievementDef>((n) => ({
id: `streak_${n}`,
icon: 'Flame',
tone: 'warning' as const,
titleKey: `achievement.streak_${n}.title`,
descKey: 'achievement.streak.desc'
})),
{
id: 'first_day',
icon: 'Sparkles',
tone: 'success',
titleKey: 'achievement.first_day.title',
descKey: 'achievement.first_day.desc'
},
{
id: 'today_quad',
icon: 'TrendingUp',
tone: 'info',
titleKey: 'achievement.today_quad.title',
descKey: 'achievement.today_quad.desc'
}
]
export function computeAchievements(
history: HistoryEntry[],
exercises: Exercise[]
): AchievementProgress[] {
const total = totalDoneReps(history, exercises)
const days = totalDoneDays(history)
const longest = longestStreak(history)
const currentStreakLen = currentStreak(history)
const today = dayKey(Date.now())
const todayCount = dailyReps(history, exercises, today)
return DEFINITIONS.map<AchievementProgress>((def) => {
if (def.id.startsWith('reps_')) {
const target = Number(def.id.split('_')[1])
return { def, current: total, target, unlocked: total >= target }
}
if (def.id.startsWith('streak_')) {
const target = Number(def.id.split('_')[1])
// Учитываем максимальный исторический и текущий — берём больший.
const cur = Math.max(longest, currentStreakLen)
return { def, current: cur, target, unlocked: cur >= target }
}
if (def.id === 'first_day') {
return { def, current: days >= 1 ? 1 : 0, target: 1, unlocked: days >= 1 }
}
if (def.id === 'today_quad') {
return {
def,
current: todayCount,
target: 40,
unlocked: todayCount >= 40
}
}
return { def, current: 0, target: 1, unlocked: false }
})
}

View File

@@ -5,6 +5,7 @@ import { useAppStore } from '../store/appStore'
import { ExerciseCard } from '../components/ExerciseCard'
import { ExerciseEditor } from '../components/ExerciseEditor'
import { HistoryHeatmap } from '../components/HistoryHeatmap'
import { AchievementsCard } from '../components/AchievementsCard'
import { Button } from '../components/ui/Button'
import type { Exercise, HistoryEntry } from '@shared/types'
import { formatCountdown } from '../lib/format'
@@ -179,8 +180,9 @@ export default function Dashboard(): JSX.Element {
</div>
{history.length > 0 && (
<div className="mb-8">
<div className="mb-8 space-y-3">
<HistoryHeatmap history={history} exercises={exercises} />
<AchievementsCard history={history} exercises={exercises} />
</div>
)}