Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions config/settings.yaml.example
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@
year: 2025
title: "Spending Analysis"

# Language for report UI (default: "en")
# Supported: "en" (English), "tr" (Turkish)
# language: en

# Currency display format (default: "${amount}")
# Use {amount} as placeholder for the formatted number
# currency_format: "${amount}" # US Dollar (prefix)
Expand Down
3 changes: 2 additions & 1 deletion src/tally/commands/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,7 @@ def cmd_run(args):
verbose = args.verbose if hasattr(args, 'verbose') else 0

currency_format = config.get('currency_format', '${amount}')
language = config.get('language', 'en')

if output_format == 'json':
# JSON output with reasoning
Expand Down Expand Up @@ -217,7 +218,7 @@ def cmd_run(args):
source_names = [s.get('name', 'Unknown') for s in data_sources if not s.get('_supplemental', False)]
write_summary_file_vue(stats, output_path, year=year,
currency_format=currency_format, sources=source_names,
embedded_html=args.embedded_html)
embedded_html=args.embedded_html, language=language)
if not args.quiet:
# Make the path clickable using OSC 8 hyperlink escape sequence
abs_path = os.path.abspath(output_path)
Expand Down
284 changes: 284 additions & 0 deletions src/tally/i18n.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,284 @@
// i18n.js - Internationalization support for spending report
// Supports: English (en), Turkish (tr)

(function() {
'use strict';

// Translation strings
const translations = {
en: {
// Report title and header
reportTitle: '{year} Spending Report',
dataFrom: 'Data from {sources}',

// Theme toggle
switchToLight: 'Switch to light mode',
switchToDark: 'Switch to dark mode',

// Search and filters
searchPlaceholder: 'Search merchants, categories, locations...',
dateFilter: '+ Date filter',
months: 'Individual Months',
clearAll: 'Clear all',
searchTransactions: 'Search transactions: "{query}"',

// Help section
helpTitle: 'How to Read This Report',
helpCategories: 'Categories:',
helpCategoriesDesc: 'Merchants are grouped by their assigned category from merchant_categories.csv',
helpTerms: 'Terms:',
helpYtdDesc: 'year-to-date total',
helpCountDesc: 'number of transactions',
helpFilters: 'Filters:',
helpFiltersDesc: 'Click on categories, tags, or locations to filter. Use search to find specific merchants.',

// Summary cards
totalSpending: 'Total Spending (YTD)',
creditsApplied: 'Credits Applied',
uncategorized: 'Uncategorized',
cashFlow: 'Cash Flow',
income: 'Income',
spending: 'Spending',
transfers: 'Transfers',
excluded: 'Excluded',
notIncludedInSpending: 'Not included in spending',
transactionCount: '{count} transactions',

// Charts
spendingTrends: 'Spending Trends',
monthlyTrend: 'Monthly Spending Trend',
monthlySpending: 'Monthly Spending',
byCategory: 'Spending by Category',
categoryTrends: 'Category Trends by Month',

// View toggle
byView: 'By View',

// Table headers
merchant: 'Merchant',
source: 'Source',
category: 'Category',
subcategory: 'Subcategory',
count: 'Count',
tags: 'Tags',
total: 'Total',
amount: 'Amount',

// Table content
refund: 'REFUND',

// Match info popup
whyThisMatched: 'Why This Matched',
merchantPattern: 'Merchant Pattern',
assignedTo: 'Assigned To',
viewFilter: 'View Filter',
fromSource: 'From',
transactionDetails: 'Transaction Details',

// Credits section
totalCredits: 'Total Credits',
reducedSpending: '(reduced spending)',

// Excluded section
notIncluded: '(not included in spending)',

// Footer
generatedBy: 'Generated by tally',
dataThrough: 'Data through {date}',
present: 'present',

// Months (short)
monthsShort: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'],

// Per month suffix
perMonth: '/mo'
},
tr: {
// Rapor başlığı ve header
reportTitle: '{year} Harcama Raporu',
dataFrom: '{sources} verilerinden',

// Tema değiştirme
switchToLight: 'Açık temaya geç',
switchToDark: 'Koyu temaya geç',

// Arama ve filtreler
searchPlaceholder: 'Satıcı, kategori, konum ara...',
dateFilter: '+ Tarih filtresi',
months: 'Aylar',
clearAll: 'Tümünü temizle',
searchTransactions: 'İşlemlerde ara: "{query}"',

// Yardım bölümü
helpTitle: 'Bu Raporu Nasıl Okuyabilirim',
helpCategories: 'Kategoriler:',
helpCategoriesDesc: 'Satıcılar merchant_categories.csv dosyasındaki kategorilere göre gruplandırılmıştır',
helpTerms: 'Terimler:',
helpYtdDesc: 'yıl başından bugüne toplam',
helpCountDesc: 'işlem sayısı',
helpFilters: 'Filtreler:',
helpFiltersDesc: 'Filtrelemek için kategorilere, etiketlere veya konumlara tıklayın. Belirli satıcıları bulmak için arama kullanın.',

// Özet kartları
totalSpending: 'Toplam Harcama (YTD)',
creditsApplied: 'Uygulanan İadeler',
uncategorized: 'Kategorisiz',
cashFlow: 'Nakit Akışı',
income: 'Gelir',
spending: 'Harcama',
transfers: 'Transferler',
excluded: 'Hariç Tutulan',
notIncludedInSpending: 'Harcamaya dahil değil',
transactionCount: '{count} işlem',

// Grafikler
spendingTrends: 'Harcama Trendleri',
monthlyTrend: 'Aylık Harcama Trendi',
monthlySpending: 'Aylık Harcama',
byCategory: 'Kategoriye Göre Harcama',
categoryTrends: 'Aylık Kategori Trendleri',

// Görünüm değiştirme
byView: 'Görünüme Göre',

// Tablo başlıkları
merchant: 'Satıcı',
source: 'Kaynak',
category: 'Kategori',
subcategory: 'Alt Kategori',
count: 'Adet',
tags: 'Etiketler',
total: 'Toplam',
amount: 'Tutar',

// Tablo içeriği
refund: 'İADE',

// Eşleşme bilgisi popup
whyThisMatched: 'Neden Eşleşti',
merchantPattern: 'Satıcı Deseni',
assignedTo: 'Atanan',
viewFilter: 'Görünüm Filtresi',
fromSource: 'Kaynak',
transactionDetails: 'İşlem Detayları',

// Krediler bölümü
totalCredits: 'Toplam İade',
reducedSpending: '(harcamayı azalttı)',

// Hariç tutulan bölümü
notIncluded: '(harcamaya dahil değil)',

// Alt bilgi
generatedBy: 'Tally ile oluşturuldu',
dataThrough: '{date} tarihine kadar veri',
present: 'güncel',

// Aylar (kısa)
monthsShort: ['Oca', 'Şub', 'Mar', 'Nis', 'May', 'Haz', 'Tem', 'Ağu', 'Eyl', 'Eki', 'Kas', 'Ara'],

// Aylık eki
perMonth: '/ay'
}
};

// Currency formatting by language
const currencyFormats = {
en: {
format: (amount, customFormat) => {
const formatted = Math.round(Math.abs(amount)).toLocaleString('en-US');
if (customFormat) {
return customFormat.replace('{amount}', formatted);
}
return '$' + formatted;
},
formatAxis: (value, customFormat) => {
if (customFormat) {
return customFormat.replace('{amount}', value);
}
return '$' + value;
}
},
tr: {
format: (amount, customFormat) => {
const formatted = Math.round(Math.abs(amount)).toLocaleString('tr-TR');
if (customFormat) {
return customFormat.replace('{amount}', formatted);
}
return formatted + ' ₺';
},
formatAxis: (value, customFormat) => {
if (customFormat) {
return customFormat.replace('{amount}', value.toLocaleString('tr-TR'));
}
return value.toLocaleString('tr-TR') + ' ₺';
}
}
};

// Get current language from spending data
function getLanguage() {
return (window.spendingData && window.spendingData.language) || 'en';
}

// Get custom currency format from spending data
function getCurrencyFormat() {
return window.spendingData && window.spendingData.currencyFormat;
}

// Translation function with parameter interpolation
function t(key, params) {
const lang = getLanguage();
const strings = translations[lang] || translations.en;
let text = strings[key] || translations.en[key] || key;

if (params) {
for (const [k, v] of Object.entries(params)) {
text = text.replace(new RegExp(`\\{${k}\\}`, 'g'), v);
}
}

return text;
}

// Get short month name by index (0-11)
function getMonthShort(idx) {
const lang = getLanguage();
const strings = translations[lang] || translations.en;
const months = strings.monthsShort || translations.en.monthsShort;
return months[idx] || '';
}

// Format currency amount (no decimals)
function formatCurrency(amount) {
const lang = getLanguage();
const formatter = currencyFormats[lang] || currencyFormats.en;
return formatter.format(amount, getCurrencyFormat());
}

// Format currency for chart axis
function formatCurrencyAxis(value) {
const lang = getLanguage();
const formatter = currencyFormats[lang] || currencyFormats.en;
return formatter.formatAxis(value, getCurrencyFormat());
}

// Get per-month formatted string (accepts formatted currency)
function getPerMonth(formattedAmount) {
const lang = getLanguage();
const strings = translations[lang] || translations.en;
const suffix = strings.perMonth || '/mo';
return formattedAmount + suffix;
}

// Export to window
window.i18n = {
t,
getMonthShort,
formatCurrency,
formatCurrencyAxis,
getPerMonth,
getLanguage,
translations
};
})();
15 changes: 14 additions & 1 deletion src/tally/report.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ def generate_embeddings(items):
# VUE-BASED HTML REPORT (Modern)
# ============================================================================

def write_summary_file_vue(stats, filepath, year=2025, currency_format="${amount}", sources=None, embedded_html=True):
def write_summary_file_vue(stats, filepath, year=2025, currency_format="${amount}", sources=None, embedded_html=True, language='en'):
"""Write summary to HTML file using Vue 3 for client-side rendering.

Args:
Expand All @@ -94,14 +94,17 @@ def write_summary_file_vue(stats, filepath, year=2025, currency_format="${amount
currency_format: Format string for currency display, e.g. "${amount}" or "{amount} zl"
sources: List of data source names (e.g., ['Amex', 'Chase'])
embedded_html: If True (default), embed CSS/JS inline. If False, output separate files.
language: Language code for UI localization ('en' or 'tr'). Defaults to 'en'.
"""
sources = sources or []
language = language or 'en'

# Load template files
template_dir = get_template_dir()
html_template = (template_dir / 'spending_report.html').read_text(encoding='utf-8')
css_content = (template_dir / 'spending_report.css').read_text(encoding='utf-8')
js_content = (template_dir / 'spending_report.js').read_text(encoding='utf-8')
i18n_content = (template_dir / 'i18n.js').read_text(encoding='utf-8')

# Get number of months for averaging
num_months = stats['num_months']
Expand Down Expand Up @@ -395,6 +398,7 @@ def build_category_view():
'numMonths': num_months,
'sources': sources,
'dataThrough': latest_date,
'language': language,
'sections': sections,
'categoryView': category_view,
# Excluded transactions for transparency
Expand All @@ -419,6 +423,10 @@ def build_category_view():
js_path = output_dir / 'spending_report.js'
js_path.write_text(js_content, encoding='utf-8')

# Write i18n file
i18n_path = output_dir / 'i18n.js'
i18n_path.write_text(i18n_content, encoding='utf-8')

# Write data file
data_path = output_dir / 'spending_data.js'
data_path.write_text(data_script, encoding='utf-8')
Expand All @@ -430,6 +438,9 @@ def build_category_view():
).replace(
'<script>/* DATA_PLACEHOLDER */</script>',
'<script src="spending_data.js"></script>'
).replace(
'<script>/* I18N_PLACEHOLDER */</script>',
'<script src="i18n.js"></script>'
).replace(
'<script>/* JS_PLACEHOLDER */</script>',
'<script src="spending_report.js"></script>'
Expand All @@ -440,6 +451,8 @@ def build_category_view():
'/* CSS_PLACEHOLDER */', css_content
).replace(
'/* DATA_PLACEHOLDER */', data_script
).replace(
'/* I18N_PLACEHOLDER */', i18n_content
).replace(
'/* JS_PLACEHOLDER */', js_content
)
Expand Down
Loading