Files
aurum/frontend/src/lib/i18n.ts
ZProger f9413aa6e1 security: устранить находки аудита, версия 1.1.7
Конфигурация и эксплуатационный слой:
- nginx: client_max_body_size 64m — восстановление из бэкапа не работало
  вовсе, дефолтный лимит в 1 МБ резал дампы (реальный экспорт ~3.4 МБ)
  с 413 ещё до backend;
- nginx: заголовки безопасности (nosniff, Referrer-Policy,
  Permissions-Policy) и полный Content-Security-Policy. Ради него
  inline-скрипт темы вынесен в public/theme-init.js;
- nginx: limit_req на /api/ — перебор пароля Basic Auth шёл со
  скоростью сети, ни блокировки, ни задержки не было;
- nginx: server_tokens off;
- порт web привязан к 127.0.0.1, адрес настраивается через
  AURUM_BIND_ADDRESS (раньше слушался на всех интерфейсах);
- backend работает от непривилегированного пользователя,
  no-new-privileges для backend и web;
- дефолты пароля Postgres сведены к одному значению в обоих сервисах,
  в .env.example описана ловушка с инициализацией тома.

Приложение:
- версия убрана из неавторизованного /api/health и отдаётся как
  app_version в GET /api/settings;
- /api/docs, /api/redoc и /api/openapi.json отключаются через
  AURUM_ENABLE_DOCS;
- текст исключения при неудачном восстановлении уходит в лог, а не
  клиенту;
- max_length для notes в схемах транзакций, активов и регулярных
  платежей (и в соответствующих полях UI);
- срок «запомнить меня» сокращён с 30 дней до 7;
- pytest обновлён до 9.0.3 (PYSEC-2026-1845), pytest-asyncio до 1.4.0;
- CI: permissions contents:read для GITHUB_TOKEN.
2026-09-10 21:12:31 +03:00

1108 lines
67 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useSyncExternalStore } from "react";
export type Language = "ru" | "en";
const STORAGE_KEY = "aurum:language";
const ru = {
"nav.dashboard": "Дашборд",
"nav.netWorth": "Капитал",
"nav.crypto": "Криптовалюта",
"nav.roi": "Доходность",
"nav.transactions": "Транзакции",
"nav.accounts": "Счета",
"nav.categories": "Категории",
"nav.cashFlow": "Движение денег",
"nav.reports": "Отчёты",
"nav.budget": "Бюджет",
"nav.recurring": "Регулярные",
"nav.goals": "Цели",
"nav.advice": "Советы",
"nav.settings": "Настройки",
"nav.comingSoon": "скоро",
"sidebar.collapse": "Свернуть",
"sidebar.collapseMenu": "Свернуть меню",
"sidebar.expandMenu": "Развернуть меню",
"sidebar.closeMenu": "Закрыть меню",
"topbar.openMenu": "Открыть меню",
"common.loading": "Загрузка…",
"common.expand": "Развернуть",
"common.collapse": "Свернуть",
"common.back": "Назад",
"common.next": "Далее",
"common.clear": "Очистить",
"common.cancel": "Отмена",
"common.save": "Сохранить",
"common.saving": "Сохранение…",
"common.edit": "Изменить",
"common.delete": "Удалить",
"common.close": "Закрыть",
"common.add": "Добавить",
"common.pageOf": "Стр. {{page}} из {{total}}",
"common.totalCount": "{{count}} всего",
"common.perMonth": "/мес",
"common.allShort": "Всё",
"auth.subtitle": "Личные финансы",
"auth.usernameLabel": "Логин",
"auth.passwordLabel": "Пароль",
"auth.rememberMe": "Запомнить меня на 7 дней",
"auth.submitButton": "Войти",
"auth.submitting": "Вход…",
"auth.errorInvalidCredentials": "Неверный логин или пароль",
"auth.errorUnreachable": "Не удалось подключиться к серверу. Проверьте соединение и попробуйте снова.",
"auth.noAuthModalTitle": "Авторизация не настроена",
"auth.noAuthModalBody": "Этот экземпляр Aurum не защищён паролем — все данные доступны любому, кто до него достучится. Задайте AURUM_BASIC_AUTH_USER и AURUM_BASIC_AUTH_PASSWORD в .env, чтобы включить вход.",
"auth.noAuthModalAccept": "Я понимаю риски и хочу продолжить",
"dashboard.errorLoading": "Не удалось загрузить данные дашборда. Проверьте, что backend запущен.",
"dashboard.statRealIncomeLabel": "Реальный доход",
"dashboard.statRealIncomeCaption": "зарплаты, возвраты",
"dashboard.statSpentLabel": "Расходы",
"dashboard.statSpentCaption": "без учёта переводов",
"dashboard.statNetLabel": "Баланс",
"dashboard.statNetCaption": "доход − расход",
"dashboard.statSavingsRateLabel": "Норма сбережений",
"dashboard.statSavingsRateCaption": "доля дохода, оставшаяся после расходов",
"dashboard.spendingByCategoryTitle": "Расходы по категориям",
"dashboard.noExpensesThisMonth": "За этот месяц пока нет расходов. Добавьте первую транзакцию на вкладке «Транзакции».",
"dashboard.recentTransactionsTitle": "Последние транзакции",
"dashboard.allTransactionsLink": "Все транзакции",
"dashboard.noTransactionsYet": "Транзакций пока нет.",
"netWorth.periodSuffix": "за период",
"netWorth.noChartData": "Пока нет данных для графика.",
"netWorth.assetsTitlePrefix": "Активы",
"netWorth.myAssetsTitle": "Мои активы",
"netWorth.confirmDeleteAsset": "Удалить актив «{{name}}»?",
"netWorth.capitalByType": "Капитал по типу",
"netWorth.assetClass.investments": "Инвестиции",
"netWorth.assetClass.crypto": "Криптовалюта",
"netWorth.assetClass.real_estate": "Недвижимость",
"netWorth.assetClass.vehicles": "Транспорт",
"netWorth.assetClass.precious_metals": "Драгметаллы",
"netWorth.assetClass.other": "Другое",
"netWorth.assetClass.cash": "Наличные",
"netWorth.capitalRole.income": "Доходный",
"netWorth.capitalRole.neutral": "Нейтральный",
"netWorth.capitalRole.drain": "Убыточный",
"netWorth.capitalRoleHint.income": "приносит деньги",
"netWorth.capitalRoleHint.neutral": "просто есть, не тянет и не даёт",
"netWorth.capitalRoleHint.drain": "тянет деньги каждый месяц",
"netWorth.capitalRoleFormHint.income": "приносит деньги — сдаётся в аренду и т.п.",
"netWorth.capitalRoleFormHint.neutral": "просто есть/используется — техника, мебель",
"netWorth.capitalRoleFormHint.drain": "тянет деньги каждый месяц — машина и т.п.",
"netWorth.riskAllocationTitle": "Капитал по уровню риска",
"netWorth.riskAllocationOfCapital": "от капитала",
"netWorth.riskLevel.low": "Низкий",
"netWorth.riskLevel.medium": "Средний",
"netWorth.riskLevel.high": "Высокий",
"netWorth.riskLevelFormHint.low": "риск потери минимален — депозиты, наличные, гособлигации",
"netWorth.riskLevelFormHint.medium": "умеренные колебания стоимости — недвижимость, диверсифицированные инвестиции",
"netWorth.riskLevelFormHint.high": "может значительно потерять в цене — крипта, отдельные акции, стартапы",
"netWorth.assetsTable.empty": "Активов пока нет. Добавьте инвестиции, крипту, недвижимость или другое имущество.",
"netWorth.assetsTable.asOf": "на {{date}}",
"netWorth.assetsTable.annualRoi": "{{percent}}% годовых",
"crypto.addButton": "Добавить монету",
"crypto.refreshButton": "Обновить цены",
"crypto.refreshing": "Обновление…",
"crypto.lastSynced": "Обновлено {{time}}",
"crypto.neverSynced": "Цены ещё не загружались",
"crypto.totalValueLabel": "Всего в криптовалюте",
"crypto.empty": "Пока нет ни одной монеты. Добавьте первую, чтобы начать отслеживать.",
"crypto.pendingPrice": "цена ещё не получена",
"crypto.confirmDelete": "Убрать «{{name}}» из отслеживания?",
"crypto.syncError.unreachable": "Не удалось подключиться к CoinGecko — показаны последние известные цены.",
"crypto.search.placeholder": "Найти монету (например, Bitcoin или BTC)…",
"crypto.search.hint": "Начните вводить название или тикер монеты.",
"crypto.search.empty": "Ничего не найдено.",
"crypto.search.noApiKey": "Не настроен ключ CoinGecko API — задайте AURUM_COINGECKO_API_KEY в .env (бесплатный ключ на coingecko.com/en/api/pricing).",
"crypto.form.quantityLabel": "Количество",
"crypto.form.pricePerUnitLabel": "Цена за штуку",
"crypto.form.dateLabel": "Дата",
"crypto.form.noteLabel": "Заметка (необязательно)",
"crypto.form.networkLabel": "Сеть (необязательно)",
"crypto.form.networkPlaceholder": "Например, Ethereum, Tron…",
"crypto.form.addTitle": "Добавить {{name}}",
"crypto.form.tradeTitle": "Сделка: {{name}}",
"crypto.form.editTradeTitle": "Изменить сделку: {{name}}",
"crypto.form.tradeButton": "Купить / продать",
"crypto.form.buy": "Покупка",
"crypto.form.sell": "Продажа",
"crypto.form.errorSellTooMuch": "Нельзя продать больше, чем у вас есть.",
"crypto.form.saveError": "Не удалось сохранить. Проверьте данные и попробуйте снова.",
"crypto.table.name": "Название",
"crypto.table.price": "Цена",
"crypto.table.change1h": "1ч%",
"crypto.table.change24h": "24ч%",
"crypto.table.change7d": "7д%",
"crypto.table.holdings": "Держите",
"crypto.table.avgBuyPrice": "Ср. цена",
"crypto.table.profitLoss": "Прибыль/убыток",
"crypto.table.network": "Сеть",
"crypto.table.addNetwork": "+ Сеть",
"crypto.table.risk": "Риск",
"crypto.table.actions": "Действия",
"crypto.riskAllocationOfPortfolio": "от крипто-портфеля",
"crypto.history.title": "История сделок: {{name}}",
"crypto.history.empty": "Пока нет ни одной сделки.",
"crypto.history.confirmDelete": "Удалить эту сделку? Количество и средняя цена будут пересчитаны.",
"crypto.history.viewButton": "Смотреть историю",
"crypto.holdingsTitle": "Активы",
"crypto.chart.show": "Показать сумму",
"crypto.chart.hide": "Скрыть сумму",
"crypto.allocation.title": "Распределение по монетам",
"crypto.overview.history": "История",
"crypto.overview.allocation": "Аллокация",
"crypto.overview.risk": "Уровни риска",
"crypto.overview.networks": "Сети",
"crypto.networkAllocation.unset": "Сеть не указана",
"crypto.allocation.other": "Другое",
"crypto.stats.allTimeProfit": "Прибыль за всё время",
"crypto.stats.costBasis": "Вложено всего",
"crypto.stats.costBasisHint": "сумма покупок по факту",
"crypto.stats.bestPerformer": "Лучший рост",
"crypto.stats.worstPerformer": "Худшее падение",
"crypto.stats.changeLabel": "изменение цены за {{range}}",
"crypto.portfolio.all": "Все портфели",
"crypto.portfolio.addLabel": "Добавить портфель",
"crypto.portfolio.archiveLabel": "Архивировать портфель",
"crypto.portfolio.unarchiveLabel": "Вернуть из архива",
"crypto.portfolio.confirmDelete": "Удалить портфель «{{name}}»?",
"crypto.portfolio.deleteError": "Сначала переместите или удалите монеты из этого портфеля.",
"crypto.portfolio.form.newTitle": "Новый портфель",
"crypto.portfolio.form.editTitle": "Портфель",
"crypto.portfolio.form.nameLabel": "Название портфеля",
"crypto.portfolio.form.namePlaceholder": "Например, «Долгосрочные инвестиции»",
"crypto.portfolio.form.saveError": "Не удалось сохранить. Проверьте данные и попробуйте снова.",
"roi.calculator.title": "Калькулятор ROI",
"roi.calculator.investmentLabel": "Сумма инвестиции",
"roi.calculator.monthlyIncomeLabel": "Доход в месяц",
"roi.calculator.annualRoiLabel": "Доходность в год",
"roi.calculator.payback": "Окупится за {{years}} лет",
"roi.calculator.noPayback": "Не окупается — доход не покрывает вложение",
"roi.calculator.hint": "Впишите стоимость и ожидаемый доход в месяц, чтобы увидеть годовую доходность, срок окупаемости и прогноз на годы вперёд.",
"roi.calculator.projectionTitle": "Прогноз со сложным процентом",
"roi.calculator.projectionHint": "Если доходность {{percent}}% в год сохранится и весь доход будет реинвестироваться обратно в ту же инвестицию — расчёт только на основе введённых вами цифр, без данных из ваших активов.",
"roi.calculator.chartCompoundLabel": "Со сложным процентом",
"roi.calculator.chartSimpleLabel": "Без реинвестирования",
"roi.calculator.tableTitle": "Сравнение по годам",
"roi.calculator.colCompound": "Со сложным процентом",
"roi.calculator.colSimple": "Без реинвестирования",
"roi.calculator.colDifference": "Разница",
"roi.calculator.yearOne": "Через 1 год",
"roi.calculator.yearN": "Через {{years}} лет",
"netWorth.form.editTitle": "Изменить актив",
"netWorth.form.newTitle": "Новый актив",
"netWorth.form.nameLabel": "Название",
"netWorth.form.namePlaceholder": "Брокерский счёт, BTC, квартира…",
"netWorth.form.classLabel": "Категория",
"netWorth.form.currentValueLabel": "Текущая стоимость",
"netWorth.form.valueLabel": "Стоимость",
"netWorth.form.dateLabel": "Дата оценки",
"netWorth.form.roleLabel": "Тип капитала",
"netWorth.form.riskLevelLabel": "Уровень риска",
"netWorth.form.cashFlowLabel": "Денежный поток в месяц (необязательно)",
"netWorth.form.cashFlowPlaceholder": "800 для дохода, -150 для расхода",
"netWorth.form.cashFlowHint": "Ориентировочно — реальная сумма из месяца в месяц меняется, это не строгий учёт.",
"netWorth.form.notesLabel": "Заметки (необязательно)",
"netWorth.form.saveError": "Не удалось сохранить актив. Проверьте данные и попробуйте снова.",
"transactions.addButton": "Добавить",
"transactions.importButton": "Импорт CSV",
"transactions.import.title": "Импорт транзакций из CSV",
"transactions.import.accountLabel": "Счёт для импорта",
"transactions.import.accountHint": "Все транзакции из файла будут добавлены на этот счёт.",
"transactions.import.uploadTitle": "Файл",
"transactions.import.uploadHint": "Выгрузка транзакций из банка или другого приложения в формате CSV (с заголовками колонок в первой строке).",
"transactions.import.chooseFile": "Выбрать файл",
"transactions.import.pickAccountFirst": "Сначала выберите счёт.",
"transactions.import.errorEmptyFile": "В файле нет ни одной строки с данными (кроме заголовка).",
"transactions.import.mapTitle": "Сопоставьте колонки",
"transactions.import.dateColumnLabel": "Колонка с датой",
"transactions.import.dateFormatLabel": "Формат даты",
"transactions.import.selectColumn": "Выберите колонку…",
"transactions.import.amountColumnLabel": "Колонка с суммой",
"transactions.import.amountHint": "Отрицательные суммы — расход, положительные — доход.",
"transactions.import.descriptionColumnLabel": "Колонка с описанием",
"transactions.import.notMapped": "— не сопоставлено —",
"transactions.import.categoryHint": "Если текст в колонке точно совпадёт с названием существующей категории — она подставится автоматически.",
"transactions.import.startOver": "Начать заново",
"transactions.import.previewButton": "Показать превью",
"transactions.import.previewTitle": "Превью импорта",
"transactions.import.summary": "Готово к импорту: {{valid}}. Пропущено из-за ошибок: {{skipped}}.",
"transactions.import.andMore": "…и ещё {{count}}",
"transactions.import.skippedRow": "Строка {{row}}: {{reason}}",
"transactions.import.importButton": "Импортировать {{count}}",
"transactions.import.importSuccess": "Готово — добавлено транзакций: {{count}}.",
"transactions.import.goToTransactions": "К транзакциям",
"transactions.import.importError": "Не удалось импортировать. Проверьте данные и попробуйте снова.",
"transactions.import.errorBadDate": "не удалось распознать дату «{{value}}»",
"transactions.import.errorBadAmount": "не удалось распознать сумму «{{value}}»",
"transactions.import.errorNoDescription": "нет описания",
"transactions.import.encodingLabel": "Кодировка файла",
"transactions.import.encodingHint": "Определяется автоматически; если вместо русских букв — кракозябры, попробуйте выбрать вручную.",
"transactions.import.sampleValueLabel": "Пример из файла: {{value}}",
"transactions.import.amountFormatLabel": "Разделитель дробной части",
"transactions.import.amountFormat.auto": "Автоматически",
"transactions.import.amountFormat.dot-decimal": "Точка (1234.56), запятая — тысячи",
"transactions.import.amountFormat.comma-decimal": "Запятая (1234,56), точка — тысячи",
"transactions.import.duplicateCheckLoading": "Проверяем, нет ли уже таких транзакций…",
"transactions.import.duplicateCheckFailed": "Не удалось проверить, есть ли уже такие транзакции на счёте — при импорте возможны дубли.",
"transactions.import.duplicatesFound": "Найдено похожих на уже существующие: {{count}}. По умолчанию они не импортируются.",
"transactions.import.includeDuplicates": "Всё равно импортировать их",
"transactions.allTypes": "Все типы",
"transactions.expense": "Расходы",
"transactions.income": "Доходы",
"transactions.transfer": "Переводы",
"transactions.allCategories": "Все категории",
"transactions.allTags": "Все теги",
"transactions.failedToLoad": "Не удалось загрузить транзакции.",
"transactions.noneFound": "Транзакций не найдено.",
"transactions.searchPlaceholder": "Поиск по описанию, продавцу или заметке…",
"transactions.searchAcrossAllTime": "Поиск ведётся по всем периодам, а не только за выбранный месяц.",
"transactions.searchNoneFound": "Ничего не найдено. Попробуйте другой запрос.",
"transactions.jumpToMonth": "Перейти к этому месяцу",
"transactions.confirmDelete": "Удалить транзакцию «{{description}}»?",
"transactions.transferSuffix": "→ перевод",
"transactions.sortDateDesc": "Сначала новые",
"transactions.sortAmountDesc": "Сумма: сначала больше",
"transactions.sortAmountAsc": "Сумма: сначала меньше",
"transactions.form.editTitle": "Изменить транзакцию",
"transactions.form.newTitle": "Новая транзакция",
"transactions.form.typeLabel": "Тип",
"transactions.form.typeExpense": "Расход",
"transactions.form.typeIncome": "Доход",
"transactions.form.typeTransfer": "Перевод",
"transactions.form.amountLabel": "Сумма",
"transactions.form.dateLabel": "Дата",
"transactions.form.descriptionLabel": "Описание",
"transactions.form.descriptionPlaceholder": "Продукты, зарплата, аренда…",
"transactions.form.accountLabel": "Счёт",
"transactions.form.selectAccount": "Выберите счёт",
"transactions.form.transferAccountLabel": "Счёт назначения",
"transactions.form.categoryLabel": "Категория",
"transactions.form.noCategory": "Без категории",
"transactions.form.merchantLabel": "Продавец (необязательно)",
"transactions.form.notesLabel": "Заметка (необязательно)",
"transactions.form.tagsLabel": "Теги (необязательно)",
"transactions.form.tagsPlaceholder": "Начните вводить, чтобы найти или создать тег…",
"transactions.form.removeTag": "Убрать тег «{{name}}»",
"transactions.form.createTag": "Создать тег «{{name}}»",
"transactions.viewNote": "Посмотреть заметку",
"transactions.noteDialogTitle": "Заметка",
"transactions.form.errorSelectAccount": "Выберите счёт",
"transactions.form.errorSelectDestination": "Выберите счёт назначения для перевода",
"transactions.form.errorSameAccount": "Счёт назначения должен отличаться от исходного счёта",
"transactions.form.saveError": "Не удалось сохранить транзакцию. Проверьте данные и попробуйте снова.",
"transactions.form.splitToggle": "Разбить на несколько категорий",
"transactions.form.splitToggleOff": "Одна категория",
"transactions.form.splitHint": "Одна покупка — несколько категорий, например часть чека — продукты, часть — бытовая химия.",
"transactions.form.splitCategoryPlaceholder": "Выберите категорию",
"transactions.form.splitNotePlaceholder": "Заметка (необязательно)",
"transactions.form.splitAddRow": "Добавить категорию",
"transactions.form.splitRemoveRow": "Убрать категорию",
"transactions.form.splitRemainingLabel": "Осталось распределить: {{amount}}",
"transactions.form.splitOverAllocatedLabel": "Превышение суммы на: {{amount}}",
"transactions.form.splitFullyAllocatedLabel": "Вся сумма распределена",
"transactions.form.errorSplitMinRows": "Нужно как минимум 2 категории для разбивки",
"transactions.form.errorSplitIncomplete": "Выберите категорию и сумму для каждой строки разбивки",
"transactions.form.errorSplitMismatch": "Сумма по категориям должна совпадать с суммой транзакции",
"transactions.form.errorSplitNoBaseCategory": "Сначала выберите базовую категорию",
"transactions.form.splitDirectOption": "— без конкретной подкатегории —",
"transactions.form.splitNoChildren": "У этой категории пока нет подкатегорий — добавьте их на странице «Категории», чтобы разбивать покупки по ним.",
"reports.directSpendLabel": "Без подкатегории",
"reports.categoryBreakdownTotalLabel": "Итого",
"reports.categoryLabel": "Категория",
"reports.expenseGroup": "Расходы",
"reports.incomeGroup": "Доходы",
"reports.rangeAll": "Всё время",
"reports.rangeThisYear": "Этот год",
"reports.range5y": "5 лет",
"reports.rangeCustom": "Диапазон лет",
"reports.transactionsTitle": "Транзакции",
"reports.defaultChartTitle": "Расходы по категории",
"reports.averagePerMonth": "{{amount}}/мес в среднем",
"reports.noDataForPeriod": "Нет данных за выбранный период.",
"reports.categoryRankingTitle": "Топ категорий за период",
"reports.noRankingData": "Нет расходов за выбранный период.",
"settings.language": "Язык",
"settings.languageRussian": "Русский",
"settings.languageEnglish": "English",
"settings.theme": "Тема оформления",
"settings.themeLight": "Светлая",
"settings.themeDark": "Тёмная",
"settings.themeSystem": "Системная",
"settings.currency": "Основная валюта",
"settings.currencyHint":
"Меняет только отображение сумм — уже введённые значения не пересчитываются и не конвертируются.",
"settings.alertsTitle": "Проактивные предупреждения",
"settings.alertsHint": "Через сколько месяцев подряд показывать предупреждение на Dashboard и «Капитал».",
"settings.negativeCashFlowThresholdLabel": "Расходы превышают доходы, мес. подряд",
"settings.netWorthDeclineThresholdLabel": "Капитал снижается, мес. подряд",
"settings.riskyAllocationThresholdLabel": "Допустимая доля рискового капитала, %",
"settings.idleCashThresholdAmountLabel": "Простаивающий кэш — сумма от",
"settings.idleCashThresholdDaysLabel": "Простаивающий кэш — без движения, дней",
"backup.title": "Резервное копирование",
"backup.description":
"Экспортируйте все свои данные (счета, категории, транзакции, активы) в один JSON-файл и храните его в надёжном месте. Позже этот файл можно загрузить обратно, чтобы полностью восстановить данные — например, после переустановки или переезда на другой сервер.",
"backup.exportButton": "Экспортировать бэкап",
"backup.exporting": "Экспорт…",
"backup.importButton": "Восстановить из файла",
"backup.importing": "Восстановление…",
"backup.exportSuccess": "Бэкап скачан.",
"backup.exportError": "Не удалось скачать бэкап.",
"backup.importSuccess": "Данные восстановлены из бэкапа.",
"backup.importError": "Не удалось восстановить бэкап.",
"backup.invalidFile": "Файл повреждён или не является JSON-бэкапом Aurum.",
"backup.confirmImport":
"Восстановить данные из «{{filename}}»?\n\nЭто ПОЛНОСТЬЮ заменит все текущие счета, категории, транзакции и активы данными из файла. Отменить это действие нельзя — если хотите сохранить текущие данные, сначала сделайте экспорт.",
"backup.footerWarning":
"Восстановление полностью заменяет текущие данные содержимым файла — это нельзя отменить.",
"settings.version": "Aurum v{{version}}",
"insights.negativeCashFlow": "Расходы превышают доходы {{months}} мес. подряд (по последним завершённым месяцам)",
"insights.netWorthDecline": "Капитал снижается {{months}} мес. подряд",
"insights.budgetExceededOne": "Превышен бюджет в 1 категории в этом месяце",
"insights.budgetExceeded": "Превышен бюджет в {{count}} категориях в этом месяце",
"insights.riskyAllocationExceeded": "Рискованная часть капитала — {{percent}}%, превышает установленный лимит {{threshold}}%",
"insights.idleCashOne": "На 1 счёте лежит крупная сумма без движения {{days}}+ дней — возможно, эти деньги стоит куда-то вложить",
"insights.idleCash": "На {{count}} счетах лежит крупная сумма без движения {{days}}+ дней — возможно, эти деньги стоит куда-то вложить",
"budget.empty": "Бюджетов пока нет. Задайте лимит для категории расходов, чтобы отслеживать её здесь.",
"budget.remaining": "{{amount}} осталось",
"budget.overBy": "Превышен на {{amount}}",
"budget.confirmDelete": "Удалить бюджет для «{{name}}»?",
"budget.form.newTitle": "Новый бюджет",
"budget.form.editTitle": "Изменить бюджет",
"budget.form.categoryLabel": "Категория",
"budget.form.limitLabel": "Лимит в месяц",
"budget.form.noCategoriesAvailable": "Все категории расходов уже добавлены.",
"budget.form.saveError": "Не удалось сохранить бюджет. Проверьте данные и попробуйте снова.",
"advice.empty": "Пока нет советов — недостаточно данных, или всё в порядке.",
"advice.risingCategory":
"Траты на {{category}} выросли на {{percent}}% по сравнению со средним за последние 3 месяца: {{current}} против {{average}}.",
"advice.unbudgetedTopCategory":
"На «{{category}}» уходит больше всего в этом месяце — {{amount}}. Задайте для неё бюджет на вкладке «Бюджет», чтобы отслеживать лимит.",
"advice.savingsRateTrend": "Норма сбережений: {{rate}}% ({{diff}} п.п. к прошлому месяцу).",
"goal.empty": "Целей пока нет. Добавьте цель и начните копить.",
"goal.remaining": "{{amount}} осталось",
"goal.remainingWithDate": "{{amount}} осталось · до {{date}}",
"goal.reached": "Цель достигнута!",
"goal.contributeLabel": "Добавить взнос",
"goal.confirmDelete": "Удалить цель «{{name}}»?",
"goal.form.newTitle": "Новая цель",
"goal.form.editTitle": "Изменить цель",
"goal.form.nameLabel": "Название",
"goal.form.namePlaceholder": "Подушка безопасности, отпуск, новый ноутбук…",
"goal.form.targetAmountLabel": "Целевая сумма",
"goal.form.targetDateLabel": "Дата (необязательно)",
"goal.form.saveError": "Не удалось сохранить цель. Проверьте данные и попробуйте снова.",
"goal.contribution.title": "Взнос: {{name}}",
"goal.contribution.amountLabel": "Сумма",
"goal.contribution.amountPlaceholder": "100 для взноса, -50 для снятия",
"goal.contribution.dateLabel": "Дата",
"goal.contribution.hint": "Можно указать отрицательную сумму, если пришлось снять деньги с цели.",
"goal.contribution.saveError": "Не удалось сохранить взнос. Проверьте данные и попробуйте снова.",
"recurring.empty":
"Регулярных платежей пока нет. Добавьте зарплату, аренду или подписку, чтобы не заводить их вручную каждый раз.",
"recurring.postLabel": "Провести платёж",
"recurring.confirmDelete": "Удалить регулярный платёж «{{name}}»?",
"recurring.overdueDays": "Просрочено на {{days}} дн.",
"recurring.dueToday": "Пора оплатить сегодня",
"recurring.dueInDays": "Через {{days}} дн. · {{date}}",
"recurring.frequency.weekly": "Еженедельно",
"recurring.frequency.monthly": "Ежемесячно",
"recurring.frequency.yearly": "Ежегодно",
"recurring.form.newTitle": "Новый регулярный платёж",
"recurring.form.editTitle": "Изменить регулярный платёж",
"recurring.form.frequencyLabel": "Периодичность",
"recurring.form.anchorDateLabel": "Дата начала",
"recurring.form.saveError": "Не удалось сохранить регулярный платёж. Проверьте данные и попробуйте снова.",
"account.empty": "Счетов пока нет. Добавьте первый — например, текущий счёт или наличные.",
"account.showArchived": "Показывать архивные",
"account.archivedBadge": "архив",
"account.archiveLabel": "Архивировать",
"account.unarchiveLabel": "Вернуть из архива",
"account.confirmDelete":
"Удалить счёт «{{name}}»?\n\nЭто безвозвратно удалит и ВСЕ транзакции по этому счёту. Если хотите сохранить историю, заархивируйте счёт вместо удаления.",
"account.type.checking": "Текущий счёт",
"account.type.debit_card": "Дебетовая карта",
"account.type.savings": "Накопительный счёт",
"account.type.credit_card": "Кредитная карта",
"account.type.cash": "Наличные",
"account.type.investment": "Инвестиционный счёт",
"account.type.other": "Другое",
"account.form.newTitle": "Новый счёт",
"account.form.editTitle": "Изменить счёт",
"account.form.nameLabel": "Название",
"account.form.namePlaceholder": "Основной счёт, накопления, наличные…",
"account.form.typeLabel": "Тип",
"account.form.saveError": "Не удалось сохранить счёт. Проверьте данные и попробуйте снова.",
"category.expenseSectionTitle": "Категории расходов",
"category.incomeSectionTitle": "Категории доходов",
"category.empty": "Категорий пока нет.",
"category.defaultBadge": "стандартная",
"category.defaultDeleteBlocked": "Стандартную категорию можно удалить только после того, как у неё не останется транзакций.",
"category.confirmDelete": "Удалить категорию «{{name}}»?\n\nЕё транзакции останутся без категории.",
"category.kind.expense": "Расход",
"category.kind.income": "Доход",
"category.form.newTitle": "Новая категория",
"category.form.editTitle": "Изменить категорию",
"category.form.nameLabel": "Название",
"category.form.namePlaceholder": "Продукты, транспорт, подписки…",
"category.form.kindLabel": "Тип",
"category.form.parentLabel": "Родительская категория",
"category.form.noParent": "— без родителя (верхний уровень) —",
"category.form.hasChildrenHint": "У категории уже есть подкатегории — она не может сама стать подкатегорией.",
"category.form.iconLabel": "Иконка",
"category.form.colorLabel": "Цвет",
"category.form.customColorLabel": "Свой цвет",
"category.form.saveError": "Не удалось сохранить категорию. Проверьте данные и попробуйте снова.",
"cashFlow.income": "Доход",
"cashFlow.expense": "Расход",
"cashFlow.net": "Баланс",
"cashFlow.noData": "Нет данных за выбранный период.",
// Default seeded categories (backend/app/db/seed.py) — translateCategoryName()
// matches on the exact seeded English name and falls through unchanged
// for anything else (a renamed default, or a custom category added via
// the category-management UI).
"category.housingUtilities": "Жильё и коммуналка",
"category.groceries": "Продукты",
"category.diningOut": "Кафе и рестораны",
"category.transportation": "Транспорт",
"category.healthFitness": "Здоровье и фитнес",
"category.shopping": "Покупки",
"category.entertainment": "Развлечения",
"category.subscriptions": "Подписки",
"category.salary": "Зарплата",
"category.freelance": "Фриланс",
"category.investments": "Инвестиции",
"category.gifts": "Подарки",
"category.businessIncome": "Доход от бизнеса",
"category.rentalIncome": "Сдача недвижимости",
"category.benefits": "Пособия и соцвыплаты",
"category.itemSales": "Продажа личных вещей",
"category.otherIncome": "Прочий доход",
"months.jan": "Янв",
"months.feb": "Фев",
"months.mar": "Мар",
"months.apr": "Апр",
"months.may": "Май",
"months.jun": "Июн",
"months.jul": "Июл",
"months.aug": "Авг",
"months.sep": "Сен",
"months.oct": "Окт",
"months.nov": "Ноя",
"months.dec": "Дек",
};
const en: Record<keyof typeof ru, string> = {
"nav.dashboard": "Dashboard",
"nav.netWorth": "Net Worth",
"nav.crypto": "Crypto",
"nav.roi": "Returns",
"nav.transactions": "Transactions",
"nav.accounts": "Accounts",
"nav.categories": "Categories",
"nav.cashFlow": "Cash Flow",
"nav.reports": "Reports",
"nav.budget": "Budget",
"nav.recurring": "Recurring",
"nav.goals": "Goals",
"nav.advice": "Advice",
"nav.settings": "Settings",
"nav.comingSoon": "soon",
"sidebar.collapse": "Collapse",
"sidebar.collapseMenu": "Collapse menu",
"sidebar.expandMenu": "Expand menu",
"sidebar.closeMenu": "Close menu",
"topbar.openMenu": "Open menu",
"common.loading": "Loading…",
"common.expand": "Expand",
"common.collapse": "Collapse",
"common.back": "Back",
"common.next": "Next",
"common.clear": "Clear",
"common.cancel": "Cancel",
"common.save": "Save",
"common.saving": "Saving…",
"common.edit": "Edit",
"common.delete": "Delete",
"common.close": "Close",
"common.add": "Add",
"common.pageOf": "Page {{page}} of {{total}}",
"common.totalCount": "{{count}} total",
"common.perMonth": "/mo",
"common.allShort": "All",
"auth.subtitle": "Personal finance",
"auth.usernameLabel": "Username",
"auth.passwordLabel": "Password",
"auth.rememberMe": "Remember me for 7 days",
"auth.submitButton": "Sign in",
"auth.submitting": "Signing in…",
"auth.errorInvalidCredentials": "Incorrect username or password",
"auth.errorUnreachable": "Couldn't reach the server. Check your connection and try again.",
"auth.noAuthModalTitle": "Authentication not configured",
"auth.noAuthModalBody": "This Aurum instance has no password protection — all data is accessible to anyone who can reach it. Set AURUM_BASIC_AUTH_USER and AURUM_BASIC_AUTH_PASSWORD in .env to enable login.",
"auth.noAuthModalAccept": "I understand the risk and want to continue",
"dashboard.errorLoading": "Failed to load dashboard data. Check that the backend is running.",
"dashboard.statRealIncomeLabel": "Real income",
"dashboard.statRealIncomeCaption": "paychecks, refunds",
"dashboard.statSpentLabel": "Spent",
"dashboard.statSpentCaption": "excludes transfers",
"dashboard.statNetLabel": "Net",
"dashboard.statNetCaption": "income − spend",
"dashboard.statSavingsRateLabel": "Savings rate",
"dashboard.statSavingsRateCaption": "share of income left after spending",
"dashboard.spendingByCategoryTitle": "Spending by category",
"dashboard.noExpensesThisMonth": "No expenses yet this month. Add your first transaction on the Transactions tab.",
"dashboard.recentTransactionsTitle": "Recent transactions",
"dashboard.allTransactionsLink": "All transactions",
"dashboard.noTransactionsYet": "No transactions yet.",
"netWorth.periodSuffix": "over period",
"netWorth.noChartData": "No data for the chart yet.",
"netWorth.assetsTitlePrefix": "Assets",
"netWorth.myAssetsTitle": "My assets",
"netWorth.confirmDeleteAsset": "Delete asset “{{name}}”?",
"netWorth.capitalByType": "Capital by type",
"netWorth.assetClass.investments": "Investments",
"netWorth.assetClass.crypto": "Crypto",
"netWorth.assetClass.real_estate": "Real estate",
"netWorth.assetClass.vehicles": "Vehicles",
"netWorth.assetClass.precious_metals": "Precious metals",
"netWorth.assetClass.other": "Other",
"netWorth.assetClass.cash": "Cash",
"netWorth.capitalRole.income": "Income",
"netWorth.capitalRole.neutral": "Neutral",
"netWorth.capitalRole.drain": "Drain",
"netWorth.capitalRoleHint.income": "brings in money",
"netWorth.capitalRoleHint.neutral": "just sits there — no gain, no drain",
"netWorth.capitalRoleHint.drain": "costs money every month",
"netWorth.capitalRoleFormHint.income": "brings in money — rented out, etc.",
"netWorth.capitalRoleFormHint.neutral": "just there / in use — electronics, furniture",
"netWorth.capitalRoleFormHint.drain": "costs money every month — a car, etc.",
"netWorth.riskAllocationTitle": "Capital by risk level",
"netWorth.riskAllocationOfCapital": "of capital",
"netWorth.riskLevel.low": "Low",
"netWorth.riskLevel.medium": "Medium",
"netWorth.riskLevel.high": "High",
"netWorth.riskLevelFormHint.low": "minimal risk of loss — deposits, cash, government bonds",
"netWorth.riskLevelFormHint.medium": "moderate value swings — real estate, diversified investments",
"netWorth.riskLevelFormHint.high": "can lose significant value — crypto, individual stocks, startups",
"netWorth.assetsTable.empty": "No assets yet. Add investments, crypto, real estate, or other property.",
"netWorth.assetsTable.asOf": "as of {{date}}",
"netWorth.assetsTable.annualRoi": "{{percent}}%/year",
"crypto.addButton": "Add coin",
"crypto.refreshButton": "Refresh prices",
"crypto.refreshing": "Refreshing…",
"crypto.lastSynced": "Updated {{time}}",
"crypto.neverSynced": "Prices haven't loaded yet",
"crypto.totalValueLabel": "Total in crypto",
"crypto.empty": "No coins yet. Add your first one to start tracking.",
"crypto.pendingPrice": "price not fetched yet",
"crypto.confirmDelete": "Remove \"{{name}}\" from tracking?",
"crypto.syncError.unreachable": "Could not reach CoinGecko — showing the last known prices.",
"crypto.search.placeholder": "Find a coin (e.g. Bitcoin or BTC)…",
"crypto.search.hint": "Start typing a coin's name or ticker.",
"crypto.search.empty": "Nothing found.",
"crypto.search.noApiKey": "CoinGecko API key not configured — set AURUM_COINGECKO_API_KEY in .env (free key at coingecko.com/en/api/pricing).",
"crypto.form.quantityLabel": "Quantity",
"crypto.form.pricePerUnitLabel": "Price per unit",
"crypto.form.dateLabel": "Date",
"crypto.form.noteLabel": "Note (optional)",
"crypto.form.networkLabel": "Network (optional)",
"crypto.form.networkPlaceholder": "e.g. Ethereum, Tron…",
"crypto.form.addTitle": "Add {{name}}",
"crypto.form.tradeTitle": "Trade: {{name}}",
"crypto.form.editTradeTitle": "Edit trade: {{name}}",
"crypto.form.tradeButton": "Buy / sell",
"crypto.form.buy": "Buy",
"crypto.form.sell": "Sell",
"crypto.form.errorSellTooMuch": "You can't sell more than you currently hold.",
"crypto.form.saveError": "Failed to save. Check the data and try again.",
"crypto.table.name": "Name",
"crypto.table.price": "Price",
"crypto.table.change1h": "1h%",
"crypto.table.change24h": "24h%",
"crypto.table.change7d": "7d%",
"crypto.table.holdings": "Holdings",
"crypto.table.avgBuyPrice": "Avg. Price",
"crypto.table.profitLoss": "Profit/Loss",
"crypto.table.network": "Network",
"crypto.table.addNetwork": "+ Network",
"crypto.table.risk": "Risk",
"crypto.table.actions": "Actions",
"crypto.riskAllocationOfPortfolio": "of crypto portfolio",
"crypto.history.title": "Trade history: {{name}}",
"crypto.history.empty": "No trades yet.",
"crypto.history.confirmDelete": "Delete this trade? Quantity and average price will be recalculated.",
"crypto.history.viewButton": "View history",
"crypto.holdingsTitle": "Holdings",
"crypto.chart.show": "Show amount",
"crypto.chart.hide": "Hide amount",
"crypto.allocation.title": "Allocation by coin",
"crypto.overview.history": "History",
"crypto.overview.allocation": "Allocation",
"crypto.overview.risk": "Risk levels",
"crypto.overview.networks": "Networks",
"crypto.networkAllocation.unset": "No network set",
"crypto.allocation.other": "Other",
"crypto.stats.allTimeProfit": "All-time profit",
"crypto.stats.costBasis": "Total invested",
"crypto.stats.costBasisHint": "actual amount spent buying",
"crypto.stats.bestPerformer": "Best Performer",
"crypto.stats.worstPerformer": "Worst Performer",
"crypto.stats.changeLabel": "price change over {{range}}",
"crypto.portfolio.all": "All portfolios",
"crypto.portfolio.addLabel": "Add portfolio",
"crypto.portfolio.archiveLabel": "Archive portfolio",
"crypto.portfolio.unarchiveLabel": "Unarchive",
"crypto.portfolio.confirmDelete": "Delete portfolio «{{name}}»?",
"crypto.portfolio.deleteError": "Move or delete its coins before deleting a portfolio.",
"crypto.portfolio.form.newTitle": "New portfolio",
"crypto.portfolio.form.editTitle": "Portfolio",
"crypto.portfolio.form.nameLabel": "Portfolio name",
"crypto.portfolio.form.namePlaceholder": "e.g. \"Long-term investments\"",
"crypto.portfolio.form.saveError": "Couldn't save. Check the details and try again.",
"roi.calculator.title": "ROI Calculator",
"roi.calculator.investmentLabel": "Investment amount",
"roi.calculator.monthlyIncomeLabel": "Monthly income",
"roi.calculator.annualRoiLabel": "Annual return",
"roi.calculator.payback": "Pays back in {{years}} years",
"roi.calculator.noPayback": "Doesn't pay back — income doesn't cover the investment",
"roi.calculator.hint": "Enter the price and expected monthly income to see the annual return, payback period, and a multi-year projection.",
"roi.calculator.projectionTitle": "Compound interest projection",
"roi.calculator.projectionHint": "If the {{percent}}% annual return holds and all income is reinvested back into the same investment — based only on the numbers you entered, not your tracked assets.",
"roi.calculator.chartCompoundLabel": "With compounding",
"roi.calculator.chartSimpleLabel": "Without reinvesting",
"roi.calculator.tableTitle": "Year-by-year comparison",
"roi.calculator.colCompound": "With compounding",
"roi.calculator.colSimple": "Without reinvesting",
"roi.calculator.colDifference": "Difference",
"roi.calculator.yearOne": "In 1 year",
"roi.calculator.yearN": "In {{years}} years",
"netWorth.form.editTitle": "Edit asset",
"netWorth.form.newTitle": "New asset",
"netWorth.form.nameLabel": "Name",
"netWorth.form.namePlaceholder": "Brokerage account, BTC, apartment…",
"netWorth.form.classLabel": "Category",
"netWorth.form.currentValueLabel": "Current value",
"netWorth.form.valueLabel": "Value",
"netWorth.form.dateLabel": "Valuation date",
"netWorth.form.roleLabel": "Capital type",
"netWorth.form.riskLevelLabel": "Risk level",
"netWorth.form.cashFlowLabel": "Monthly cash flow (optional)",
"netWorth.form.cashFlowPlaceholder": "800 for income, -150 for expense",
"netWorth.form.cashFlowHint": "Approximate — the real amount varies month to month, this isn't strict accounting.",
"netWorth.form.notesLabel": "Notes (optional)",
"netWorth.form.saveError": "Failed to save the asset. Check the data and try again.",
"transactions.addButton": "Add",
"transactions.importButton": "Import CSV",
"transactions.import.title": "Import transactions from CSV",
"transactions.import.accountLabel": "Account to import into",
"transactions.import.accountHint": "Every transaction from the file will be added to this account.",
"transactions.import.uploadTitle": "File",
"transactions.import.uploadHint": "A transaction export from your bank or another app, in CSV format (with column headers in the first row).",
"transactions.import.chooseFile": "Choose file",
"transactions.import.pickAccountFirst": "Pick an account first.",
"transactions.import.errorEmptyFile": "The file has no data rows (besides the header).",
"transactions.import.mapTitle": "Map the columns",
"transactions.import.dateColumnLabel": "Date column",
"transactions.import.dateFormatLabel": "Date format",
"transactions.import.selectColumn": "Select a column…",
"transactions.import.amountColumnLabel": "Amount column",
"transactions.import.amountHint": "Negative amounts are expenses, positive are income.",
"transactions.import.descriptionColumnLabel": "Description column",
"transactions.import.notMapped": "— not mapped —",
"transactions.import.categoryHint": "If the column's text exactly matches an existing category name, it's filled in automatically.",
"transactions.import.startOver": "Start over",
"transactions.import.previewButton": "Show preview",
"transactions.import.previewTitle": "Import preview",
"transactions.import.summary": "Ready to import: {{valid}}. Skipped due to errors: {{skipped}}.",
"transactions.import.andMore": "…and {{count}} more",
"transactions.import.skippedRow": "Row {{row}}: {{reason}}",
"transactions.import.importButton": "Import {{count}}",
"transactions.import.importSuccess": "Done — {{count}} transactions added.",
"transactions.import.goToTransactions": "Go to transactions",
"transactions.import.importError": "Import failed. Check the data and try again.",
"transactions.import.errorBadDate": "couldn't parse the date \"{{value}}\"",
"transactions.import.errorBadAmount": "couldn't parse the amount \"{{value}}\"",
"transactions.import.errorNoDescription": "no description",
"transactions.import.encodingLabel": "File encoding",
"transactions.import.encodingHint": "Auto-detected; if you see garbled characters instead of letters, try picking one manually.",
"transactions.import.sampleValueLabel": "Example from file: {{value}}",
"transactions.import.amountFormatLabel": "Decimal separator",
"transactions.import.amountFormat.auto": "Automatic",
"transactions.import.amountFormat.dot-decimal": "Dot (1234.56), comma is thousands",
"transactions.import.amountFormat.comma-decimal": "Comma (1234,56), dot is thousands",
"transactions.import.duplicateCheckLoading": "Checking for transactions already on this account…",
"transactions.import.duplicateCheckFailed": "Couldn't check what's already on this account — importing may create duplicates.",
"transactions.import.duplicatesFound": "Found {{count}} that look like duplicates of existing transactions. They're left out by default.",
"transactions.import.includeDuplicates": "Import them anyway",
"transactions.allTypes": "All types",
"transactions.expense": "Expenses",
"transactions.income": "Income",
"transactions.transfer": "Transfers",
"transactions.allCategories": "All categories",
"transactions.allTags": "All tags",
"transactions.failedToLoad": "Failed to load transactions.",
"transactions.noneFound": "No transactions found.",
"transactions.searchPlaceholder": "Search by description, merchant, or note…",
"transactions.searchAcrossAllTime": "Searching across every period, not just the selected month.",
"transactions.searchNoneFound": "Nothing found. Try a different search.",
"transactions.jumpToMonth": "Jump to this month",
"transactions.confirmDelete": "Delete transaction “{{description}}”?",
"transactions.transferSuffix": "→ transfer",
"transactions.sortDateDesc": "Newest first",
"transactions.sortAmountDesc": "Amount: high to low",
"transactions.sortAmountAsc": "Amount: low to high",
"transactions.form.editTitle": "Edit transaction",
"transactions.form.newTitle": "New transaction",
"transactions.form.typeLabel": "Type",
"transactions.form.typeExpense": "Expense",
"transactions.form.typeIncome": "Income",
"transactions.form.typeTransfer": "Transfer",
"transactions.form.amountLabel": "Amount",
"transactions.form.dateLabel": "Date",
"transactions.form.descriptionLabel": "Description",
"transactions.form.descriptionPlaceholder": "Groceries, salary, rent…",
"transactions.form.accountLabel": "Account",
"transactions.form.selectAccount": "Select account",
"transactions.form.transferAccountLabel": "Destination account",
"transactions.form.categoryLabel": "Category",
"transactions.form.noCategory": "No category",
"transactions.form.merchantLabel": "Merchant (optional)",
"transactions.form.notesLabel": "Note (optional)",
"transactions.form.tagsLabel": "Tags (optional)",
"transactions.form.tagsPlaceholder": "Type to find or create a tag…",
"transactions.form.removeTag": "Remove tag \"{{name}}\"",
"transactions.form.createTag": "Create tag \"{{name}}\"",
"transactions.viewNote": "View note",
"transactions.noteDialogTitle": "Note",
"transactions.form.errorSelectAccount": "Select an account",
"transactions.form.errorSelectDestination": "Select a destination account for the transfer",
"transactions.form.errorSameAccount": "The destination account must be different from the source account",
"transactions.form.saveError": "Failed to save the transaction. Check the data and try again.",
"transactions.form.splitToggle": "Split across multiple categories",
"transactions.form.splitToggleOff": "Single category",
"transactions.form.splitHint": "One purchase, several categories — e.g. part of a receipt is groceries, part is household chemicals.",
"transactions.form.splitCategoryPlaceholder": "Select a category",
"transactions.form.splitNotePlaceholder": "Note (optional)",
"transactions.form.splitAddRow": "Add category",
"transactions.form.splitRemoveRow": "Remove category",
"transactions.form.splitRemainingLabel": "Remaining to allocate: {{amount}}",
"transactions.form.splitOverAllocatedLabel": "Over-allocated by: {{amount}}",
"transactions.form.splitFullyAllocatedLabel": "Fully allocated",
"transactions.form.errorSplitMinRows": "Splitting needs at least 2 categories",
"transactions.form.errorSplitIncomplete": "Pick a category and an amount for every split row",
"transactions.form.errorSplitMismatch": "Split amounts must add up to the transaction amount",
"transactions.form.errorSplitNoBaseCategory": "Pick a base category first",
"transactions.form.splitDirectOption": "— no specific subcategory —",
"transactions.form.splitNoChildren": "This category has no subcategories yet — add some on the Categories page to split purchases across them.",
"reports.directSpendLabel": "No subcategory",
"reports.categoryBreakdownTotalLabel": "Total",
"reports.categoryLabel": "Category",
"reports.expenseGroup": "Expenses",
"reports.incomeGroup": "Income",
"reports.rangeAll": "All time",
"reports.rangeThisYear": "This year",
"reports.range5y": "5 years",
"reports.rangeCustom": "Year range",
"reports.transactionsTitle": "Transactions",
"reports.defaultChartTitle": "Spending by category",
"reports.averagePerMonth": "{{amount}}/mo average",
"reports.noDataForPeriod": "No data for the selected period.",
"reports.categoryRankingTitle": "Top categories for period",
"reports.noRankingData": "No expenses for the selected period.",
"settings.language": "Language",
"settings.languageRussian": "Русский",
"settings.languageEnglish": "English",
"settings.theme": "Theme",
"settings.themeLight": "Light",
"settings.themeDark": "Dark",
"settings.themeSystem": "System",
"settings.currency": "Primary currency",
"settings.currencyHint":
"Only changes how amounts are displayed — values you've already entered aren't recalculated or converted.",
"settings.alertsTitle": "Proactive alerts",
"settings.alertsHint": "How many consecutive months before showing a warning on Dashboard and Net Worth.",
"settings.negativeCashFlowThresholdLabel": "Expenses exceed income, consecutive months",
"settings.netWorthDeclineThresholdLabel": "Net worth declining, consecutive months",
"settings.riskyAllocationThresholdLabel": "Max % of capital allowed at risk",
"settings.idleCashThresholdAmountLabel": "Idle cash — balance at or above",
"settings.idleCashThresholdDaysLabel": "Idle cash — no activity for, days",
"backup.title": "Backup & Restore",
"backup.description":
"Export all your data (accounts, categories, transactions, assets) into a single JSON file and keep it somewhere safe. Later, you can load that file back in to fully restore your data — for example, after a reinstall or moving to a different server.",
"backup.exportButton": "Export backup",
"backup.exporting": "Exporting…",
"backup.importButton": "Restore from file",
"backup.importing": "Restoring…",
"backup.exportSuccess": "Backup downloaded.",
"backup.exportError": "Failed to download the backup.",
"backup.importSuccess": "Data restored from backup.",
"backup.importError": "Failed to restore the backup.",
"backup.invalidFile": "The file is corrupted or is not an Aurum JSON backup.",
"backup.confirmImport":
"Restore data from “{{filename}}”?\n\nThis will COMPLETELY replace all current accounts, categories, transactions, and assets with the data from the file. This action cannot be undone — if you want to keep your current data, export it first.",
"backup.footerWarning":
"Restoring completely replaces your current data with the file's contents — this cannot be undone.",
"settings.version": "Aurum v{{version}}",
"insights.negativeCashFlow": "Expenses have exceeded income for {{months}} months in a row (based on the last completed months)",
"insights.netWorthDecline": "Net worth has been declining for {{months}} months in a row",
"insights.budgetExceededOne": "Budget exceeded in 1 category this month",
"insights.budgetExceeded": "Budget exceeded in {{count}} categories this month",
"insights.riskyAllocationExceeded": "Risky share of capital is {{percent}}%, above your {{threshold}}% limit",
"insights.idleCashOne": "1 account is holding a large balance with no activity for {{days}}+ days — that money might be worth putting to work",
"insights.idleCash": "{{count}} accounts are holding a large balance with no activity for {{days}}+ days — that money might be worth putting to work",
"budget.empty": "No budgets yet. Set a limit for an expense category to start tracking it here.",
"budget.remaining": "{{amount}} left",
"budget.overBy": "Over by {{amount}}",
"budget.confirmDelete": "Delete the budget for “{{name}}”?",
"budget.form.newTitle": "New budget",
"budget.form.editTitle": "Edit budget",
"budget.form.categoryLabel": "Category",
"budget.form.limitLabel": "Monthly limit",
"budget.form.noCategoriesAvailable": "Every expense category already has a budget.",
"budget.form.saveError": "Failed to save the budget. Check the data and try again.",
"advice.empty": "No advice right now — not enough data, or everything looks fine.",
"advice.risingCategory":
"Spending on {{category}} is up {{percent}}% vs. the average of the last 3 months: {{current}} vs {{average}}.",
"advice.unbudgetedTopCategory":
"\"{{category}}\" is your biggest expense this month — {{amount}}. Set a budget for it on the Budget tab to track a limit.",
"advice.savingsRateTrend": "Savings rate: {{rate}}% ({{diff}} pts vs last month).",
"goal.empty": "No goals yet. Add one and start saving.",
"goal.remaining": "{{amount}} left",
"goal.remainingWithDate": "{{amount}} left · by {{date}}",
"goal.reached": "Goal reached!",
"goal.contributeLabel": "Add contribution",
"goal.confirmDelete": "Delete the goal “{{name}}”?",
"goal.form.newTitle": "New goal",
"goal.form.editTitle": "Edit goal",
"goal.form.nameLabel": "Name",
"goal.form.namePlaceholder": "Emergency fund, vacation, new laptop…",
"goal.form.targetAmountLabel": "Target amount",
"goal.form.targetDateLabel": "Target date (optional)",
"goal.form.saveError": "Failed to save the goal. Check the data and try again.",
"goal.contribution.title": "Contribution: {{name}}",
"goal.contribution.amountLabel": "Amount",
"goal.contribution.amountPlaceholder": "100 to deposit, -50 to withdraw",
"goal.contribution.dateLabel": "Date",
"goal.contribution.hint": "You can enter a negative amount if you had to withdraw from the goal.",
"goal.contribution.saveError": "Failed to save the contribution. Check the data and try again.",
"recurring.empty":
"No recurring transactions yet. Add your salary, rent, or a subscription so you don't have to enter it manually every time.",
"recurring.postLabel": "Post now",
"recurring.confirmDelete": "Delete the recurring transaction “{{name}}”?",
"recurring.overdueDays": "{{days}} days overdue",
"recurring.dueToday": "Due today",
"recurring.dueInDays": "In {{days}} days · {{date}}",
"recurring.frequency.weekly": "Weekly",
"recurring.frequency.monthly": "Monthly",
"recurring.frequency.yearly": "Yearly",
"recurring.form.newTitle": "New recurring transaction",
"recurring.form.editTitle": "Edit recurring transaction",
"recurring.form.frequencyLabel": "Frequency",
"recurring.form.anchorDateLabel": "Start date",
"recurring.form.saveError": "Failed to save the recurring transaction. Check the data and try again.",
"account.empty": "No accounts yet. Add your first one — a checking account or cash, for example.",
"account.showArchived": "Show archived",
"account.archivedBadge": "archived",
"account.archiveLabel": "Archive",
"account.unarchiveLabel": "Unarchive",
"account.confirmDelete":
"Delete the account “{{name}}”?\n\nThis will permanently delete ALL transactions on this account too. If you want to keep the history, archive the account instead of deleting it.",
"account.type.checking": "Checking",
"account.type.debit_card": "Debit card",
"account.type.savings": "Savings",
"account.type.credit_card": "Credit card",
"account.type.cash": "Cash",
"account.type.investment": "Investment",
"account.type.other": "Other",
"account.form.newTitle": "New account",
"account.form.editTitle": "Edit account",
"account.form.nameLabel": "Name",
"account.form.namePlaceholder": "Main account, savings, cash…",
"account.form.typeLabel": "Type",
"account.form.saveError": "Failed to save the account. Check the data and try again.",
"category.expenseSectionTitle": "Expense categories",
"category.incomeSectionTitle": "Income categories",
"category.empty": "No categories yet.",
"category.defaultBadge": "default",
"category.defaultDeleteBlocked": "A default category can only be deleted once it has no transactions left.",
"category.confirmDelete": "Delete the category “{{name}}”?\n\nIts transactions will be left uncategorized.",
"category.kind.expense": "Expense",
"category.kind.income": "Income",
"category.form.newTitle": "New category",
"category.form.editTitle": "Edit category",
"category.form.nameLabel": "Name",
"category.form.namePlaceholder": "Groceries, transport, subscriptions…",
"category.form.kindLabel": "Type",
"category.form.parentLabel": "Parent category",
"category.form.noParent": "— no parent (top level) —",
"category.form.hasChildrenHint": "This category already has subcategories — it can't become a subcategory itself.",
"category.form.iconLabel": "Icon",
"category.form.colorLabel": "Color",
"category.form.customColorLabel": "Custom color",
"category.form.saveError": "Failed to save the category. Check the data and try again.",
"cashFlow.income": "Income",
"cashFlow.expense": "Expense",
"cashFlow.net": "Net",
"cashFlow.noData": "No data for the selected period.",
"category.housingUtilities": "Housing & Utilities",
"category.groceries": "Groceries",
"category.diningOut": "Dining Out",
"category.transportation": "Transportation",
"category.healthFitness": "Health & Fitness",
"category.shopping": "Shopping",
"category.entertainment": "Entertainment",
"category.subscriptions": "Subscriptions",
"category.salary": "Salary",
"category.freelance": "Freelance",
"category.investments": "Investments",
"category.gifts": "Gifts",
"category.businessIncome": "Business Income",
"category.rentalIncome": "Rental Income",
"category.benefits": "Benefits",
"category.itemSales": "Item Sales",
"category.otherIncome": "Other Income",
"months.jan": "Jan",
"months.feb": "Feb",
"months.mar": "Mar",
"months.apr": "Apr",
"months.may": "May",
"months.jun": "Jun",
"months.jul": "Jul",
"months.aug": "Aug",
"months.sep": "Sep",
"months.oct": "Oct",
"months.nov": "Nov",
"months.dec": "Dec",
};
const translations: Record<Language, Record<keyof typeof ru, string>> = { ru, en };
export type TranslationKey = keyof typeof ru;
function readInitialLanguage(): Language {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored === "ru" || stored === "en") return stored;
return "ru";
}
let currentLanguage: Language = readInitialLanguage();
// The primary display currency (ISO 4217 code, e.g. "USD"/"UAH"/"EUR"). Unlike
// language this isn't client-only — it's persisted server-side (GET/PATCH
// /api/settings) since it also drives what new accounts/assets are created
// with. App.tsx syncs this from the server once on boot via setCurrency();
// until that resolves, formatCurrency() falls back to this default.
let currentCurrency = "USD";
const listeners = new Set<() => void>();
export function getLanguage(): Language {
return currentLanguage;
}
export function setLanguage(language: Language): void {
if (language === currentLanguage) return;
currentLanguage = language;
localStorage.setItem(STORAGE_KEY, language);
listeners.forEach((listener) => listener());
}
export function getCurrency(): string {
return currentCurrency;
}
/** Updates the reactive local mirror only — call this after the server
* fetch/update succeeds, not in place of it. */
export function setCurrency(currency: string): void {
if (currency === currentCurrency) return;
currentCurrency = currency;
listeners.forEach((listener) => listener());
}
function subscribe(listener: () => void): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}
function interpolate(template: string, params?: Record<string, string | number>): string {
if (!params) return template;
return template.replace(/\{\{(\w+)\}\}/g, (match, key) => {
const value = params[key];
return value === undefined ? match : String(value);
});
}
/** Usable outside React (e.g. api/ modules) — reads the current language at call time. */
export function t(key: TranslationKey, params?: Record<string, string | number>): string {
return interpolate(translations[currentLanguage][key], params);
}
/** Subscribes the component to language AND currency changes so it re-renders
* on either toggle — most components that call formatCurrency() already call
* this hook for text, so that alone is enough to pick up a fresh currency on
* their next render even without reading `currency` from the return value. */
export function useTranslation() {
const language = useSyncExternalStore(subscribe, () => currentLanguage);
const currency = useSyncExternalStore(subscribe, () => currentCurrency);
return { t, language, setLanguage, currency, setCurrency };
}