From 973339ca6228476dd1595a03aa305e763f8ba157 Mon Sep 17 00:00:00 2001 From: AnRil Date: Sun, 17 May 2026 23:28:34 +0700 Subject: [PATCH] feat(i18n): bilingual UI (Russian + English) + language selector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Все 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 --- package.json | 2 +- src/main/games/registry.ts | 3 +- src/renderer/src/App.tsx | 5 +- src/renderer/src/ReminderApp.tsx | 83 ++-- src/renderer/src/components/ExerciseCard.tsx | 24 +- .../src/components/ExerciseEditor.tsx | 30 +- src/renderer/src/components/Sidebar.tsx | 36 +- src/renderer/src/components/Titlebar.tsx | 31 +- src/renderer/src/components/UpdaterCard.tsx | 54 ++- src/renderer/src/i18n/dict.ts | 414 ++++++++++++++++++ src/renderer/src/i18n/i18n.test.ts | 97 ++++ src/renderer/src/i18n/index.ts | 83 ++++ src/renderer/src/lib/format.ts | 30 +- src/renderer/src/pages/Challenges.tsx | 71 +-- src/renderer/src/pages/Dashboard.tsx | 63 +-- src/renderer/src/pages/Exercises.tsx | 35 +- src/renderer/src/pages/Games.tsx | 57 ++- src/renderer/src/pages/Settings.tsx | 118 +++-- src/shared/types.ts | 19 + 19 files changed, 999 insertions(+), 256 deletions(-) create mode 100644 src/renderer/src/i18n/dict.ts create mode 100644 src/renderer/src/i18n/i18n.test.ts create mode 100644 src/renderer/src/i18n/index.ts diff --git a/package.json b/package.json index 1226470..f1d5172 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "laude", - "version": "0.3.7", + "version": "0.4.0", "description": "Exercise reminder — Windows desktop app", "main": "out/main/index.js", "author": "AnRil", diff --git a/src/main/games/registry.ts b/src/main/games/registry.ts index dd0adde..fe8f0e3 100644 --- a/src/main/games/registry.ts +++ b/src/main/games/registry.ts @@ -38,7 +38,8 @@ async function onMatchEnd(gameId: GameId, payload: MatchEndPayload): Promise
- setMobileNavOpen(true)} - /> + setMobileNavOpen(true)} />
{ if (mode.kind !== 'exercise') return const ex = mode.exercise @@ -67,12 +69,15 @@ export default function ReminderApp(): JSX.Element { window.api.reminderClose() } + const lang: Language = settings?.language ?? 'ru' + if (mode.kind === 'idle') return
if (mode.kind === 'exercise') { return ( ) @@ -81,6 +86,7 @@ export default function ReminderApp(): JSX.Element { setMode({ kind: 'match', @@ -96,12 +102,17 @@ export default function ReminderApp(): JSX.Element { function ExerciseReminder({ exercise, snoozeMinutes, + lang, onClose }: { exercise: Exercise snoozeMinutes: number + lang: Language onClose: () => void }): JSX.Element { + const t = (key: string, vars?: Record): string => + translate(lang, key, vars) + async function done(): Promise { await window.api.markDone(exercise.id) onClose() @@ -121,7 +132,7 @@ function ExerciseReminder({ @@ -140,7 +151,7 @@ function ExerciseReminder({
- Время тренировки + {t('reminder.kicker')}

{exercise.name} @@ -150,35 +161,39 @@ function ExerciseReminder({ {exercise.reps} - раз + + {t('reminder.reps')} +

- Следующее через {formatInterval(exercise.intervalMinutes)} + {t('reminder.next_in', { + interval: formatInterval(exercise.intervalMinutes, lang) + })}
- {/* iOS action sheet — buttons stacked vertically, equal width */}
@@ -189,14 +204,24 @@ function ExerciseReminder({ function MatchSummaryView({ summary, done, + lang, onMarkDone, onClose }: { summary: MatchSummary done: Set + lang: Language onMarkDone: (id: string) => void onClose: () => void }): JSX.Element { + const t = (key: string, vars?: Record): string => + translate(lang, key, vars) + const tn = ( + base: string, + n: number, + vars?: Record + ): string => translateN(lang, base, n, vars) + const allDone = summary.results.every((r) => done.has(r.challengeId)) const totalReps = summary.results.reduce((s, r) => s + r.reps, 0) const remainingReps = summary.results @@ -204,6 +229,7 @@ function MatchSummaryView({ .reduce((s, r) => s + r.reps, 0) const won = summary.won === true const lost = summary.won === false + const minutes = Math.floor(summary.durationMs / 60_000) return (
@@ -214,7 +240,7 @@ function MatchSummaryView({ @@ -239,19 +265,23 @@ function MatchSummaryView({ )}

- {won ? 'Победа' : lost ? 'Поражение' : 'Матч завершён'} + {won + ? t('match.title.won') + : lost + ? t('match.title.lost') + : t('match.title.draw')}

- - {Math.floor(summary.durationMs / 60_000)} - {' '} - мин · {summary.results.length} челлендж - {summary.results.length === 1 ? '' : 'а'} ·{' '} + {minutes}{' '} + {t('fmt.m')} ·{' '} + {tn('match.summary.challenges', summary.results.length)}{' · '} {allDone ? ( - всё готово + + {t('match.summary.all_done')} + ) : ( - {remainingReps} осталось + {t('match.summary.remaining', { n: remainingReps })} )}

@@ -262,6 +292,7 @@ function MatchSummaryView({ onMarkDone(r.challengeId)} /> @@ -270,11 +301,11 @@ function MatchSummaryView({
- Всего ·{' '} + {t('match.total')} ·{' '} {totalReps} {' '} - повторов + {t('match.total_reps_suffix')}
@@ -298,13 +329,16 @@ function MatchSummaryView({ function ChallengeRow({ result, + lang, done, onMarkDone }: { result: ChallengeResult + lang: Language done: boolean onMarkDone: () => void }): JSX.Element { + const label = result.stat ? statLabel(result.stat, lang) : result.statLabel return ( {result.statValue} {' '} - {result.statLabel} → {result.name} + {label} → {result.name}
diff --git a/src/renderer/src/components/ExerciseCard.tsx b/src/renderer/src/components/ExerciseCard.tsx index 89f00b8..570215d 100644 --- a/src/renderer/src/components/ExerciseCard.tsx +++ b/src/renderer/src/components/ExerciseCard.tsx @@ -5,6 +5,7 @@ import type { Exercise, Tick } from '@shared/types' import { Icon } from '../lib/icon' import { formatCountdown, formatInterval } from '../lib/format' import { Switch } from './ui/Switch' +import { useT } from '../i18n' type Props = { exercise: Exercise @@ -34,6 +35,7 @@ export function ExerciseCard({ const elapsedPct = total > 0 ? 1 - remaining / total : 0 const isDue = ms <= 0 && exercise.enabled const [menuOpen, setMenuOpen] = useState(false) + const { t, lang } = useT() // Ring math const R = 22 @@ -104,7 +106,7 @@ export function ExerciseCard({ @@ -122,7 +124,7 @@ export function ExerciseCard({ }} className="w-full text-left px-3 py-2 text-[13px] hover:bg-surface-2 active:bg-hairline/25" > - Редактировать + {t('btn.edit')}
@@ -139,14 +141,17 @@ export function ExerciseCard({
- {exercise.reps} раз · каждые {formatInterval(exercise.intervalMinutes)} + {t('editor.exercise.preview.meta', { + reps: exercise.reps, + min: exercise.intervalMinutes + })}
{/* Countdown + switch */}
- {isDue ? 'Сейчас' : 'Через'} + {isDue ? t('dashboard.stat.next.now') : t('fmt.through')}
- {exercise.enabled ? formatCountdown(ms) : 'на паузе'} + {exercise.enabled + ? formatCountdown(ms, lang) + : t('fmt.paused')}
- {/* Done action — appears as filled pill at bottom only on due */} {isDue && ( - Готово + {t('btn.done')} )} diff --git a/src/renderer/src/components/ExerciseEditor.tsx b/src/renderer/src/components/ExerciseEditor.tsx index ce5dfbb..cac283e 100644 --- a/src/renderer/src/components/ExerciseEditor.tsx +++ b/src/renderer/src/components/ExerciseEditor.tsx @@ -3,6 +3,7 @@ import type { Exercise } from '@shared/types' import { Modal } from './ui/Modal' import { Button } from './ui/Button' import { ICON_CHOICES, Icon } from '../lib/icon' +import { useT } from '../i18n' type Draft = { name: string @@ -34,6 +35,7 @@ export function ExerciseEditor({ onSave }: Props): JSX.Element { const [draft, setDraft] = useState(EMPTY) + const { t } = useT() useEffect(() => { if (exercise) { @@ -55,46 +57,52 @@ export function ExerciseEditor({ } >
- {/* Live preview header */}
- {draft.name || 'Без названия'} + {draft.name || t('editor.exercise.preview.placeholder')}
- {draft.reps} раз · каждые {draft.intervalMinutes} мин + {t('editor.exercise.preview.meta', { + reps: draft.reps, + min: draft.intervalMinutes + })}
- + setDraft({ ...draft, name: e.target.value })} - placeholder="Приседания" + placeholder={t('editor.field.name.placeholder')} className="ios-input" autoFocus />
- + - +
- +
{ICON_CHOICES.map((name) => ( @@ -93,21 +97,20 @@ export function Sidebar({ } function SidebarContent({ onNav }: { onNav?: () => void }): JSX.Element { + const { t } = useT() return ( <> - {/* Brand */}
Laude
- Двигайся осознанно + {t('sidebar.slogan')}
- {/* Nav */} - {/* Status footer */}
- Активность отслеживается + {t('sidebar.status_tracking')}
diff --git a/src/renderer/src/components/Titlebar.tsx b/src/renderer/src/components/Titlebar.tsx index 79179de..d477c11 100644 --- a/src/renderer/src/components/Titlebar.tsx +++ b/src/renderer/src/components/Titlebar.tsx @@ -1,46 +1,49 @@ import { Minus, X, Square, Menu } from 'lucide-react' +import { useT } from '../i18n' type Props = { - title: string + title?: string onMenuClick?: () => void } -/** - * macOS-style translucent titlebar. Title centred small, no app icon. - * Window buttons sit right; a left-side hamburger surfaces on mobile only. - */ export function Titlebar({ title, onMenuClick }: Props): JSX.Element { + const { t } = useT() + const effectiveTitle = title ?? t('titlebar.app_title') + return (
- {/* Left: hamburger only on small */}
{onMenuClick && ( )}
- {/* Centre title */}
- {title} + {effectiveTitle}
- {/* Right window controls */}
- window.api.minimizeMain()} label="Свернуть"> + window.api.minimizeMain()} + label={t('titlebar.minimize_aria')} + > - window.api.hideMain()} label="В трей"> + window.api.hideMain()} + label={t('titlebar.tray_aria')} + > window.api.closeMain()} - label="Закрыть" + label={t('titlebar.close_aria')} danger > diff --git a/src/renderer/src/components/UpdaterCard.tsx b/src/renderer/src/components/UpdaterCard.tsx index 0647adb..72715ba 100644 --- a/src/renderer/src/components/UpdaterCard.tsx +++ b/src/renderer/src/components/UpdaterCard.tsx @@ -10,6 +10,7 @@ import { import { motion } from 'framer-motion' import { Button } from './ui/Button' import { Card } from './ui/Card' +import { useT } from '../i18n' import type { UpdaterStatus } from '@shared/types' export function UpdaterCard(): JSX.Element { @@ -67,13 +68,15 @@ function Body({ onDownload: () => void onInstall: () => void }): JSX.Element { + const { t } = useT() + if (status.kind === 'unsupported') { return ( } - title="Auto-update недоступен" - subtitle={status.reason} + title={t('updater.unsupported')} + subtitle={t('updater.unsupported.reason_dev')} /> ) } @@ -81,8 +84,14 @@ function Body({ return ( } - title="Проверяем обновления…" + icon={ + + } + title={t('updater.checking')} /> ) } @@ -91,11 +100,11 @@ function Body({ } - title="Последняя версия" - subtitle={`Текущая: v${status.currentVersion}`} + title={t('updater.up_to_date')} + subtitle={t('updater.up_to_date.subtitle', { v: status.currentVersion })} action={ } /> @@ -106,15 +115,15 @@ function Body({ } - title={`Доступна v${status.version}`} + title={t('updater.available.title', { v: status.version })} subtitle={ status.releaseDate - ? new Date(status.releaseDate).toLocaleString('ru-RU') + ? new Date(status.releaseDate).toLocaleString() : undefined } action={ } /> @@ -131,11 +140,14 @@ function Body({
- Загружаем обновление + {t('updater.downloading.title')}
- {mb(status.transferred)} / {mb(status.total)} МБ ·{' '} - {(status.bytesPerSecond / 1024 / 1024).toFixed(2)} МБ/с + {t('updater.downloading.subtitle', { + got: mb(status.transferred), + total: mb(status.total), + speed: (status.bytesPerSecond / 1024 / 1024).toFixed(2) + })}
@@ -157,11 +169,11 @@ function Body({ } - title={`Готово · v${status.version}`} - subtitle="Перезапусти для применения" + title={t('updater.downloaded.title', { v: status.version })} + subtitle={t('updater.downloaded.subtitle')} action={ } /> @@ -172,11 +184,11 @@ function Body({ } - title="Ошибка проверки" + title={t('updater.error.title')} subtitle={status.message} action={ } /> @@ -186,11 +198,11 @@ function Body({ } - title="Проверить обновления" - subtitle="Авто-проверка раз в час" + title={t('updater.idle.title')} + subtitle={t('updater.idle.subtitle')} action={ } /> diff --git a/src/renderer/src/i18n/dict.ts b/src/renderer/src/i18n/dict.ts new file mode 100644 index 0000000..7ef4684 --- /dev/null +++ b/src/renderer/src/i18n/dict.ts @@ -0,0 +1,414 @@ +/** + * Flat string dictionary for ru/en. Keys use dot notation but are just + * strings — no nesting overhead. + * + * Interpolation: `{name}` placeholders are replaced via `useT()` helper. + * + * Pluralization: keys ending in `.one`/`.few`/`.many` (ru) or + * `.one`/`.other` (en) are picked by `tn()` helper based on count. + */ + +export type Dict = Record + +export const ru: Dict = { + // Sidebar / nav + 'nav.today': 'Сегодня', + 'nav.exercises': 'Упражнения', + 'nav.games': 'Игры', + 'nav.challenges': 'Челленджи', + 'nav.settings': 'Настройки', + 'sidebar.slogan': 'Двигайся осознанно', + 'sidebar.status_tracking': 'Активность отслеживается', + 'titlebar.menu_aria': 'Меню', + 'titlebar.minimize_aria': 'Свернуть', + 'titlebar.tray_aria': 'В трей', + 'titlebar.close_aria': 'Закрыть', + 'titlebar.app_title': 'Exercise Reminder', + + // Common buttons / actions + 'btn.add': 'Добавить', + 'btn.new': 'Новый', + 'btn.cancel': 'Отмена', + 'btn.save': 'Сохранить', + 'btn.done': 'Готово', + 'btn.start': 'Старт', + 'btn.pause': 'Пауза', + 'btn.refresh': 'Обновить', + 'btn.edit': 'Редактировать', + 'btn.delete': 'Удалить', + 'btn.snooze_min': 'Отложить {n} мин', + 'btn.skip': 'Пропустить', + 'btn.close': 'Закрыть', + 'btn.later': 'Позже', + 'btn.connect': 'Подключить', + 'btn.disconnect': 'Отключить', + 'btn.check': 'Проверить', + 'btn.download': 'Скачать', + 'btn.restart': 'Перезапустить', + 'btn.retry': 'Повторить', + + // Dashboard + 'dashboard.kicker': 'Тренировка дня', + 'dashboard.title': 'Сегодня', + 'dashboard.stat.active': 'Активных', + 'dashboard.stat.active.of': 'из {total}', + 'dashboard.stat.next': 'До следующего', + 'dashboard.stat.next.now': 'Сейчас', + 'dashboard.stat.next.subtitle_paused': 'на паузе', + 'dashboard.stat.next.subtitle_running': 'отсчёт идёт', + 'dashboard.stat.tracking': 'Трекинг матчей', + 'dashboard.stat.tracking.on': 'On', + 'dashboard.stat.tracking.off': 'Off', + 'dashboard.stat.tracking.subtitle_on': 'в реальном времени', + 'dashboard.stat.tracking.subtitle_off': 'выключен', + 'dashboard.paused.title': 'Напоминания на паузе', + 'dashboard.paused.hint': 'Возобнови, чтобы продолжить отсчёт', + 'dashboard.empty.title': 'Программа пуста', + 'dashboard.empty.hint': 'Добавь первое упражнение, чтобы начать', + + // Exercises + 'exercises.kicker': 'Программа', + 'exercises.title': 'Упражнения', + 'exercises.section.active': 'Активные · {n}', + 'exercises.section.disabled': 'Выключенные · {n}', + 'exercises.row.meta': '{reps} раз · {interval}', + 'exercises.empty': 'Программа пуста — добавь первое упражнение', + + // Exercise editor + 'editor.exercise.title.new': 'Новое упражнение', + 'editor.exercise.title.edit': 'Редактировать', + 'editor.exercise.preview.placeholder': 'Без названия', + 'editor.exercise.preview.meta': '{reps} раз · каждые {min} мин', + 'editor.field.name': 'Название', + 'editor.field.name.placeholder': 'Приседания', + 'editor.field.reps': 'Повторений', + 'editor.field.interval_min': 'Интервал (мин)', + 'editor.field.icon': 'Иконка', + + // Challenges + 'challenges.kicker': 'Правила за матч', + 'challenges.title': 'Челленджи', + 'challenges.subtitle': 'Повторов = {formula}', + 'challenges.subtitle.formula': 'статистика × коэффициент', + 'challenges.warning.no_games': + 'Челленджи срабатывают после матча. Подключи игру во вкладке «Игры».', + 'challenges.section.all': 'Все · {n}', + 'challenges.empty': + 'Челленджей пока нет. Привяжи упражнение к статистике матча.', + + // Challenge editor + 'editor.challenge.title.new': 'Новый челлендж', + 'editor.challenge.title.edit': 'Редактировать', + 'editor.field.challenge_name': 'Название', + 'editor.field.challenge_name.placeholder': 'За смерти — приседания', + 'editor.field.game': 'Игра', + 'editor.field.stat': 'Статистика', + 'editor.field.multiplier': 'Коэффициент', + 'editor.field.exercise_name': 'Упражнение', + 'editor.field.exercise_name.placeholder': 'Приседания', + 'editor.challenge.preview.kicker': 'Превью · 5 событий', + 'editor.challenge.preview.fallback': 'повторов', + + // Games + 'games.kicker': 'Трекинг матчей', + 'games.title': 'Игры', + 'games.subtitle': + 'Подключи игру — челленджи сработают сразу после матча', + 'games.subtitle.live': '{n} live', + 'games.section.supported': 'Поддерживаемые', + 'games.scanning': 'Сканируем установленные игры…', + 'games.queued.body': + 'Steam запущен. Параметр {opt} пропишется автоматически при следующем закрытии Steam.', + 'games.no_user.body': + 'В Steam нет залогиненного аккаунта (нет папки userdata). Запусти Steam один раз и нажми «Установить интеграцию».', + 'games.not_installed.hint': 'Установи игру в Steam и нажми «Обновить»', + 'games.dev.toggle': 'dev · симулировать конец матча', + 'games.badge.live': 'Live', + 'games.badge.ready': 'Готово', + 'games.badge.queued': 'В очереди', + 'games.badge.installed': 'Установлена', + 'games.badge.not_found': 'Не найдена', + + // Settings + 'settings.kicker': 'Конфигурация', + 'settings.title': 'Настройки', + 'settings.section.reminders': 'Напоминания', + 'settings.section.window': 'Окно и трей', + 'settings.section.appearance': 'Внешний вид', + 'settings.section.language': 'Язык', + 'settings.section.updates': 'Обновления', + 'settings.notification_mode.label': 'Режим уведомления', + 'settings.notification_mode.hint': 'Как должно выглядеть напоминание', + 'settings.notification_mode.modal': 'Окно поверх всех', + 'settings.notification_mode.toast': 'Системное уведомление', + 'settings.notification_mode.both': 'Окно и уведомление', + 'settings.sound.label': 'Звук уведомления', + 'settings.sound.hint': 'Короткий сигнал при срабатывании', + 'settings.snooze.label': '«Отложить» на', + 'settings.snooze.hint': 'Сколько минут добавлять при отложении', + 'settings.snooze.1': '1 минута', + 'settings.snooze.5': '5 минут', + 'settings.snooze.10': '10 минут', + 'settings.snooze.15': '15 минут', + 'settings.snooze.30': '30 минут', + 'settings.tray.label': 'Сворачивать в трей', + 'settings.tray.hint': 'При закрытии остаётся работать в фоне', + 'settings.autostart.label': 'Запускать с Windows', + 'settings.autostart.hint': 'Открывать при входе в систему', + 'settings.start_minimized.label': 'Запускать свёрнутым', + 'settings.start_minimized.hint': 'При автозапуске открывать сразу в трее', + 'settings.theme.label': 'Тема', + 'settings.theme.hint': 'Светлая / тёмная / как в системе', + 'settings.theme.system': 'Как в системе', + 'settings.theme.light': 'Светлая', + 'settings.theme.dark': 'Тёмная', + 'settings.language.label': 'Язык интерфейса', + 'settings.language.hint': 'Применяется сразу', + 'settings.language.ru': 'Русский', + 'settings.language.en': 'English', + 'settings.loading': 'Загрузка…', + + // Updater + 'updater.unsupported': 'Auto-update недоступен', + 'updater.unsupported.reason_dev': 'Auto-update недоступен в dev-режиме', + 'updater.checking': 'Проверяем обновления…', + 'updater.up_to_date': 'Последняя версия', + 'updater.up_to_date.subtitle': 'Текущая: v{v}', + 'updater.available.title': 'Доступна v{v}', + 'updater.downloading.title': 'Загружаем обновление', + 'updater.downloading.subtitle': '{got} / {total} МБ · {speed} МБ/с', + 'updater.downloaded.title': 'Готово · v{v}', + 'updater.downloaded.subtitle': 'Перезапусти для применения', + 'updater.error.title': 'Ошибка проверки', + 'updater.idle.title': 'Проверить обновления', + 'updater.idle.subtitle': 'Авто-проверка раз в час', + + // Reminder window + 'reminder.kicker': 'Время тренировки', + 'reminder.subkicker': 'Двигайся', + 'reminder.reps': 'раз', + 'reminder.next_in': 'Следующее через {interval}', + 'reminder.btn.done': 'Готово', + 'match.title.won': 'Победа', + 'match.title.lost': 'Поражение', + 'match.title.draw': 'Матч завершён', + 'match.summary.minutes_count': '{n} мин', + 'match.summary.challenges_one': '{n} челлендж', + 'match.summary.challenges_few': '{n} челленджа', + 'match.summary.challenges_many': '{n} челленджей', + 'match.summary.all_done': 'всё готово', + 'match.summary.remaining': '{n} осталось', + 'match.total': 'Всего', + 'match.total_reps_suffix': 'повторов', + + // Format helpers + 'fmt.now': 'сейчас', + 'fmt.h': 'ч', + 'fmt.m': 'мин', + 'fmt.h_short': 'ч', + 'fmt.m_short': 'м', + 'fmt.s_short': 'с', + 'fmt.paused': 'на паузе', + 'fmt.through': 'Через' +} + +export const en: Dict = { + // Sidebar / nav + 'nav.today': 'Today', + 'nav.exercises': 'Exercises', + 'nav.games': 'Games', + 'nav.challenges': 'Challenges', + 'nav.settings': 'Settings', + 'sidebar.slogan': 'Move with intention', + 'sidebar.status_tracking': 'Activity tracking is on', + 'titlebar.menu_aria': 'Menu', + 'titlebar.minimize_aria': 'Minimize', + 'titlebar.tray_aria': 'To tray', + 'titlebar.close_aria': 'Close', + 'titlebar.app_title': 'Exercise Reminder', + + // Common buttons + 'btn.add': 'Add', + 'btn.new': 'New', + 'btn.cancel': 'Cancel', + 'btn.save': 'Save', + 'btn.done': 'Done', + 'btn.start': 'Start', + 'btn.pause': 'Pause', + 'btn.refresh': 'Refresh', + 'btn.edit': 'Edit', + 'btn.delete': 'Delete', + 'btn.snooze_min': 'Snooze {n}m', + 'btn.skip': 'Skip', + 'btn.close': 'Close', + 'btn.later': 'Later', + 'btn.connect': 'Connect', + 'btn.disconnect': 'Disconnect', + 'btn.check': 'Check', + 'btn.download': 'Download', + 'btn.restart': 'Restart', + 'btn.retry': 'Retry', + + // Dashboard + 'dashboard.kicker': 'Daily training', + 'dashboard.title': 'Today', + 'dashboard.stat.active': 'Active', + 'dashboard.stat.active.of': 'of {total}', + 'dashboard.stat.next': 'Next in', + 'dashboard.stat.next.now': 'Now', + 'dashboard.stat.next.subtitle_paused': 'paused', + 'dashboard.stat.next.subtitle_running': 'counting down', + 'dashboard.stat.tracking': 'Match tracking', + 'dashboard.stat.tracking.on': 'On', + 'dashboard.stat.tracking.off': 'Off', + 'dashboard.stat.tracking.subtitle_on': 'real-time', + 'dashboard.stat.tracking.subtitle_off': 'disabled', + 'dashboard.paused.title': 'Reminders paused', + 'dashboard.paused.hint': 'Resume to continue countdown', + 'dashboard.empty.title': 'Program is empty', + 'dashboard.empty.hint': 'Add your first exercise to start', + + // Exercises + 'exercises.kicker': 'Program', + 'exercises.title': 'Exercises', + 'exercises.section.active': 'Active · {n}', + 'exercises.section.disabled': 'Disabled · {n}', + 'exercises.row.meta': '{reps} reps · {interval}', + 'exercises.empty': 'Program is empty — add your first exercise', + + // Exercise editor + 'editor.exercise.title.new': 'New exercise', + 'editor.exercise.title.edit': 'Edit', + 'editor.exercise.preview.placeholder': 'Untitled', + 'editor.exercise.preview.meta': '{reps} reps · every {min} min', + 'editor.field.name': 'Name', + 'editor.field.name.placeholder': 'Squats', + 'editor.field.reps': 'Reps', + 'editor.field.interval_min': 'Interval (min)', + 'editor.field.icon': 'Icon', + + // Challenges + 'challenges.kicker': 'Per-match rules', + 'challenges.title': 'Challenges', + 'challenges.subtitle': 'Reps = {formula}', + 'challenges.subtitle.formula': 'stat × multiplier', + 'challenges.warning.no_games': + 'Challenges trigger after a match. Connect a game in the Games tab.', + 'challenges.section.all': 'All · {n}', + 'challenges.empty': + 'No challenges yet. Tie an exercise to a match statistic.', + + // Challenge editor + 'editor.challenge.title.new': 'New challenge', + 'editor.challenge.title.edit': 'Edit', + 'editor.field.challenge_name': 'Name', + 'editor.field.challenge_name.placeholder': 'Squats per death', + 'editor.field.game': 'Game', + 'editor.field.stat': 'Statistic', + 'editor.field.multiplier': 'Multiplier', + 'editor.field.exercise_name': 'Exercise', + 'editor.field.exercise_name.placeholder': 'Squats', + 'editor.challenge.preview.kicker': 'Preview · 5 events', + 'editor.challenge.preview.fallback': 'reps', + + // Games + 'games.kicker': 'Match tracking', + 'games.title': 'Games', + 'games.subtitle': 'Connect a game — challenges fire right after the match', + 'games.subtitle.live': '{n} live', + 'games.section.supported': 'Supported', + 'games.scanning': 'Scanning installed games…', + 'games.queued.body': + 'Steam is running. The {opt} option will be added automatically next time Steam closes.', + 'games.no_user.body': + 'No logged-in Steam account (no userdata folder). Launch Steam once, then click “Connect”.', + 'games.not_installed.hint': 'Install the game in Steam and click Refresh', + 'games.dev.toggle': 'dev · simulate match end', + 'games.badge.live': 'Live', + 'games.badge.ready': 'Ready', + 'games.badge.queued': 'Queued', + 'games.badge.installed': 'Installed', + 'games.badge.not_found': 'Not found', + + // Settings + 'settings.kicker': 'Configuration', + 'settings.title': 'Settings', + 'settings.section.reminders': 'Reminders', + 'settings.section.window': 'Window & tray', + 'settings.section.appearance': 'Appearance', + 'settings.section.language': 'Language', + 'settings.section.updates': 'Updates', + 'settings.notification_mode.label': 'Notification mode', + 'settings.notification_mode.hint': 'How a reminder appears', + 'settings.notification_mode.modal': 'Window on top', + 'settings.notification_mode.toast': 'System notification', + 'settings.notification_mode.both': 'Window and notification', + 'settings.sound.label': 'Notification sound', + 'settings.sound.hint': 'Short beep on trigger', + 'settings.snooze.label': '“Snooze” for', + 'settings.snooze.hint': 'How many minutes to postpone', + 'settings.snooze.1': '1 minute', + 'settings.snooze.5': '5 minutes', + 'settings.snooze.10': '10 minutes', + 'settings.snooze.15': '15 minutes', + 'settings.snooze.30': '30 minutes', + 'settings.tray.label': 'Minimize to tray', + 'settings.tray.hint': 'Keep running in background when closed', + 'settings.autostart.label': 'Start with Windows', + 'settings.autostart.hint': 'Open at system login', + 'settings.start_minimized.label': 'Start minimized', + 'settings.start_minimized.hint': 'On autostart open straight to tray', + 'settings.theme.label': 'Theme', + 'settings.theme.hint': 'Light / dark / follow system', + 'settings.theme.system': 'System', + 'settings.theme.light': 'Light', + 'settings.theme.dark': 'Dark', + 'settings.language.label': 'Interface language', + 'settings.language.hint': 'Applied immediately', + 'settings.language.ru': 'Русский', + 'settings.language.en': 'English', + 'settings.loading': 'Loading…', + + // Updater + 'updater.unsupported': 'Auto-update unavailable', + 'updater.unsupported.reason_dev': 'Auto-update is disabled in dev mode', + 'updater.checking': 'Checking for updates…', + 'updater.up_to_date': 'Up to date', + 'updater.up_to_date.subtitle': 'Current: v{v}', + 'updater.available.title': 'v{v} available', + 'updater.downloading.title': 'Downloading update', + 'updater.downloading.subtitle': '{got} / {total} MB · {speed} MB/s', + 'updater.downloaded.title': 'Ready · v{v}', + 'updater.downloaded.subtitle': 'Restart to apply', + 'updater.error.title': 'Check failed', + 'updater.idle.title': 'Check for updates', + 'updater.idle.subtitle': 'Auto-check every hour', + + // Reminder window + 'reminder.kicker': 'Workout time', + 'reminder.subkicker': 'Move', + 'reminder.reps': 'reps', + 'reminder.next_in': 'Next in {interval}', + 'reminder.btn.done': 'Done', + 'match.title.won': 'Victory', + 'match.title.lost': 'Defeat', + 'match.title.draw': 'Match finished', + 'match.summary.minutes_count': '{n} min', + 'match.summary.challenges_one': '{n} challenge', + 'match.summary.challenges_few': '{n} challenges', + 'match.summary.challenges_many': '{n} challenges', + 'match.summary.all_done': 'all done', + 'match.summary.remaining': '{n} left', + 'match.total': 'Total', + 'match.total_reps_suffix': 'reps', + + // Format helpers + 'fmt.now': 'now', + 'fmt.h': 'h', + 'fmt.m': 'min', + 'fmt.h_short': 'h', + 'fmt.m_short': 'm', + 'fmt.s_short': 's', + 'fmt.paused': 'paused', + 'fmt.through': 'In' +} diff --git a/src/renderer/src/i18n/i18n.test.ts b/src/renderer/src/i18n/i18n.test.ts new file mode 100644 index 0000000..1046ef7 --- /dev/null +++ b/src/renderer/src/i18n/i18n.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from 'vitest' +import { translate, translateN } from './index' + +describe('translate', () => { + it('returns the matching string by key', () => { + expect(translate('ru', 'btn.save')).toBe('Сохранить') + expect(translate('en', 'btn.save')).toBe('Save') + }) + + it('falls back to the key when missing', () => { + expect(translate('ru', 'totally.unknown.key')).toBe('totally.unknown.key') + }) + + it('substitutes single variable', () => { + expect(translate('ru', 'btn.snooze_min', { n: 5 })).toBe('Отложить 5 мин') + expect(translate('en', 'btn.snooze_min', { n: 10 })).toBe('Snooze 10m') + }) + + it('substitutes multiple variables', () => { + expect( + translate('en', 'updater.downloading.subtitle', { + got: '1.5', + total: '80.0', + speed: '2.5' + }) + ).toBe('1.5 / 80.0 MB · 2.5 MB/s') + }) + + it('handles unknown language with fallback to ru', () => { + // @ts-expect-error testing fallback + expect(translate('fr', 'btn.save')).toBe('Сохранить') + }) +}) + +describe('translateN (plural)', () => { + describe('russian plural rules', () => { + it('one: 1, 21, 101', () => { + expect(translateN('ru', 'match.summary.challenges', 1)).toBe('1 челлендж') + expect(translateN('ru', 'match.summary.challenges', 21)).toBe( + '21 челлендж' + ) + expect(translateN('ru', 'match.summary.challenges', 101)).toBe( + '101 челлендж' + ) + }) + + it('few: 2, 3, 4, 22, 23, 24', () => { + expect(translateN('ru', 'match.summary.challenges', 2)).toBe( + '2 челленджа' + ) + expect(translateN('ru', 'match.summary.challenges', 3)).toBe( + '3 челленджа' + ) + expect(translateN('ru', 'match.summary.challenges', 22)).toBe( + '22 челленджа' + ) + }) + + it('many: 0, 5-20, 25-30, 111-114', () => { + expect(translateN('ru', 'match.summary.challenges', 0)).toBe( + '0 челленджей' + ) + expect(translateN('ru', 'match.summary.challenges', 5)).toBe( + '5 челленджей' + ) + expect(translateN('ru', 'match.summary.challenges', 11)).toBe( + '11 челленджей' + ) + expect(translateN('ru', 'match.summary.challenges', 13)).toBe( + '13 челленджей' + ) + expect(translateN('ru', 'match.summary.challenges', 20)).toBe( + '20 челленджей' + ) + }) + }) + + describe('english plural rules', () => { + it('one for 1', () => { + expect(translateN('en', 'match.summary.challenges', 1)).toBe( + '1 challenge' + ) + }) + + it('many/other for anything else', () => { + expect(translateN('en', 'match.summary.challenges', 0)).toBe( + '0 challenges' + ) + expect(translateN('en', 'match.summary.challenges', 2)).toBe( + '2 challenges' + ) + expect(translateN('en', 'match.summary.challenges', 21)).toBe( + '21 challenges' + ) + }) + }) +}) diff --git a/src/renderer/src/i18n/index.ts b/src/renderer/src/i18n/index.ts new file mode 100644 index 0000000..f5c7820 --- /dev/null +++ b/src/renderer/src/i18n/index.ts @@ -0,0 +1,83 @@ +import { useAppStore } from '../store/appStore' +import { ru, en, type Dict } from './dict' +import type { Language } from '@shared/types' + +const dicts: Record = { ru, en } + +export function getDict(lang: Language): Dict { + return dicts[lang] ?? ru +} + +export type TVars = Record + +/** + * Look up a key in the dictionary, substitute `{var}` placeholders. + * Returns the key itself if not found — surfaces missing translations. + */ +export function translate( + lang: Language, + key: string, + vars?: TVars +): string { + const dict = getDict(lang) + let s = dict[key] ?? key + if (vars) { + for (const k of Object.keys(vars)) { + s = s.replace(new RegExp(`\\{${k}\\}`, 'g'), String(vars[k])) + } + } + return s +} + +/** + * Russian CLDR plural categories — covers nominal forms. + * one → 1, 21, 31, 41… (но не 11) + * few → 2-4, 22-24… (но не 12-14) + * many → 0, 5-20, 25-30… + */ +function pluralRu(n: number): 'one' | 'few' | 'many' { + const mod10 = n % 10 + const mod100 = n % 100 + if (mod10 === 1 && mod100 !== 11) return 'one' + if (mod10 >= 2 && mod10 <= 4 && (mod100 < 10 || mod100 >= 20)) return 'few' + return 'many' +} + +/** + * Plural lookup. Pass `keyBase` like `match.summary.challenges` — the + * function appends `_one`/`_few`/`_many` (ru) or `_one`/`_many` (en). + * The `n` value is exposed as `{n}` in the resulting string. + */ +export function translateN( + lang: Language, + keyBase: string, + n: number, + vars?: TVars +): string { + const form = + lang === 'ru' + ? pluralRu(n) + : n === 1 + ? 'one' + : 'many' + return translate(lang, `${keyBase}_${form}`, { n, ...vars }) +} + +/* ---------------- React hook ---------------- */ + +export function useLang(): Language { + return useAppStore((s) => s.state?.settings?.language ?? 'ru') +} + +export function useT(): { + t: (key: string, vars?: TVars) => string + tn: (keyBase: string, n: number, vars?: TVars) => string + lang: Language +} { + const lang = useLang() + return { + t: (key, vars) => translate(lang, key, vars), + tn: (keyBase, n, vars) => translateN(lang, keyBase, n, vars), + lang + } +} diff --git a/src/renderer/src/lib/format.ts b/src/renderer/src/lib/format.ts index e866a86..abd13f1 100644 --- a/src/renderer/src/lib/format.ts +++ b/src/renderer/src/lib/format.ts @@ -1,17 +1,29 @@ -export function formatCountdown(ms: number): string { - if (ms <= 0) return 'сейчас' +import type { Language } from '@shared/types' + +const SUFFIX = { + ru: { now: 'сейчас', h: 'ч', m: 'м', s: 'с', minLong: 'мин', hLong: 'ч' }, + en: { now: 'now', h: 'h', m: 'm', s: 's', minLong: 'min', hLong: 'h' } +} + +export function formatCountdown(ms: number, lang: Language = 'ru'): string { + const s = SUFFIX[lang] ?? SUFFIX.ru + if (ms <= 0) return s.now const totalSec = Math.floor(ms / 1000) const h = Math.floor(totalSec / 3600) const m = Math.floor((totalSec % 3600) / 60) - const s = totalSec % 60 - if (h > 0) return `${h}ч ${String(m).padStart(2, '0')}м` - if (m > 0) return `${m}м ${String(s).padStart(2, '0')}с` - return `${s}с` + const sec = totalSec % 60 + if (h > 0) return `${h}${s.h} ${String(m).padStart(2, '0')}${s.m}` + if (m > 0) return `${m}${s.m} ${String(sec).padStart(2, '0')}${s.s}` + return `${sec}${s.s}` } -export function formatInterval(minutes: number): string { - if (minutes < 60) return `${minutes} мин` +export function formatInterval( + minutes: number, + lang: Language = 'ru' +): string { + const s = SUFFIX[lang] ?? SUFFIX.ru + if (minutes < 60) return `${minutes} ${s.minLong}` const h = Math.floor(minutes / 60) const m = minutes % 60 - return m === 0 ? `${h} ч` : `${h} ч ${m} мин` + return m === 0 ? `${h} ${s.hLong}` : `${h} ${s.hLong} ${m} ${s.minLong}` } diff --git a/src/renderer/src/pages/Challenges.tsx b/src/renderer/src/pages/Challenges.tsx index 872a0da..f544198 100644 --- a/src/renderer/src/pages/Challenges.tsx +++ b/src/renderer/src/pages/Challenges.tsx @@ -6,13 +6,15 @@ import { Switch } from '../components/ui/Switch' import { Modal } from '../components/ui/Modal' import { Card, Row, SectionHeader } from '../components/ui/Card' import { ICON_CHOICES, Icon } from '../lib/icon' -import { GAME_STATS, STAT_LABELS } from '@shared/types' +import { GAME_STATS, statLabel } from '@shared/types' import type { Challenge, GameId, GameStat, - GameStatus + GameStatus, + Language } from '@shared/types' +import { useT } from '../i18n' const GAME_NAMES: Record = { dota2: 'Dota 2' @@ -35,6 +37,7 @@ export default function ChallengesPage(): JSX.Element { const [games, setGames] = useState([]) const [editorOpen, setEditorOpen] = useState(false) const [editing, setEditing] = useState(null) + const { t, lang } = useT() useEffect(() => { void window.api.listGames().then(setGames) @@ -49,13 +52,15 @@ export default function ChallengesPage(): JSX.Element {
- Правила за матч + {t('challenges.kicker')}

- Челленджи + {t('challenges.title')}

- Повторов = статистика × коэффициент + {t('challenges.subtitle', { + formula: t('challenges.subtitle.formula') + })}

@@ -74,15 +79,16 @@ export default function ChallengesPage(): JSX.Element {
- Челленджи срабатывают после матча. Подключи игру во вкладке{' '} - «Игры». + {t('challenges.warning.no_games')}
)} {challenges.length > 0 ? ( <> - + {challenges.map((c, i) => ( {GAME_NAMES[c.gameId]} ·{' '} - {STAT_LABELS[c.stat]} × {c.multiplier} + {statLabel(c.stat, lang)} × {c.multiplier} {' '} → {c.exerciseName}
@@ -129,8 +135,8 @@ export default function ChallengesPage(): JSX.Element { ) : ( -
- Челленджей пока нет. Привяжи упражнение к статистике матча. +
+ {t('challenges.empty')}
)} @@ -138,6 +144,7 @@ export default function ChallengesPage(): JSX.Element { setEditorOpen(false)} onSave={async (draft) => { if (editing) await window.api.updateChallenge(editing.id, draft) @@ -153,15 +160,18 @@ export default function ChallengesPage(): JSX.Element { function ChallengeEditor({ open, challenge, + lang, onClose, onSave }: { open: boolean challenge: Challenge | null + lang: Language onClose: () => void onSave: (draft: Draft) => void }): JSX.Element { const [draft, setDraft] = useState(EMPTY_DRAFT) + const { t } = useT() useEffect(() => { if (challenge) { @@ -190,30 +200,34 @@ function ChallengeEditor({ } >
- + setDraft({ ...draft, name: e.target.value })} - placeholder="За смерти — приседания" + placeholder={t('editor.field.challenge_name.placeholder')} className="ios-input" autoFocus /> - + @@ -240,12 +254,12 @@ function ChallengeEditor({ > {GAME_STATS[draft.gameId].map((s) => ( ))} - +
- + setDraft({ ...draft, exerciseName: e.target.value }) } - placeholder="Приседания" + placeholder={t('editor.field.exercise_name.placeholder')} className="ios-input" /> - +
{ICON_CHOICES.map((name) => (
-