feat(i18n): bilingual UI (Russian + English) + language selector
Some checks failed
CI / Typecheck + Tests (push) Has been cancelled
CI / Build (Windows) (push) Has been cancelled
Release / Build installer + publish release (push) Has been cancelled

Все UI-строки приложения переведены и переключаются на лету через
Settings → Язык интерфейса.

== i18n архитектура ==
- src/renderer/src/i18n/dict.ts — плоский словарь ru/en с ~190 ключами,
  поддержка интерполяции {var} и плюрализации
- src/renderer/src/i18n/index.ts — useT() React hook + чистые
  translate/translateN функции (для ReminderApp вне store context)
- Settings.language: 'ru' | 'en', default 'ru'
- Изменение языка применяется немедленно через Zustand reactive update

== Что переведено ==
- Sidebar nav + slogan + status
- Titlebar window controls (aria-labels)
- Dashboard: hero, 3 stat-карточки (Активных / До следующего /
  Трекинг матчей), Paused banner, empty state
- Exercises: hero, секции (активные / выключенные), row meta, empty
- Challenges: hero, formula subtitle, warning, row format
  «{stat} × {mult} → {exercise}», empty
- Games: hero, status badges (Live/Ready/Queued/Installed/Not found),
  queued/no_user banners, dev panel
- Settings: все секции + новый Language selector
- UpdaterCard: все состояния (checking/available/downloading/
  downloaded/error/idle) с интерполяцией версии и MB/s
- ReminderApp: kicker «Время тренировки», reps подпись, snooze label
  с динамическими минутами, кнопки done/skip
- Match summary: победа/поражение, плюрализация «N челлендж/-а/-ей»
  vs «N challenge/-s»
- Format helpers (formatCountdown, formatInterval) — теперь принимают
  Language параметр

== Локалезависимая дата ==
Dashboard hero показывает today в текущей локали:
  ru-RU → "воскресенье, 17 мая"
  en-US → "Sunday, May 17"

== STAT_LABELS bilingual ==
- shared/types.ts: STAT_LABELS_EN + statLabel(stat, lang) helper
- ChallengeResult получил поле stat?: GameStat (для resolve на стороне
  renderer'а с актуальным языком, вместо baked-in label)
- main/games/registry.ts кладёт stat в результат

== Тесты ==
- src/renderer/src/i18n/i18n.test.ts: 10 кейсов
  * translate: lookup, fallback, interpolation, multi-var, lang fallback
  * translateN: ru plural rules (1/21/101 → one; 2-4 → few; 0/5-20 → many)
    и en (1 → one, else → many)
- Всего 33 теста зелёные

== Известное ограничение ==
SAMPLE_EXERCISES (5-6 русских "Приседания / Отжимания / ...") остаются
русскими — это seed данных на первый запуск. Английский юзер сразу
переключит язык и сможет переименовать вручную. Делать seed-per-locale
оверкилл — слишком много кода ради малого.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
AnRil
2026-05-17 23:28:34 +07:00
parent 70eb4717ec
commit 973339ca62
19 changed files with 999 additions and 256 deletions

View File

@@ -7,12 +7,14 @@ import { ExerciseEditor } from '../components/ExerciseEditor'
import { Button } from '../components/ui/Button'
import type { Exercise } from '@shared/types'
import { formatCountdown } from '../lib/format'
import { useT } from '../i18n'
export default function Dashboard(): JSX.Element {
const state = useAppStore((s) => s.state)
const ticks = useAppStore((s) => s.ticks)
const [editorOpen, setEditorOpen] = useState(false)
const [editing, setEditing] = useState<Exercise | null>(null)
const { t, lang } = useT()
const exercises = state?.exercises ?? []
const settings = state?.settings
@@ -57,70 +59,79 @@ export default function Dashboard(): JSX.Element {
await window.api.updateSettings({ globalEnabled: !settings.globalEnabled })
}
const today = new Date().toLocaleDateString('ru-RU', {
weekday: 'long',
day: 'numeric',
month: 'long'
})
const today = new Date().toLocaleDateString(
lang === 'en' ? 'en-US' : 'ru-RU',
{ weekday: 'long', day: 'numeric', month: 'long' }
)
return (
<div className="h-full overflow-y-auto">
<div className="max-w-5xl mx-auto px-4 sm:px-6 lg:px-10 pt-8 pb-12">
{/* Hero — iOS Large Title */}
<div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4 mb-8">
<div className="min-w-0">
<div className="text-[14px] text-text/65 font-semibold capitalize">
{today}
</div>
<h1 className="font-serif text-[34px] sm:text-[40px] leading-[1.02] tracking-tight mt-1 font-bold">
Сегодня
{t('dashboard.title')}
</h1>
</div>
<div className="flex items-center gap-2">
<Button variant="tinted" onClick={togglePause}>
{!paused ? (
<>
<Pause size={14} strokeWidth={2.5} /> Пауза
<Pause size={14} strokeWidth={2.5} /> {t('btn.pause')}
</>
) : (
<>
<Play size={14} strokeWidth={2.5} /> Старт
<Play size={14} strokeWidth={2.5} /> {t('btn.start')}
</>
)}
</Button>
<Button onClick={openCreate}>
<Plus size={15} strokeWidth={2.5} /> Добавить
<Plus size={15} strokeWidth={2.5} /> {t('btn.add')}
</Button>
</div>
</div>
{/* Hero stat panel — Apple Fitness style */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 mb-8">
<HeroStat
tone="accent"
label="Активных"
label={t('dashboard.stat.active')}
value={`${stats.active}`}
subvalue={`из ${stats.total}`}
subvalue={t('dashboard.stat.active.of', { total: stats.total })}
icon={<Activity size={14} strokeWidth={2.6} />}
/>
<HeroStat
tone="info"
label="До следующего"
label={t('dashboard.stat.next')}
value={
stats.nextMs === Infinity
? '—'
: stats.nextMs <= 0
? 'Сейчас'
: formatCountdown(stats.nextMs)
? t('dashboard.stat.next.now')
: formatCountdown(stats.nextMs, lang)
}
subvalue={
paused
? t('dashboard.stat.next.subtitle_paused')
: t('dashboard.stat.next.subtitle_running')
}
subvalue={paused ? 'на паузе' : 'отсчёт идёт'}
icon={<Flame size={14} strokeWidth={2.6} />}
/>
<HeroStat
tone={gamesEnabled ? 'success' : 'muted'}
label="Трекинг матчей"
value={gamesEnabled ? 'On' : 'Off'}
subvalue={gamesEnabled ? 'в реальном времени' : 'выключен'}
label={t('dashboard.stat.tracking')}
value={
gamesEnabled
? t('dashboard.stat.tracking.on')
: t('dashboard.stat.tracking.off')
}
subvalue={
gamesEnabled
? t('dashboard.stat.tracking.subtitle_on')
: t('dashboard.stat.tracking.subtitle_off')
}
icon={
<span
className={[
@@ -132,7 +143,6 @@ export default function Dashboard(): JSX.Element {
/>
</div>
{/* Paused banner */}
{paused && (
<motion.div
initial={{ opacity: 0, y: -4 }}
@@ -144,19 +154,18 @@ export default function Dashboard(): JSX.Element {
</div>
<div className="flex-1 min-w-0">
<div className="text-[16px] font-semibold leading-tight">
Напоминания на паузе
{t('dashboard.paused.title')}
</div>
<div className="text-[14px] text-text/70 mt-1">
Возобнови, чтобы продолжить отсчёт
{t('dashboard.paused.hint')}
</div>
</div>
<Button variant="filled" size="sm" onClick={togglePause}>
<Play size={14} strokeWidth={2.5} /> Старт
<Play size={14} strokeWidth={2.5} /> {t('btn.start')}
</Button>
</motion.div>
)}
{/* Cards grid */}
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-2 gap-4">
<AnimatePresence>
{exercises.map((ex) => (
@@ -179,10 +188,10 @@ export default function Dashboard(): JSX.Element {
<Plus size={24} strokeWidth={2.5} />
</div>
<div className="font-display text-[20px] font-semibold">
Программа пуста
{t('dashboard.empty.title')}
</div>
<p className="text-[14px] text-text/55 mt-1">
Добавь первое упражнение, чтобы начать
{t('dashboard.empty.hint')}
</p>
</div>
)}