mirror of
https://github.com/Zproger/Aurum.git
synced 2026-10-12 18:46:40 +07:00
Конфигурация и эксплуатационный слой: - 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.
1108 lines
67 KiB
TypeScript
1108 lines
67 KiB
TypeScript
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 };
|
||
}
|