diff --git a/app/src/entities/app-settings/types/currency-code.type.ts b/app/src/entities/app-settings/types/currency-code.type.ts index 27845a2..cdd8437 100644 --- a/app/src/entities/app-settings/types/currency-code.type.ts +++ b/app/src/entities/app-settings/types/currency-code.type.ts @@ -1 +1 @@ -export type CurrencyCode = 'UAH' | 'USD' | 'EUR'; +export type CurrencyCode = string; diff --git a/app/src/entities/currency/index.ts b/app/src/entities/currency/index.ts new file mode 100644 index 0000000..f16a65c --- /dev/null +++ b/app/src/entities/currency/index.ts @@ -0,0 +1,3 @@ +export { getDefaultCurrencies } from './lib/get-default-currencies'; +export type { CurrencyId } from './types/currency-id.type'; +export type { Currency } from './types/currency.type'; diff --git a/app/src/entities/currency/lib/get-default-currencies.ts b/app/src/entities/currency/lib/get-default-currencies.ts new file mode 100644 index 0000000..db91919 --- /dev/null +++ b/app/src/entities/currency/lib/get-default-currencies.ts @@ -0,0 +1,101 @@ +import type { Currency } from '../types/currency.type'; + +/** + * Seed list used only to initialize the `Currencies` sheet and as an offline + * fallback when Google Sheets is unavailable. The sheet remains the source of + * truth: new currencies can be added there without touching this list. + */ +export function getDefaultCurrencies(): Currency[] { + return [ + { + id: 'uah', + code: 'UAH', + name: 'Ukrainian Hryvnia', + symbol: '₴', + decimalDigits: 2, + isDefault: true, + isEnabled: true, + }, + { + id: 'usd', + code: 'USD', + name: 'US Dollar', + symbol: '$', + decimalDigits: 2, + isDefault: true, + isEnabled: true, + }, + { + id: 'eur', + code: 'EUR', + name: 'Euro', + symbol: '€', + decimalDigits: 2, + isDefault: true, + isEnabled: true, + }, + { + id: 'rub', + code: 'RUB', + name: 'Russian Ruble', + symbol: '₽', + decimalDigits: 2, + isDefault: true, + isEnabled: true, + }, + { + id: 'pln', + code: 'PLN', + name: 'Polish Zloty', + symbol: 'zł', + decimalDigits: 2, + isDefault: true, + isEnabled: true, + }, + { + id: 'gbp', + code: 'GBP', + name: 'British Pound', + symbol: '£', + decimalDigits: 2, + isDefault: true, + isEnabled: true, + }, + { + id: 'chf', + code: 'CHF', + name: 'Swiss Franc', + symbol: 'CHF', + decimalDigits: 2, + isDefault: true, + isEnabled: true, + }, + { + id: 'cad', + code: 'CAD', + name: 'Canadian Dollar', + symbol: 'C$', + decimalDigits: 2, + isDefault: true, + isEnabled: true, + }, + { + id: 'try', + code: 'TRY', + name: 'Turkish Lira', + symbol: '₺', + decimalDigits: 2, + isDefault: true, + isEnabled: true, + }, + { + id: 'jpy', + code: 'JPY', + name: 'Japanese Yen', + symbol: '¥', + decimalDigits: 0, + isDefault: true, + isEnabled: true, + }, + ]; +} diff --git a/app/src/entities/currency/types/currency-id.type.ts b/app/src/entities/currency/types/currency-id.type.ts new file mode 100644 index 0000000..d5f1d42 --- /dev/null +++ b/app/src/entities/currency/types/currency-id.type.ts @@ -0,0 +1 @@ +export type CurrencyId = string; diff --git a/app/src/entities/currency/types/currency.type.ts b/app/src/entities/currency/types/currency.type.ts new file mode 100644 index 0000000..1cf0886 --- /dev/null +++ b/app/src/entities/currency/types/currency.type.ts @@ -0,0 +1,11 @@ +import type { CurrencyId } from './currency-id.type'; + +export type Currency = { + id: CurrencyId; + code: string; + name: string; + symbol: string; + decimalDigits: number; + isDefault: boolean; + isEnabled: boolean; +}; diff --git a/app/src/features/analyze-transactions/lib/analytics-row.mapper.test.ts b/app/src/features/analyze-transactions/lib/analytics-row.mapper.test.ts index d6aa1d4..f0aabba 100644 --- a/app/src/features/analyze-transactions/lib/analytics-row.mapper.test.ts +++ b/app/src/features/analyze-transactions/lib/analytics-row.mapper.test.ts @@ -4,8 +4,9 @@ import { mapRowToCategoryStats, mapRowToMonthlyStats } from './analytics-row.map describe('analytics row mapper', () => { it('maps monthly stats rows', () => { - expect(mapRowToMonthlyStats(['2026-05', '1000', '250', '750'])).toEqual({ + expect(mapRowToMonthlyStats(['2026-05', 'UAH', '1000', '250', '750'])).toEqual({ balance: 750, + currency: 'UAH', expense: 250, income: 1000, label: 'May 2026', @@ -14,9 +15,10 @@ describe('analytics row mapper', () => { }); it('maps category stats rows', () => { - expect(mapRowToCategoryStats(['Food', 'expense', '350', '2'])).toEqual({ + expect(mapRowToCategoryStats(['Food', 'expense', 'UAH', '350', '2'])).toEqual({ categoryName: 'Food', count: 2, + currency: 'UAH', expense: 350, income: 0, kind: 'expense', @@ -24,8 +26,27 @@ describe('analytics row mapper', () => { }); }); + it('maps income category stats rows', () => { + expect(mapRowToCategoryStats(['Salary', 'income', 'USD', '3000', '1'])).toEqual({ + categoryName: 'Salary', + count: 1, + currency: 'USD', + expense: 0, + income: 3000, + kind: 'income', + total: 3000, + }); + }); + + it('tolerates missing currency in monthly stats rows', () => { + const result = mapRowToMonthlyStats(['2026-05', '', '1000', '250', '750']); + + expect(result).not.toBeNull(); + expect(result?.currency).toBe(''); + }); + it('skips invalid rows', () => { - expect(mapRowToMonthlyStats(['2026-05', 'bad', '250', '750'])).toBeNull(); - expect(mapRowToCategoryStats(['Food', 'other', '350', '2'])).toBeNull(); + expect(mapRowToMonthlyStats(['2026-05', 'UAH', 'bad', '250', '750'])).toBeNull(); + expect(mapRowToCategoryStats(['Food', 'other', 'UAH', '350', '2'])).toBeNull(); }); }); diff --git a/app/src/features/analyze-transactions/lib/analytics-row.mapper.ts b/app/src/features/analyze-transactions/lib/analytics-row.mapper.ts index 0532927..8a3c4c4 100644 --- a/app/src/features/analyze-transactions/lib/analytics-row.mapper.ts +++ b/app/src/features/analyze-transactions/lib/analytics-row.mapper.ts @@ -30,7 +30,7 @@ function isTransactionKind(value: string | undefined): value is TransactionKind } export function mapRowToMonthlyStats(row: string[]): MonthlyStats | null { - const [month, incomeValue, expenseValue, balanceValue] = row; + const [month, currency, incomeValue, expenseValue, balanceValue] = row; const income = parseAmount(incomeValue); const expense = parseAmount(expenseValue); const balance = parseAmount(balanceValue); @@ -41,6 +41,7 @@ export function mapRowToMonthlyStats(row: string[]): MonthlyStats | null { return { balance, + currency: currency?.trim() || '', expense, income, label: formatMonthLabel(month), @@ -49,7 +50,7 @@ export function mapRowToMonthlyStats(row: string[]): MonthlyStats | null { } export function mapRowToCategoryStats(row: string[]): CategoryStats | null { - const [categoryName, kind, totalValue, countValue] = row; + const [categoryName, kind, currency, totalValue, countValue] = row; const total = parseAmount(totalValue); const count = parseAmount(countValue); @@ -60,6 +61,7 @@ export function mapRowToCategoryStats(row: string[]): CategoryStats | null { return { categoryName, count, + currency: currency?.trim() || '', expense: kind === 'expense' ? total : 0, income: kind === 'income' ? total : 0, kind, diff --git a/app/src/features/analyze-transactions/lib/analytics-stats.service.test.ts b/app/src/features/analyze-transactions/lib/analytics-stats.service.test.ts index 22d1097..34aad74 100644 --- a/app/src/features/analyze-transactions/lib/analytics-stats.service.test.ts +++ b/app/src/features/analyze-transactions/lib/analytics-stats.service.test.ts @@ -25,12 +25,12 @@ describe('readAnalyticsStats', () => { it('reads analytics from Google Sheets when stats are available', async () => { const ranges = new Map([ [ - 'MonthlyStats!A2:D', - { range: 'MonthlyStats!A2:D', values: [['2026-05', '1000', '250', '750']] }, + 'MonthlyStats!A2:E', + { range: 'MonthlyStats!A2:E', values: [['2026-05', 'UAH', '1000', '250', '750']] }, ], [ - 'CategoryStats!A2:D', - { range: 'CategoryStats!A2:D', values: [['Food', 'expense', '250', '1']] }, + 'CategoryStats!A2:E', + { range: 'CategoryStats!A2:E', values: [['Food', 'expense', 'UAH', '250', '1']] }, ], ]); @@ -45,8 +45,79 @@ describe('readAnalyticsStats', () => { }), ).resolves.toMatchObject({ source: 'google-sheets', - monthlyStats: [{ month: '2026-05' }], - categoryStats: [{ categoryName: 'Food', kind: 'expense' }], + monthlyStats: [{ month: '2026-05', currency: 'UAH' }], + categoryStats: [{ categoryName: 'Food', kind: 'expense', currency: 'UAH' }], + }); + }); + + it('forces local stats when forceLocal is true even with valid credentials', async () => { + const ranges = new Map([ + [ + 'MonthlyStats!A2:E', + { range: 'MonthlyStats!A2:E', values: [['2026-05', 'UAH', '999', '999', '0']] }, + ], + [ + 'CategoryStats!A2:E', + { range: 'CategoryStats!A2:E', values: [['Remote', 'expense', 'UAH', '999', '1']] }, + ], + ]); + + await expect( + readAnalyticsStats({ + accessToken: 'token', + forceLocal: true, + googleSheetsClient: { + readRange: ({ range }) => Promise.resolve(ranges.get(range) ?? { range, values: [] }), + }, + spreadsheetId: 'sheet-id', + transactions: [ + createTransaction({ + amount: 120, + categoryName: 'Food', + id: 'food', + kind: 'expense', + signedAmount: -120, + }), + ], + }), + ).resolves.toMatchObject({ + source: 'local', + categoryStats: [{ categoryName: 'Food' }], + }); + }); + + it('local analytics keeps UAH and USD as separate monthly and category entries', async () => { + await expect( + readAnalyticsStats({ + accessToken: null, + spreadsheetId: null, + transactions: [ + createTransaction({ + id: 'uah', + amount: 100, + currency: 'UAH', + kind: 'expense', + signedAmount: -100, + }), + createTransaction({ + id: 'usd', + amount: 50, + currency: 'USD', + kind: 'expense', + signedAmount: -50, + }), + ], + }), + ).resolves.toMatchObject({ + source: 'local', + monthlyStats: expect.arrayContaining([ + expect.objectContaining({ currency: 'UAH', expense: 100 }), + expect.objectContaining({ currency: 'USD', expense: 50 }), + ]), + categoryStats: expect.arrayContaining([ + expect.objectContaining({ currency: 'UAH', categoryName: 'Category', expense: 100 }), + expect.objectContaining({ currency: 'USD', categoryName: 'Category', expense: 50 }), + ]), }); }); diff --git a/app/src/features/analyze-transactions/lib/analytics-stats.service.ts b/app/src/features/analyze-transactions/lib/analytics-stats.service.ts index 9708ec7..b6a1851 100644 --- a/app/src/features/analyze-transactions/lib/analytics-stats.service.ts +++ b/app/src/features/analyze-transactions/lib/analytics-stats.service.ts @@ -10,6 +10,7 @@ import type { CategoryStats, MonthlyStats } from '../types/analytics-stat.types' type ReadAnalyticsStatsParams = { accessToken: string | null; + forceLocal?: boolean; googleSheetsClient?: Pick; spreadsheetId: string | null; transactions: Transaction[]; @@ -33,13 +34,14 @@ function isCategoryStats(value: CategoryStats | null): value is CategoryStats { export async function readAnalyticsStats({ accessToken, + forceLocal = false, googleSheetsClient = createGoogleSheetsClient(), spreadsheetId, transactions, }: ReadAnalyticsStatsParams): Promise { const localStats = buildLocalAnalyticsStats(transactions); - if (!accessToken || !spreadsheetId) { + if (!accessToken || !spreadsheetId || forceLocal) { return localStats; } @@ -56,8 +58,12 @@ export async function readAnalyticsStats({ spreadsheetId, }), ]); - const monthlyStats = monthlyRange.values.map(mapRowToMonthlyStats).filter(isMonthlyStats); - const categoryStats = categoryRange.values.map(mapRowToCategoryStats).filter(isCategoryStats); + const monthlyStats = (monthlyRange.values ?? []) + .map(mapRowToMonthlyStats) + .filter(isMonthlyStats); + const categoryStats = (categoryRange.values ?? []) + .map(mapRowToCategoryStats) + .filter(isCategoryStats); if (!monthlyStats.length && !categoryStats.length) { return localStats; diff --git a/app/src/features/analyze-transactions/lib/calculate-category-stats.test.ts b/app/src/features/analyze-transactions/lib/calculate-category-stats.test.ts index c19794f..bf843af 100644 --- a/app/src/features/analyze-transactions/lib/calculate-category-stats.test.ts +++ b/app/src/features/analyze-transactions/lib/calculate-category-stats.test.ts @@ -51,6 +51,7 @@ describe('calculateCategoryStats', () => { { categoryName: 'Food', count: 2, + currency: 'UAH', expense: 350, income: 0, kind: 'expense', @@ -59,6 +60,7 @@ describe('calculateCategoryStats', () => { { categoryName: 'Salary', count: 1, + currency: 'UAH', expense: 0, income: 1000, kind: 'income', @@ -66,6 +68,58 @@ describe('calculateCategoryStats', () => { }, ]); }); + + it('groups by category and currency separately — never mixes currencies', () => { + const result = calculateCategoryStats([ + createTransaction({ + amount: 100, + categoryName: 'Food', + currency: 'UAH', + id: 'food-uah', + kind: 'expense', + signedAmount: -100, + }), + createTransaction({ + amount: 50, + categoryName: 'Food', + currency: 'USD', + id: 'food-usd', + kind: 'expense', + signedAmount: -50, + }), + ]); + + expect(result).toHaveLength(2); + expect(result).toEqual( + expect.arrayContaining([ + expect.objectContaining({ categoryName: 'Food', currency: 'UAH', expense: 100 }), + expect.objectContaining({ categoryName: 'Food', currency: 'USD', expense: 50 }), + ]), + ); + }); + + it('sums same-currency transactions in the same category', () => { + expect( + calculateCategoryStats([ + createTransaction({ + amount: 100, + categoryName: 'Food', + id: 'food-1', + kind: 'expense', + signedAmount: -100, + }), + createTransaction({ + amount: 50, + categoryName: 'Food', + id: 'food-2', + kind: 'expense', + signedAmount: -50, + }), + ]), + ).toEqual([ + expect.objectContaining({ categoryName: 'Food', currency: 'UAH', expense: 150, count: 2 }), + ]); + }); }); describe('calculateTopCategories', () => { diff --git a/app/src/features/analyze-transactions/lib/calculate-category-stats.ts b/app/src/features/analyze-transactions/lib/calculate-category-stats.ts index 915f639..535cd62 100644 --- a/app/src/features/analyze-transactions/lib/calculate-category-stats.ts +++ b/app/src/features/analyze-transactions/lib/calculate-category-stats.ts @@ -3,15 +3,17 @@ import type { Transaction } from '@entities/transaction'; import type { CategoryStats, TopCategoryStats } from '../types/analytics-stat.types'; export function calculateCategoryStats(transactions: Transaction[]): CategoryStats[] { - const statsByCategory = new Map(); + const statsByKey = new Map(); transactions.forEach((transaction) => { - const statsKey = `${transaction.kind}:${transaction.categoryName}`; + const { currency } = transaction; + const statsKey = `${transaction.kind}:${transaction.categoryName}:${currency}`; const currentStats = - statsByCategory.get(statsKey) ?? + statsByKey.get(statsKey) ?? ({ categoryName: transaction.categoryName, count: 0, + currency, expense: 0, income: 0, kind: transaction.kind, @@ -26,10 +28,10 @@ export function calculateCategoryStats(transactions: Transaction[]): CategorySta currentStats.count += 1; currentStats.total += Math.abs(transaction.amount); - statsByCategory.set(statsKey, currentStats); + statsByKey.set(statsKey, currentStats); }); - return [...statsByCategory.values()].sort((left, right) => + return [...statsByKey.values()].sort((left, right) => left.categoryName === right.categoryName ? left.kind.localeCompare(right.kind) : left.categoryName.localeCompare(right.categoryName), diff --git a/app/src/features/analyze-transactions/lib/calculate-monthly-stats.test.ts b/app/src/features/analyze-transactions/lib/calculate-monthly-stats.test.ts index 46a72e2..2b8aa45 100644 --- a/app/src/features/analyze-transactions/lib/calculate-monthly-stats.test.ts +++ b/app/src/features/analyze-transactions/lib/calculate-monthly-stats.test.ts @@ -50,6 +50,7 @@ describe('calculateMonthlyStats', () => { ).toEqual([ { balance: 750, + currency: 'UAH', expense: 250, income: 1000, label: 'May 2026', @@ -57,6 +58,7 @@ describe('calculateMonthlyStats', () => { }, { balance: -100, + currency: 'UAH', expense: 100, income: 0, label: 'Jun 2026', @@ -65,6 +67,46 @@ describe('calculateMonthlyStats', () => { ]); }); + it('groups by month and currency separately — never mixes currencies', () => { + const result = calculateMonthlyStats([ + createTransaction({ + amount: 100, + currency: 'UAH', + date: '2026-05-01', + id: 'uah-expense', + kind: 'expense', + signedAmount: -100, + }), + createTransaction({ + amount: 50, + currency: 'USD', + date: '2026-05-01', + id: 'usd-expense', + kind: 'expense', + signedAmount: -50, + }), + ]); + + expect(result).toHaveLength(2); + expect(result).toEqual( + expect.arrayContaining([ + expect.objectContaining({ month: '2026-05', currency: 'UAH', expense: 100, income: 0 }), + expect.objectContaining({ month: '2026-05', currency: 'USD', expense: 50, income: 0 }), + ]), + ); + }); + + it('sums same-currency transactions in the same month', () => { + expect( + calculateMonthlyStats([ + createTransaction({ amount: 100, id: 'uah-1', kind: 'expense', signedAmount: -100 }), + createTransaction({ amount: 50, id: 'uah-2', kind: 'expense', signedAmount: -50 }), + ]), + ).toEqual([ + expect.objectContaining({ currency: 'UAH', expense: 150, income: 0, balance: -150 }), + ]); + }); + it('returns empty stats for empty transactions', () => { expect(calculateMonthlyStats([])).toEqual([]); }); diff --git a/app/src/features/analyze-transactions/lib/calculate-monthly-stats.ts b/app/src/features/analyze-transactions/lib/calculate-monthly-stats.ts index 7874ba6..eea5d2e 100644 --- a/app/src/features/analyze-transactions/lib/calculate-monthly-stats.ts +++ b/app/src/features/analyze-transactions/lib/calculate-monthly-stats.ts @@ -13,14 +13,17 @@ function formatMonthLabel(month: string) { } export function calculateMonthlyStats(transactions: Transaction[]): MonthlyStats[] { - const statsByMonth = new Map(); + const statsByKey = new Map(); transactions.forEach((transaction) => { const month = transaction.date.slice(0, 7); + const { currency } = transaction; + const key = `${month}:${currency}`; const currentStats = - statsByMonth.get(month) ?? + statsByKey.get(key) ?? ({ balance: 0, + currency, expense: 0, income: 0, label: formatMonthLabel(month), @@ -34,8 +37,12 @@ export function calculateMonthlyStats(transactions: Transaction[]): MonthlyStats } currentStats.balance += transaction.signedAmount; - statsByMonth.set(month, currentStats); + statsByKey.set(key, currentStats); }); - return [...statsByMonth.values()].sort((left, right) => left.month.localeCompare(right.month)); + return [...statsByKey.values()].sort((left, right) => + left.month === right.month + ? left.currency.localeCompare(right.currency) + : left.month.localeCompare(right.month), + ); } diff --git a/app/src/features/analyze-transactions/model/useAnalyticsStats.ts b/app/src/features/analyze-transactions/model/useAnalyticsStats.ts index aa8943f..56a9215 100644 --- a/app/src/features/analyze-transactions/model/useAnalyticsStats.ts +++ b/app/src/features/analyze-transactions/model/useAnalyticsStats.ts @@ -7,7 +7,7 @@ import { localStorageService } from '@shared/lib/storage/local-storage.service'; import { readAnalyticsStats } from '../lib/analytics-stats.service'; import type { AnalyticsStats } from '../types/analytics-stat.types'; -export function useAnalyticsStats(transactions: Transaction[]) { +export function useAnalyticsStats(transactions: Transaction[], forceLocal = false) { const googleAuth = useGoogleAuth(); const [stats, setStats] = useState({ categoryStats: [], @@ -22,6 +22,7 @@ export function useAnalyticsStats(transactions: Transaction[]) { async function loadStats() { const nextStats = await readAnalyticsStats({ accessToken: googleAuth.accessToken, + forceLocal, spreadsheetId, transactions, }); @@ -36,7 +37,7 @@ export function useAnalyticsStats(transactions: Transaction[]) { return () => { isActive = false; }; - }, [googleAuth.accessToken, transactions]); + }, [googleAuth.accessToken, forceLocal, transactions]); return stats; } diff --git a/app/src/features/analyze-transactions/types/analytics-stat.types.ts b/app/src/features/analyze-transactions/types/analytics-stat.types.ts index d0c7c75..de71acd 100644 --- a/app/src/features/analyze-transactions/types/analytics-stat.types.ts +++ b/app/src/features/analyze-transactions/types/analytics-stat.types.ts @@ -2,6 +2,7 @@ import type { TransactionKind } from '@entities/transaction'; export type MonthlyStats = { balance: number; + currency: string; expense: number; income: number; label: string; @@ -11,6 +12,7 @@ export type MonthlyStats = { export type CategoryStats = { categoryName: string; count: number; + currency: string; expense: number; income: number; kind: TransactionKind; diff --git a/app/src/features/create-transaction/ui/CreateTransactionModal.tsx b/app/src/features/create-transaction/ui/CreateTransactionModal.tsx index 55c96d9..ba8c9ad 100644 --- a/app/src/features/create-transaction/ui/CreateTransactionModal.tsx +++ b/app/src/features/create-transaction/ui/CreateTransactionModal.tsx @@ -1,12 +1,13 @@ import { useState } from 'react'; -import type { CurrencyCode } from '@entities/app-settings'; import type { Category } from '@entities/category'; import type { Container } from '@entities/container'; import type { Transaction } from '@entities/transaction'; import { CategoryCombobox } from '@features/manage-categories'; import { ContainerCombobox } from '@features/manage-containers'; +import { CurrencySelect } from '@features/manage-currencies'; import { Button } from '@shared/ui/button'; +import { DatePicker } from '@shared/ui/date-picker'; import { Input } from '@shared/ui/input'; import { localStorageService } from '@shared/lib/storage/local-storage.service'; import { Modal } from '@shared/ui/modal'; @@ -133,7 +134,7 @@ export function CreateTransactionModal({ ? { color: '#6366f1', createdAt: initialTransaction.createdAt, - currency: initialTransaction.currency as CurrencyCode, + currency: initialTransaction.currency, icon: 'wallet', id: initialTransaction.containerId, isDefault: false, @@ -224,40 +225,34 @@ export function CreateTransactionModal({ + />
- setValues({ ...values, date: event.target.value })} - type="date" + onChange={(date) => setValues({ ...values, date })} value={values.date} /> - - setValues({ ...values, currency: event.target.value.toUpperCase() }) - } - placeholder="UAH" + setValues({ ...values, currency })} value={values.currency} />
@@ -284,7 +279,7 @@ export function CreateTransactionModal({ {containersEnabled ? ( @@ -303,11 +298,16 @@ export function CreateTransactionModal({ placeholder="Optional note" value={values.comment} /> -
- -
diff --git a/app/src/features/filter-transactions/index.ts b/app/src/features/filter-transactions/index.ts index c09be1c..fd83d57 100644 --- a/app/src/features/filter-transactions/index.ts +++ b/app/src/features/filter-transactions/index.ts @@ -3,5 +3,6 @@ export { filterTransactions, getTransactionContainerOptions, getTransactionCategoryOptions, + getTransactionCurrencyOptions, } from './lib/transaction-filters'; export type { TransactionFilters, TransactionKindFilter } from './types/transaction-filters.type'; diff --git a/app/src/features/filter-transactions/lib/transaction-filters.test.ts b/app/src/features/filter-transactions/lib/transaction-filters.test.ts index 84e8999..e0e5ef8 100644 --- a/app/src/features/filter-transactions/lib/transaction-filters.test.ts +++ b/app/src/features/filter-transactions/lib/transaction-filters.test.ts @@ -6,6 +6,7 @@ import { createDefaultTransactionFilters, filterTransactions, getTransactionCategoryOptions, + getTransactionCurrencyOptions, } from './transaction-filters'; function createTransaction(overrides: Partial): Transaction { @@ -177,3 +178,68 @@ describe('getTransactionCategoryOptions', () => { ).toEqual(['Food', 'Salary', 'Transport']); }); }); + +describe('filterTransactions currency filter', () => { + const multiCurrencyTransactions = [ + createTransaction({ id: 'uah-1', currency: 'UAH' }), + createTransaction({ id: 'usd-1', currency: 'USD' }), + createTransaction({ id: 'eur-1', currency: 'EUR' }), + createTransaction({ id: 'uah-2', currency: 'UAH' }), + ]; + + it('shows all currencies when filter is empty', () => { + expect( + filterTransactions(multiCurrencyTransactions, createDefaultTransactionFilters()).map( + (transaction) => transaction.id, + ), + ).toEqual(['uah-1', 'usd-1', 'eur-1', 'uah-2']); + }); + + it('shows only UAH transactions', () => { + expect( + filterTransactions(multiCurrencyTransactions, { + ...createDefaultTransactionFilters(), + currency: 'UAH', + }).map((transaction) => transaction.id), + ).toEqual(['uah-1', 'uah-2']); + }); + + it('shows only USD transactions', () => { + expect( + filterTransactions(multiCurrencyTransactions, { + ...createDefaultTransactionFilters(), + currency: 'USD', + }).map((transaction) => transaction.id), + ).toEqual(['usd-1']); + }); + + it('does not break other active filters', () => { + expect( + filterTransactions( + [ + ...multiCurrencyTransactions, + createTransaction({ + id: 'uah-income', + currency: 'UAH', + kind: 'income', + signedAmount: 500, + }), + ], + { ...createDefaultTransactionFilters(), currency: 'UAH', kind: 'income' }, + ).map((transaction) => transaction.id), + ).toEqual(['uah-income']); + }); +}); + +describe('getTransactionCurrencyOptions', () => { + it('dedupes and sorts currencies', () => { + expect( + getTransactionCurrencyOptions([ + createTransaction({ currency: 'USD', id: 'usd-1' }), + createTransaction({ currency: 'UAH', id: 'uah-1' }), + createTransaction({ currency: 'EUR', id: 'eur-1' }), + createTransaction({ currency: 'UAH', id: 'uah-2' }), + ]), + ).toEqual(['EUR', 'UAH', 'USD']); + }); +}); diff --git a/app/src/features/filter-transactions/lib/transaction-filters.ts b/app/src/features/filter-transactions/lib/transaction-filters.ts index 0aa88ef..2c19fb4 100644 --- a/app/src/features/filter-transactions/lib/transaction-filters.ts +++ b/app/src/features/filter-transactions/lib/transaction-filters.ts @@ -8,6 +8,7 @@ export function createDefaultTransactionFilters(): TransactionFilters { amountTo: '', category: '', container: '', + currency: '', dateFrom: '', dateTo: '', kind: 'all', @@ -93,6 +94,10 @@ export function filterTransactions( return false; } + if (filters.currency && transaction.currency !== filters.currency) { + return false; + } + return matchesQuery(transaction, filters.query); }); } @@ -108,3 +113,9 @@ export function getTransactionContainerOptions(transactions: Transaction[]) { .filter((containerName): containerName is string => Boolean(containerName)) .sort((left, right) => left.localeCompare(right)); } + +export function getTransactionCurrencyOptions(transactions: Transaction[]) { + return [...new Set(transactions.map((transaction) => transaction.currency))] + .filter(Boolean) + .sort((left, right) => left.localeCompare(right)); +} diff --git a/app/src/features/filter-transactions/types/transaction-filters.type.ts b/app/src/features/filter-transactions/types/transaction-filters.type.ts index ae5b9c5..e6850ab 100644 --- a/app/src/features/filter-transactions/types/transaction-filters.type.ts +++ b/app/src/features/filter-transactions/types/transaction-filters.type.ts @@ -7,6 +7,7 @@ export type TransactionFilters = { amountTo: string; category: string; container: string; + currency: string; dateFrom: string; dateTo: string; kind: TransactionKindFilter; diff --git a/app/src/features/manage-categories/lib/manage-categories.service.ts b/app/src/features/manage-categories/lib/manage-categories.service.ts index f543ca4..a825d41 100644 --- a/app/src/features/manage-categories/lib/manage-categories.service.ts +++ b/app/src/features/manage-categories/lib/manage-categories.service.ts @@ -76,7 +76,7 @@ export async function readCategories({ range: sheetRanges.categoriesData, spreadsheetId, }); - const remoteCategories = valueRange.values + const remoteCategories = (valueRange.values ?? []) .map(mapRowToCategory) .filter((category) => category !== null); diff --git a/app/src/features/manage-containers/lib/container-row.mapper.ts b/app/src/features/manage-containers/lib/container-row.mapper.ts index 76c0318..80b82f5 100644 --- a/app/src/features/manage-containers/lib/container-row.mapper.ts +++ b/app/src/features/manage-containers/lib/container-row.mapper.ts @@ -1,4 +1,3 @@ -import type { CurrencyCode } from '@entities/app-settings'; import type { Container } from '@entities/container'; export function mapContainerToRow(container: Container): string[] { @@ -23,7 +22,7 @@ export function mapRowToContainer(row: string[]): Container | null { return { color: color || '#6366f1', createdAt, - currency: currency.toUpperCase() as CurrencyCode, + currency: currency.toUpperCase(), icon: icon || 'wallet', id, isDefault: isDefault === 'TRUE', diff --git a/app/src/features/manage-containers/lib/manage-containers.service.ts b/app/src/features/manage-containers/lib/manage-containers.service.ts index 3047a51..b783a71 100644 --- a/app/src/features/manage-containers/lib/manage-containers.service.ts +++ b/app/src/features/manage-containers/lib/manage-containers.service.ts @@ -77,7 +77,7 @@ export async function readContainers({ range: sheetRanges.containersData, spreadsheetId, }); - const remoteContainers = valueRange.values + const remoteContainers = (valueRange.values ?? []) .map(mapRowToContainer) .filter((container) => container !== null); diff --git a/app/src/features/manage-containers/ui/ManageContainersModal.tsx b/app/src/features/manage-containers/ui/ManageContainersModal.tsx index bbdf0c4..996aa64 100644 --- a/app/src/features/manage-containers/ui/ManageContainersModal.tsx +++ b/app/src/features/manage-containers/ui/ManageContainersModal.tsx @@ -1,10 +1,10 @@ import { useState } from 'react'; -import type { CurrencyCode } from '@entities/app-settings'; import { Badge } from '@shared/ui/badge'; import { Button } from '@shared/ui/button'; import { Input } from '@shared/ui/input'; import { Modal } from '@shared/ui/modal'; +import { CurrencySelect } from '@features/manage-currencies'; import { useContainers } from '../model/useContainers'; @@ -17,7 +17,7 @@ export function ManageContainersModal({ isOpen, onClose }: ManageContainersModal const { containers, createAndSelectContainer, error, isCreating, isLoading, warning } = useContainers(); const [name, setName] = useState(''); - const [currency, setCurrency] = useState('UAH'); + const [currency, setCurrency] = useState('UAH'); async function submitContainer() { if (!name.trim()) { @@ -52,13 +52,7 @@ export function ManageContainersModal({ isOpen, onClose }: ManageContainersModal placeholder="Cash, Savings, EUR card" value={name} /> - setCurrency(event.target.value.toUpperCase() as CurrencyCode)} - value={currency} - /> + diff --git a/app/src/features/manage-currencies/index.ts b/app/src/features/manage-currencies/index.ts new file mode 100644 index 0000000..98c6a09 --- /dev/null +++ b/app/src/features/manage-currencies/index.ts @@ -0,0 +1,10 @@ +export { formatCurrencyLabel, getEnabledCurrencies } from './lib/currency-options'; +export { mapCurrencyToRow, mapRowToCurrency } from './lib/currency-row.mapper'; +export { readCurrencies } from './lib/manage-currencies.service'; +export { useCurrencies } from './model/useCurrencies'; +export { CurrencySelect } from './ui/CurrencySelect'; +export type { + CurrenciesTemplateStatus, + ReadCurrenciesContext, + ReadCurrenciesResult, +} from './lib/manage-currencies.service'; diff --git a/app/src/features/manage-currencies/lib/currency-options.test.ts b/app/src/features/manage-currencies/lib/currency-options.test.ts new file mode 100644 index 0000000..dc1a935 --- /dev/null +++ b/app/src/features/manage-currencies/lib/currency-options.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; + +import type { Currency } from '@entities/currency'; + +import { formatCurrencyLabel, getEnabledCurrencies } from './currency-options'; + +function makeCurrency(overrides: Partial): Currency { + return { + id: 'usd', + code: 'USD', + name: 'US Dollar', + symbol: '$', + decimalDigits: 2, + isDefault: false, + isEnabled: true, + ...overrides, + }; +} + +describe('getEnabledCurrencies', () => { + it('keeps only enabled currencies', () => { + const currencies = [ + makeCurrency({ id: 'usd', code: 'USD', isEnabled: true }), + makeCurrency({ id: 'rub', code: 'RUB', isEnabled: false }), + ]; + + expect(getEnabledCurrencies(currencies).map((currency) => currency.code)).toEqual(['USD']); + }); +}); + +describe('formatCurrencyLabel', () => { + it('shows code and symbol', () => { + expect(formatCurrencyLabel({ code: 'UAH', symbol: '₴' })).toBe('UAH ₴'); + }); + + it('shows code only when symbol duplicates or is empty', () => { + expect(formatCurrencyLabel({ code: 'CHF', symbol: 'CHF' })).toBe('CHF'); + expect(formatCurrencyLabel({ code: 'XXX', symbol: '' })).toBe('XXX'); + }); +}); diff --git a/app/src/features/manage-currencies/lib/currency-options.ts b/app/src/features/manage-currencies/lib/currency-options.ts new file mode 100644 index 0000000..728bfce --- /dev/null +++ b/app/src/features/manage-currencies/lib/currency-options.ts @@ -0,0 +1,11 @@ +import type { Currency } from '@entities/currency'; + +export function getEnabledCurrencies(currencies: Currency[]): Currency[] { + return currencies.filter((currency) => currency.isEnabled); +} + +export function formatCurrencyLabel(currency: Pick): string { + const symbol = currency.symbol.trim(); + + return symbol && symbol !== currency.code ? `${currency.code} ${symbol}` : currency.code; +} diff --git a/app/src/features/manage-currencies/lib/currency-row.mapper.test.ts b/app/src/features/manage-currencies/lib/currency-row.mapper.test.ts new file mode 100644 index 0000000..cf9f045 --- /dev/null +++ b/app/src/features/manage-currencies/lib/currency-row.mapper.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest'; + +import type { Currency } from '@entities/currency'; + +import { mapCurrencyToRow, mapRowToCurrency } from './currency-row.mapper'; + +const currency: Currency = { + id: 'uah', + code: 'UAH', + name: 'Ukrainian Hryvnia', + symbol: '₴', + decimalDigits: 2, + isDefault: true, + isEnabled: true, +}; + +describe('currency row mapper', () => { + it('maps currency to Google Sheets row', () => { + expect(mapCurrencyToRow(currency)).toEqual([ + 'uah', + 'UAH', + 'Ukrainian Hryvnia', + '₴', + '2', + 'TRUE', + 'TRUE', + ]); + }); + + it('maps row to currency', () => { + expect(mapRowToCurrency(['uah', 'UAH', 'Ukrainian Hryvnia', '₴', '2', 'TRUE', 'TRUE'])).toEqual( + currency, + ); + }); + + it('parses lowercase booleans and uppercases the code', () => { + expect(mapRowToCurrency(['eur', 'eur', 'Euro', '€', '2', 'true', 'false'])).toEqual({ + id: 'eur', + code: 'EUR', + name: 'Euro', + symbol: '€', + decimalDigits: 2, + isDefault: true, + isEnabled: false, + }); + }); + + it('treats blank flags as not default but enabled so hand-added rows show up', () => { + expect(mapRowToCurrency(['gbp', 'GBP', 'British Pound', '£', '', '', ''])).toEqual({ + id: 'gbp', + code: 'GBP', + name: 'British Pound', + symbol: '£', + decimalDigits: 2, + isDefault: false, + isEnabled: true, + }); + }); + + it('falls back to code for missing name and symbol', () => { + expect(mapRowToCurrency(['czk', 'czk', '', '', 'x', 'FALSE', 'TRUE'])).toEqual({ + id: 'czk', + code: 'CZK', + name: 'CZK', + symbol: 'CZK', + decimalDigits: 2, + isDefault: false, + isEnabled: true, + }); + }); + + it('returns null for rows without an id or code', () => { + expect(mapRowToCurrency(['', 'UAH'])).toBeNull(); + expect(mapRowToCurrency(['uah', ''])).toBeNull(); + }); +}); diff --git a/app/src/features/manage-currencies/lib/currency-row.mapper.ts b/app/src/features/manage-currencies/lib/currency-row.mapper.ts new file mode 100644 index 0000000..fa1e99f --- /dev/null +++ b/app/src/features/manage-currencies/lib/currency-row.mapper.ts @@ -0,0 +1,51 @@ +import type { Currency } from '@entities/currency'; + +function parseSheetBoolean(value: string | undefined, fallback: boolean): boolean { + if (value === undefined || value.trim() === '') { + return fallback; + } + + return ['true', '1', 'yes'].includes(value.trim().toLowerCase()); +} + +function parseDecimalDigits(value: string | undefined): number { + if (value === undefined || value.trim() === '') { + return 2; + } + + const parsed = Number(value); + + return Number.isInteger(parsed) && parsed >= 0 ? parsed : 2; +} + +export function mapCurrencyToRow(currency: Currency): string[] { + return [ + currency.id, + currency.code, + currency.name, + currency.symbol, + String(currency.decimalDigits), + currency.isDefault ? 'TRUE' : 'FALSE', + currency.isEnabled ? 'TRUE' : 'FALSE', + ]; +} + +export function mapRowToCurrency(row: string[]): Currency | null { + const [id, code, name, symbol, decimalDigits, isDefault, isEnabled] = row; + + if (!id || !code) { + return null; + } + + const normalizedCode = code.trim().toUpperCase(); + + return { + id: id.trim(), + code: normalizedCode, + name: name?.trim() || normalizedCode, + symbol: symbol?.trim() || normalizedCode, + decimalDigits: parseDecimalDigits(decimalDigits), + isDefault: parseSheetBoolean(isDefault, false), + isEnabled: parseSheetBoolean(isEnabled, true), + }; +} diff --git a/app/src/features/manage-currencies/lib/manage-currencies.service.test.ts b/app/src/features/manage-currencies/lib/manage-currencies.service.test.ts new file mode 100644 index 0000000..a614f1c --- /dev/null +++ b/app/src/features/manage-currencies/lib/manage-currencies.service.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { readCurrencies } from './manage-currencies.service'; + +const metadataWithCurrencies = { + spreadsheetId: 'sheet-id', + sheets: [ + { + properties: { + title: 'Currencies', + }, + }, + ], +}; + +describe('manage currencies service', () => { + it('returns default currencies without Google context', async () => { + await expect(readCurrencies({ accessToken: null, spreadsheetId: null })).resolves.toEqual({ + currencies: expect.arrayContaining([ + expect.objectContaining({ code: 'UAH' }), + expect.objectContaining({ code: 'USD' }), + ]), + templateStatus: 'unknown', + warning: null, + }); + }); + + it('reads currencies from Google Sheets rows including disabled ones', async () => { + const getSpreadsheetMetadata = vi.fn().mockResolvedValue(metadataWithCurrencies); + const readRange = vi.fn().mockResolvedValue({ + values: [ + ['uah', 'UAH', 'Ukrainian Hryvnia', '₴', '2', 'TRUE', 'TRUE'], + ['rub', 'RUB', 'Russian Ruble', '₽', '2', 'TRUE', 'FALSE'], + ], + }); + + await expect( + readCurrencies({ + accessToken: 'token', + googleSheetsClient: { getSpreadsheetMetadata, readRange }, + spreadsheetId: 'sheet-id', + }), + ).resolves.toEqual({ + currencies: [ + { + id: 'uah', + code: 'UAH', + name: 'Ukrainian Hryvnia', + symbol: '₴', + decimalDigits: 2, + isDefault: true, + isEnabled: true, + }, + { + id: 'rub', + code: 'RUB', + name: 'Russian Ruble', + symbol: '₽', + decimalDigits: 2, + isDefault: true, + isEnabled: false, + }, + ], + templateStatus: 'ready', + warning: null, + }); + }); + + it('falls back to defaults without reading values when Currencies sheet is missing', async () => { + const getSpreadsheetMetadata = vi.fn().mockResolvedValue({ + spreadsheetId: 'sheet-id', + sheets: [{ properties: { title: 'Ledger' } }], + }); + const readRange = vi.fn(); + + const result = await readCurrencies({ + accessToken: 'token', + googleSheetsClient: { getSpreadsheetMetadata, readRange }, + spreadsheetId: 'sheet-id', + }); + + expect(result.templateStatus).toBe('needs-setup'); + expect(result.warning).toContain('Spreadsheet template is not ready'); + expect(result.currencies).toEqual( + expect.arrayContaining([expect.objectContaining({ code: 'UAH' })]), + ); + expect(readRange).not.toHaveBeenCalled(); + }); + + it('falls back to defaults when the sheet has no rows', async () => { + const getSpreadsheetMetadata = vi.fn().mockResolvedValue(metadataWithCurrencies); + const readRange = vi.fn().mockResolvedValue({ values: [] }); + + const result = await readCurrencies({ + accessToken: 'token', + googleSheetsClient: { getSpreadsheetMetadata, readRange }, + spreadsheetId: 'sheet-id', + }); + + expect(result.templateStatus).toBe('ready'); + expect(result.currencies.length).toBeGreaterThan(0); + }); +}); diff --git a/app/src/features/manage-currencies/lib/manage-currencies.service.ts b/app/src/features/manage-currencies/lib/manage-currencies.service.ts new file mode 100644 index 0000000..d899a9a --- /dev/null +++ b/app/src/features/manage-currencies/lib/manage-currencies.service.ts @@ -0,0 +1,91 @@ +import { getDefaultCurrencies, type Currency } from '@entities/currency'; +import { + createGoogleSheetsClient, + GoogleSheetsApiError, + type GoogleSheetsClient, +} from '@shared/api/google-sheets'; +import { sheetNames, sheetRanges } from '@shared/config/constants/sheet.constants'; + +import { mapRowToCurrency } from './currency-row.mapper'; + +export type CurrenciesTemplateStatus = 'ready' | 'needs-setup' | 'unknown'; + +export type ReadCurrenciesContext = { + accessToken: string | null; + googleSheetsClient?: Pick; + spreadsheetId: string | null; +}; + +export type ReadCurrenciesResult = { + currencies: Currency[]; + templateStatus: CurrenciesTemplateStatus; + warning: string | null; +}; + +const templateNotReadyMessage = + 'Spreadsheet template is not ready. Open Settings and run setup to load currencies.'; + +function hasCurrenciesSheet( + metadata: Awaited>, +) { + return Boolean( + metadata.sheets?.some((sheet) => sheet.properties?.title === sheetNames.currencies), + ); +} + +export async function readCurrencies({ + accessToken, + googleSheetsClient = createGoogleSheetsClient(), + spreadsheetId, +}: ReadCurrenciesContext): Promise { + if (!accessToken || !spreadsheetId) { + return { + currencies: getDefaultCurrencies(), + templateStatus: 'unknown', + warning: null, + }; + } + + const metadata = await googleSheetsClient.getSpreadsheetMetadata({ + accessToken, + spreadsheetId, + }); + + if (!hasCurrenciesSheet(metadata)) { + return { + currencies: getDefaultCurrencies(), + templateStatus: 'needs-setup', + warning: templateNotReadyMessage, + }; + } + + try { + const valueRange = await googleSheetsClient.readRange({ + accessToken, + range: sheetRanges.currenciesData, + spreadsheetId, + }); + const remoteCurrencies = (valueRange.values ?? []) + .map(mapRowToCurrency) + .filter((currency): currency is Currency => currency !== null); + + return { + currencies: remoteCurrencies.length ? remoteCurrencies : getDefaultCurrencies(), + templateStatus: 'ready', + warning: null, + }; + } catch (error) { + if ( + error instanceof GoogleSheetsApiError && + ['template-not-ready', 'unknown'].includes(error.code) + ) { + return { + currencies: getDefaultCurrencies(), + templateStatus: 'needs-setup', + warning: templateNotReadyMessage, + }; + } + + throw error; + } +} diff --git a/app/src/features/manage-currencies/model/useCurrencies.ts b/app/src/features/manage-currencies/model/useCurrencies.ts new file mode 100644 index 0000000..bfe9d48 --- /dev/null +++ b/app/src/features/manage-currencies/model/useCurrencies.ts @@ -0,0 +1,66 @@ +import { useEffect, useState } from 'react'; + +import { getDefaultCurrencies, type Currency } from '@entities/currency'; +import { useGoogleAuth } from '@features/google-auth'; +import { localStorageService } from '@shared/lib/storage/local-storage.service'; + +import { type CurrenciesTemplateStatus, readCurrencies } from '../lib/manage-currencies.service'; + +export function useCurrencies() { + const googleAuth = useGoogleAuth(); + const spreadsheetId = localStorageService.get('spreadsheetId'); + const [currencies, setCurrencies] = useState(getDefaultCurrencies); + const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [templateStatus, setTemplateStatus] = useState('unknown'); + const [warning, setWarning] = useState(null); + + useEffect(() => { + let isActive = true; + + async function loadCurrencies() { + setIsLoading(true); + setError(null); + setWarning(null); + + try { + const result = await readCurrencies({ + accessToken: googleAuth.accessToken, + spreadsheetId, + }); + + if (isActive) { + setCurrencies(result.currencies); + setTemplateStatus(result.templateStatus); + setWarning(result.warning); + } + } catch (caughtError) { + if (isActive) { + setTemplateStatus('unknown'); + setCurrencies(getDefaultCurrencies()); + setError( + caughtError instanceof Error ? caughtError.message : 'Could not load currencies.', + ); + } + } finally { + if (isActive) { + setIsLoading(false); + } + } + } + + void loadCurrencies(); + + return () => { + isActive = false; + }; + }, [googleAuth.accessToken, spreadsheetId]); + + return { + currencies, + error, + isLoading, + templateStatus, + warning, + }; +} diff --git a/app/src/features/manage-currencies/ui/CurrencySelect.tsx b/app/src/features/manage-currencies/ui/CurrencySelect.tsx new file mode 100644 index 0000000..d7700a3 --- /dev/null +++ b/app/src/features/manage-currencies/ui/CurrencySelect.tsx @@ -0,0 +1,61 @@ +import { useMemo } from 'react'; + +import type { Currency } from '@entities/currency'; +import { Select } from '@shared/ui/select'; + +import { formatCurrencyLabel, getEnabledCurrencies } from '../lib/currency-options'; +import { useCurrencies } from '../model/useCurrencies'; + +type CurrencySelectProps = { + id?: string; + label?: string; + onChange: (code: string) => void; + value: string; +}; + +function buildUnknownCurrency(code: string): Currency { + return { + id: code.toLowerCase(), + code, + name: code, + symbol: '', + decimalDigits: 2, + isDefault: false, + isEnabled: true, + }; +} + +export function CurrencySelect({ + id = 'transaction-currency', + label = 'Currency', + onChange, + value, +}: CurrencySelectProps) { + const { currencies, isLoading, warning } = useCurrencies(); + + const options = useMemo(() => { + const enabled = getEnabledCurrencies(currencies); + + // Keep a legacy/disabled currency selectable so editing an older transaction + // never silently rewrites its currency to a different option. + if (value && !enabled.some((currency) => currency.code === value)) { + return [buildUnknownCurrency(value), ...enabled]; + } + + return enabled; + }, [currencies, value]); + + return ( + + /> + /> diff --git a/app/src/shared/config/constants/sheet.constants.ts b/app/src/shared/config/constants/sheet.constants.ts index b77cca4..5bb0aa4 100644 --- a/app/src/shared/config/constants/sheet.constants.ts +++ b/app/src/shared/config/constants/sheet.constants.ts @@ -2,6 +2,7 @@ export const sheetNames = { ledger: 'Ledger', categories: 'Categories', containers: 'Containers', + currencies: 'Currencies', summary: 'Summary', monthlyStats: 'MonthlyStats', categoryStats: 'CategoryStats', @@ -39,20 +40,31 @@ export const containerHeaders = [ 'createdAt', ] as const; +export const currencyHeaders = [ + 'id', + 'code', + 'name', + 'symbol', + 'decimalDigits', + 'isDefault', + 'isEnabled', +] as const; + export const summaryHeaders = ['metric', 'value'] as const; -export const monthlyStatsHeaders = ['month', 'income', 'expense', 'balance'] as const; +export const monthlyStatsHeaders = ['month', 'currency', 'income', 'expense', 'balance'] as const; -export const categoryStatsHeaders = ['category', 'kind', 'total', 'count'] as const; +export const categoryStatsHeaders = ['category', 'kind', 'currency', 'total', 'count'] as const; export const settingsHeaders = ['key', 'value'] as const; -export const templateVersion = '2'; +export const templateVersion = '4'; export const requiredSheetNames = [ sheetNames.ledger, sheetNames.categories, sheetNames.containers, + sheetNames.currencies, sheetNames.summary, sheetNames.monthlyStats, sheetNames.categoryStats, @@ -66,16 +78,20 @@ export const sheetRanges = { containers: `${sheetNames.containers}!A:G`, containersData: `${sheetNames.containers}!A2:G`, containersHeaders: `${sheetNames.containers}!A1:G1`, + currencies: `${sheetNames.currencies}!A:G`, + currenciesData: `${sheetNames.currencies}!A2:G`, + currenciesHeaders: `${sheetNames.currencies}!A1:G1`, ledger: `${sheetNames.ledger}!A:P`, ledgerData: `${sheetNames.ledger}!A2:P`, ledgerHeaders: `${sheetNames.ledger}!A1:P1`, - categoryStatsHeaders: `${sheetNames.categoryStats}!A1:D1`, - categoryStatsData: `${sheetNames.categoryStats}!A2:D`, + categoryStatsHeaders: `${sheetNames.categoryStats}!A1:E1`, + categoryStatsData: `${sheetNames.categoryStats}!A2:E`, categoryStatsFormula: `${sheetNames.categoryStats}!A2`, - monthlyStatsHeaders: `${sheetNames.monthlyStats}!A1:D1`, - monthlyStatsData: `${sheetNames.monthlyStats}!A2:D`, + monthlyStatsHeaders: `${sheetNames.monthlyStats}!A1:E1`, + monthlyStatsData: `${sheetNames.monthlyStats}!A2:E`, monthlyStatsFormula: `${sheetNames.monthlyStats}!A2`, summary: `${sheetNames.summary}!A:B`, + summaryByCurrencyFormula: `${sheetNames.summary}!A8`, settingsHeaders: `${sheetNames.settings}!A1:B1`, settingsTemplateVersion: `${sheetNames.settings}!A2:B2`, } as const; diff --git a/app/src/shared/lib/storage/storage-keys.ts b/app/src/shared/lib/storage/storage-keys.ts index 05cbc3b..476fdd3 100644 --- a/app/src/shared/lib/storage/storage-keys.ts +++ b/app/src/shared/lib/storage/storage-keys.ts @@ -16,6 +16,7 @@ export type LocalStorageKey = | 'templateReadyAt' | 'templateReadySpreadsheetId' | 'templateReadyVersion' + | 'analyticsActiveCurrency' | 'analyticsChartSlots' | 'analyticsWideChartId' | 'googleAuthEverConnected'; @@ -38,6 +39,7 @@ export const localStorageKeys: LocalStorageKey[] = [ 'templateReadyAt', 'templateReadySpreadsheetId', 'templateReadyVersion', + 'analyticsActiveCurrency', 'analyticsChartSlots', 'analyticsWideChartId', 'googleAuthEverConnected', diff --git a/app/src/shared/ui/date-picker/DatePicker.tsx b/app/src/shared/ui/date-picker/DatePicker.tsx new file mode 100644 index 0000000..eab2290 --- /dev/null +++ b/app/src/shared/ui/date-picker/DatePicker.tsx @@ -0,0 +1,261 @@ +import { useEffect, useId, useRef, useState } from 'react'; + +import { cn } from '@shared/lib/classnames/cn'; + +type DatePickerProps = { + error?: string; + hint?: string; + id?: string; + label?: string; + onChange: (value: string) => void; + value: string; +}; + +const WEEKDAYS = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su']; +const MONTHS = [ + 'January', + 'February', + 'March', + 'April', + 'May', + 'June', + 'July', + 'August', + 'September', + 'October', + 'November', + 'December', +]; + +function toDateString(date: Date): string { + const y = date.getFullYear(); + const m = String(date.getMonth() + 1).padStart(2, '0'); + const d = String(date.getDate()).padStart(2, '0'); + return `${y}-${m}-${d}`; +} + +function formatDisplay(value: string): string { + if (!value) return ''; + const [year, month, day] = value.split('-'); + return `${day}.${month}.${year}`; +} + +export function DatePicker({ error, hint, id, label, onChange, value }: DatePickerProps) { + const generatedId = useId(); + const inputId = id ?? generatedId; + const description = error ?? hint; + + const [isOpen, setIsOpen] = useState(false); + + const today = new Date(); + const initialDate = value ? new Date(`${value}T00:00:00`) : today; + const [viewDate, setViewDate] = useState( + new Date(initialDate.getFullYear(), initialDate.getMonth(), 1), + ); + + const containerRef = useRef(null); + + useEffect(() => { + if (!isOpen) return; + + function handlePointerDown(e: PointerEvent) { + if (!containerRef.current?.contains(e.target as Node)) { + setIsOpen(false); + } + } + + function handleKeyDown(e: KeyboardEvent) { + if (e.key === 'Escape') setIsOpen(false); + } + + document.addEventListener('pointerdown', handlePointerDown); + document.addEventListener('keydown', handleKeyDown); + return () => { + document.removeEventListener('pointerdown', handlePointerDown); + document.removeEventListener('keydown', handleKeyDown); + }; + }, [isOpen]); + + function moveMonth(direction: -1 | 1) { + setViewDate((d) => new Date(d.getFullYear(), d.getMonth() + direction, 1)); + } + + const firstWeekday = (viewDate.getDay() + 6) % 7; + const daysInMonth = new Date(viewDate.getFullYear(), viewDate.getMonth() + 1, 0).getDate(); + const todayStr = toDateString(today); + + return ( +
+ {label ? {label} : null} + + + + {description ? ( + + {description} + + ) : null} + + {isOpen ? ( +
+
+ + + {MONTHS[viewDate.getMonth()]} {viewDate.getFullYear()} + + +
+ +
+
+ {WEEKDAYS.map((day) => ( +
+ {day} +
+ ))} +
+ +
+ {Array.from({ length: firstWeekday }, (_, i) => ( + + ))} + {Array.from({ length: daysInMonth }, (_, i) => { + const day = i + 1; + const dateStr = toDateString( + new Date(viewDate.getFullYear(), viewDate.getMonth(), day), + ); + const isSelected = dateStr === value; + const isToday = dateStr === todayStr; + + return ( + + ); + })} +
+
+ +
+ + +
+
+ ) : null} +
+ ); +} + +function CalendarIcon() { + return ( + + ); +} + +function ChevronLeftIcon() { + return ( + + ); +} + +function ChevronRightIcon() { + return ( + + ); +} diff --git a/app/src/shared/ui/date-picker/index.ts b/app/src/shared/ui/date-picker/index.ts new file mode 100644 index 0000000..a4eb7f5 --- /dev/null +++ b/app/src/shared/ui/date-picker/index.ts @@ -0,0 +1 @@ +export { DatePicker } from './DatePicker'; diff --git a/app/src/shared/ui/empty-state/EmptyState.tsx b/app/src/shared/ui/empty-state/EmptyState.tsx index 696521a..610d362 100644 --- a/app/src/shared/ui/empty-state/EmptyState.tsx +++ b/app/src/shared/ui/empty-state/EmptyState.tsx @@ -6,6 +6,8 @@ type EmptyStateProps = { className?: string; description: string; onAction?: () => void; + onSecondAction?: () => void; + secondActionLabel?: string; title: string; }; @@ -14,6 +16,8 @@ export function EmptyState({ className, description, onAction, + onSecondAction, + secondActionLabel, title, }: EmptyStateProps) { return ( @@ -26,9 +30,14 @@ export function EmptyState({

{title}

{description}

{actionLabel && onAction ? ( - +
+ + {secondActionLabel && onSecondAction ? ( + + ) : null} +
) : null} ); diff --git a/app/src/shared/ui/modal/Modal.tsx b/app/src/shared/ui/modal/Modal.tsx index a46560e..55f850f 100644 --- a/app/src/shared/ui/modal/Modal.tsx +++ b/app/src/shared/ui/modal/Modal.tsx @@ -38,7 +38,7 @@ export function Modal({ children, description, footer, isOpen, onClose, title }: return (
{ if (event.target === event.currentTarget) { onClose(); @@ -49,10 +49,10 @@ export function Modal({ children, description, footer, isOpen, onClose, title }: aria-describedby={description ? descriptionId : undefined} aria-labelledby={titleId} aria-modal="true" - className="w-full max-w-lg rounded-lg bg-white shadow-xl" + className="max-h-[92vh] w-full overflow-y-auto rounded-t-lg bg-white shadow-xl sm:max-w-lg sm:rounded-lg" role="dialog" > -
+

{title} @@ -67,8 +67,8 @@ export function Modal({ children, description, footer, isOpen, onClose, title }: X

-
{children}
- {footer ?
{footer}
: null} +
{children}
+ {footer ?
{footer}
: null}
); diff --git a/app/src/shared/ui/page-header/PageHeader.tsx b/app/src/shared/ui/page-header/PageHeader.tsx index 714bc71..a440bed 100644 --- a/app/src/shared/ui/page-header/PageHeader.tsx +++ b/app/src/shared/ui/page-header/PageHeader.tsx @@ -9,13 +9,15 @@ type PageHeaderProps = { export function PageHeader({ actions, description, title }: PageHeaderProps) { return (
-
+

{title}

{description ? (

{description}

) : null}
- {actions ?
{actions}
: null} + {actions ? ( +
{actions}
+ ) : null}
); } diff --git a/app/src/shared/ui/select/Select.tsx b/app/src/shared/ui/select/Select.tsx index 32e8fa0..b36122c 100644 --- a/app/src/shared/ui/select/Select.tsx +++ b/app/src/shared/ui/select/Select.tsx @@ -1,44 +1,159 @@ -import type { SelectHTMLAttributes } from 'react'; -import { useId } from 'react'; +import { useEffect, useId, useRef, useState } from 'react'; import { cn } from '@shared/lib/classnames/cn'; -type SelectProps = SelectHTMLAttributes & { +export type SelectOption = { + label: string; + value: string; +}; + +type SelectProps = { + disabled?: boolean; error?: string; hint?: string; + id?: string; label?: string; + onChange: (value: string) => void; + options: SelectOption[]; + value: string; }; -export function Select({ children, className, error, hint, id, label, ...props }: SelectProps) { +export function Select({ + disabled, + error, + hint, + id, + label, + onChange, + options, + value, +}: SelectProps) { const generatedId = useId(); const selectId = id ?? generatedId; - const descriptionId = `${selectId}-description`; const description = error ?? hint; + const [isOpen, setIsOpen] = useState(false); + const containerRef = useRef(null); + + const selectedOption = options.find((opt) => opt.value === value); + + useEffect(() => { + if (!isOpen) return; + + function handlePointerDown(e: PointerEvent) { + if (!containerRef.current?.contains(e.target as Node)) { + setIsOpen(false); + } + } + + function handleKeyDown(e: KeyboardEvent) { + if (e.key === 'Escape') setIsOpen(false); + } + + document.addEventListener('pointerdown', handlePointerDown); + document.addEventListener('keydown', handleKeyDown); + return () => { + document.removeEventListener('pointerdown', handlePointerDown); + document.removeEventListener('keydown', handleKeyDown); + }; + }, [isOpen]); + return ( - + + {isOpen && !disabled ? ( +
+ {options.map((option) => { + const isSelected = option.value === value; + return ( + + ); + })} +
+ ) : null} +
+ ); +} + +function ChevronDownIcon({ className }: { className?: string }) { + return ( + + ); +} + +function CheckIcon({ className }: { className?: string }) { + return ( + ); } diff --git a/app/src/shared/ui/select/index.ts b/app/src/shared/ui/select/index.ts index 205709b..95658d5 100644 --- a/app/src/shared/ui/select/index.ts +++ b/app/src/shared/ui/select/index.ts @@ -1 +1,2 @@ export { Select } from './Select'; +export type { SelectOption } from './Select'; diff --git a/app/src/widgets/app-layout/ui/AppLayout.tsx b/app/src/widgets/app-layout/ui/AppLayout.tsx index fa57984..24d7725 100644 --- a/app/src/widgets/app-layout/ui/AppLayout.tsx +++ b/app/src/widgets/app-layout/ui/AppLayout.tsx @@ -24,14 +24,14 @@ export function AppLayout({ actions, children }: AppLayoutProps) { return (
-
-
+
+

{appConfig.name}

{appConfig.description}

-
-
+
{renderChart({ categoryStats, chartId, dailyExpenseStats, monthlyStats })} @@ -310,7 +311,40 @@ function ChartDragPreview({ } export function DashboardAnalytics({ transactions }: DashboardAnalyticsProps) { - const { categoryStats, monthlyStats, source } = useAnalyticsStats(transactions); + const availableCurrencies = useMemo( + () => getTransactionCurrencyOptions(transactions), + [transactions], + ); + const isMultiCurrency = availableCurrencies.length > 1; + + const [activeCurrency, setActiveCurrency] = useState(() => { + const stored = localStorageService.get('analyticsActiveCurrency'); + return stored ?? availableCurrencies[0] ?? ''; + }); + + const displayCurrency = useMemo( + () => + availableCurrencies.includes(activeCurrency) + ? activeCurrency + : (availableCurrencies[0] ?? ''), + [availableCurrencies, activeCurrency], + ); + + function handleCurrencyChange(currency: string) { + setActiveCurrency(currency); + localStorageService.set('analyticsActiveCurrency', currency); + } + + const filteredTransactions = useMemo( + () => + isMultiCurrency ? transactions.filter((t) => t.currency === displayCurrency) : transactions, + [transactions, isMultiCurrency, displayCurrency], + ); + + const { categoryStats, monthlyStats, source } = useAnalyticsStats( + filteredTransactions, + isMultiCurrency, + ); const [chartSlots, setChartSlots] = useState(() => parseChartSlots(localStorageService.get('analyticsChartSlots')), ); @@ -319,7 +353,10 @@ export function DashboardAnalytics({ transactions }: DashboardAnalyticsProps) { ); const [isPickerOpen, setIsPickerOpen] = useState(false); const [selectedSlotIndex, setSelectedSlotIndex] = useState<0 | 1>(0); - const dailyExpenseStats = useMemo(() => calculateDailyExpenseStats(transactions), [transactions]); + const dailyExpenseStats = useMemo( + () => calculateDailyExpenseStats(filteredTransactions), + [filteredTransactions], + ); function updateChartSlot(slotIndex: 0 | 1, chartId: ChartId) { const nextSlots: ChartSlots = @@ -366,9 +403,29 @@ export function DashboardAnalytics({ transactions }: DashboardAnalyticsProps) {

{source === 'google-sheets' ? 'Google Sheets analytics' : 'Local analytics'}

- +
+ {isMultiCurrency ? ( +
+ {availableCurrencies.map((currency) => ( + + ))} +
+ ) : null} + +
{wideChartId ? ( {isPickerOpen ? ( -