diff --git a/package.json b/package.json index 47aef5ae..9ac55fd6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "homepage", - "version": "1.54.1", + "version": "1.54.2", "private": true, "type": "module", "scripts": { diff --git a/scripts/codemod-fitness-t-to-m.ts b/scripts/codemod-fitness-t-to-m.ts deleted file mode 100644 index 082ac01d..00000000 --- a/scripts/codemod-fitness-t-to-m.ts +++ /dev/null @@ -1,104 +0,0 @@ -/** - * Migrate fitness call sites from t('key', lang) to t.key (or t[expr] for - * dynamic keys), where t = m[lang] derived once per file. - * - * Run: pnpm exec vite-node scripts/codemod-fitness-t-to-m.ts [--dry] - */ - -import { readFileSync, writeFileSync, readdirSync, statSync } from 'node:fs'; -import { join, extname } from 'node:path'; - -const DRY = process.argv.includes('--dry'); -const ROOTS = ['src/routes/fitness', 'src/lib/components/fitness']; - -const IMPORT_RE = - /import\s*\{([^}]+)\}\s*from\s*['"]\$lib\/js\/fitnessI18n['"]\s*;?/; - -function walk(dir: string, out: string[] = []): string[] { - for (const name of readdirSync(dir)) { - const p = join(dir, name); - const s = statSync(p); - if (s.isDirectory()) walk(p, out); - else if (extname(p) === '.svelte' || extname(p) === '.ts') out.push(p); - } - return out; -} - -function migrate(src: string): { code: string; changed: boolean } { - const m0 = IMPORT_RE.exec(src); - if (!m0) return { code: src, changed: false }; - - const items = m0[1].split(',').map((s) => s.trim()).filter(Boolean); - if (!items.includes('t')) return { code: src, changed: false }; - - // 1. Rewrite import: drop `t`, ensure `m` present. - const tIdx = items.indexOf('t'); - items.splice(tIdx, 1); - if (!items.includes('m')) items.push('m'); - let out = src.replace(IMPORT_RE, `import { ${items.join(', ')} } from '$lib/js/fitnessI18n';`); - - // 2. Insert `const t = $derived(m[lang]);` at the right spot, depending - // on how `lang` enters scope. - // Pattern A: `const lang = $derived(...)` — derived from URL - // Pattern B: `let { ... lang ... } = $props()` — passed as prop (single or multi-line) - let inserted = false; - - // Pattern A: derived. Allow up to two levels of nested parens inside - // $derived(...) so detectFitnessLang(page.url.pathname) matches. - const langDerivedRe = - /^([ \t]*)(const\s+lang\s*=\s*\$derived\((?:[^()]|\([^()]*\))+\)\s*;?)([ \t]*\n)/m; - if (langDerivedRe.test(out)) { - out = out.replace(langDerivedRe, (_, indent, decl, nl) => { - inserted = true; - return `${indent}${decl}${nl}${indent}const t = $derived(m[lang]);${nl}`; - }); - } - - // Pattern B: $props() destructure, possibly spanning multiple lines. - // Match any `let { ... } = $props()` and only insert if `lang` is in it. - if (!inserted) { - const propsRe = - /^([ \t]*)(let\s*\{[\s\S]*?\}\s*=\s*\$props\(\)\s*;?)([ \t]*\n)/m; - out = out.replace(propsRe, (full, indent, decl, nl) => { - if (!/\blang\b/.test(decl)) return full; - inserted = true; - return `${indent}${decl}${nl}${indent}const t = $derived(m[lang]);${nl}`; - }); - } - - if (!inserted) { - console.warn(` WARN: could not auto-insert \`const t = $derived(m[lang])\` — manual fix needed`); - } - - // 3. Replace t('static_key', lang) → t.static_key - out = out.replace( - /\bt\(\s*['"]([a-z_][a-z0-9_]*)['"]\s*,\s*lang\s*\)/g, - 't.$1' - ); - - // 4. Replace t(, lang) → t[] for any remaining call. - // Expression captured allows up to single-level nested parens, which - // covers our /** @type {FitnessKey} */ (expr) patterns. - out = out.replace( - /\bt\(((?:[^()]|\([^()]*\))+?)\s*,\s*lang\s*\)/g, - (match, expr) => { - const trimmed = expr.trim(); - return `t[${trimmed}]`; - } - ); - - return { code: out, changed: out !== src }; -} - -let total = 0; -for (const root of ROOTS) { - for (const f of walk(root)) { - const orig = readFileSync(f, 'utf8'); - const { code, changed } = migrate(orig); - if (!changed) continue; - if (!DRY) writeFileSync(f, code); - total++; - console.log(` ${f}`); - } -} -console.log(`\n${DRY ? '[dry] ' : ''}${total} files migrated`); diff --git a/scripts/codemod-i18n-t-to-m.ts b/scripts/codemod-i18n-t-to-m.ts new file mode 100644 index 00000000..37d2d6fe --- /dev/null +++ b/scripts/codemod-i18n-t-to-m.ts @@ -0,0 +1,126 @@ +/** + * Migrate i18n call sites from t('key', lang) to t.key (or t[expr] for + * dynamic keys), where t = m[lang] derived once per file. Generic version + * — pass the i18n module path and the directories to scan. + * + * Usage: + * pnpm exec vite-node scripts/codemod-i18n-t-to-m.ts \ + * --module=$lib/js/cospendI18n \ + * --root=src/routes/'[cospendRoot=cospendRoot]' \ + * --root=src/lib/components/cospend \ + * [--dry] + */ + +import { readFileSync, writeFileSync, readdirSync, statSync } from 'node:fs'; +import { join, extname } from 'node:path'; + +const args = process.argv.slice(2); +const DRY = args.includes('--dry'); +const modArg = args.find((a) => a.startsWith('--module=')); +if (!modArg) { + console.error('missing --module='); + process.exit(1); +} +const modulePath = modArg.slice('--module='.length); +const roots = args + .filter((a) => a.startsWith('--root=')) + .map((a) => a.slice('--root='.length)); +if (roots.length === 0) { + console.error('missing --root= (at least one)'); + process.exit(1); +} +const fnFlag = args.find((a) => a.startsWith('--fn=')); +const FN = fnFlag ? fnFlag.slice('--fn='.length) : 't'; +const mFlag = args.find((a) => a.startsWith('--m=')); +const M_NAME = mFlag ? mFlag.slice('--m='.length) : 'm'; + +// Match imports from any path ending in the module basename — call sites +// reach calendarI18n via wildly different relative-path depths, so we +// don't pin the full path. +function esc(s: string) { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} +const IMPORT_RE = new RegExp( + `import\\s*\\{([^}]+)\\}\\s*from\\s*(['"])([^'"]*${esc(modulePath)})\\2\\s*;?` +); + +function walk(dir: string, out: string[] = []): string[] { + for (const name of readdirSync(dir)) { + const p = join(dir, name); + const s = statSync(p); + if (s.isDirectory()) walk(p, out); + else if (extname(p) === '.svelte' || extname(p) === '.ts') out.push(p); + } + return out; +} + +function migrate(src: string): { code: string; changed: boolean } { + const m0 = IMPORT_RE.exec(src); + if (!m0) return { code: src, changed: false }; + + const items = m0[1].split(',').map((s) => s.trim()).filter(Boolean); + if (!items.includes(FN)) return { code: src, changed: false }; + + const matchedPath = m0[3]; + + // 1. Rewrite import: drop FN, ensure M_NAME present. Preserve original path. + const fnIdx = items.indexOf(FN); + items.splice(fnIdx, 1); + if (!items.includes(M_NAME)) items.push(M_NAME); + let out = src.replace(IMPORT_RE, `import { ${items.join(', ')} } from '${matchedPath}';`); + + // 2. Insert `const FN = $derived(M_NAME[lang]);` at the right spot. + const insertion = `const ${FN} = $derived(${M_NAME}[lang]);`; + let inserted = false; + + const langDerivedRe = + /^([ \t]*)(const\s+lang\s*=\s*\$derived\((?:[^()]|\([^()]*\))+\)\s*;?)([ \t]*\n)/m; + if (langDerivedRe.test(out)) { + out = out.replace(langDerivedRe, (_, indent, decl, nl) => { + inserted = true; + return `${indent}${decl}${nl}${indent}${insertion}${nl}`; + }); + } + + if (!inserted) { + const propsRe = + /^([ \t]*)(let\s*\{[\s\S]*?\}\s*=\s*\$props(?:<[\s\S]*?>)?\(\)\s*;?)([ \t]*\n)/m; + out = out.replace(propsRe, (full, indent, decl, nl) => { + if (!/\blang\b/.test(decl)) return full; + inserted = true; + return `${indent}${decl}${nl}${indent}${insertion}${nl}`; + }); + } + + if (!inserted) { + console.warn(` WARN: could not auto-insert \`${insertion}\` — manual fix needed`); + } + + // Build dynamic regex for FN(...) — escape `1962`-style suffixes. + const fnEsc = FN.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + // 3. FN('static_key', lang) → FN.static_key (snake_case OR camelCase identifier) + out = out.replace( + new RegExp(`\\b${fnEsc}\\(\\s*['"]([a-zA-Z_$][a-zA-Z0-9_$]*)['"]\\s*,\\s*lang\\s*\\)`, 'g'), + `${FN}.$1` + ); + // 4. FN(, lang) → FN[] + out = out.replace( + new RegExp(`\\b${fnEsc}\\(((?:[^()]|\\([^()]*\\))+?)\\s*,\\s*lang\\s*\\)`, 'g'), + (_match, expr) => `${FN}[${expr.trim()}]` + ); + + return { code: out, changed: out !== src }; +} + +let total = 0; +for (const root of roots) { + for (const f of walk(root)) { + const orig = readFileSync(f, 'utf8'); + const { code, changed } = migrate(orig); + if (!changed) continue; + if (!DRY) writeFileSync(f, code); + total++; + console.log(` ${f}`); + } +} +console.log(`\n${DRY ? '[dry] ' : ''}${total} files migrated`); diff --git a/scripts/split-fitness-i18n.ts b/scripts/split-fitness-i18n.ts deleted file mode 100644 index aad656a4..00000000 --- a/scripts/split-fitness-i18n.ts +++ /dev/null @@ -1,74 +0,0 @@ -/** - * One-shot script: extract the translations object from fitnessI18n.ts - * into per-locale files src/lib/i18n/fitness/de.ts and en.ts. - * - * Run: pnpm exec vite-node scripts/split-fitness-i18n.ts - */ - -import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'; - -const src = readFileSync('src/lib/js/fitnessI18n.ts', 'utf8'); - -// Slice out just the translations object body so we don't accidentally -// match function bodies or other constructs above/below. -const startMarker = 'const translations: Translations = {'; -const startIdx = src.indexOf(startMarker); -if (startIdx === -1) throw new Error('translations object not found'); -const endIdx = src.indexOf('\n};\n', startIdx); -if (endIdx === -1) throw new Error('translations object end not found'); -const body = src.slice(startIdx + startMarker.length, endIdx); - -// Match each entry. Single-line and multi-line variants both supported. -// Strings are single-quoted in the source and contain no escaped single -// quotes (the file uses unicode escapes like ’ for apostrophes), so -// [^']* is safe. -const re = - /^\s*(\w+):\s*\{\s*\n?\s*en:\s*'([^']*)'\s*,\s*\n?\s*de:\s*'([^']*)'\s*,?\s*\n?\s*\}\s*,?/gm; - -/** - * Convert a JS single-quoted string body into the actual string. The captured - * regex content is literal source text — escapes like `’` are still 6 chars - * (`\`, `u`, `2`, `0`, `1`, `9`) and need decoding. - */ -function decodeJsString(raw: string): string { - const jsonReady = '"' + raw.replace(/\\'/g, "'").replace(/"/g, '\\"') + '"'; - return JSON.parse(jsonReady); -} - -const entries: Array<{ key: string; en: string; de: string }> = []; -let m: RegExpExecArray | null; -while ((m = re.exec(body)) !== null) { - entries.push({ - key: m[1], - en: decodeJsString(m[2]), - de: decodeJsString(m[3]) - }); -} -console.log(`extracted ${entries.length} entries`); - -mkdirSync('src/lib/i18n/fitness', { recursive: true }); - -// de.ts is the source of truth for the key set. -const deLines = [ - '/** Generated by scripts/split-fitness-i18n.ts. */', - '/** German fitness UI strings — source of truth for the key set. */', - '', - 'export const de = {' -]; -for (const e of entries) deLines.push(`\t${e.key}: ${JSON.stringify(e.de)},`); -deLines.push('} as const;', ''); -writeFileSync('src/lib/i18n/fitness/de.ts', deLines.join('\n')); - -// en.ts uses `satisfies Record` so any key missing -// from en that exists in de raises a TypeScript error. -const enLines = [ - '/** Generated by scripts/split-fitness-i18n.ts. */', - "import type { de } from './de';", - '', - 'export const en = {' -]; -for (const e of entries) enLines.push(`\t${e.key}: ${JSON.stringify(e.en)},`); -enLines.push('} as const satisfies Record;', ''); -writeFileSync('src/lib/i18n/fitness/en.ts', enLines.join('\n')); - -console.log('wrote src/lib/i18n/fitness/de.ts and en.ts'); diff --git a/scripts/split-i18n.ts b/scripts/split-i18n.ts new file mode 100644 index 00000000..8adc8f29 --- /dev/null +++ b/scripts/split-i18n.ts @@ -0,0 +1,132 @@ +/** + * Split a single-file i18n module (with an object literal whose values are + * `Record`) into per-locale files under + * src/lib/i18n//.ts. + * + * The first locale is the source of truth; others use `as const satisfies + * Record, string>` so missing translations fail + * type-checking. + * + * Run: pnpm exec vite-node scripts/split-i18n.ts [--marker=] [--basename=] + * e.g. ... cospendI18n.ts cospend de,en + * ... calendarI18n.ts calendar de,en,la --marker='export const ui = {' --basename=de + * + * Defaults: marker = `const translations: Translations = {`, basename = first locale. + */ + +import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'; + +const [, , srcPath, namespace, localesCsv, ...flags] = process.argv; +if (!srcPath || !namespace || !localesCsv) { + console.error( + 'usage: split-i18n.ts [--marker=...] [--basename=...]' + ); + process.exit(1); +} +const locales = localesCsv.split(',').map((s) => s.trim()).filter(Boolean); +const markerFlag = flags.find((f) => f.startsWith('--marker=')); +const startMarker = markerFlag + ? markerFlag.slice('--marker='.length) + : 'const translations: Translations = {'; +const basenameFlag = flags.find((f) => f.startsWith('--basename=')); +const fileBase = basenameFlag ? basenameFlag.slice('--basename='.length) : ''; + +const src = readFileSync(srcPath, 'utf8'); + +// Slice the translations object body +const startIdx = src.indexOf(startMarker); +if (startIdx === -1) throw new Error(`marker not found in ${srcPath}: ${startMarker}`); +// Object literal can close with `};` or `} as const;` — pick the earliest match. +const candA = src.indexOf('\n};', startIdx); +const candB = src.indexOf('\n} as const', startIdx); +const endIdx = + candA < 0 ? candB : candB < 0 ? candA : Math.min(candA, candB); +if (endIdx === -1) throw new Error('translations object end not found'); +const body = src.slice(startIdx + startMarker.length, endIdx); + +// Match each translation entry boundary: `key: { ...inner... },`. Each +// entry's body is then parsed independently for `loc: 'value'` pairs, so +// locale order in the source file doesn't matter. +const entryRe = /^\s*(\w+)\s*:\s*\{([\s\S]*?)\}\s*,?\s*$/gm; +// Match `loc: '...'` OR `loc: "..."` (double quotes are used when the string +// contains a literal apostrophe). +const localeRe = /(\w+)\s*:\s*(?:'([^']*)'|"((?:\\.|[^"\\])*)")/g; + +function decodeJsString(raw: string, doubleQuoted: boolean): string { + if (doubleQuoted) { + // Already valid JSON (escapes preserved). Parse directly. + return JSON.parse('"' + raw + '"'); + } + // Single-quoted: convert any \' → ' and escape literal " for JSON. + const jsonReady = '"' + raw.replace(/\\'/g, "'").replace(/"/g, '\\"') + '"'; + return JSON.parse(jsonReady); +} + +interface Entry { + key: string; + values: Record; +} + +const entries: Entry[] = []; +let m: RegExpExecArray | null; +while ((m = entryRe.exec(body)) !== null) { + const inner = m[2]; + const values: Record = {}; + let lm: RegExpExecArray | null; + while ((lm = localeRe.exec(inner)) !== null) { + const single = lm[2]; + const double = lm[3]; + values[lm[1]] = single !== undefined + ? decodeJsString(single, false) + : decodeJsString(double, true); + } + for (const loc of locales) { + if (!(loc in values)) { + throw new Error(`entry "${m[1]}" is missing locale "${loc}"`); + } + } + entries.push({ key: m[1], values }); +} +console.log(`extracted ${entries.length} entries`); + +const outDir = `src/lib/i18n/${namespace}`; +mkdirSync(outDir, { recursive: true }); + +const sourceLocale = locales[0]; + +// Optional file prefix lets us split multiple tables into the same dir +// (e.g. calendar `ui` → de.ts, calendar `ui1962` → de_1962.ts). +const path = (loc: string) => `${outDir}/${fileBase ? `${loc}_${fileBase}` : loc}.ts`; + +// Write the source-of-truth locale (no satisfies clause). +{ + const lines = [ + '/** Generated by scripts/split-i18n.ts. */', + `/** ${sourceLocale.toUpperCase()} ${namespace}${fileBase ? ` (${fileBase})` : ''} UI strings — source of truth for the key set. */`, + '', + `export const ${sourceLocale} = {` + ]; + for (const e of entries) lines.push(`\t${e.key}: ${JSON.stringify(e.values[sourceLocale])},`); + lines.push('} as const;', ''); + writeFileSync(path(sourceLocale), lines.join('\n')); +} + +// Write the other locales with `satisfies` constraint. +const sourceFile = fileBase ? `${sourceLocale}_${fileBase}` : sourceLocale; +for (let i = 1; i < locales.length; i++) { + const loc = locales[i]; + const lines = [ + '/** Generated by scripts/split-i18n.ts. */', + `import type { ${sourceLocale} } from './${sourceFile}';`, + '', + `export const ${loc} = {` + ]; + for (const e of entries) lines.push(`\t${e.key}: ${JSON.stringify(e.values[loc])},`); + lines.push( + `} as const satisfies Record;`, + '' + ); + writeFileSync(path(loc), lines.join('\n')); +} + +console.log(`wrote ${locales.map(path).join(', ')}`); diff --git a/src/lib/components/cospend/DebtBreakdown.svelte b/src/lib/components/cospend/DebtBreakdown.svelte index c032a02c..9c92a523 100644 --- a/src/lib/components/cospend/DebtBreakdown.svelte +++ b/src/lib/components/cospend/DebtBreakdown.svelte @@ -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}
-

{t('debt_overview', lang)}

+

{t.debt_overview}

{#if loading} -
{t('loading_debt_breakdown', lang)}
+
{t.loading_debt_breakdown}
{:else if error} -
{t('error_prefix', lang)}: {error}
+
{t.error_prefix}: {error}
{:else}
{#if debtData.whoOwesMe.length > 0}
-

{t('who_owes_you', lang)}

+

{t.who_owes_you}

- {t('total', lang)}: {formatCurrency(debtData.totalOwedToMe, 'CHF', loc)} + {t.total}: {formatCurrency(debtData.totalOwedToMe, 'CHF', loc)}
@@ -92,7 +93,7 @@
- {debt.transactions.length} {debt.transactions.length !== 1 ? t('transactions', lang) : t('transaction', lang)} + {debt.transactions.length} {debt.transactions.length !== 1 ? t.transactions : t.transaction}
{/each} @@ -102,9 +103,9 @@ {#if debtData.whoIOwe.length > 0}
-

{t('you_owe_section', lang)}

+

{t.you_owe_section}

- {t('total', lang)}: {formatCurrency(debtData.totalIOwe, 'CHF', loc)} + {t.total}: {formatCurrency(debtData.totalIOwe, 'CHF', loc)}
@@ -118,7 +119,7 @@
- {debt.transactions.length} {debt.transactions.length !== 1 ? t('transactions', lang) : t('transaction', lang)} + {debt.transactions.length} {debt.transactions.length !== 1 ? t.transactions : t.transaction}
{/each} diff --git a/src/lib/components/cospend/EnhancedBalance.svelte b/src/lib/components/cospend/EnhancedBalance.svelte index 2f53df71..f3fcec73 100644 --- a/src/lib/components/cospend/EnhancedBalance.svelte +++ b/src/lib/components/cospend/EnhancedBalance.svelte @@ -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}
-

{t('your_balance', lang)}

-
{t('loading', lang)}
+

{t.your_balance}

+
{t.loading}
{:else if error} -

{t('your_balance', lang)}

-
{t('error_prefix', lang)}: {error}
+

{t.your_balance}

+
{t.error_prefix}: {error}
{:else if shouldShowIntegratedView} -

{t('your_balance', lang)}

+

{t.your_balance}

{#if balance.netBalance < 0} +{formatCurrency(balance.netBalance)} - {t('you_are_owed', lang)} + {t.you_are_owed} {:else if balance.netBalance > 0} -{formatCurrency(balance.netBalance)} - {t('you_owe_balance', lang)} + {t.you_owe_balance} {:else} CHF 0.00 - {t('all_even', lang)} + {t.all_even} {/if}
@@ -154,9 +155,9 @@ {singleDebtUser.user.username} {#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}
@@ -166,24 +167,24 @@
{#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}
{:else} -

{t('your_balance', lang)}

+

{t.your_balance}

{#if balance.netBalance < 0} +{formatCurrency(balance.netBalance)} - {t('you_are_owed', lang)} + {t.you_are_owed} {:else if balance.netBalance > 0} -{formatCurrency(balance.netBalance)} - {t('you_owe_balance', lang)} + {t.you_owe_balance} {:else} CHF 0.00 - {t('all_even', lang)} + {t.all_even} {/if}
{/if} diff --git a/src/lib/components/cospend/PaymentModal.svelte b/src/lib/components/cospend/PaymentModal.svelte index e3d2636e..b4a67934 100644 --- a/src/lib/components/cospend/PaymentModal.svelte +++ b/src/lib/components/cospend/PaymentModal.svelte @@ -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 @@
-

{t('payment_details', lang)}

+

{t.payment_details}

+
{/if} diff --git a/src/lib/components/cospend/SplitMethodSelector.svelte b/src/lib/components/cospend/SplitMethodSelector.svelte index 433e7725..163905c9 100644 --- a/src/lib/components/cospend/SplitMethodSelector.svelte +++ b/src/lib/components/cospend/SplitMethodSelector.svelte @@ -1,9 +1,10 @@
-

{t('split_method', lang)}

+

{t.split_method}

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

{t('custom_split_amounts', lang)}

+

{t.custom_split_amounts}

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

{t('personal_amounts', lang)}

-

{t('personal_amounts_desc', lang)}

+

{t.personal_amounts}

+

{t.personal_amounts_desc}

{#each users as user}
@@ -184,10 +185,10 @@ {#if amount} {@const personalTotal = Object.values(personalAmounts).reduce((/** @type {number} */ sum, /** @type {number} */ val) => sum + (Number(val) || 0), 0)}
- {t('total_personal', lang)}: {currency} {personalTotal.toFixed(2)} - {t('remainder_to_split', lang)}: {currency} {Math.max(0, Number(amount) - personalTotal).toFixed(2)} + {t.total_personal}: {currency} {personalTotal.toFixed(2)} + {t.remainder_to_split}: {currency} {Math.max(0, Number(amount) - personalTotal).toFixed(2)} {#if personalTotalError} -
{t('personal_exceeds_total', lang)}
+
{t.personal_exceeds_total}
{/if}
{/if} @@ -196,7 +197,7 @@ {#if Object.keys(splitAmounts).length > 0}
-

{t('split_preview', lang)}

+

{t.split_preview}

{#each users as user}
@@ -205,11 +206,11 @@
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}
diff --git a/src/lib/components/cospend/UsersList.svelte b/src/lib/components/cospend/UsersList.svelte index 1c4262cc..d7b330ff 100644 --- a/src/lib/components/cospend/UsersList.svelte +++ b/src/lib/components/cospend/UsersList.svelte @@ -1,6 +1,6 @@
-

{t('split_between_users', lang)}

+

{t.split_between_users}

{#if predefinedMode}
-

{t('predefined_note', lang)}

+

{t.predefined_note}

{#each users as user}
{user} {#if user === currentUser} - {t('you', lang)} + {t.you} {/if}
{/each} @@ -62,11 +63,11 @@ {user} {#if user === currentUser} - {t('you', lang)} + {t.you} {/if} {#if canRemoveUsers && user !== currentUser} {/if}
@@ -77,10 +78,10 @@ e.key === 'Enter' && (e.preventDefault(), addUser())} /> - +
{/if}
diff --git a/src/lib/i18n/calendar/de.ts b/src/lib/i18n/calendar/de.ts new file mode 100644 index 00000000..a0463743 --- /dev/null +++ b/src/lib/i18n/calendar/de.ts @@ -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; diff --git a/src/lib/i18n/calendar/de_1962.ts b/src/lib/i18n/calendar/de_1962.ts new file mode 100644 index 00000000..eeef665f --- /dev/null +++ b/src/lib/i18n/calendar/de_1962.ts @@ -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; diff --git a/src/lib/i18n/calendar/en.ts b/src/lib/i18n/calendar/en.ts new file mode 100644 index 00000000..b83b8a65 --- /dev/null +++ b/src/lib/i18n/calendar/en.ts @@ -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; diff --git a/src/lib/i18n/calendar/en_1962.ts b/src/lib/i18n/calendar/en_1962.ts new file mode 100644 index 00000000..fb3eb7dd --- /dev/null +++ b/src/lib/i18n/calendar/en_1962.ts @@ -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; diff --git a/src/lib/i18n/calendar/la.ts b/src/lib/i18n/calendar/la.ts new file mode 100644 index 00000000..c3c918da --- /dev/null +++ b/src/lib/i18n/calendar/la.ts @@ -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; diff --git a/src/lib/i18n/calendar/la_1962.ts b/src/lib/i18n/calendar/la_1962.ts new file mode 100644 index 00000000..22179dfc --- /dev/null +++ b/src/lib/i18n/calendar/la_1962.ts @@ -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; diff --git a/src/lib/i18n/cospend/de.ts b/src/lib/i18n/cospend/de.ts new file mode 100644 index 00000000..31eb2fa7 --- /dev/null +++ b/src/lib/i18n/cospend/de.ts @@ -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; diff --git a/src/lib/i18n/cospend/en.ts b/src/lib/i18n/cospend/en.ts new file mode 100644 index 00000000..742c0049 --- /dev/null +++ b/src/lib/i18n/cospend/en.ts @@ -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; diff --git a/src/lib/js/cospendI18n.ts b/src/lib/js/cospendI18n.ts index fbd5a072..df649b91 100644 --- a/src/lib/js/cospendI18n.ts +++ b/src/lib/js/cospendI18n.ts @@ -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>; -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> = { @@ -326,7 +55,7 @@ const categoryDisplayNames: Record> = { }; /** 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> = { }; /** 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 = { 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(); diff --git a/src/routes/[cospendRoot=cospendRoot]/dash/+page.svelte b/src/routes/[cospendRoot=cospendRoot]/dash/+page.svelte index 70847c3d..98da898c 100644 --- a/src/routes/[cospendRoot=cospendRoot]/dash/+page.svelte +++ b/src/routes/[cospendRoot=cospendRoot]/dash/+page.svelte @@ -14,10 +14,11 @@ import { formatCurrency } from '$lib/utils/formatters'; - import { detectCospendLang, cospendRoot, t, locale, paymentCategoryName } from '$lib/js/cospendI18n'; + import { detectCospendLang, cospendRoot, locale, paymentCategoryName, m } from '$lib/js/cospendI18n'; let { data } = $props(); // Contains session data and balance from server const lang = $derived(detectCospendLang(page.url.pathname)); + const t = $derived(m[lang]); const root = $derived(cospendRoot(lang)); const loc = $derived(locale(lang)); @@ -125,11 +126,11 @@ - {t('cospend_title', lang)} + {t.cospend_title}
-

{t('cospend', lang)}

+

{t.cospend}

@@ -138,7 +139,7 @@
{#if balance.netBalance !== 0} - {t('settle_debts', lang)} + {t.settle_debts} {/if}
@@ -148,11 +149,11 @@
{#if expensesLoading} -
{t('loading_monthly', lang)}
+
{t.loading_monthly}
{:else if monthlyExpensesData.datasets && monthlyExpensesData.datasets.length > 0} categoryFilter = categories} @@ -168,19 +169,19 @@
{#if loading} -
{t('loading_recent', lang)}
+
{t.loading_recent}
{:else if error}
Error: {error}
{:else if balance.recentSplits && balance.recentSplits.length > 0}
-

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

+

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

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

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

+

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

{/if}
{#each filteredSplits as split} @@ -226,9 +227,9 @@
} */ @@ -427,7 +428,7 @@
diff --git a/src/routes/[cospendRoot=cospendRoot]/payments/edit/[id]/+page.svelte b/src/routes/[cospendRoot=cospendRoot]/payments/edit/[id]/+page.svelte index 9e60ba0a..58a1be85 100644 --- a/src/routes/[cospendRoot=cospendRoot]/payments/edit/[id]/+page.svelte +++ b/src/routes/[cospendRoot=cospendRoot]/payments/edit/[id]/+page.svelte @@ -2,7 +2,7 @@ import { onMount } from 'svelte'; import { goto } from '$app/navigation'; import { page } from '$app/state'; - import { detectCospendLang, cospendRoot, locale, t, getCategoryOptionsI18n } from '$lib/js/cospendI18n'; + import { detectCospendLang, cospendRoot, locale, getCategoryOptionsI18n, m } from '$lib/js/cospendI18n'; import { confirm } from '$lib/js/confirmDialog.svelte'; import FormSection from '$lib/components/FormSection.svelte'; import ImageUpload from '$lib/components/ImageUpload.svelte'; @@ -16,6 +16,7 @@ let { data } = $props(); const lang = $derived(detectCospendLang(page.url.pathname)); + const t = $derived(m[lang]); const root = $derived(cospendRoot(lang)); const loc = $derived(locale(lang)); @@ -368,25 +369,25 @@ - {t('edit_payment_title', lang)} - {t('cospend', lang)} + {t.edit_payment_title} - {t.cospend}
-

{t('edit_payment_title', lang)}

-

{t('edit_payment_subtitle', lang)}

+

{t.edit_payment_title}

+

{t.edit_payment_subtitle}

{#if loading} -
{t('loading_payments', lang)}
+
{t.loading_payments}
{:else if error}
Error: {error}
{:else if payment}
{ e.preventDefault(); handleSubmit(); } } class="payment-form"> - +
- +
- +
- +
- + - - + +
-

{t('recurring_schedule', lang)}

+

{t.recurring_schedule}

- +
- +
@@ -449,14 +450,14 @@ {/if}
- + -
{t('end_date_hint', lang)}
+
{t.end_date_hint}
{#if nextExecutionPreview}
-

{t('next_execution_preview', lang)}

+

{t.next_execution_preview}

{nextExecutionPreview}

{frequencyDescription(/** @type {any} */ (formData), lang)}

@@ -488,7 +489,7 @@
{error}
{/if} - + {/if}
diff --git a/src/routes/[cospendRoot=cospendRoot]/settle/+page.svelte b/src/routes/[cospendRoot=cospendRoot]/settle/+page.svelte index 25bcf42b..7fbd888d 100644 --- a/src/routes/[cospendRoot=cospendRoot]/settle/+page.svelte +++ b/src/routes/[cospendRoot=cospendRoot]/settle/+page.svelte @@ -5,13 +5,14 @@ import { page } from '$app/state'; import ProfilePicture from '$lib/components/cospend/ProfilePicture.svelte'; import { PREDEFINED_USERS, isPredefinedUsersMode } from '$lib/config/users'; - import { detectCospendLang, cospendRoot, t, locale } from '$lib/js/cospendI18n'; + import { detectCospendLang, cospendRoot, locale, m } from '$lib/js/cospendI18n'; import { formatCurrency } from '$lib/utils/formatters'; let { data, form } = $props(); const lang = $derived(detectCospendLang(page.url.pathname)); + const t = $derived(m[lang]); const root = $derived(cospendRoot(lang)); const loc = $derived(locale(lang)); @@ -44,7 +45,7 @@ from: debtData.whoOwesMe[0].username, to: data.currentUser, amount: debtData.whoOwesMe[0].netAmount, - description: `${t('settlement_payment', lang)}: ${debtData.whoOwesMe[0].username} → ${data.currentUser}` + description: `${t.settlement_payment}: ${debtData.whoOwesMe[0].username} → ${data.currentUser}` }; if (!settlementAmount) { settlementAmount = debtData.whoOwesMe[0].netAmount.toString(); @@ -55,7 +56,7 @@ from: data.currentUser, to: debtData.whoIOwe[0].username, amount: debtData.whoIOwe[0].netAmount, - description: `${t('settlement_payment', lang)}: ${data.currentUser} → ${debtData.whoIOwe[0].username}` + description: `${t.settlement_payment}: ${data.currentUser} → ${debtData.whoIOwe[0].username}` }; if (!settlementAmount) { settlementAmount = debtData.whoIOwe[0].netAmount.toString(); @@ -73,7 +74,7 @@ from: user, to: currentUser, amount: amount, - description: `${t('settlement_payment', lang)}: ${user} → ${currentUser}` + description: `${t.settlement_payment}: ${user} → ${currentUser}` }; } else { selectedSettlement = { @@ -81,7 +82,7 @@ from: currentUser, to: user, amount: amount, - description: `${t('settlement_payment', lang)}: ${currentUser} → ${user}` + description: `${t.settlement_payment}: ${currentUser} → ${user}` }; } settlementAmount = amount.toString(); @@ -89,13 +90,13 @@ async function processSettlement() { if (!selectedSettlement || !settlementAmount) { - error = t('error_select_settlement', lang); + error = t.error_select_settlement; return; } const amount = parseFloat(/** @type {string} */ (settlementAmount)); if (isNaN(amount) || amount <= 0) { - error = t('error_valid_amount', lang); + error = t.error_valid_amount; return; } @@ -148,36 +149,36 @@ - {t('settle_title', lang)} - {t('cospend', lang)} + {t.settle_title} - {t.cospend}
-

{t('settle_title', lang)}

-

{t('settle_subtitle', lang)}

+

{t.settle_title}

+

{t.settle_subtitle}

{#if loading} -
{t('loading_debts', lang)}
+
{t.loading_debts}
{:else if error}
Error: {error}
{:else if debtData.whoOwesMe.length === 0 && debtData.whoIOwe.length === 0}
-

🎉 {t('all_settled', lang)}

-

{t('no_debts_msg', lang)}

+

🎉 {t.all_settled}

+

{t.no_debts_msg}

{:else}
-

{t('available_settlements', lang)}

+

{t.available_settlements}

{#if debtData.whoOwesMe.length > 0}
-

{t('money_owed_to_you', lang)}

+

{t.money_owed_to_you}

{#each debtData.whoOwesMe as debt}
{debt.username} - {t('owes_you', lang)} {formatCurrency(debt.netAmount, 'CHF', loc)} + {t.owes_you} {formatCurrency(debt.netAmount, 'CHF', loc)}
- {t('receive_payment', lang)} + {t.receive_payment}
{/each} @@ -203,7 +204,7 @@ {#if debtData.whoIOwe.length > 0}
-

{t('money_you_owe', lang)}

+

{t.money_you_owe}

{#each debtData.whoIOwe as debt}
{debt.username} - {t('you_owe', lang)} {formatCurrency(debt.netAmount, 'CHF', loc)} + {t.you_owe} {formatCurrency(debt.netAmount, 'CHF', loc)}
- {t('make_payment', lang)} + {t.make_payment}
{/each} @@ -231,7 +232,7 @@ {#if selectedSettlement}
-

{t('settlement_details', lang)}

+

{t.settlement_details}

@@ -239,7 +240,7 @@ {selectedSettlement.from} {#if selectedSettlement.from === data.currentUser} - {t('you', lang)} + {t.you} {/if}
@@ -247,13 +248,13 @@ {selectedSettlement.to} {#if selectedSettlement.to === data.currentUser} - {t('you', lang)} + {t.you} {/if}
- +
CHF {#if submitting} - {t('recording_settlement', lang)} + {t.recording_settlement} {:else} - {t('record_settlement', lang)} + {t.record_settlement} {/if}
{:else}
-

{t('record_settlement', lang)}

+

{t.record_settlement}

- +
- +
- +
- + - {t('cancel', lang)} + {t.cancel}
diff --git a/src/routes/[faithLang=faithLang]/[calendar=calendarLang]/HeroCard.svelte b/src/routes/[faithLang=faithLang]/[calendar=calendarLang]/HeroCard.svelte index 40f57df3..25d49f65 100644 --- a/src/routes/[faithLang=faithLang]/[calendar=calendarLang]/HeroCard.svelte +++ b/src/routes/[faithLang=faithLang]/[calendar=calendarLang]/HeroCard.svelte @@ -1,14 +1,6 @@