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 && ( -
+
+
)}