diff --git a/src/renderer/src/components/AchievementsCard.tsx b/src/renderer/src/components/AchievementsCard.tsx
new file mode 100644
index 0000000..eb3c306
--- /dev/null
+++ b/src/renderer/src/components/AchievementsCard.tsx
@@ -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 (
+
+
+
+
+
+ {t('achievements.title')}
+
+
+
+ {t('achievements.unlocked_of', {
+ n: unlocked.length,
+ total: achievements.length
+ })}
+
+
+
+ {visible.map((a) => (
+
+ ))}
+
+
+ )
+}
+
+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 (
+
+
+
+ {a.unlocked ? (
+
+ ) : (
+
+ )}
+
+
+ {t(a.def.titleKey)}
+
+
+ {!a.unlocked && (
+ <>
+
+
+ {t('achievements.progress', { n: a.target - a.current })}
+
+ >
+ )}
+
+ )
+}
diff --git a/src/renderer/src/i18n/dict.ts b/src/renderer/src/i18n/dict.ts
index f009cd7..cd35dc3 100644
--- a/src/renderer/src/i18n/dict.ts
+++ b/src/renderer/src/i18n/dict.ts
@@ -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',
diff --git a/src/renderer/src/lib/achievements.ts b/src/renderer/src/lib/achievements.ts
new file mode 100644
index 0000000..24779f9
--- /dev/null
+++ b/src/renderer/src/lib/achievements.ts
@@ -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()
+ 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()
+ 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((n) => ({
+ id: `reps_${n}`,
+ icon: 'Activity',
+ tone: 'accent' as const,
+ titleKey: `achievement.reps_${n}.title`,
+ descKey: 'achievement.reps.desc'
+ })),
+ ...STREAK_MILESTONES.map((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((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 }
+ })
+}
diff --git a/src/renderer/src/pages/Dashboard.tsx b/src/renderer/src/pages/Dashboard.tsx
index 9f87f79..e3ece5a 100644
--- a/src/renderer/src/pages/Dashboard.tsx
+++ b/src/renderer/src/pages/Dashboard.tsx
@@ -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 {
{history.length > 0 && (
-