Files
aurum/frontend/src/lib/i18n.ts
ZProger 19f5cfa429 feat: import transactions from a CSV bank statement
Fourth of the four ideas picked from the Maybe/Sure feature audit, and the
last one queued up. A 3-step wizard (account -> column mapping -> preview)
at /transactions/import, parsed entirely client-side (new lib/csv.ts —
delimiter auto-detect, 5 date formats, thousands/decimal-separator-aware
amounts) and submitted through a new all-or-nothing POST /transactions/bulk
endpoint, mirroring backup restore's validate-everything-before-touching-
anything approach.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 02:44:30 +03:00

870 lines
51 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.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.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": "Всё",
"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}}% годовых",
"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.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": "Не удалось сохранить транзакцию. Проверьте данные и попробуйте снова.",
"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":
"Восстановление полностью заменяет текущие данные содержимым файла — это нельзя отменить.",
"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.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.defaultCannotDelete": "Стандартные категории нельзя удалить",
"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.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.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",
"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",
"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.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.",
"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.",
"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.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.defaultCannotDelete": "Default categories cannot be deleted",
"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 };
}