feat(#10): достижения — milestones по reps/streaks с прогрессом
This commit is contained in:
128
src/renderer/src/components/AchievementsCard.tsx
Normal file
128
src/renderer/src/components/AchievementsCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
155
src/renderer/src/lib/achievements.ts
Normal file
155
src/renderer/src/lib/achievements.ts
Normal 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 }
|
||||
})
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user