refactor(i18n): split cospend + calendar per-locale, adopt t.key syntax

Cospend translations move to src/lib/i18n/cospend/{de,en}.ts with
satisfies-based key-set enforcement, mirroring the fitness layout
shipped earlier. cospendI18n.ts becomes the same kind of slim shim
exporting m, CospendLang, CospendKey while keeping every existing
helper (detectCospendLang, paymentCategoryName, splitDescription,
formatNextExecutionI18n, etc.) on the same surface.

Calendar gets the same treatment but with three locales (de/en/la)
and two namespaces — `ui` and the rite-1962-specific `ui1962`.
calendarI18n.ts now imports both as m / m1962, types them as
CalendarKey / Calendar1962Key, and routes t() / t1962() through
them. The 1962 fallback is per-namespace dir with file-prefixed
locale files (de_1962.ts etc.) so they can co-exist.

19 cospend route/component files and 3 calendar pages migrated to
the t.key / t1962.key syntax. Two notable hand fixes: UsersList.svelte
needed `as CospendLang` because the `lang` prop default uses an `as`
cast that breaks TS narrowing of m[lang]; and a sed pass converted
codemod-emitted t['camelCase'] to t.camelCase since the static-key
regex initially only matched snake_case.

The split + codemod scripts are now generic — split-i18n.ts takes
namespace, locales, optional marker and basename for multi-table
modules; codemod-i18n-t-to-m.ts takes module basename, fn name, and
m alias name (so t1962 / m1962 share the same machinery as t / m).
The fitness-specific one-shots are deleted, superseded.
This commit is contained in:
2026-05-01 12:47:46 +02:00
parent ac05367ee4
commit 3347619816
32 changed files with 1253 additions and 903 deletions
+11 -10
View File
@@ -3,9 +3,10 @@
import { page } from '$app/state';
import ProfilePicture from './ProfilePicture.svelte';
import { formatCurrency } from '$lib/utils/formatters';
import { detectCospendLang, locale, t } from '$lib/js/cospendI18n';
import { detectCospendLang, locale, m } from '$lib/js/cospendI18n';
const lang = $derived(detectCospendLang(page.url.pathname));
const t = $derived(m[lang]);
const loc = $derived(locale(lang));
/**
@@ -66,19 +67,19 @@
{#if !shouldHide}
<div class="debt-breakdown">
<h2>{t('debt_overview', lang)}</h2>
<h2>{t.debt_overview}</h2>
{#if loading}
<div class="loading">{t('loading_debt_breakdown', lang)}</div>
<div class="loading">{t.loading_debt_breakdown}</div>
{:else if error}
<div class="error">{t('error_prefix', lang)}: {error}</div>
<div class="error">{t.error_prefix}: {error}</div>
{:else}
<div class="debt-sections">
{#if debtData.whoOwesMe.length > 0}
<div class="debt-section owed-to-me">
<h3>{t('who_owes_you', lang)}</h3>
<h3>{t.who_owes_you}</h3>
<div class="total-amount positive">
{t('total', lang)}: {formatCurrency(debtData.totalOwedToMe, 'CHF', loc)}
{t.total}: {formatCurrency(debtData.totalOwedToMe, 'CHF', loc)}
</div>
<div class="debt-list">
@@ -92,7 +93,7 @@
</div>
</div>
<div class="transaction-count">
{debt.transactions.length} {debt.transactions.length !== 1 ? t('transactions', lang) : t('transaction', lang)}
{debt.transactions.length} {debt.transactions.length !== 1 ? t.transactions : t.transaction}
</div>
</div>
{/each}
@@ -102,9 +103,9 @@
{#if debtData.whoIOwe.length > 0}
<div class="debt-section owe-to-others">
<h3>{t('you_owe_section', lang)}</h3>
<h3>{t.you_owe_section}</h3>
<div class="total-amount negative">
{t('total', lang)}: {formatCurrency(debtData.totalIOwe, 'CHF', loc)}
{t.total}: {formatCurrency(debtData.totalIOwe, 'CHF', loc)}
</div>
<div class="debt-list">
@@ -118,7 +119,7 @@
</div>
</div>
<div class="transaction-count">
{debt.transactions.length} {debt.transactions.length !== 1 ? t('transactions', lang) : t('transaction', lang)}
{debt.transactions.length} {debt.transactions.length !== 1 ? t.transactions : t.transaction}
</div>
</div>
{/each}
@@ -3,9 +3,10 @@
import { page } from '$app/state';
import ProfilePicture from './ProfilePicture.svelte';
import { formatCurrency as formatCurrencyUtil } from '$lib/utils/formatters';
import { detectCospendLang, locale, t } from '$lib/js/cospendI18n';
import { detectCospendLang, locale, m } from '$lib/js/cospendI18n';
const lang = $derived(detectCospendLang(page.url.pathname));
const t = $derived(m[lang]);
const loc = $derived(locale(lang));
let { initialBalance = null, initialDebtData = null } = $props<{ initialBalance?: any, initialDebtData?: any }>();
@@ -122,26 +123,26 @@
{#if loading}
<div class="loading-content">
<h3>{t('your_balance', lang)}</h3>
<div class="loading">{t('loading', lang)}</div>
<h3>{t.your_balance}</h3>
<div class="loading">{t.loading}</div>
</div>
{:else if error}
<h3>{t('your_balance', lang)}</h3>
<div class="error">{t('error_prefix', lang)}: {error}</div>
<h3>{t.your_balance}</h3>
<div class="error">{t.error_prefix}: {error}</div>
{:else if shouldShowIntegratedView}
<!-- Enhanced view with single user debt -->
<h3>{t('your_balance', lang)}</h3>
<h3>{t.your_balance}</h3>
<div class="enhanced-balance">
<div class="main-amount">
{#if balance.netBalance < 0}
<span class="positive">+{formatCurrency(balance.netBalance)}</span>
<small>{t('you_are_owed', lang)}</small>
<small>{t.you_are_owed}</small>
{:else if balance.netBalance > 0}
<span class="negative">-{formatCurrency(balance.netBalance)}</span>
<small>{t('you_owe_balance', lang)}</small>
<small>{t.you_owe_balance}</small>
{:else}
<span class="even">CHF 0.00</span>
<small>{t('all_even', lang)}</small>
<small>{t.all_even}</small>
{/if}
</div>
@@ -154,9 +155,9 @@
<span class="username">{singleDebtUser.user.username}</span>
<span class="debt-description">
{#if singleDebtUser.type === 'owesMe'}
{t('owes_you_balance', lang)} {formatCurrency(singleDebtUser.amount)}
{t.owes_you_balance} {formatCurrency(singleDebtUser.amount)}
{:else}
{t('you_owe_user', lang)} {formatCurrency(singleDebtUser.amount)}
{t.you_owe_user} {formatCurrency(singleDebtUser.amount)}
{/if}
</span>
</div>
@@ -166,24 +167,24 @@
</div>
<div class="transaction-count">
{#if singleDebtUser && singleDebtUser.user && singleDebtUser.user.transactions}
{singleDebtUser.user.transactions.length} {singleDebtUser.user.transactions.length !== 1 ? t('transactions', lang) : t('transaction', lang)}
{singleDebtUser.user.transactions.length} {singleDebtUser.user.transactions.length !== 1 ? t.transactions : t.transaction}
{/if}
</div>
</div>
</div>
{:else}
<!-- Standard balance view -->
<h3>{t('your_balance', lang)}</h3>
<h3>{t.your_balance}</h3>
<div class="amount">
{#if balance.netBalance < 0}
<span class="positive">+{formatCurrency(balance.netBalance)}</span>
<small>{t('you_are_owed', lang)}</small>
<small>{t.you_are_owed}</small>
{:else if balance.netBalance > 0}
<span class="negative">-{formatCurrency(balance.netBalance)}</span>
<small>{t('you_owe_balance', lang)}</small>
<small>{t.you_owe_balance}</small>
{:else}
<span class="even">CHF 0.00</span>
<small>{t('all_even', lang)}</small>
<small>{t.all_even}</small>
{/if}
</div>
{/if}
+19 -18
View File
@@ -7,7 +7,7 @@
import EditButton from '$lib/components/EditButton.svelte';
import { getCategoryEmoji } from '$lib/utils/categories';
import { formatCurrency as formatCurrencyUtil } from '$lib/utils/formatters';
import { detectCospendLang, cospendRoot, t, locale, splitDescription, paymentCategoryName } from '$lib/js/cospendI18n';
import { detectCospendLang, cospendRoot, locale, splitDescription, paymentCategoryName, m } from '$lib/js/cospendI18n';
import { confirm } from '$lib/js/confirmDialog.svelte';
let { paymentId, onclose, onpaymentDeleted } = $props();
@@ -16,6 +16,7 @@
let session = $derived(page.data?.session);
const lang = $derived(detectCospendLang(page.url.pathname));
const t = $derived(m[lang]);
const root = $derived(cospendRoot(lang));
const loc = $derived(locale(lang));
@@ -112,7 +113,7 @@
let deleting = $state(false);
async function deletePayment() {
if (!await confirm(t('delete_payment_confirm', lang))) {
if (!await confirm(t.delete_payment_confirm)) {
return;
}
@@ -140,7 +141,7 @@
<div class="panel-content" bind:this={modal}>
<div class="panel-header">
<h2>{t('payment_details', lang)}</h2>
<h2>{t.payment_details}</h2>
<button class="close-button" onclick={closeModal} aria-label="Close modal">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"></line>
@@ -151,9 +152,9 @@
<div class="panel-body">
{#if loading}
<div class="loading">{t('loading_payments', lang)}</div>
<div class="loading">{t.loading_payments}</div>
{:else if error}
<div class="error">{t('error_prefix', lang)}: {error}</div>
<div class="error">{t.error_prefix}: {error}</div>
{:else if payment}
<div class="payment-details">
<div class="payment-header">
@@ -168,7 +169,7 @@
</div>
{#if payment.image}
<div class="receipt-image">
<img src={payment.image} alt={t('receipt', lang)} />
<img src={payment.image} alt={t.receipt} />
</div>
{/if}
</div>
@@ -176,30 +177,30 @@
<div class="payment-info">
<div class="info-grid">
<div class="info-item">
<span class="label">{t('date', lang)}</span>
<span class="label">{t.date}</span>
<span class="value">{formatDate(payment.date)}</span>
</div>
<div class="info-item">
<span class="label">{t('paid_by_label', lang)}</span>
<span class="label">{t.paid_by_label}</span>
<span class="value">{payment.paidBy}</span>
</div>
<div class="info-item">
<span class="label">{t('created_by', lang)}</span>
<span class="label">{t.created_by}</span>
<span class="value">{payment.createdBy}</span>
</div>
<div class="info-item">
<span class="label">{t('category_label', lang)}</span>
<span class="label">{t.category_label}</span>
<span class="value">{paymentCategoryName(payment.category || 'groceries', lang)}</span>
</div>
<div class="info-item">
<span class="label">{t('split_method_label', lang)}</span>
<span class="label">{t.split_method_label}</span>
<span class="value">{getSplitDescription(payment)}</span>
</div>
</div>
{#if payment.description}
<div class="description">
<h3>{t('description', lang)}</h3>
<h3>{t.description}</h3>
<p>{payment.description}</p>
</div>
{/if}
@@ -207,7 +208,7 @@
{#if payment.splits && payment.splits.length > 0}
<div class="splits-section">
<h3>{t('split_details', lang)}</h3>
<h3>{t.split_details}</h3>
<div class="splits-list">
{#each payment.splits as split}
<div class="split-item" class:current-user={split.username === session?.user?.nickname}>
@@ -216,17 +217,17 @@
<div class="user-info">
<span class="username">{split.username}</span>
{#if split.username === session?.user?.nickname}
<span class="you-badge">{t('you', lang)}</span>
<span class="you-badge">{t.you}</span>
{/if}
</div>
</div>
<div class="split-amount" class:positive={split.amount < 0} class:negative={split.amount > 0}>
{#if split.amount > 0}
{t('owes', lang)} {formatCurrency(split.amount)}
{t.owes} {formatCurrency(split.amount)}
{:else if split.amount < 0}
{t('owed', lang)} {formatCurrency(split.amount)}
{t.owed} {formatCurrency(split.amount)}
{:else}
{t('even', lang)}
{t.even}
{/if}
</div>
</div>
@@ -236,7 +237,7 @@
{/if}
<div class="panel-actions">
<button class="btn-secondary" onclick={closeModal}>{t('close', lang)}</button>
<button class="btn-secondary" onclick={closeModal}>{t.close}</button>
</div>
</div>
{/if}
@@ -1,9 +1,10 @@
<script>
import ProfilePicture from './ProfilePicture.svelte';
import { page } from '$app/state';
import { detectCospendLang, t } from '$lib/js/cospendI18n';
import { detectCospendLang, m } from '$lib/js/cospendI18n';
const lang = $derived(detectCospendLang(page.url.pathname));
const t = $derived(m[lang]);
let {
splitMethod = $bindable('equal'),
@@ -22,20 +23,20 @@
// Reactive text for "Paid in Full" option
let paidInFullText = $derived((() => {
if (!paidBy) {
return t('paid_in_full', lang);
return t.paid_in_full;
}
// Special handling for 2-user predefined setup
if (predefinedMode && users.length === 2) {
const otherUser = users.find((/** @type {string} */ user) => user !== paidBy);
return otherUser ? `${t('paid_in_full_for', lang)} ${otherUser}` : t('paid_in_full', lang);
return otherUser ? `${t.paid_in_full_for} ${otherUser}` : t.paid_in_full;
}
// General case
if (paidBy === currentUser) {
return t('paid_in_full_by_you', lang);
return t.paid_in_full_by_you;
} else {
return `${t('paid_in_full_by', lang)} ${paidBy}`;
return `${t.paid_in_full_by} ${paidBy}`;
}
})());
@@ -132,21 +133,21 @@
</script>
<div class="form-section">
<h2>{t('split_method', lang)}</h2>
<h2>{t.split_method}</h2>
<div class="form-group">
<label for="splitMethod">{t('how_split', lang)}</label>
<label for="splitMethod">{t.how_split}</label>
<select id="splitMethod" name="splitMethod" bind:value={splitMethod} required>
<option value="equal">{predefinedMode && users.length === 2 ? t('split_5050', lang) : t('equal_split', lang)}</option>
<option value="personal_equal">{t('personal_equal_split', lang)}</option>
<option value="equal">{predefinedMode && users.length === 2 ? t.split_5050 : t.equal_split}</option>
<option value="personal_equal">{t.personal_equal_split}</option>
<option value="full">{paidInFullText}</option>
<option value="proportional">{t('custom_proportions', lang)}</option>
<option value="proportional">{t.custom_proportions}</option>
</select>
</div>
{#if splitMethod === 'proportional'}
<div class="proportional-splits">
<h3>{t('custom_split_amounts', lang)}</h3>
<h3>{t.custom_split_amounts}</h3>
{#each users as user}
<div class="split-input">
<label for="split_{user}">{user}</label>
@@ -165,8 +166,8 @@
{#if splitMethod === 'personal_equal'}
<div class="personal-splits">
<h3>{t('personal_amounts', lang)}</h3>
<p class="description">{t('personal_amounts_desc', lang)}</p>
<h3>{t.personal_amounts}</h3>
<p class="description">{t.personal_amounts_desc}</p>
{#each users as user}
<div class="split-input">
<label for="personal_{user}">{user}</label>
@@ -184,10 +185,10 @@
{#if amount}
{@const personalTotal = Object.values(personalAmounts).reduce((/** @type {number} */ sum, /** @type {number} */ val) => sum + (Number(val) || 0), 0)}
<div class="remainder-info" class:error={personalTotalError}>
<span>{t('total_personal', lang)}: {currency} {personalTotal.toFixed(2)}</span>
<span>{t('remainder_to_split', lang)}: {currency} {Math.max(0, Number(amount) - personalTotal).toFixed(2)}</span>
<span>{t.total_personal}: {currency} {personalTotal.toFixed(2)}</span>
<span>{t.remainder_to_split}: {currency} {Math.max(0, Number(amount) - personalTotal).toFixed(2)}</span>
{#if personalTotalError}
<div class="error-message">{t('personal_exceeds_total', lang)}</div>
<div class="error-message">{t.personal_exceeds_total}</div>
{/if}
</div>
{/if}
@@ -196,7 +197,7 @@
{#if Object.keys(splitAmounts).length > 0}
<div class="split-preview">
<h3>{t('split_preview', lang)}</h3>
<h3>{t.split_preview}</h3>
{#each users as user}
<div class="split-item">
<div class="split-user">
@@ -205,11 +206,11 @@
</div>
<span class="amount" class:positive={splitAmounts[user] < 0} class:negative={splitAmounts[user] > 0}>
{#if splitAmounts[user] > 0}
{t('owes', lang)} {currency} {splitAmounts[user].toFixed(2)}
{t.owes} {currency} {splitAmounts[user].toFixed(2)}
{:else if splitAmounts[user] < 0}
{t('is_owed', lang)} {currency} {Math.abs(splitAmounts[user]).toFixed(2)}
{t.is_owed} {currency} {Math.abs(splitAmounts[user]).toFixed(2)}
{:else}
{t('owes', lang)} {currency} {splitAmounts[user].toFixed(2)}
{t.owes} {currency} {splitAmounts[user].toFixed(2)}
{/if}
</span>
</div>
+9 -8
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import ProfilePicture from './ProfilePicture.svelte';
import { t } from '$lib/js/cospendI18n';
import { m, type CospendLang } from '$lib/js/cospendI18n';
let {
users = $bindable([]),
@@ -17,6 +17,7 @@
newUser?: string,
lang?: 'en' | 'de'
}>();
const t = $derived(m[lang as CospendLang]);
function addUser() {
if (predefinedMode) return;
@@ -38,18 +39,18 @@
</script>
<div class="form-section">
<h2>{t('split_between_users', lang)}</h2>
<h2>{t.split_between_users}</h2>
{#if predefinedMode}
<div class="predefined-users">
<p class="predefined-note">{t('predefined_note', lang)}</p>
<p class="predefined-note">{t.predefined_note}</p>
<div class="users-list">
{#each users as user}
<div class="user-item with-profile">
<ProfilePicture username={user} size={32} />
<span class="username">{user}</span>
{#if user === currentUser}
<span class="you-badge">{t('you', lang)}</span>
<span class="you-badge">{t.you}</span>
{/if}
</div>
{/each}
@@ -62,11 +63,11 @@
<ProfilePicture username={user} size={32} />
<span class="username">{user}</span>
{#if user === currentUser}
<span class="you-badge">{t('you', lang)}</span>
<span class="you-badge">{t.you}</span>
{/if}
{#if canRemoveUsers && user !== currentUser}
<button type="button" class="remove-user" onclick={() => removeUser(user)}>
{t('remove', lang)}
{t.remove}
</button>
{/if}
</div>
@@ -77,10 +78,10 @@
<input
type="text"
bind:value={newUser}
placeholder={t('add_user_placeholder', lang)}
placeholder={t.add_user_placeholder}
onkeydown={(e) => e.key === 'Enter' && (e.preventDefault(), addUser())}
/>
<button type="button" onclick={addUser}>{t('add_user', lang)}</button>
<button type="button" onclick={addUser}>{t.add_user}</button>
</div>
{/if}
</div>
+20
View File
@@ -0,0 +1,20 @@
/** Generated by scripts/split-i18n.ts. */
/** DE calendar UI strings — source of truth for the key set. */
export const de = {
today: "Heute",
calendar: "Liturgischer Kalender",
jumpToToday: "Zu heute",
prev: "Vorheriger Monat",
next: "Nächster Monat",
psalterWeek: "Psalterwoche",
cycle: "Lesejahr",
rite1969Long: "Römisches Messbuch 1969 (Ordentliche Form)",
rite1962Long: "Römisches Messbuch 1962 (Ausserordentliche Form)",
wipTitle: "In Arbeit",
wipBody: "Der Kalender für den alten Ritus (Messbuch 1962) ist noch nicht verfügbar. Bleib dran.",
rite1962DisclaimerTitle: "Genauigkeit wird noch geprüft",
rite1962DisclaimerBody: "Der Kalender für den Ritus von 1962 stammt aus divinum-officium-Daten und wird noch Tag für Tag mit maßgeblichen Quellen abgeglichen. Nur die in romcal enthaltenen Schweizer Diözesankalender werden angewendet; weitere Landes- oder Ortskalender sind noch nicht verfügbar.",
calendarVariant: "Kalender",
rite1969SwissNote: "romcal liefert für 1969 nur einen nationalen Schweizer Kalender — diözesane Unterkalender sind für diesen Ritus nicht verfügbar.",
} as const;
+18
View File
@@ -0,0 +1,18 @@
/** Generated by scripts/split-i18n.ts. */
/** DE calendar (1962) UI strings — source of truth for the key set. */
export const de = {
commemorations: "Kommemorationen",
octave: "Oktav",
octaveDay: "Tag",
vigilOf: "Vigil von",
transferredFrom: "Übertragen von",
source: "Quelle",
propers: "Messproprium",
stationChurch: "Stationskirche",
viewLatin: "Latein",
viewParallel: "Parallel",
viewVernacular: "Deutsch",
fallbackBadge: "Allioli",
fallbackHint: "Keine Übersetzung im Messbuch vorhanden. Text aus der Allioli-Bibelübersetzung an der angegebenen Stelle.",
} as const;
+20
View File
@@ -0,0 +1,20 @@
/** Generated by scripts/split-i18n.ts. */
import type { de } from './de';
export const en = {
today: "Today",
calendar: "Liturgical Calendar",
jumpToToday: "Jump to today",
prev: "Previous month",
next: "Next month",
psalterWeek: "Psalter week",
cycle: "Sunday cycle",
rite1969Long: "Roman Missal of 1969 (Ordinary Form)",
rite1962Long: "Roman Missal of 1962 (Extraordinary Form)",
wipTitle: "Work in progress",
wipBody: "The calendar for the older rite (1962 Missal) is not yet available. Stay tuned.",
rite1962DisclaimerTitle: "Accuracy still being verified",
rite1962DisclaimerBody: "The 1962 calendar is derived from divinum-officium data and is still being checked day-by-day against authoritative sources. Only the Swiss diocesan propers shipped by romcal are applied; other national/local calendars are not yet available.",
calendarVariant: "Calendar",
rite1969SwissNote: "romcal ships only a national Swiss calendar for 1969 — diocesan sub-calendars are not available for this rite.",
} as const satisfies Record<keyof typeof de, string>;
+18
View File
@@ -0,0 +1,18 @@
/** Generated by scripts/split-i18n.ts. */
import type { de } from './de_1962';
export const en = {
commemorations: "Commemorations",
octave: "Octave",
octaveDay: "day",
vigilOf: "Vigil of",
transferredFrom: "Transferred from",
source: "Source",
propers: "Mass propers",
stationChurch: "Station church",
viewLatin: "Latin",
viewParallel: "Parallel",
viewVernacular: "English",
fallbackBadge: "Douay-Rheims",
fallbackHint: "Translation not provided in the missal. Text taken from the Douay-Rheims Bible at the cited reference.",
} as const satisfies Record<keyof typeof de, string>;
+20
View File
@@ -0,0 +1,20 @@
/** Generated by scripts/split-i18n.ts. */
import type { de } from './de';
export const la = {
today: "Hodie",
calendar: "Calendarium Liturgicum",
jumpToToday: "Ad hodiernum",
prev: "Mensis praecedens",
next: "Mensis sequens",
psalterWeek: "Hebdomada psalterii",
cycle: "Cyclus dominicalis",
rite1969Long: "Missale Romanum 1969 (Forma Ordinaria)",
rite1962Long: "Missale Romanum 1962 (Forma Extraordinaria)",
wipTitle: "In opere",
wipBody: "Calendarium ritus antiqui (Missale 1962) nondum paratum est. Exspecta paulisper.",
rite1962DisclaimerTitle: "Accuratio adhuc probanda",
rite1962DisclaimerBody: "Calendarium anni 1962 ex datis divinum-officium ductum est et adhuc diebus singulis contra fontes fideles examinatur. Tantum calendaria propria dioecesium Helvetiae a romcal provisa adhibentur; cetera calendaria nationalia vel localia nondum praesto sunt.",
calendarVariant: "Calendarium",
rite1969SwissNote: "Pro anno 1969 romcal solum calendarium Helvetiae nationale praebet — calendaria dioecesana propria pro hoc ritu non adsunt.",
} as const satisfies Record<keyof typeof de, string>;
+18
View File
@@ -0,0 +1,18 @@
/** Generated by scripts/split-i18n.ts. */
import type { de } from './de_1962';
export const la = {
commemorations: "Commemorationes",
octave: "Octava",
octaveDay: "dies",
vigilOf: "Vigilia",
transferredFrom: "Translatum ex",
source: "Fons",
propers: "Propria Missæ",
stationChurch: "Statio",
viewLatin: "Latine",
viewParallel: "Parallelum",
viewVernacular: "Vernacula",
fallbackBadge: "Vulgata",
fallbackHint: "Interpretatio localis deest. Textus ex Biblia Sacra locis citatis.",
} as const satisfies Record<keyof typeof de, string>;
+237
View File
@@ -0,0 +1,237 @@
/** Generated by scripts/split-i18n.ts. */
/** DE cospend UI strings — source of truth for the key set. */
export const de = {
cospend_title: "Cospend - Ausgabenteilung",
all_payments_title: "Alle Zahlungen",
settle_title: "Schulden begleichen",
recurring_title: "Wiederkehrende Zahlungen",
shopping_list_title: "Einkaufsliste",
payment_details: "Zahlungsdetails",
cospend: "Cospend",
settle_debts: "Schulden begleichen",
monthly_expenses_chart: "Monatliche Ausgaben nach Kategorie",
loading_monthly: "Monatliche Ausgaben werden geladen...",
loading_recent: "Letzte Aktivitäten werden geladen...",
recent_activity: "Letzte Aktivität",
clear_filter: "Filter löschen",
no_recent_in: "Keine Aktivität in",
paid_by: "Bezahlt von",
payment: "Zahlung",
loading_payments: "Zahlungen werden geladen...",
no_payments_yet: "Noch keine Zahlungen",
start_first_expense: "Füge deine erste geteilte Ausgabe hinzu",
add_first_payment: "Erste Zahlung hinzufügen",
settlement: "Ausgleich",
split_details: "Aufteilung",
owes: "schuldet",
owed: "bekommt",
even: "ausgeglichen",
previous: "← Zurück",
next: "Weiter →",
load_more: "Mehr laden",
loading_ellipsis: "Laden...",
delete_payment_confirm: "Diese Zahlung wirklich löschen?",
date: "Datum:",
paid_by_label: "Bezahlt von:",
created_by: "Erstellt von:",
category_label: "Kategorie:",
split_method_label: "Aufteilungsart:",
description: "Beschreibung",
exchange_rate: "Wechselkurs",
receipt: "Beleg",
receipt_image: "Belegbild",
remove_image: "Bild entfernen",
replace_image: "Bild ersetzen",
upload_receipt: "Beleg hochladen",
uploading_image: "Bild wird hochgeladen...",
file_too_large: "Dateigrösse muss unter 5MB sein",
invalid_image: "Bitte eine gültige Bilddatei wählen (JPEG, PNG, WebP)",
you: "Du",
close: "Schliessen",
no_splits: "Keine Aufteilung",
split_equal: "Gleichmässig aufgeteilt auf",
paid_full_by: "Vollständig bezahlt von",
personal_equal: "Persönliche Beträge + Gleichverteilung auf",
custom_split: "Individuelle Aufteilung auf",
people: "Personen",
settle_subtitle: "Zahlungen erfassen, um offene Schulden auszugleichen",
loading_debts: "Schuldeninformationen werden geladen...",
all_settled: "Alles beglichen!",
no_debts_msg: "Keine offenen Schulden. Alle sind ausgeglichen!",
back_to_dashboard: "Zurück zum Dashboard",
available_settlements: "Mögliche Ausgleiche",
money_owed_to_you: "Geld, das du bekommst",
owes_you: "schuldet dir",
receive_payment: "Zahlung empfangen",
money_you_owe: "Geld, das du schuldest",
you_owe: "du schuldest",
make_payment: "Zahlung leisten",
settlement_details: "Ausgleichsdetails",
settlement_amount: "Ausgleichsbetrag",
record_settlement: "Ausgleich erfassen",
recording_settlement: "Ausgleich wird erfasst...",
cancel: "Abbrechen",
settlement_type: "Ausgleichsart",
select_settlement: "Ausgleichsart wählen",
receive_from: "Empfangen",
from: "von",
pay_to: "Zahlen",
to: "an",
from_user: "Von Benutzer",
select_payer: "Zahler wählen",
to_user: "An Benutzer",
select_recipient: "Empfänger wählen",
settlement_amount_chf: "Ausgleichsbetrag (CHF)",
error_select_settlement: "Bitte einen Ausgleich wählen und Betrag eingeben",
error_valid_amount: "Bitte einen gültigen positiven Betrag eingeben",
settlement_payment: "Ausgleichszahlung",
recurring_subtitle: "Automatisiere deine regelmässigen geteilten Ausgaben",
show_active_only: "Nur aktive anzeigen",
loading_recurring: "Wiederkehrende Zahlungen werden geladen...",
no_recurring: "Keine wiederkehrenden Zahlungen gefunden",
no_recurring_desc: "Erstelle deine erste wiederkehrende Zahlung für regelmässige Ausgaben wie Miete, Nebenkosten oder Abos.",
active: "Aktiv",
inactive: "Inaktiv",
frequency: "Häufigkeit:",
next_execution: "Nächste Ausführung:",
last_executed: "Zuletzt ausgeführt:",
ends: "Endet:",
split_between: "Aufgeteilt zwischen:",
gets: "bekommt",
edit: "Bearbeiten",
pause: "Pausieren",
activate: "Aktivieren",
delete_: "Löschen",
delete_recurring_confirm: "Wiederkehrende Zahlung wirklich löschen",
items_done: "erledigt",
add_item_placeholder: "Artikel hinzufügen...",
empty_list: "Die Einkaufsliste ist leer",
clear_checked: "Erledigte entfernen",
share: "Teilen",
shared_links: "Geteilte Links",
share_desc: "Jeder mit einem aktiven Link kann die Einkaufsliste bearbeiten.",
loading: "Laden...",
no_active_links: "Keine aktiven Links.",
remaining: "noch",
change: "Ändern",
copy_link: "Link kopieren",
create_new_link: "Neuen Link erstellen",
copied: "Kopiert",
expired: "abgelaufen",
ttl_1h: "1 Stunde",
ttl_6h: "6 Stunden",
ttl_24h: "24 Stunden",
ttl_3d: "3 Tage",
ttl_7d: "7 Tage",
kategorie: "Kategorie",
icon: "Icon",
search_icon: "Icon suchen...",
save: "Speichern",
saving: "Speichern...",
edit_name: "Name",
edit_qty: "Menge",
edit_qty_ph: "z.B. 3x, 500g, 1L",
your_balance: "Dein Saldo",
you_are_owed: "Du bekommst",
you_owe_balance: "Du schuldest",
all_even: "Alles ausgeglichen",
owes_you_balance: "schuldet dir",
you_owe_user: "du schuldest",
transaction: "Transaktion",
transactions: "Transaktionen",
debt_overview: "Schuldenübersicht",
loading_debt_breakdown: "Schuldenübersicht wird geladen...",
who_owes_you: "Wer dir schuldet",
you_owe_section: "Du schuldest",
total: "Gesamt",
freq_every_day: "Jeden Tag",
freq_every_week: "Jede Woche",
freq_every_month: "Jeden Monat",
freq_custom: "Benutzerdefiniert",
freq_unknown: "Unbekannte Häufigkeit",
today_at: "Heute um",
tomorrow_at: "Morgen um",
in_days_at: "In {days} Tagen um",
split_between_users: "Aufteilen zwischen",
predefined_note: "Aufteilung zwischen vordefinierten Benutzern:",
remove: "Entfernen",
add_user_placeholder: "Benutzer hinzufügen...",
add_user: "Benutzer hinzufügen",
split_method: "Aufteilungsmethode",
how_split: "Wie soll diese Zahlung aufgeteilt werden?",
split_5050: "50/50 teilen",
custom_split_amounts: "Individuelle Beträge",
personal_exceeds_total: "Warnung: Persönliche Beträge übersteigen den Gesamtbetrag!",
is_owed: "bekommt",
error_prefix: "Fehler",
cat_groceries: "Lebensmittel",
cat_shopping: "Einkauf",
cat_travel: "Reise",
cat_restaurant: "Restaurant",
cat_utilities: "Nebenkosten",
cat_fun: "Freizeit",
cat_settlement: "Ausgleich",
add_payment_title: "Neue Zahlung",
add_payment_subtitle: "Neue geteilte Ausgabe oder wiederkehrende Zahlung erstellen",
edit_payment_title: "Zahlung bearbeiten",
edit_payment_subtitle: "Zahlungsdetails und Beleg bearbeiten",
edit_recurring_title: "Wiederkehrende Zahlung bearbeiten",
payment_details_section: "Zahlungsdetails",
title_label: "Titel *",
title_placeholder: "z.B. Abendessen im Restaurant",
description_label: "Beschreibung",
description_placeholder: "Weitere Details...",
category_star: "Kategorie *",
amount_label: "Betrag *",
payment_date: "Zahlungsdatum",
paid_by_form: "Bezahlt von",
make_recurring: "Als wiederkehrende Zahlung einrichten",
recurring_section: "Wiederkehrende Zahlung",
recurring_schedule: "Wiederkehrender Zeitplan",
frequency_label: "Häufigkeit *",
freq_daily: "Täglich",
freq_weekly: "Wöchentlich",
freq_monthly: "Monatlich",
freq_quarterly: "Vierteljährlich",
freq_yearly: "Jährlich",
start_date: "Startdatum *",
end_date_optional: "Enddatum (optional)",
end_date_hint: "Leer lassen für unbefristete Wiederholung",
next_execution_preview: "Nächste Ausführung",
status_label: "Status",
create_payment: "Zahlung erstellen",
save_changes: "Änderungen speichern",
delete_payment: "Zahlung löschen",
deleting: "Löschen...",
split_config: "Aufteilungskonfiguration",
split_method_form: "Aufteilungsart:",
equal_split: "Gleichmässige Aufteilung",
personal_equal_split: "Persönliche Beträge + Gleichverteilung",
custom_proportions: "Individuelle Anteile",
personal_amounts: "Persönliche Beträge",
personal_amounts_desc: "Persönliche Beträge für jeden Benutzer eingeben. Der Rest wird gleichmässig aufgeteilt.",
total_personal: "Persönliche Summe",
remainder_to_split: "Rest zum Aufteilen",
personal_exceeds: "Persönliche Beträge übersteigen den Gesamtbetrag!",
split_preview: "Aufteilungsvorschau",
conversion_hint: "Betrag wird anhand des Wechselkurses am Zahlungstag in CHF umgerechnet",
fetching_rate: "Wechselkurs wird abgerufen...",
exchange_rate_date: "Wechselkurs wird für dieses Datum abgerufen",
paid_in_full: "Vollständig bezahlt",
paid_in_full_for: "Vollständig bezahlt für",
paid_in_full_by_you: "Vollständig von dir bezahlt",
paid_in_full_by: "Vollständig bezahlt von",
cat_fruits_veg: "Obst & Gemüse",
cat_meat_fish: "Fleisch & Fisch",
cat_dairy: "Milchprodukte",
cat_bakery: "Brot & Backwaren",
cat_grains: "Pasta, Reis & Getreide",
cat_spices: "Gewürze & Saucen",
cat_drinks: "Getränke",
cat_sweets: "Süßes & Snacks",
cat_frozen: "Tiefkühl",
cat_household: "Haushalt",
cat_hygiene: "Hygiene & Körperpflege",
cat_other: "Sonstiges",
} as const;
+237
View File
@@ -0,0 +1,237 @@
/** Generated by scripts/split-i18n.ts. */
import type { de } from './de';
export const en = {
cospend_title: "Expenses - Expense Sharing",
all_payments_title: "All Payments",
settle_title: "Settle Debts",
recurring_title: "Recurring Payments",
shopping_list_title: "Shopping List",
payment_details: "Payment Details",
cospend: "Expenses",
settle_debts: "Settle Debts",
monthly_expenses_chart: "Monthly Expenses by Category",
loading_monthly: "Loading monthly expenses chart...",
loading_recent: "Loading recent activity...",
recent_activity: "Recent Activity",
clear_filter: "Clear filter",
no_recent_in: "No recent activity in",
paid_by: "Paid by",
payment: "Payment",
loading_payments: "Loading payments...",
no_payments_yet: "No payments yet",
start_first_expense: "Start by adding your first shared expense",
add_first_payment: "Add Your First Payment",
settlement: "Settlement",
split_details: "Split Details",
owes: "owes",
owed: "owed",
even: "even",
previous: "← Previous",
next: "Next →",
load_more: "Load More",
loading_ellipsis: "Loading...",
delete_payment_confirm: "Are you sure you want to delete this payment?",
date: "Date:",
paid_by_label: "Paid by:",
created_by: "Created by:",
category_label: "Category:",
split_method_label: "Split method:",
description: "Description",
exchange_rate: "Exchange rate",
receipt: "Receipt",
receipt_image: "Receipt Image",
remove_image: "Remove Image",
replace_image: "Replace Image",
upload_receipt: "Upload Receipt Image",
uploading_image: "Uploading image...",
file_too_large: "File size must be less than 5MB",
invalid_image: "Please select a valid image file (JPEG, PNG, WebP)",
you: "You",
close: "Close",
no_splits: "No splits",
split_equal: "Split equally among",
paid_full_by: "Paid in full by",
personal_equal: "Personal amounts + equal split among",
custom_split: "Custom split among",
people: "people",
settle_subtitle: "Record payments to settle outstanding debts between users",
loading_debts: "Loading debt information...",
all_settled: "All Settled!",
no_debts_msg: "No outstanding debts to settle. Everyone is even!",
back_to_dashboard: "Back to Dashboard",
available_settlements: "Available Settlements",
money_owed_to_you: "Money You're Owed",
owes_you: "owes you",
receive_payment: "Receive Payment",
money_you_owe: "Money You Owe",
you_owe: "you owe",
make_payment: "Make Payment",
settlement_details: "Settlement Details",
settlement_amount: "Settlement Amount",
record_settlement: "Record Settlement",
recording_settlement: "Recording Settlement...",
cancel: "Cancel",
settlement_type: "Settlement Type",
select_settlement: "Select settlement type",
receive_from: "Receive",
from: "from",
pay_to: "Pay",
to: "to",
from_user: "From User",
select_payer: "Select payer",
to_user: "To User",
select_recipient: "Select recipient",
settlement_amount_chf: "Settlement Amount (CHF)",
error_select_settlement: "Please select a settlement and enter an amount",
error_valid_amount: "Please enter a valid positive amount",
settlement_payment: "Settlement Payment",
recurring_subtitle: "Automate your regular shared expenses",
show_active_only: "Show active only",
loading_recurring: "Loading recurring payments...",
no_recurring: "No recurring payments found",
no_recurring_desc: "Create your first recurring payment to automate regular expenses like rent, utilities, or subscriptions.",
active: "Active",
inactive: "Inactive",
frequency: "Frequency:",
next_execution: "Next execution:",
last_executed: "Last executed:",
ends: "Ends:",
split_between: "Split between:",
gets: "gets",
edit: "Edit",
pause: "Pause",
activate: "Activate",
delete_: "Delete",
delete_recurring_confirm: "Are you sure you want to delete the recurring payment",
items_done: "done",
add_item_placeholder: "Add item...",
empty_list: "The shopping list is empty",
clear_checked: "Remove checked",
share: "Share",
shared_links: "Shared Links",
share_desc: "Anyone with an active link can edit the shopping list.",
loading: "Loading...",
no_active_links: "No active links.",
remaining: "remaining",
change: "Change",
copy_link: "Copy link",
create_new_link: "Create new link",
copied: "Copied",
expired: "expired",
ttl_1h: "1 hour",
ttl_6h: "6 hours",
ttl_24h: "24 hours",
ttl_3d: "3 days",
ttl_7d: "7 days",
kategorie: "Category",
icon: "Icon",
search_icon: "Search icon...",
save: "Save",
saving: "Saving...",
edit_name: "Name",
edit_qty: "Amount",
edit_qty_ph: "e.g. 3x, 500g, 1L",
your_balance: "Your Balance",
you_are_owed: "You are owed",
you_owe_balance: "You owe",
all_even: "You're all even",
owes_you_balance: "owes you",
you_owe_user: "you owe",
transaction: "transaction",
transactions: "transactions",
debt_overview: "Debt Overview",
loading_debt_breakdown: "Loading debt breakdown...",
who_owes_you: "Who owes you",
you_owe_section: "You owe",
total: "Total",
freq_every_day: "Every day",
freq_every_week: "Every week",
freq_every_month: "Every month",
freq_custom: "Custom",
freq_unknown: "Unknown frequency",
today_at: "Today at",
tomorrow_at: "Tomorrow at",
in_days_at: "In {days} days at",
split_between_users: "Split Between Users",
predefined_note: "Splitting between predefined users:",
remove: "Remove",
add_user_placeholder: "Add user...",
add_user: "Add User",
split_method: "Split Method",
how_split: "How should this payment be split?",
split_5050: "Split 50/50",
custom_split_amounts: "Custom Split Amounts",
personal_exceeds_total: "Warning: Personal amounts exceed total payment amount!",
is_owed: "is owed",
error_prefix: "Error",
cat_groceries: "Groceries",
cat_shopping: "Shopping",
cat_travel: "Travel",
cat_restaurant: "Restaurant",
cat_utilities: "Utilities",
cat_fun: "Fun",
cat_settlement: "Settlement",
add_payment_title: "Add New Payment",
add_payment_subtitle: "Create a new shared expense or recurring payment",
edit_payment_title: "Edit Payment",
edit_payment_subtitle: "Modify payment details and receipt image",
edit_recurring_title: "Edit Recurring Payment",
payment_details_section: "Payment Details",
title_label: "Title *",
title_placeholder: "e.g., Dinner at restaurant",
description_label: "Description",
description_placeholder: "Additional details...",
category_star: "Category *",
amount_label: "Amount *",
payment_date: "Payment Date",
paid_by_form: "Paid by",
make_recurring: "Make this a recurring payment",
recurring_section: "Recurring Payment",
recurring_schedule: "Recurring Schedule",
frequency_label: "Frequency *",
freq_daily: "Daily",
freq_weekly: "Weekly",
freq_monthly: "Monthly",
freq_quarterly: "Quarterly",
freq_yearly: "Yearly",
start_date: "Start Date *",
end_date_optional: "End Date (optional)",
end_date_hint: "Leave empty for indefinite recurring",
next_execution_preview: "Next Execution",
status_label: "Status",
create_payment: "Create payment",
save_changes: "Save changes",
delete_payment: "Delete Payment",
deleting: "Deleting...",
split_config: "Split Configuration",
split_method_form: "Split Method:",
equal_split: "Equal Split",
personal_equal_split: "Personal + Equal Split",
custom_proportions: "Custom Proportions",
personal_amounts: "Personal Amounts",
personal_amounts_desc: "Enter personal amounts for each user. The remainder will be split equally.",
total_personal: "Total Personal",
remainder_to_split: "Remainder to Split",
personal_exceeds: "Personal amounts exceed total payment amount!",
split_preview: "Split Preview",
conversion_hint: "Amount will be converted to CHF using exchange rates for the payment date",
fetching_rate: "Fetching exchange rate...",
exchange_rate_date: "Exchange rate will be fetched for this date",
paid_in_full: "Paid in Full",
paid_in_full_for: "Paid in Full for",
paid_in_full_by_you: "Paid in Full by You",
paid_in_full_by: "Paid in Full by",
cat_fruits_veg: "Fruits & Vegetables",
cat_meat_fish: "Meat & Fish",
cat_dairy: "Dairy",
cat_bakery: "Bread & Bakery",
cat_grains: "Pasta, Rice & Grains",
cat_spices: "Spices & Sauces",
cat_drinks: "Beverages",
cat_sweets: "Sweets & Snacks",
cat_frozen: "Frozen",
cat_household: "Household",
cat_hygiene: "Hygiene & Body Care",
cat_other: "Other",
} as const satisfies Record<keyof typeof de, string>;
+26 -293
View File
@@ -1,24 +1,24 @@
/** 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' {
export function detectCospendLang(pathname: string): CospendLang {
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 {
export function convertCospendPath(pathname: string, targetLang: CospendLang): 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 {
export function cospendRoot(lang: CospendLang): string {
return lang === 'en' ? 'expenses' : 'cospend';
}
/** Get translated nav labels */
export function cospendLabels(lang: 'en' | 'de') {
export function cospendLabels(lang: CospendLang) {
return {
dash: lang === 'en' ? 'Dashboard' : 'Dashboard',
list: lang === 'en' ? 'List' : 'Liste',
@@ -27,287 +27,16 @@ export function cospendLabels(lang: 'en' | 'de') {
};
}
type Translations = Record<string, Record<string, string>>;
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' },
import { de } from '$lib/i18n/cospend/de';
import { en } from '$lib/i18n/cospend/en';
// 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 cospend translations, keyed by locale. */
export const m = { de, en } as const;
// 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?' },
export type CospendLang = keyof typeof m;
export type CospendKey = keyof typeof de;
// 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...' },
edit_name: { en: 'Name', de: 'Name' },
edit_qty: { en: 'Amount', de: 'Menge' },
edit_qty_ph: { en: 'e.g. 3x, 500g, 1L', de: 'z.B. 3x, 500g, 1L' },
// 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:' },
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' },
custom_split_amounts: { en: 'Custom Split Amounts', de: 'Individuelle Beträge' },
personal_exceeds_total: { en: 'Warning: Personal amounts exceed total payment amount!', de: 'Warnung: Persönliche Beträge übersteigen den Gesamtbetrag!' },
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' },
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<string, Record<string, string>> = {
@@ -326,7 +55,7 @@ const categoryDisplayNames: Record<string, Record<string, string>> = {
};
/** Get translated category display name (shopping categories) */
export function categoryName(category: string, lang: 'en' | 'de'): string {
export function categoryName(category: string, lang: CospendLang): string {
return categoryDisplayNames[category]?.[lang] ?? category;
}
@@ -342,12 +71,12 @@ const paymentCategoryNames: Record<string, Record<string, string>> = {
};
/** Get translated payment category name */
export function paymentCategoryName(category: string, lang: 'en' | 'de'): string {
export function paymentCategoryName(category: string, lang: CospendLang): string {
return paymentCategoryNames[category]?.[lang] ?? category;
}
/** Get category options with translated labels */
export function getCategoryOptionsI18n(lang: 'en' | 'de') {
export function getCategoryOptionsI18n(lang: CospendLang) {
const emojis: Record<string, string> = {
groceries: '🛒', shopping: '🛍️', travel: '🚆',
restaurant: '🍽️', utilities: '⚡', fun: '🎉', settlement: '🤝'
@@ -360,13 +89,17 @@ export function getCategoryOptionsI18n(lang: 'en' | 'de') {
}));
}
/** Get a translated string */
export function t(key: string, lang: 'en' | 'de'): string {
return translations[key]?.[lang] ?? translations[key]?.en ?? key;
/**
* Get a translated string. Prefer `m[lang].key` directly in new code — this
* helper is kept for the existing call sites and falls back to English then
* the key itself if the lookup misses.
*/
export function t(key: CospendKey, lang: CospendLang): string {
return m[lang][key] ?? m.en[key] ?? key;
}
/** Format TTL remaining time in the target language */
export function formatTTL(expiresAt: string, lang: 'en' | 'de'): string {
export function formatTTL(expiresAt: string, lang: CospendLang): string {
const diff = new Date(expiresAt).getTime() - Date.now();
if (diff <= 0) return t('expired', lang);
const mins = Math.round(diff / 60000);
@@ -378,7 +111,7 @@ export function formatTTL(expiresAt: string, lang: 'en' | 'de'): string {
}
/** Get TTL options for the given language */
export function ttlOptions(lang: 'en' | 'de') {
export function ttlOptions(lang: CospendLang) {
return [
{ label: t('ttl_1h', lang), ms: 1 * 60 * 60 * 1000 },
{ label: t('ttl_6h', lang), ms: 6 * 60 * 60 * 1000 },
@@ -389,12 +122,12 @@ export function ttlOptions(lang: 'en' | 'de') {
}
/** Get locale string for number/date formatting */
export function locale(lang: 'en' | 'de'): string {
export function locale(lang: CospendLang): 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 {
export function splitDescription(payment: { splits?: any[]; splitMethod?: string; paidBy?: string }, lang: CospendLang): string {
if (!payment.splits || payment.splits.length === 0) return t('no_splits', lang);
const count = payment.splits.length;
@@ -410,7 +143,7 @@ export function splitDescription(payment: { splits?: any[]; splitMethod?: string
}
/** Get translated frequency description for a recurring payment */
export function frequencyDescription(payment: { frequency: string; cronExpression?: string }, lang: 'en' | 'de'): string {
export function frequencyDescription(payment: { frequency: string; cronExpression?: string }, lang: CospendLang): string {
switch (payment.frequency) {
case 'daily': return t('freq_every_day', lang);
case 'weekly': return t('freq_every_week', lang);
@@ -421,7 +154,7 @@ export function frequencyDescription(payment: { frequency: string; cronExpressio
}
/** Format next execution date with i18n */
export function formatNextExecutionI18n(date: Date, lang: 'en' | 'de'): string {
export function formatNextExecutionI18n(date: Date, lang: CospendLang): string {
const loc = locale(lang);
const now = new Date();
const diffMs = date.getTime() - now.getTime();