From 3347619816a43148a87e42d5a925fa56996b922a Mon Sep 17 00:00:00 2001 From: Alexander Bocken Date: Fri, 1 May 2026 12:47:46 +0200 Subject: [PATCH] refactor(i18n): split cospend + calendar per-locale, adopt t.key syntax MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- package.json | 2 +- scripts/codemod-fitness-t-to-m.ts | 104 ------ scripts/codemod-i18n-t-to-m.ts | 126 +++++++ scripts/split-fitness-i18n.ts | 74 ---- scripts/split-i18n.ts | 132 ++++++++ .../components/cospend/DebtBreakdown.svelte | 21 +- .../components/cospend/EnhancedBalance.svelte | 33 +- .../components/cospend/PaymentModal.svelte | 37 +- .../cospend/SplitMethodSelector.svelte | 41 +-- src/lib/components/cospend/UsersList.svelte | 17 +- src/lib/i18n/calendar/de.ts | 20 ++ src/lib/i18n/calendar/de_1962.ts | 18 + src/lib/i18n/calendar/en.ts | 20 ++ src/lib/i18n/calendar/en_1962.ts | 18 + src/lib/i18n/calendar/la.ts | 20 ++ src/lib/i18n/calendar/la_1962.ts | 18 + src/lib/i18n/cospend/de.ts | 237 +++++++++++++ src/lib/i18n/cospend/en.ts | 237 +++++++++++++ src/lib/js/cospendI18n.ts | 319 ++---------------- .../dash/+page.svelte | 25 +- .../list/+page.svelte | 49 +-- .../payments/+page.svelte | 37 +- .../payments/add/+page.svelte | 73 ++-- .../payments/edit/[id]/+page.svelte | 59 ++-- .../payments/view/[id]/+page.svelte | 31 +- .../recurring/+page.svelte | 49 +-- .../recurring/edit/[id]/+page.svelte | 53 +-- .../settle/+page.svelte | 85 ++--- .../[calendar=calendarLang]/HeroCard.svelte | 22 +- .../[[dd=calendarDay]]/+page.svelte | 40 +-- .../[dd=calendarDay]/+page.svelte | 37 +- .../[calendar=calendarLang]/calendarI18n.ts | 102 ++---- 32 files changed, 1253 insertions(+), 903 deletions(-) delete mode 100644 scripts/codemod-fitness-t-to-m.ts create mode 100644 scripts/codemod-i18n-t-to-m.ts delete mode 100644 scripts/split-fitness-i18n.ts create mode 100644 scripts/split-i18n.ts create mode 100644 src/lib/i18n/calendar/de.ts create mode 100644 src/lib/i18n/calendar/de_1962.ts create mode 100644 src/lib/i18n/calendar/en.ts create mode 100644 src/lib/i18n/calendar/en_1962.ts create mode 100644 src/lib/i18n/calendar/la.ts create mode 100644 src/lib/i18n/calendar/la_1962.ts create mode 100644 src/lib/i18n/cospend/de.ts create mode 100644 src/lib/i18n/cospend/en.ts 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 @@