From a5de45f56a92054f85feb0c09c9093725663b68b Mon Sep 17 00:00:00 2001 From: Alexander Bocken Date: Wed, 8 Apr 2026 15:46:00 +0200 Subject: [PATCH] feat: add EN/DE internationalization to cospend section Move cospend routes to parameterized [cospendRoot=cospendRoot] supporting both /cospend (DE) and /expenses (EN). Add cospendI18n.ts with 100+ translation keys covering all pages, components, categories, frequency descriptions, and error messages. Translate BarChart legend, ImageUpload, UsersList, SplitMethodSelector, DebtBreakdown, EnhancedBalance, and PaymentModal. Update LanguageSelector and hooks.server.ts for /expenses. --- package.json | 2 +- src/hooks.server.ts | 4 +- src/lib/components/ImageUpload.svelte | 26 +- src/lib/components/LanguageSelector.svelte | 19 +- src/lib/components/cospend/BarChart.svelte | 22 +- .../components/cospend/DebtBreakdown.svelte | 27 +- .../components/cospend/EnhancedBalance.svelte | 39 +- .../components/cospend/PaymentModal.svelte | 63 ++- .../cospend/SplitMethodSelector.svelte | 42 +- src/lib/components/cospend/UsersList.svelte | 21 +- src/lib/js/cospendI18n.ts | 451 ++++++++++++++++++ src/params/cospendRoot.ts | 5 + .../+layout.server.ts | 9 + .../+layout.svelte | 29 +- .../[cospendRoot=cospendRoot]/+page.server.ts | 7 + .../dash/+page.server.ts | 0 .../dash/+page.svelte | 49 +- .../list/+page.server.ts | 0 .../list/+page.svelte | 95 ++-- .../payments/+page.server.ts | 0 .../payments/+page.svelte | 70 ++- .../payments/add/+page.server.ts | 0 .../payments/add/+page.svelte | 91 ++-- .../payments/edit/[id]/+page.server.ts | 0 .../payments/edit/[id]/+page.svelte | 70 +-- .../payments/view/[id]/+page.server.ts | 0 .../payments/view/[id]/+page.svelte | 58 ++- .../recurring/+page.server.ts | 0 .../recurring/+page.svelte | 69 +-- .../recurring/edit/[id]/+page.server.ts | 0 .../recurring/edit/[id]/+page.svelte | 68 +-- .../settle/+page.server.ts | 8 +- .../settle/+page.svelte | 93 ++-- src/routes/cospend/+layout.server.ts | 7 - src/routes/cospend/+page.server.ts | 5 - 35 files changed, 991 insertions(+), 458 deletions(-) create mode 100644 src/lib/js/cospendI18n.ts create mode 100644 src/params/cospendRoot.ts create mode 100644 src/routes/[cospendRoot=cospendRoot]/+layout.server.ts rename src/routes/{cospend => [cospendRoot=cospendRoot]}/+layout.svelte (71%) create mode 100644 src/routes/[cospendRoot=cospendRoot]/+page.server.ts rename src/routes/{cospend => [cospendRoot=cospendRoot]}/dash/+page.server.ts (100%) rename src/routes/{cospend => [cospendRoot=cospendRoot]}/dash/+page.svelte (90%) rename src/routes/{cospend => [cospendRoot=cospendRoot]}/list/+page.server.ts (100%) rename src/routes/{cospend => [cospendRoot=cospendRoot]}/list/+page.svelte (92%) rename src/routes/{cospend => [cospendRoot=cospendRoot]}/payments/+page.server.ts (100%) rename src/routes/{cospend => [cospendRoot=cospendRoot]}/payments/+page.svelte (89%) rename src/routes/{cospend => [cospendRoot=cospendRoot]}/payments/add/+page.server.ts (100%) rename src/routes/{cospend => [cospendRoot=cospendRoot]}/payments/add/+page.svelte (88%) rename src/routes/{cospend => [cospendRoot=cospendRoot]}/payments/edit/[id]/+page.server.ts (100%) rename src/routes/{cospend => [cospendRoot=cospendRoot]}/payments/edit/[id]/+page.svelte (91%) rename src/routes/{cospend => [cospendRoot=cospendRoot]}/payments/view/[id]/+page.server.ts (100%) rename src/routes/{cospend => [cospendRoot=cospendRoot]}/payments/view/[id]/+page.svelte (81%) rename src/routes/{cospend => [cospendRoot=cospendRoot]}/recurring/+page.server.ts (100%) rename src/routes/{cospend => [cospendRoot=cospendRoot]}/recurring/+page.svelte (83%) rename src/routes/{cospend => [cospendRoot=cospendRoot]}/recurring/edit/[id]/+page.server.ts (100%) rename src/routes/{cospend => [cospendRoot=cospendRoot]}/recurring/edit/[id]/+page.svelte (88%) rename src/routes/{cospend => [cospendRoot=cospendRoot]}/settle/+page.server.ts (93%) rename src/routes/{cospend => [cospendRoot=cospendRoot]}/settle/+page.svelte (87%) delete mode 100644 src/routes/cospend/+layout.server.ts delete mode 100644 src/routes/cospend/+page.server.ts diff --git a/package.json b/package.json index 8dd4c97..12580b3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "homepage", - "version": "1.11.3", + "version": "1.12.0", "private": true, "type": "module", "scripts": { diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 3457655..8a0e8d0 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -37,10 +37,10 @@ async function authorization({ event, resolve }: Parameters[0]) { } // Protect cospend routes and API endpoints - if (event.url.pathname.startsWith('/cospend') || event.url.pathname.startsWith('/api/cospend')) { + if (event.url.pathname.startsWith('/cospend') || event.url.pathname.startsWith('/expenses') || event.url.pathname.startsWith('/api/cospend')) { if (!session) { // Allow share-token access to shopping list routes - const isShoppingRoute = event.url.pathname.startsWith('/cospend/list') || event.url.pathname.startsWith('/api/cospend/list'); + const isShoppingRoute = event.url.pathname.startsWith('/cospend/list') || event.url.pathname.startsWith('/expenses/list') || event.url.pathname.startsWith('/api/cospend/list'); const shareToken = event.url.searchParams.get('token'); if (isShoppingRoute && shareToken) { const { validateShareToken } = await import('$lib/server/shoppingAuth'); diff --git a/src/lib/components/ImageUpload.svelte b/src/lib/components/ImageUpload.svelte index 9aca6f9..48ada74 100644 --- a/src/lib/components/ImageUpload.svelte +++ b/src/lib/components/ImageUpload.svelte @@ -1,10 +1,13 @@
-

{title}

+

{displayTitle}

{#if currentImage}
- Receipt + {t('receipt',
@@ -75,9 +81,9 @@ {#if imagePreview}
- Receipt preview + {t('receipt',
{:else} @@ -89,7 +95,7 @@ -

{currentImage ? 'Replace Image' : 'Upload Receipt Image'}

+

{currentImage ? t('replace_image', lang) : t('upload_receipt', lang)}

JPEG, PNG, WebP (max 5MB)
@@ -105,7 +111,7 @@ {/if} {#if uploading} -
Uploading image...
+
{t('uploading_image', lang)}
{/if} diff --git a/src/lib/components/LanguageSelector.svelte b/src/lib/components/LanguageSelector.svelte index 4b524a2..cb5bf90 100644 --- a/src/lib/components/LanguageSelector.svelte +++ b/src/lib/components/LanguageSelector.svelte @@ -4,6 +4,7 @@ import { recipeTranslationStore } from '$lib/stores/recipeTranslation'; import { languageStore } from '$lib/stores/language'; import { convertFitnessPath } from '$lib/js/fitnessI18n'; + import { convertCospendPath } from '$lib/js/cospendI18n'; import { onMount } from 'svelte'; let { lang = undefined }: { lang?: 'de' | 'en' | 'la' } = $props(); @@ -41,6 +42,10 @@ // Latin route — no language switching needed } else if (path.startsWith('/fitness')) { // Language is determined by sub-route slugs; don't override store + } else if (path.startsWith('/cospend')) { + languageStore.set('de'); + } else if (path.startsWith('/expenses')) { + languageStore.set('en'); } else { // On other pages, read from localStorage if (typeof localStorage !== 'undefined') { @@ -83,6 +88,10 @@ return convertFitnessPath(path, targetLang); } + if ((path.startsWith('/cospend') || path.startsWith('/expenses')) && targetLang !== 'la') { + return convertCospendPath(path, targetLang); + } + // Use translated recipe slugs from page data when available (works during SSR) const pageData = $page.data; if (targetLang === 'en' && path.startsWith('/rezepte')) { @@ -125,7 +134,8 @@ // dispatch event and stay on the page if (!path.startsWith('/rezepte') && !path.startsWith('/recipes') && !path.startsWith('/glaube') && !path.startsWith('/faith') && !path.startsWith('/fides') - && !path.startsWith('/fitness')) { + && !path.startsWith('/fitness') + && !path.startsWith('/cospend') && !path.startsWith('/expenses')) { window.dispatchEvent(new CustomEvent('languagechange', { detail: { lang } })); return; } @@ -137,6 +147,13 @@ return; } + // Handle cospend/expenses pages + if ((path.startsWith('/cospend') || path.startsWith('/expenses')) && lang !== 'la') { + const newPath = convertCospendPath(path, lang); + await goto(newPath); + return; + } + // Handle fitness pages if (path.startsWith('/fitness')) { const newPath = convertFitnessPath(path, lang); diff --git a/src/lib/components/cospend/BarChart.svelte b/src/lib/components/cospend/BarChart.svelte index 5ceb460..1c36117 100644 --- a/src/lib/components/cospend/BarChart.svelte +++ b/src/lib/components/cospend/BarChart.svelte @@ -1,6 +1,7 @@
-

Split Method

+

{t('split_method', lang)}

- +
{#if splitMethod === 'proportional'}
-

Custom Split Amounts

+

{t('custom_split_amounts', lang)}

{#each users as user}
@@ -161,8 +165,8 @@ {#if splitMethod === 'personal_equal'}
-

Personal Amounts

-

Enter personal amounts for each user. The remainder will be split equally.

+

{t('personal_amounts', lang)}

+

{t('personal_amounts_desc', lang)}

{#each users as user}
@@ -180,10 +184,10 @@ {#if amount} {@const personalTotal = Object.values(personalAmounts).reduce((/** @type {number} */ sum, /** @type {number} */ val) => sum + (Number(val) || 0), 0)}
- Total Personal: {currency} {personalTotal.toFixed(2)} - Remainder to Split: {currency} {Math.max(0, Number(amount) - personalTotal).toFixed(2)} + {t('total_personal', lang)}: {currency} {personalTotal.toFixed(2)} + {t('remainder_to_split', lang)}: {currency} {Math.max(0, Number(amount) - personalTotal).toFixed(2)} {#if personalTotalError} -
Warning: Personal amounts exceed total payment amount!
+
{t('personal_exceeds_total', lang)}
{/if}
{/if} @@ -192,7 +196,7 @@ {#if Object.keys(splitAmounts).length > 0}
-

Split Preview

+

{t('split_preview', lang)}

{#each users as user}
@@ -201,11 +205,11 @@
0}> {#if splitAmounts[user] > 0} - owes {currency} {splitAmounts[user].toFixed(2)} + {t('owes', lang)} {currency} {splitAmounts[user].toFixed(2)} {:else if splitAmounts[user] < 0} - is owed {currency} {Math.abs(splitAmounts[user]).toFixed(2)} + {t('is_owed', lang)} {currency} {Math.abs(splitAmounts[user]).toFixed(2)} {:else} - owes {currency} {splitAmounts[user].toFixed(2)} + {t('owes', lang)} {currency} {splitAmounts[user].toFixed(2)} {/if}
diff --git a/src/lib/components/cospend/UsersList.svelte b/src/lib/components/cospend/UsersList.svelte index ffd8fb9..1c4262c 100644 --- a/src/lib/components/cospend/UsersList.svelte +++ b/src/lib/components/cospend/UsersList.svelte @@ -1,18 +1,21 @@
-

Split Between Users

+

{t('split_between_users', lang)}

{#if predefinedMode}
-

Splitting between predefined users:

+

{t('predefined_note', lang)}

{#each users as user}
{user} {#if user === currentUser} - You + {t('you', lang)} {/if}
{/each} @@ -59,11 +62,11 @@ {user} {#if user === currentUser} - You + {t('you', lang)} {/if} {#if canRemoveUsers && user !== currentUser} {/if}
@@ -74,10 +77,10 @@ e.key === 'Enter' && (e.preventDefault(), addUser())} /> - +
{/if}
diff --git a/src/lib/js/cospendI18n.ts b/src/lib/js/cospendI18n.ts new file mode 100644 index 0000000..92186b9 --- /dev/null +++ b/src/lib/js/cospendI18n.ts @@ -0,0 +1,451 @@ +/** Cospend route i18n — slug mappings and UI translations */ + +/** Detect language from a cospend path by checking the root segment */ +export function detectCospendLang(pathname: string): 'en' | 'de' { + const first = pathname.split('/').filter(Boolean)[0]; + return first === 'expenses' ? 'en' : 'de'; +} + +/** Convert a cospend path to the target language */ +export function convertCospendPath(pathname: string, targetLang: 'en' | 'de'): string { + const targetRoot = targetLang === 'en' ? 'expenses' : 'cospend'; + return pathname.replace(/^\/(cospend|expenses)/, `/${targetRoot}`); +} + +/** Get the root slug for a given language */ +export function cospendRoot(lang: 'en' | 'de'): string { + return lang === 'en' ? 'expenses' : 'cospend'; +} + +/** Get translated nav labels */ +export function cospendLabels(lang: 'en' | 'de') { + return { + dash: lang === 'en' ? 'Dashboard' : 'Dashboard', + list: lang === 'en' ? 'List' : 'Liste', + payments: lang === 'en' ? 'All Payments' : 'Alle Zahlungen', + recurring: lang === 'en' ? 'Recurring' : 'Wiederkehrend' + }; +} + +type Translations = Record>; + +const translations: Translations = { + // Page titles + cospend_title: { en: 'Expenses - Expense Sharing', de: 'Cospend - Ausgabenteilung' }, + all_payments_title: { en: 'All Payments', de: 'Alle Zahlungen' }, + settle_title: { en: 'Settle Debts', de: 'Schulden begleichen' }, + recurring_title: { en: 'Recurring Payments', de: 'Wiederkehrende Zahlungen' }, + shopping_list_title: { en: 'Shopping List', de: 'Einkaufsliste' }, + payment_details: { en: 'Payment Details', de: 'Zahlungsdetails' }, + + // Dashboard + cospend: { en: 'Expenses', de: 'Cospend' }, + settle_debts: { en: 'Settle Debts', de: 'Schulden begleichen' }, + monthly_expenses_chart: { en: 'Monthly Expenses by Category', de: 'Monatliche Ausgaben nach Kategorie' }, + loading_monthly: { en: 'Loading monthly expenses chart...', de: 'Monatliche Ausgaben werden geladen...' }, + loading_recent: { en: 'Loading recent activity...', de: 'Letzte Aktivitäten werden geladen...' }, + recent_activity: { en: 'Recent Activity', de: 'Letzte Aktivität' }, + clear_filter: { en: 'Clear filter', de: 'Filter löschen' }, + no_recent_in: { en: 'No recent activity in', de: 'Keine Aktivität in' }, + paid_by: { en: 'Paid by', de: 'Bezahlt von' }, + payment: { en: 'Payment', de: 'Zahlung' }, + + // All Payments page + loading_payments: { en: 'Loading payments...', de: 'Zahlungen werden geladen...' }, + no_payments_yet: { en: 'No payments yet', de: 'Noch keine Zahlungen' }, + start_first_expense: { en: 'Start by adding your first shared expense', de: 'Füge deine erste geteilte Ausgabe hinzu' }, + add_first_payment: { en: 'Add Your First Payment', de: 'Erste Zahlung hinzufügen' }, + settlement: { en: 'Settlement', de: 'Ausgleich' }, + split_details: { en: 'Split Details', de: 'Aufteilung' }, + owes: { en: 'owes', de: 'schuldet' }, + owed: { en: 'owed', de: 'bekommt' }, + even: { en: 'even', de: 'ausgeglichen' }, + previous: { en: '← Previous', de: '← Zurück' }, + next: { en: 'Next →', de: 'Weiter →' }, + load_more: { en: 'Load More', de: 'Mehr laden' }, + loading_ellipsis: { en: 'Loading...', de: 'Laden...' }, + delete_payment_confirm: { en: 'Are you sure you want to delete this payment?', de: 'Diese Zahlung wirklich löschen?' }, + + // Payment detail labels + date: { en: 'Date:', de: 'Datum:' }, + paid_by_label: { en: 'Paid by:', de: 'Bezahlt von:' }, + created_by: { en: 'Created by:', de: 'Erstellt von:' }, + category_label: { en: 'Category:', de: 'Kategorie:' }, + split_method_label: { en: 'Split method:', de: 'Aufteilungsart:' }, + description: { en: 'Description', de: 'Beschreibung' }, + exchange_rate: { en: 'Exchange rate', de: 'Wechselkurs' }, + receipt: { en: 'Receipt', de: 'Beleg' }, + receipt_image: { en: 'Receipt Image', de: 'Belegbild' }, + remove_image: { en: 'Remove Image', de: 'Bild entfernen' }, + replace_image: { en: 'Replace Image', de: 'Bild ersetzen' }, + upload_receipt: { en: 'Upload Receipt Image', de: 'Beleg hochladen' }, + uploading_image: { en: 'Uploading image...', de: 'Bild wird hochgeladen...' }, + file_too_large: { en: 'File size must be less than 5MB', de: 'Dateigrösse muss unter 5MB sein' }, + invalid_image: { en: 'Please select a valid image file (JPEG, PNG, WebP)', de: 'Bitte eine gültige Bilddatei wählen (JPEG, PNG, WebP)' }, + you: { en: 'You', de: 'Du' }, + close: { en: 'Close', de: 'Schliessen' }, + + // Split descriptions + no_splits: { en: 'No splits', de: 'Keine Aufteilung' }, + split_equal: { en: 'Split equally among', de: 'Gleichmässig aufgeteilt auf' }, + paid_full_by: { en: 'Paid in full by', de: 'Vollständig bezahlt von' }, + personal_equal: { en: 'Personal amounts + equal split among', de: 'Persönliche Beträge + Gleichverteilung auf' }, + custom_split: { en: 'Custom split among', de: 'Individuelle Aufteilung auf' }, + people: { en: 'people', de: 'Personen' }, + + // Settle page + settle_subtitle: { en: 'Record payments to settle outstanding debts between users', de: 'Zahlungen erfassen, um offene Schulden auszugleichen' }, + loading_debts: { en: 'Loading debt information...', de: 'Schuldeninformationen werden geladen...' }, + all_settled: { en: 'All Settled!', de: 'Alles beglichen!' }, + no_debts_msg: { en: 'No outstanding debts to settle. Everyone is even!', de: 'Keine offenen Schulden. Alle sind ausgeglichen!' }, + back_to_dashboard: { en: 'Back to Dashboard', de: 'Zurück zum Dashboard' }, + available_settlements: { en: 'Available Settlements', de: 'Mögliche Ausgleiche' }, + money_owed_to_you: { en: "Money You're Owed", de: 'Geld, das du bekommst' }, + owes_you: { en: 'owes you', de: 'schuldet dir' }, + receive_payment: { en: 'Receive Payment', de: 'Zahlung empfangen' }, + money_you_owe: { en: 'Money You Owe', de: 'Geld, das du schuldest' }, + you_owe: { en: 'you owe', de: 'du schuldest' }, + make_payment: { en: 'Make Payment', de: 'Zahlung leisten' }, + settlement_details: { en: 'Settlement Details', de: 'Ausgleichsdetails' }, + settlement_amount: { en: 'Settlement Amount', de: 'Ausgleichsbetrag' }, + record_settlement: { en: 'Record Settlement', de: 'Ausgleich erfassen' }, + recording_settlement: { en: 'Recording Settlement...', de: 'Ausgleich wird erfasst...' }, + cancel: { en: 'Cancel', de: 'Abbrechen' }, + settlement_type: { en: 'Settlement Type', de: 'Ausgleichsart' }, + select_settlement: { en: 'Select settlement type', de: 'Ausgleichsart wählen' }, + receive_from: { en: 'Receive', de: 'Empfangen' }, + from: { en: 'from', de: 'von' }, + pay_to: { en: 'Pay', de: 'Zahlen' }, + to: { en: 'to', de: 'an' }, + from_user: { en: 'From User', de: 'Von Benutzer' }, + select_payer: { en: 'Select payer', de: 'Zahler wählen' }, + to_user: { en: 'To User', de: 'An Benutzer' }, + select_recipient: { en: 'Select recipient', de: 'Empfänger wählen' }, + settlement_amount_chf: { en: 'Settlement Amount (CHF)', de: 'Ausgleichsbetrag (CHF)' }, + error_select_settlement: { en: 'Please select a settlement and enter an amount', de: 'Bitte einen Ausgleich wählen und Betrag eingeben' }, + error_valid_amount: { en: 'Please enter a valid positive amount', de: 'Bitte einen gültigen positiven Betrag eingeben' }, + settlement_payment: { en: 'Settlement Payment', de: 'Ausgleichszahlung' }, + + // Recurring page + recurring_subtitle: { en: 'Automate your regular shared expenses', de: 'Automatisiere deine regelmässigen geteilten Ausgaben' }, + show_active_only: { en: 'Show active only', de: 'Nur aktive anzeigen' }, + loading_recurring: { en: 'Loading recurring payments...', de: 'Wiederkehrende Zahlungen werden geladen...' }, + no_recurring: { en: 'No recurring payments found', de: 'Keine wiederkehrenden Zahlungen gefunden' }, + no_recurring_desc: { en: 'Create your first recurring payment to automate regular expenses like rent, utilities, or subscriptions.', de: 'Erstelle deine erste wiederkehrende Zahlung für regelmässige Ausgaben wie Miete, Nebenkosten oder Abos.' }, + active: { en: 'Active', de: 'Aktiv' }, + inactive: { en: 'Inactive', de: 'Inaktiv' }, + frequency: { en: 'Frequency:', de: 'Häufigkeit:' }, + next_execution: { en: 'Next execution:', de: 'Nächste Ausführung:' }, + last_executed: { en: 'Last executed:', de: 'Zuletzt ausgeführt:' }, + ends: { en: 'Ends:', de: 'Endet:' }, + split_between: { en: 'Split between:', de: 'Aufgeteilt zwischen:' }, + gets: { en: 'gets', de: 'bekommt' }, + edit: { en: 'Edit', de: 'Bearbeiten' }, + pause: { en: 'Pause', de: 'Pausieren' }, + activate: { en: 'Activate', de: 'Aktivieren' }, + delete_: { en: 'Delete', de: 'Löschen' }, + delete_recurring_confirm: { en: 'Are you sure you want to delete the recurring payment', de: 'Wiederkehrende Zahlung wirklich löschen' }, + + // Shopping list + items_done: { en: 'done', de: 'erledigt' }, + add_item_placeholder: { en: 'Add item...', de: 'Artikel hinzufügen...' }, + empty_list: { en: 'The shopping list is empty', de: 'Die Einkaufsliste ist leer' }, + clear_checked: { en: 'Remove checked', de: 'Erledigte entfernen' }, + share: { en: 'Share', de: 'Teilen' }, + + // Share modal + shared_links: { en: 'Shared Links', de: 'Geteilte Links' }, + share_desc: { en: 'Anyone with an active link can edit the shopping list.', de: 'Jeder mit einem aktiven Link kann die Einkaufsliste bearbeiten.' }, + loading: { en: 'Loading...', de: 'Laden...' }, + no_active_links: { en: 'No active links.', de: 'Keine aktiven Links.' }, + remaining: { en: 'remaining', de: 'noch' }, + change: { en: 'Change', de: 'Ändern' }, + copy_link: { en: 'Copy link', de: 'Link kopieren' }, + create_new_link: { en: 'Create new link', de: 'Neuen Link erstellen' }, + copied: { en: 'Copied', de: 'Kopiert' }, + expired: { en: 'expired', de: 'abgelaufen' }, + + // TTL + ttl_1h: { en: '1 hour', de: '1 Stunde' }, + ttl_6h: { en: '6 hours', de: '6 Stunden' }, + ttl_24h: { en: '24 hours', de: '24 Stunden' }, + ttl_3d: { en: '3 days', de: '3 Tage' }, + ttl_7d: { en: '7 days', de: '7 Tage' }, + + // Edit modal + kategorie: { en: 'Category', de: 'Kategorie' }, + icon: { en: 'Icon', de: 'Icon' }, + search_icon: { en: 'Search icon...', de: 'Icon suchen...' }, + save: { en: 'Save', de: 'Speichern' }, + saving: { en: 'Saving...', de: 'Speichern...' }, + + // EnhancedBalance + your_balance: { en: 'Your Balance', de: 'Dein Saldo' }, + you_are_owed: { en: 'You are owed', de: 'Du bekommst' }, + you_owe_balance: { en: 'You owe', de: 'Du schuldest' }, + all_even: { en: "You're all even", de: 'Alles ausgeglichen' }, + owes_you_balance: { en: 'owes you', de: 'schuldet dir' }, + you_owe_user: { en: 'you owe', de: 'du schuldest' }, + transaction: { en: 'transaction', de: 'Transaktion' }, + transactions: { en: 'transactions', de: 'Transaktionen' }, + + // DebtBreakdown + debt_overview: { en: 'Debt Overview', de: 'Schuldenübersicht' }, + loading_debt_breakdown: { en: 'Loading debt breakdown...', de: 'Schuldenübersicht wird geladen...' }, + who_owes_you: { en: 'Who owes you', de: 'Wer dir schuldet' }, + you_owe_section: { en: 'You owe', de: 'Du schuldest' }, + total: { en: 'Total', de: 'Gesamt' }, + + // Frequency descriptions (recurring payments) + freq_every_day: { en: 'Every day', de: 'Jeden Tag' }, + freq_every_week: { en: 'Every week', de: 'Jede Woche' }, + freq_every_month: { en: 'Every month', de: 'Jeden Monat' }, + freq_custom: { en: 'Custom', de: 'Benutzerdefiniert' }, + freq_unknown: { en: 'Unknown frequency', de: 'Unbekannte Häufigkeit' }, + + // Next execution + today_at: { en: 'Today at', de: 'Heute um' }, + tomorrow_at: { en: 'Tomorrow at', de: 'Morgen um' }, + in_days_at: { en: 'In {days} days at', de: 'In {days} Tagen um' }, + + // UsersList + split_between_users: { en: 'Split Between Users', de: 'Aufteilen zwischen' }, + predefined_note: { en: 'Splitting between predefined users:', de: 'Aufteilung zwischen vordefinierten Benutzern:' }, + you: { en: 'You', de: 'Du' }, + remove: { en: 'Remove', de: 'Entfernen' }, + add_user_placeholder: { en: 'Add user...', de: 'Benutzer hinzufügen...' }, + add_user: { en: 'Add User', de: 'Benutzer hinzufügen' }, + + // SplitMethodSelector + split_method: { en: 'Split Method', de: 'Aufteilungsmethode' }, + how_split: { en: 'How should this payment be split?', de: 'Wie soll diese Zahlung aufgeteilt werden?' }, + split_5050: { en: 'Split 50/50', de: '50/50 teilen' }, + equal_split: { en: 'Equal Split', de: 'Gleichmässig' }, + personal_equal_split: { en: 'Personal + Equal Split', de: 'Persönlich + Gleichmässig' }, + custom_proportions: { en: 'Custom Proportions', de: 'Individuelle Anteile' }, + custom_split_amounts: { en: 'Custom Split Amounts', de: 'Individuelle Beträge' }, + personal_amounts: { en: 'Personal Amounts', de: 'Persönliche Beträge' }, + personal_amounts_desc: { en: 'Enter personal amounts for each user. The remainder will be split equally.', de: 'Persönliche Beträge pro Benutzer eingeben. Der Rest wird gleichmässig aufgeteilt.' }, + total_personal: { en: 'Total Personal', de: 'Persönlich gesamt' }, + remainder_to_split: { en: 'Remainder to Split', de: 'Restbetrag zum Aufteilen' }, + personal_exceeds_total: { en: 'Warning: Personal amounts exceed total payment amount!', de: 'Warnung: Persönliche Beträge übersteigen den Gesamtbetrag!' }, + split_preview: { en: 'Split Preview', de: 'Aufteilungsvorschau' }, + owes: { en: 'owes', de: 'schuldet' }, + is_owed: { en: 'is owed', de: 'bekommt' }, + error_prefix: { en: 'Error', de: 'Fehler' }, + + // Payment categories (for expense categories, not shopping) + cat_groceries: { en: 'Groceries', de: 'Lebensmittel' }, + cat_shopping: { en: 'Shopping', de: 'Einkauf' }, + cat_travel: { en: 'Travel', de: 'Reise' }, + cat_restaurant: { en: 'Restaurant', de: 'Restaurant' }, + cat_utilities: { en: 'Utilities', de: 'Nebenkosten' }, + cat_fun: { en: 'Fun', de: 'Freizeit' }, + cat_settlement: { en: 'Settlement', de: 'Ausgleich' }, + + // Payment add/edit forms + add_payment_title: { en: 'Add New Payment', de: 'Neue Zahlung' }, + add_payment_subtitle: { en: 'Create a new shared expense or recurring payment', de: 'Neue geteilte Ausgabe oder wiederkehrende Zahlung erstellen' }, + edit_payment_title: { en: 'Edit Payment', de: 'Zahlung bearbeiten' }, + edit_payment_subtitle: { en: 'Modify payment details and receipt image', de: 'Zahlungsdetails und Beleg bearbeiten' }, + edit_recurring_title: { en: 'Edit Recurring Payment', de: 'Wiederkehrende Zahlung bearbeiten' }, + payment_details_section: { en: 'Payment Details', de: 'Zahlungsdetails' }, + title_label: { en: 'Title *', de: 'Titel *' }, + title_placeholder: { en: 'e.g., Dinner at restaurant', de: 'z.B. Abendessen im Restaurant' }, + description_label: { en: 'Description', de: 'Beschreibung' }, + description_placeholder: { en: 'Additional details...', de: 'Weitere Details...' }, + category_star: { en: 'Category *', de: 'Kategorie *' }, + amount_label: { en: 'Amount *', de: 'Betrag *' }, + payment_date: { en: 'Payment Date', de: 'Zahlungsdatum' }, + paid_by_form: { en: 'Paid by', de: 'Bezahlt von' }, + make_recurring: { en: 'Make this a recurring payment', de: 'Als wiederkehrende Zahlung einrichten' }, + recurring_section: { en: 'Recurring Payment', de: 'Wiederkehrende Zahlung' }, + recurring_schedule: { en: 'Recurring Schedule', de: 'Wiederkehrender Zeitplan' }, + frequency_label: { en: 'Frequency *', de: 'Häufigkeit *' }, + freq_daily: { en: 'Daily', de: 'Täglich' }, + freq_weekly: { en: 'Weekly', de: 'Wöchentlich' }, + freq_monthly: { en: 'Monthly', de: 'Monatlich' }, + freq_quarterly: { en: 'Quarterly', de: 'Vierteljährlich' }, + freq_yearly: { en: 'Yearly', de: 'Jährlich' }, + freq_custom: { en: 'Custom (Cron)', de: 'Benutzerdefiniert (Cron)' }, + start_date: { en: 'Start Date *', de: 'Startdatum *' }, + end_date_optional: { en: 'End Date (optional)', de: 'Enddatum (optional)' }, + end_date_hint: { en: 'Leave empty for indefinite recurring', de: 'Leer lassen für unbefristete Wiederholung' }, + next_execution_preview: { en: 'Next Execution', de: 'Nächste Ausführung' }, + status_label: { en: 'Status', de: 'Status' }, + create_payment: { en: 'Create payment', de: 'Zahlung erstellen' }, + save_changes: { en: 'Save changes', de: 'Änderungen speichern' }, + delete_payment: { en: 'Delete Payment', de: 'Zahlung löschen' }, + deleting: { en: 'Deleting...', de: 'Löschen...' }, + + // Split configuration (edit page) + split_config: { en: 'Split Configuration', de: 'Aufteilungskonfiguration' }, + split_method_form: { en: 'Split Method:', de: 'Aufteilungsart:' }, + equal_split: { en: 'Equal Split', de: 'Gleichmässige Aufteilung' }, + personal_equal_split: { en: 'Personal + Equal Split', de: 'Persönliche Beträge + Gleichverteilung' }, + custom_proportions: { en: 'Custom Proportions', de: 'Individuelle Anteile' }, + personal_amounts: { en: 'Personal Amounts', de: 'Persönliche Beträge' }, + personal_amounts_desc: { en: 'Enter personal amounts for each user. The remainder will be split equally.', de: 'Persönliche Beträge für jeden Benutzer eingeben. Der Rest wird gleichmässig aufgeteilt.' }, + total_personal: { en: 'Total Personal', de: 'Persönliche Summe' }, + remainder_to_split: { en: 'Remainder to Split', de: 'Rest zum Aufteilen' }, + personal_exceeds: { en: 'Personal amounts exceed total payment amount!', de: 'Persönliche Beträge übersteigen den Gesamtbetrag!' }, + split_preview: { en: 'Split Preview', de: 'Aufteilungsvorschau' }, + + // Currency conversion + conversion_hint: { en: 'Amount will be converted to CHF using exchange rates for the payment date', de: 'Betrag wird anhand des Wechselkurses am Zahlungstag in CHF umgerechnet' }, + fetching_rate: { en: 'Fetching exchange rate...', de: 'Wechselkurs wird abgerufen...' }, + exchange_rate_date: { en: 'Exchange rate will be fetched for this date', de: 'Wechselkurs wird für dieses Datum abgerufen' }, + + // SplitMethodSelector + paid_in_full: { en: 'Paid in Full', de: 'Vollständig bezahlt' }, + paid_in_full_for: { en: 'Paid in Full for', de: 'Vollständig bezahlt für' }, + paid_in_full_by_you: { en: 'Paid in Full by You', de: 'Vollständig von dir bezahlt' }, + paid_in_full_by: { en: 'Paid in Full by', de: 'Vollständig bezahlt von' }, + + // Shopping category names (for EN display) + cat_fruits_veg: { en: 'Fruits & Vegetables', de: 'Obst & Gemüse' }, + cat_meat_fish: { en: 'Meat & Fish', de: 'Fleisch & Fisch' }, + cat_dairy: { en: 'Dairy', de: 'Milchprodukte' }, + cat_bakery: { en: 'Bread & Bakery', de: 'Brot & Backwaren' }, + cat_grains: { en: 'Pasta, Rice & Grains', de: 'Pasta, Reis & Getreide' }, + cat_spices: { en: 'Spices & Sauces', de: 'Gewürze & Saucen' }, + cat_drinks: { en: 'Beverages', de: 'Getränke' }, + cat_sweets: { en: 'Sweets & Snacks', de: 'Süßes & Snacks' }, + cat_frozen: { en: 'Frozen', de: 'Tiefkühl' }, + cat_household: { en: 'Household', de: 'Haushalt' }, + cat_hygiene: { en: 'Hygiene & Body Care', de: 'Hygiene & Körperpflege' }, + cat_other: { en: 'Other', de: 'Sonstiges' }, +}; + +/** Category name translation map (German key → display name per language) */ +const categoryDisplayNames: Record> = { + 'Obst & Gemüse': { en: 'Fruits & Vegetables', de: 'Obst & Gemüse' }, + 'Fleisch & Fisch': { en: 'Meat & Fish', de: 'Fleisch & Fisch' }, + 'Milchprodukte': { en: 'Dairy', de: 'Milchprodukte' }, + 'Brot & Backwaren': { en: 'Bread & Bakery', de: 'Brot & Backwaren' }, + 'Pasta, Reis & Getreide': { en: 'Pasta, Rice & Grains', de: 'Pasta, Reis & Getreide' }, + 'Gewürze & Saucen': { en: 'Spices & Sauces', de: 'Gewürze & Saucen' }, + 'Getränke': { en: 'Beverages', de: 'Getränke' }, + 'Süßes & Snacks': { en: 'Sweets & Snacks', de: 'Süßes & Snacks' }, + 'Tiefkühl': { en: 'Frozen', de: 'Tiefkühl' }, + 'Haushalt': { en: 'Household', de: 'Haushalt' }, + 'Hygiene & Körperpflege': { en: 'Hygiene & Body Care', de: 'Hygiene & Körperpflege' }, + 'Sonstiges': { en: 'Other', de: 'Sonstiges' }, +}; + +/** Get translated category display name (shopping categories) */ +export function categoryName(category: string, lang: 'en' | 'de'): string { + return categoryDisplayNames[category]?.[lang] ?? category; +} + +/** Payment category translation map */ +const paymentCategoryNames: Record> = { + groceries: { en: 'Groceries', de: 'Lebensmittel' }, + shopping: { en: 'Shopping', de: 'Einkauf' }, + travel: { en: 'Travel', de: 'Reise' }, + restaurant: { en: 'Restaurant', de: 'Restaurant' }, + utilities: { en: 'Utilities', de: 'Nebenkosten' }, + fun: { en: 'Fun', de: 'Freizeit' }, + settlement: { en: 'Settlement', de: 'Ausgleich' }, +}; + +/** Get translated payment category name */ +export function paymentCategoryName(category: string, lang: 'en' | 'de'): string { + return paymentCategoryNames[category]?.[lang] ?? category; +} + +/** Get category options with translated labels */ +export function getCategoryOptionsI18n(lang: 'en' | 'de') { + const emojis: Record = { + groceries: '🛒', shopping: '🛍️', travel: '🚆', + restaurant: '🍽️', utilities: '⚡', fun: '🎉', settlement: '🤝' + }; + return Object.keys(paymentCategoryNames).map(key => ({ + value: key, + label: `${emojis[key] || ''} ${paymentCategoryName(key, lang)}`, + emoji: emojis[key] || '', + name: paymentCategoryName(key, lang) + })); +} + +/** Get a translated string */ +export function t(key: string, lang: 'en' | 'de'): string { + return translations[key]?.[lang] ?? translations[key]?.en ?? key; +} + +/** Format TTL remaining time in the target language */ +export function formatTTL(expiresAt: string, lang: 'en' | 'de'): string { + const diff = new Date(expiresAt).getTime() - Date.now(); + if (diff <= 0) return t('expired', lang); + const mins = Math.round(diff / 60000); + if (mins < 60) return `${mins} min`; + const hours = Math.round(diff / 3600000); + if (hours < 24) return `${hours} ${lang === 'en' ? 'hrs' : 'Std.'}`; + const days = Math.round(diff / 86400000); + return `${days} ${lang === 'en' ? (days > 1 ? 'days' : 'day') : (days > 1 ? 'Tage' : 'Tag')}`; +} + +/** Get TTL options for the given language */ +export function ttlOptions(lang: 'en' | 'de') { + return [ + { label: t('ttl_1h', lang), ms: 1 * 60 * 60 * 1000 }, + { label: t('ttl_6h', lang), ms: 6 * 60 * 60 * 1000 }, + { label: t('ttl_24h', lang), ms: 24 * 60 * 60 * 1000 }, + { label: t('ttl_3d', lang), ms: 3 * 24 * 60 * 60 * 1000 }, + { label: t('ttl_7d', lang), ms: 7 * 24 * 60 * 60 * 1000 }, + ]; +} + +/** Get locale string for number/date formatting */ +export function locale(lang: 'en' | 'de'): string { + return lang === 'en' ? 'en-CH' : 'de-CH'; +} + +/** Build a split description string */ +export function splitDescription(payment: { splits?: any[]; splitMethod?: string; paidBy?: string }, lang: 'en' | 'de'): string { + if (!payment.splits || payment.splits.length === 0) return t('no_splits', lang); + + const count = payment.splits.length; + if (payment.splitMethod === 'equal') { + return `${t('split_equal', lang)} ${count} ${t('people', lang)}`; + } else if (payment.splitMethod === 'full') { + return `${t('paid_full_by', lang)} ${payment.paidBy}`; + } else if (payment.splitMethod === 'personal_equal') { + return `${t('personal_equal', lang)} ${count} ${t('people', lang)}`; + } else { + return `${t('custom_split', lang)} ${count} ${t('people', lang)}`; + } +} + +/** Get translated frequency description for a recurring payment */ +export function frequencyDescription(payment: { frequency: string; cronExpression?: string }, lang: 'en' | 'de'): string { + switch (payment.frequency) { + case 'daily': return t('freq_every_day', lang); + case 'weekly': return t('freq_every_week', lang); + case 'monthly': return t('freq_every_month', lang); + case 'custom': return `${t('freq_custom', lang)}: ${payment.cronExpression}`; + default: return t('freq_unknown', lang); + } +} + +/** Format next execution date with i18n */ +export function formatNextExecutionI18n(date: Date, lang: 'en' | 'de'): string { + const loc = locale(lang); + const now = new Date(); + const diffMs = date.getTime() - now.getTime(); + const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); + const timeStr = date.toLocaleTimeString(loc, { hour: '2-digit', minute: '2-digit' }); + + if (diffDays === 0) { + return `${t('today_at', lang)} ${timeStr}`; + } else if (diffDays === 1) { + return `${t('tomorrow_at', lang)} ${timeStr}`; + } else if (diffDays < 7) { + return `${t('in_days_at', lang).replace('{days}', String(diffDays))} ${timeStr}`; + } else { + return date.toLocaleString(loc, { + year: 'numeric', month: 'short', day: 'numeric', + hour: '2-digit', minute: '2-digit' + }); + } +} diff --git a/src/params/cospendRoot.ts b/src/params/cospendRoot.ts new file mode 100644 index 0000000..9426484 --- /dev/null +++ b/src/params/cospendRoot.ts @@ -0,0 +1,5 @@ +import type { ParamMatcher } from '@sveltejs/kit'; + +export const match: ParamMatcher = (param) => { + return param === 'cospend' || param === 'expenses'; +}; diff --git a/src/routes/[cospendRoot=cospendRoot]/+layout.server.ts b/src/routes/[cospendRoot=cospendRoot]/+layout.server.ts new file mode 100644 index 0000000..3b53cdb --- /dev/null +++ b/src/routes/[cospendRoot=cospendRoot]/+layout.server.ts @@ -0,0 +1,9 @@ +import type { LayoutServerLoad } from "./$types" +import { detectCospendLang } from '$lib/js/cospendI18n'; + +export const load : LayoutServerLoad = async ({locals, url}) => { + return { + session: locals.session ?? await locals.auth(), + lang: detectCospendLang(url.pathname) as 'en' | 'de' + } +}; diff --git a/src/routes/cospend/+layout.svelte b/src/routes/[cospendRoot=cospendRoot]/+layout.svelte similarity index 71% rename from src/routes/cospend/+layout.svelte rename to src/routes/[cospendRoot=cospendRoot]/+layout.svelte index a5cd9a2..98ab9db 100644 --- a/src/routes/cospend/+layout.svelte +++ b/src/routes/[cospendRoot=cospendRoot]/+layout.svelte @@ -7,10 +7,16 @@ import PaymentModal from '$lib/components/cospend/PaymentModal.svelte'; import Header from '$lib/components/Header.svelte'; import UserHeader from '$lib/components/UserHeader.svelte'; + import LanguageSelector from '$lib/components/LanguageSelector.svelte'; import { LayoutDashboard, Wallet, RefreshCw, ShoppingCart } from '@lucide/svelte'; + import { detectCospendLang, cospendRoot, cospendLabels } from '$lib/js/cospendI18n'; let { data, children } = $props(); + const lang = $derived(detectCospendLang($page.url.pathname)); + const root = $derived(cospendRoot(lang)); + const labels = $derived(cospendLabels(lang)); + let showModal = $state(false); /** @type {string | null} */ let paymentId = $state(null); @@ -19,14 +25,14 @@ $effect(() => { // Check if URL contains payment view route OR if we have paymentId in state - const match = $page.url.pathname.match(/\/cospend\/payments\/view\/([^\/]+)/); + const match = $page.url.pathname.match(/\/(cospend|expenses)\/payments\/view\/([^\/]+)/); const statePaymentId = $page.state?.paymentId; - const isOnDashboard = $page.route.id === '/cospend/dash'; + const isOnDashboard = $page.route.id === '/[cospendRoot=cospendRoot]/dash'; // Only show modal if we're on the dashboard AND have a payment to show if (isOnDashboard && (match || statePaymentId)) { showModal = true; - paymentId = match ? match[1] : statePaymentId ?? null; + paymentId = match ? match[2] : statePaymentId ?? null; } else { showModal = false; paymentId = null; @@ -39,7 +45,7 @@ paymentId = null; // Dispatch a custom event to trigger dashboard refresh - if ($page.route.id === '/cospend/dash') { + if ($page.route.id === '/[cospendRoot=cospendRoot]/dash') { window.dispatchEvent(new CustomEvent('dashboardRefresh')); } } @@ -47,9 +53,9 @@ /** @param {string} path */ function isActive(path) { const currentPath = $page.url.pathname; - // Exact match for cospend root - if (path === '/cospend/dash') { - return currentPath === '/cospend/dash' || currentPath === '/cospend/dash/'; + // Exact match for dash + if (path.endsWith('/dash')) { + return currentPath === path || currentPath === path + '/'; } // For other paths, check if current path starts with the link path return currentPath.startsWith(path); @@ -60,17 +66,18 @@ {#snippet links()} {/snippet} {#snippet right_side()} + {/snippet} diff --git a/src/routes/[cospendRoot=cospendRoot]/+page.server.ts b/src/routes/[cospendRoot=cospendRoot]/+page.server.ts new file mode 100644 index 0000000..a53b479 --- /dev/null +++ b/src/routes/[cospendRoot=cospendRoot]/+page.server.ts @@ -0,0 +1,7 @@ +import { redirect } from '@sveltejs/kit'; +import { detectCospendLang, cospendRoot } from '$lib/js/cospendI18n'; + +export function load({ url }) { + const lang = detectCospendLang(url.pathname); + redirect(302, `/${cospendRoot(lang)}/list`); +} diff --git a/src/routes/cospend/dash/+page.server.ts b/src/routes/[cospendRoot=cospendRoot]/dash/+page.server.ts similarity index 100% rename from src/routes/cospend/dash/+page.server.ts rename to src/routes/[cospendRoot=cospendRoot]/dash/+page.server.ts diff --git a/src/routes/cospend/dash/+page.svelte b/src/routes/[cospendRoot=cospendRoot]/dash/+page.svelte similarity index 90% rename from src/routes/cospend/dash/+page.svelte rename to src/routes/[cospendRoot=cospendRoot]/dash/+page.svelte index d09bd44..f1f9ef9 100644 --- a/src/routes/cospend/dash/+page.svelte +++ b/src/routes/[cospendRoot=cospendRoot]/dash/+page.svelte @@ -7,14 +7,18 @@ import EnhancedBalance from '$lib/components/cospend/EnhancedBalance.svelte'; import DebtBreakdown from '$lib/components/cospend/DebtBreakdown.svelte'; import BarChart from '$lib/components/cospend/BarChart.svelte'; - import { getCategoryEmoji, getCategoryName } from '$lib/utils/categories'; + import { getCategoryEmoji } from '$lib/utils/categories'; import { isSettlementPayment, getSettlementIcon, getSettlementClasses, getSettlementReceiver } from '$lib/utils/settlements'; import AddButton from '$lib/components/AddButton.svelte'; import { formatCurrency } from '$lib/utils/formatters'; + import { detectCospendLang, cospendRoot, t, locale, paymentCategoryName } from '$lib/js/cospendI18n'; let { data } = $props(); // Contains session data and balance from server + const lang = $derived(detectCospendLang($page.url.pathname)); + const root = $derived(cospendRoot(lang)); + const loc = $derived(locale(lang)); // Use server-side data, with fallback for progressive enhancement let balance = $derived(data.balance || { @@ -79,7 +83,7 @@ } function formatDate(/** @type {string} */ dateString) { - return new Date(dateString).toLocaleDateString('de-CH'); + return new Date(dateString).toLocaleDateString(loc); } function truncateDescription(/** @type {string} */ description, maxLength = 100) { @@ -92,7 +96,7 @@ // Progressive enhancement: if JavaScript is available, use pushState for modal behavior if (typeof pushState !== 'undefined') { event.preventDefault(); - pushState(`/cospend/payments/view/${paymentId}`, { paymentId }); + pushState(`/${root}/payments/view/${paymentId}`, { paymentId }); } // Otherwise, let the regular link navigation happen (no preventDefault) } @@ -120,11 +124,11 @@ - Cospend - Expense Sharing + {t('cospend_title', lang)}
-

Cospend

+

{t('cospend', lang)}

@@ -133,7 +137,7 @@
{#if balance.netBalance !== 0} - Settle Debts + {t('settle_debts', lang)} {/if}
@@ -143,12 +147,13 @@
{#if expensesLoading} -
Loading monthly expenses chart...
+
{t('loading_monthly', lang)}
{:else if monthlyExpensesData.datasets && monthlyExpensesData.datasets.length > 0} categoryFilter = categories} /> {:else} @@ -162,26 +167,26 @@
{#if loading} -
Loading recent activity...
+
{t('loading_recent', lang)}
{:else if error}
Error: {error}
{:else if balance.recentSplits && balance.recentSplits.length > 0}
-

Recent Activity{#if categoryFilter} — {categoryFilter.map((/** @type {any} */ c) => getCategoryName(c)).join(', ')}{/if}

+

{t('recent_activity', lang)}{#if categoryFilter} — {categoryFilter.map((/** @type {any} */ c) => paymentCategoryName(c, lang)).join(', ')}{/if}

{#if categoryFilter} - + {/if}
{#if filteredSplits.length === 0} -

No recent activity in {categoryFilter ? categoryFilter.map((/** @type {any} */ c) => getCategoryName(c)).join(', ') : ''}.

+

{t('no_recent_in', lang)} {categoryFilter ? categoryFilter.map((/** @type {any} */ c) => paymentCategoryName(c, lang)).join(', ') : ''}.

{/if}
{#each filteredSplits as split} {#if isSettlementPayment(split.paymentId)} handlePaymentClick(split.paymentId?._id, e)} > @@ -193,7 +198,7 @@
- +