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

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

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

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

The split + codemod scripts are now generic — split-i18n.ts takes
namespace, locales, optional marker and basename for multi-table
modules; codemod-i18n-t-to-m.ts takes module basename, fn name, and
m alias name (so t1962 / m1962 share the same machinery as t / m).
The fitness-specific one-shots are deleted, superseded.
This commit is contained in:
2026-05-01 12:47:46 +02:00
parent ac05367ee4
commit 3347619816
32 changed files with 1253 additions and 903 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "homepage", "name": "homepage",
"version": "1.54.1", "version": "1.54.2",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
-104
View File
@@ -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(<expr>, lang) → t[<expr>] 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`);
+126
View File
@@ -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=<path>');
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=<dir> (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(<expr>, lang) → FN[<expr>]
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`);
-74
View File
@@ -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<keyof typeof de, string>` 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<keyof typeof de, string>;', '');
writeFileSync('src/lib/i18n/fitness/en.ts', enLines.join('\n'));
console.log('wrote src/lib/i18n/fitness/de.ts and en.ts');
+132
View File
@@ -0,0 +1,132 @@
/**
* Split a single-file i18n module (with an object literal whose values are
* `Record<locale, string>`) into per-locale files under
* src/lib/i18n/<namespace>/<locale>.ts.
*
* The first locale is the source of truth; others use `as const satisfies
* Record<keyof typeof <first>, string>` so missing translations fail
* type-checking.
*
* Run: pnpm exec vite-node scripts/split-i18n.ts <source> <namespace> <locales,csv> [--marker=<marker>] [--basename=<name>]
* 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 <source> <namespace> <locales,csv> [--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<string, string>;
}
const entries: Entry[] = [];
let m: RegExpExecArray | null;
while ((m = entryRe.exec(body)) !== null) {
const inner = m[2];
const values: Record<string, string> = {};
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<keyof typeof ${sourceLocale}, string>;`,
''
);
writeFileSync(path(loc), lines.join('\n'));
}
console.log(`wrote ${locales.map(path).join(', ')}`);
+11 -10
View File
@@ -3,9 +3,10 @@
import { page } from '$app/state'; import { page } from '$app/state';
import ProfilePicture from './ProfilePicture.svelte'; import ProfilePicture from './ProfilePicture.svelte';
import { formatCurrency } from '$lib/utils/formatters'; 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 lang = $derived(detectCospendLang(page.url.pathname));
const t = $derived(m[lang]);
const loc = $derived(locale(lang)); const loc = $derived(locale(lang));
/** /**
@@ -66,19 +67,19 @@
{#if !shouldHide} {#if !shouldHide}
<div class="debt-breakdown"> <div class="debt-breakdown">
<h2>{t('debt_overview', lang)}</h2> <h2>{t.debt_overview}</h2>
{#if loading} {#if loading}
<div class="loading">{t('loading_debt_breakdown', lang)}</div> <div class="loading">{t.loading_debt_breakdown}</div>
{:else if error} {:else if error}
<div class="error">{t('error_prefix', lang)}: {error}</div> <div class="error">{t.error_prefix}: {error}</div>
{:else} {:else}
<div class="debt-sections"> <div class="debt-sections">
{#if debtData.whoOwesMe.length > 0} {#if debtData.whoOwesMe.length > 0}
<div class="debt-section owed-to-me"> <div class="debt-section owed-to-me">
<h3>{t('who_owes_you', lang)}</h3> <h3>{t.who_owes_you}</h3>
<div class="total-amount positive"> <div class="total-amount positive">
{t('total', lang)}: {formatCurrency(debtData.totalOwedToMe, 'CHF', loc)} {t.total}: {formatCurrency(debtData.totalOwedToMe, 'CHF', loc)}
</div> </div>
<div class="debt-list"> <div class="debt-list">
@@ -92,7 +93,7 @@
</div> </div>
</div> </div>
<div class="transaction-count"> <div class="transaction-count">
{debt.transactions.length} {debt.transactions.length !== 1 ? t('transactions', lang) : t('transaction', lang)} {debt.transactions.length} {debt.transactions.length !== 1 ? t.transactions : t.transaction}
</div> </div>
</div> </div>
{/each} {/each}
@@ -102,9 +103,9 @@
{#if debtData.whoIOwe.length > 0} {#if debtData.whoIOwe.length > 0}
<div class="debt-section owe-to-others"> <div class="debt-section owe-to-others">
<h3>{t('you_owe_section', lang)}</h3> <h3>{t.you_owe_section}</h3>
<div class="total-amount negative"> <div class="total-amount negative">
{t('total', lang)}: {formatCurrency(debtData.totalIOwe, 'CHF', loc)} {t.total}: {formatCurrency(debtData.totalIOwe, 'CHF', loc)}
</div> </div>
<div class="debt-list"> <div class="debt-list">
@@ -118,7 +119,7 @@
</div> </div>
</div> </div>
<div class="transaction-count"> <div class="transaction-count">
{debt.transactions.length} {debt.transactions.length !== 1 ? t('transactions', lang) : t('transaction', lang)} {debt.transactions.length} {debt.transactions.length !== 1 ? t.transactions : t.transaction}
</div> </div>
</div> </div>
{/each} {/each}
@@ -3,9 +3,10 @@
import { page } from '$app/state'; import { page } from '$app/state';
import ProfilePicture from './ProfilePicture.svelte'; import ProfilePicture from './ProfilePicture.svelte';
import { formatCurrency as formatCurrencyUtil } from '$lib/utils/formatters'; 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 lang = $derived(detectCospendLang(page.url.pathname));
const t = $derived(m[lang]);
const loc = $derived(locale(lang)); const loc = $derived(locale(lang));
let { initialBalance = null, initialDebtData = null } = $props<{ initialBalance?: any, initialDebtData?: any }>(); let { initialBalance = null, initialDebtData = null } = $props<{ initialBalance?: any, initialDebtData?: any }>();
@@ -122,26 +123,26 @@
{#if loading} {#if loading}
<div class="loading-content"> <div class="loading-content">
<h3>{t('your_balance', lang)}</h3> <h3>{t.your_balance}</h3>
<div class="loading">{t('loading', lang)}</div> <div class="loading">{t.loading}</div>
</div> </div>
{:else if error} {:else if error}
<h3>{t('your_balance', lang)}</h3> <h3>{t.your_balance}</h3>
<div class="error">{t('error_prefix', lang)}: {error}</div> <div class="error">{t.error_prefix}: {error}</div>
{:else if shouldShowIntegratedView} {:else if shouldShowIntegratedView}
<!-- Enhanced view with single user debt --> <!-- Enhanced view with single user debt -->
<h3>{t('your_balance', lang)}</h3> <h3>{t.your_balance}</h3>
<div class="enhanced-balance"> <div class="enhanced-balance">
<div class="main-amount"> <div class="main-amount">
{#if balance.netBalance < 0} {#if balance.netBalance < 0}
<span class="positive">+{formatCurrency(balance.netBalance)}</span> <span class="positive">+{formatCurrency(balance.netBalance)}</span>
<small>{t('you_are_owed', lang)}</small> <small>{t.you_are_owed}</small>
{:else if balance.netBalance > 0} {:else if balance.netBalance > 0}
<span class="negative">-{formatCurrency(balance.netBalance)}</span> <span class="negative">-{formatCurrency(balance.netBalance)}</span>
<small>{t('you_owe_balance', lang)}</small> <small>{t.you_owe_balance}</small>
{:else} {:else}
<span class="even">CHF 0.00</span> <span class="even">CHF 0.00</span>
<small>{t('all_even', lang)}</small> <small>{t.all_even}</small>
{/if} {/if}
</div> </div>
@@ -154,9 +155,9 @@
<span class="username">{singleDebtUser.user.username}</span> <span class="username">{singleDebtUser.user.username}</span>
<span class="debt-description"> <span class="debt-description">
{#if singleDebtUser.type === 'owesMe'} {#if singleDebtUser.type === 'owesMe'}
{t('owes_you_balance', lang)} {formatCurrency(singleDebtUser.amount)} {t.owes_you_balance} {formatCurrency(singleDebtUser.amount)}
{:else} {:else}
{t('you_owe_user', lang)} {formatCurrency(singleDebtUser.amount)} {t.you_owe_user} {formatCurrency(singleDebtUser.amount)}
{/if} {/if}
</span> </span>
</div> </div>
@@ -166,24 +167,24 @@
</div> </div>
<div class="transaction-count"> <div class="transaction-count">
{#if singleDebtUser && singleDebtUser.user && singleDebtUser.user.transactions} {#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} {/if}
</div> </div>
</div> </div>
</div> </div>
{:else} {:else}
<!-- Standard balance view --> <!-- Standard balance view -->
<h3>{t('your_balance', lang)}</h3> <h3>{t.your_balance}</h3>
<div class="amount"> <div class="amount">
{#if balance.netBalance < 0} {#if balance.netBalance < 0}
<span class="positive">+{formatCurrency(balance.netBalance)}</span> <span class="positive">+{formatCurrency(balance.netBalance)}</span>
<small>{t('you_are_owed', lang)}</small> <small>{t.you_are_owed}</small>
{:else if balance.netBalance > 0} {:else if balance.netBalance > 0}
<span class="negative">-{formatCurrency(balance.netBalance)}</span> <span class="negative">-{formatCurrency(balance.netBalance)}</span>
<small>{t('you_owe_balance', lang)}</small> <small>{t.you_owe_balance}</small>
{:else} {:else}
<span class="even">CHF 0.00</span> <span class="even">CHF 0.00</span>
<small>{t('all_even', lang)}</small> <small>{t.all_even}</small>
{/if} {/if}
</div> </div>
{/if} {/if}
+19 -18
View File
@@ -7,7 +7,7 @@
import EditButton from '$lib/components/EditButton.svelte'; import EditButton from '$lib/components/EditButton.svelte';
import { getCategoryEmoji } from '$lib/utils/categories'; import { getCategoryEmoji } from '$lib/utils/categories';
import { formatCurrency as formatCurrencyUtil } from '$lib/utils/formatters'; 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'; import { confirm } from '$lib/js/confirmDialog.svelte';
let { paymentId, onclose, onpaymentDeleted } = $props(); let { paymentId, onclose, onpaymentDeleted } = $props();
@@ -16,6 +16,7 @@
let session = $derived(page.data?.session); let session = $derived(page.data?.session);
const lang = $derived(detectCospendLang(page.url.pathname)); const lang = $derived(detectCospendLang(page.url.pathname));
const t = $derived(m[lang]);
const root = $derived(cospendRoot(lang)); const root = $derived(cospendRoot(lang));
const loc = $derived(locale(lang)); const loc = $derived(locale(lang));
@@ -112,7 +113,7 @@
let deleting = $state(false); let deleting = $state(false);
async function deletePayment() { async function deletePayment() {
if (!await confirm(t('delete_payment_confirm', lang))) { if (!await confirm(t.delete_payment_confirm)) {
return; return;
} }
@@ -140,7 +141,7 @@
<div class="panel-content" bind:this={modal}> <div class="panel-content" bind:this={modal}>
<div class="panel-header"> <div class="panel-header">
<h2>{t('payment_details', lang)}</h2> <h2>{t.payment_details}</h2>
<button class="close-button" onclick={closeModal} aria-label="Close modal"> <button class="close-button" onclick={closeModal} aria-label="Close modal">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"></line> <line x1="18" y1="6" x2="6" y2="18"></line>
@@ -151,9 +152,9 @@
<div class="panel-body"> <div class="panel-body">
{#if loading} {#if loading}
<div class="loading">{t('loading_payments', lang)}</div> <div class="loading">{t.loading_payments}</div>
{:else if error} {:else if error}
<div class="error">{t('error_prefix', lang)}: {error}</div> <div class="error">{t.error_prefix}: {error}</div>
{:else if payment} {:else if payment}
<div class="payment-details"> <div class="payment-details">
<div class="payment-header"> <div class="payment-header">
@@ -168,7 +169,7 @@
</div> </div>
{#if payment.image} {#if payment.image}
<div class="receipt-image"> <div class="receipt-image">
<img src={payment.image} alt={t('receipt', lang)} /> <img src={payment.image} alt={t.receipt} />
</div> </div>
{/if} {/if}
</div> </div>
@@ -176,30 +177,30 @@
<div class="payment-info"> <div class="payment-info">
<div class="info-grid"> <div class="info-grid">
<div class="info-item"> <div class="info-item">
<span class="label">{t('date', lang)}</span> <span class="label">{t.date}</span>
<span class="value">{formatDate(payment.date)}</span> <span class="value">{formatDate(payment.date)}</span>
</div> </div>
<div class="info-item"> <div class="info-item">
<span class="label">{t('paid_by_label', lang)}</span> <span class="label">{t.paid_by_label}</span>
<span class="value">{payment.paidBy}</span> <span class="value">{payment.paidBy}</span>
</div> </div>
<div class="info-item"> <div class="info-item">
<span class="label">{t('created_by', lang)}</span> <span class="label">{t.created_by}</span>
<span class="value">{payment.createdBy}</span> <span class="value">{payment.createdBy}</span>
</div> </div>
<div class="info-item"> <div class="info-item">
<span class="label">{t('category_label', lang)}</span> <span class="label">{t.category_label}</span>
<span class="value">{paymentCategoryName(payment.category || 'groceries', lang)}</span> <span class="value">{paymentCategoryName(payment.category || 'groceries', lang)}</span>
</div> </div>
<div class="info-item"> <div class="info-item">
<span class="label">{t('split_method_label', lang)}</span> <span class="label">{t.split_method_label}</span>
<span class="value">{getSplitDescription(payment)}</span> <span class="value">{getSplitDescription(payment)}</span>
</div> </div>
</div> </div>
{#if payment.description} {#if payment.description}
<div class="description"> <div class="description">
<h3>{t('description', lang)}</h3> <h3>{t.description}</h3>
<p>{payment.description}</p> <p>{payment.description}</p>
</div> </div>
{/if} {/if}
@@ -207,7 +208,7 @@
{#if payment.splits && payment.splits.length > 0} {#if payment.splits && payment.splits.length > 0}
<div class="splits-section"> <div class="splits-section">
<h3>{t('split_details', lang)}</h3> <h3>{t.split_details}</h3>
<div class="splits-list"> <div class="splits-list">
{#each payment.splits as split} {#each payment.splits as split}
<div class="split-item" class:current-user={split.username === session?.user?.nickname}> <div class="split-item" class:current-user={split.username === session?.user?.nickname}>
@@ -216,17 +217,17 @@
<div class="user-info"> <div class="user-info">
<span class="username">{split.username}</span> <span class="username">{split.username}</span>
{#if split.username === session?.user?.nickname} {#if split.username === session?.user?.nickname}
<span class="you-badge">{t('you', lang)}</span> <span class="you-badge">{t.you}</span>
{/if} {/if}
</div> </div>
</div> </div>
<div class="split-amount" class:positive={split.amount < 0} class:negative={split.amount > 0}> <div class="split-amount" class:positive={split.amount < 0} class:negative={split.amount > 0}>
{#if split.amount > 0} {#if split.amount > 0}
{t('owes', lang)} {formatCurrency(split.amount)} {t.owes} {formatCurrency(split.amount)}
{:else if split.amount < 0} {:else if split.amount < 0}
{t('owed', lang)} {formatCurrency(split.amount)} {t.owed} {formatCurrency(split.amount)}
{:else} {:else}
{t('even', lang)} {t.even}
{/if} {/if}
</div> </div>
</div> </div>
@@ -236,7 +237,7 @@
{/if} {/if}
<div class="panel-actions"> <div class="panel-actions">
<button class="btn-secondary" onclick={closeModal}>{t('close', lang)}</button> <button class="btn-secondary" onclick={closeModal}>{t.close}</button>
</div> </div>
</div> </div>
{/if} {/if}
@@ -1,9 +1,10 @@
<script> <script>
import ProfilePicture from './ProfilePicture.svelte'; import ProfilePicture from './ProfilePicture.svelte';
import { page } from '$app/state'; import { page } from '$app/state';
import { detectCospendLang, t } from '$lib/js/cospendI18n'; import { detectCospendLang, m } from '$lib/js/cospendI18n';
const lang = $derived(detectCospendLang(page.url.pathname)); const lang = $derived(detectCospendLang(page.url.pathname));
const t = $derived(m[lang]);
let { let {
splitMethod = $bindable('equal'), splitMethod = $bindable('equal'),
@@ -22,20 +23,20 @@
// Reactive text for "Paid in Full" option // Reactive text for "Paid in Full" option
let paidInFullText = $derived((() => { let paidInFullText = $derived((() => {
if (!paidBy) { if (!paidBy) {
return t('paid_in_full', lang); return t.paid_in_full;
} }
// Special handling for 2-user predefined setup // Special handling for 2-user predefined setup
if (predefinedMode && users.length === 2) { if (predefinedMode && users.length === 2) {
const otherUser = users.find((/** @type {string} */ user) => user !== paidBy); const otherUser = users.find((/** @type {string} */ user) => user !== paidBy);
return otherUser ? `${t('paid_in_full_for', lang)} ${otherUser}` : t('paid_in_full', lang); return otherUser ? `${t.paid_in_full_for} ${otherUser}` : t.paid_in_full;
} }
// General case // General case
if (paidBy === currentUser) { if (paidBy === currentUser) {
return t('paid_in_full_by_you', lang); return t.paid_in_full_by_you;
} else { } else {
return `${t('paid_in_full_by', lang)} ${paidBy}`; return `${t.paid_in_full_by} ${paidBy}`;
} }
})()); })());
@@ -132,21 +133,21 @@
</script> </script>
<div class="form-section"> <div class="form-section">
<h2>{t('split_method', lang)}</h2> <h2>{t.split_method}</h2>
<div class="form-group"> <div class="form-group">
<label for="splitMethod">{t('how_split', lang)}</label> <label for="splitMethod">{t.how_split}</label>
<select id="splitMethod" name="splitMethod" bind:value={splitMethod} required> <select id="splitMethod" name="splitMethod" bind:value={splitMethod} required>
<option value="equal">{predefinedMode && users.length === 2 ? t('split_5050', lang) : t('equal_split', lang)}</option> <option value="equal">{predefinedMode && users.length === 2 ? t.split_5050 : t.equal_split}</option>
<option value="personal_equal">{t('personal_equal_split', lang)}</option> <option value="personal_equal">{t.personal_equal_split}</option>
<option value="full">{paidInFullText}</option> <option value="full">{paidInFullText}</option>
<option value="proportional">{t('custom_proportions', lang)}</option> <option value="proportional">{t.custom_proportions}</option>
</select> </select>
</div> </div>
{#if splitMethod === 'proportional'} {#if splitMethod === 'proportional'}
<div class="proportional-splits"> <div class="proportional-splits">
<h3>{t('custom_split_amounts', lang)}</h3> <h3>{t.custom_split_amounts}</h3>
{#each users as user} {#each users as user}
<div class="split-input"> <div class="split-input">
<label for="split_{user}">{user}</label> <label for="split_{user}">{user}</label>
@@ -165,8 +166,8 @@
{#if splitMethod === 'personal_equal'} {#if splitMethod === 'personal_equal'}
<div class="personal-splits"> <div class="personal-splits">
<h3>{t('personal_amounts', lang)}</h3> <h3>{t.personal_amounts}</h3>
<p class="description">{t('personal_amounts_desc', lang)}</p> <p class="description">{t.personal_amounts_desc}</p>
{#each users as user} {#each users as user}
<div class="split-input"> <div class="split-input">
<label for="personal_{user}">{user}</label> <label for="personal_{user}">{user}</label>
@@ -184,10 +185,10 @@
{#if amount} {#if amount}
{@const personalTotal = Object.values(personalAmounts).reduce((/** @type {number} */ sum, /** @type {number} */ val) => sum + (Number(val) || 0), 0)} {@const personalTotal = Object.values(personalAmounts).reduce((/** @type {number} */ sum, /** @type {number} */ val) => sum + (Number(val) || 0), 0)}
<div class="remainder-info" class:error={personalTotalError}> <div class="remainder-info" class:error={personalTotalError}>
<span>{t('total_personal', lang)}: {currency} {personalTotal.toFixed(2)}</span> <span>{t.total_personal}: {currency} {personalTotal.toFixed(2)}</span>
<span>{t('remainder_to_split', lang)}: {currency} {Math.max(0, Number(amount) - personalTotal).toFixed(2)}</span> <span>{t.remainder_to_split}: {currency} {Math.max(0, Number(amount) - personalTotal).toFixed(2)}</span>
{#if personalTotalError} {#if personalTotalError}
<div class="error-message">{t('personal_exceeds_total', lang)}</div> <div class="error-message">{t.personal_exceeds_total}</div>
{/if} {/if}
</div> </div>
{/if} {/if}
@@ -196,7 +197,7 @@
{#if Object.keys(splitAmounts).length > 0} {#if Object.keys(splitAmounts).length > 0}
<div class="split-preview"> <div class="split-preview">
<h3>{t('split_preview', lang)}</h3> <h3>{t.split_preview}</h3>
{#each users as user} {#each users as user}
<div class="split-item"> <div class="split-item">
<div class="split-user"> <div class="split-user">
@@ -205,11 +206,11 @@
</div> </div>
<span class="amount" class:positive={splitAmounts[user] < 0} class:negative={splitAmounts[user] > 0}> <span class="amount" class:positive={splitAmounts[user] < 0} class:negative={splitAmounts[user] > 0}>
{#if splitAmounts[user] > 0} {#if splitAmounts[user] > 0}
{t('owes', lang)} {currency} {splitAmounts[user].toFixed(2)} {t.owes} {currency} {splitAmounts[user].toFixed(2)}
{:else if splitAmounts[user] < 0} {: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} {:else}
{t('owes', lang)} {currency} {splitAmounts[user].toFixed(2)} {t.owes} {currency} {splitAmounts[user].toFixed(2)}
{/if} {/if}
</span> </span>
</div> </div>
+9 -8
View File
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import ProfilePicture from './ProfilePicture.svelte'; import ProfilePicture from './ProfilePicture.svelte';
import { t } from '$lib/js/cospendI18n'; import { m, type CospendLang } from '$lib/js/cospendI18n';
let { let {
users = $bindable([]), users = $bindable([]),
@@ -17,6 +17,7 @@
newUser?: string, newUser?: string,
lang?: 'en' | 'de' lang?: 'en' | 'de'
}>(); }>();
const t = $derived(m[lang as CospendLang]);
function addUser() { function addUser() {
if (predefinedMode) return; if (predefinedMode) return;
@@ -38,18 +39,18 @@
</script> </script>
<div class="form-section"> <div class="form-section">
<h2>{t('split_between_users', lang)}</h2> <h2>{t.split_between_users}</h2>
{#if predefinedMode} {#if predefinedMode}
<div class="predefined-users"> <div class="predefined-users">
<p class="predefined-note">{t('predefined_note', lang)}</p> <p class="predefined-note">{t.predefined_note}</p>
<div class="users-list"> <div class="users-list">
{#each users as user} {#each users as user}
<div class="user-item with-profile"> <div class="user-item with-profile">
<ProfilePicture username={user} size={32} /> <ProfilePicture username={user} size={32} />
<span class="username">{user}</span> <span class="username">{user}</span>
{#if user === currentUser} {#if user === currentUser}
<span class="you-badge">{t('you', lang)}</span> <span class="you-badge">{t.you}</span>
{/if} {/if}
</div> </div>
{/each} {/each}
@@ -62,11 +63,11 @@
<ProfilePicture username={user} size={32} /> <ProfilePicture username={user} size={32} />
<span class="username">{user}</span> <span class="username">{user}</span>
{#if user === currentUser} {#if user === currentUser}
<span class="you-badge">{t('you', lang)}</span> <span class="you-badge">{t.you}</span>
{/if} {/if}
{#if canRemoveUsers && user !== currentUser} {#if canRemoveUsers && user !== currentUser}
<button type="button" class="remove-user" onclick={() => removeUser(user)}> <button type="button" class="remove-user" onclick={() => removeUser(user)}>
{t('remove', lang)} {t.remove}
</button> </button>
{/if} {/if}
</div> </div>
@@ -77,10 +78,10 @@
<input <input
type="text" type="text"
bind:value={newUser} bind:value={newUser}
placeholder={t('add_user_placeholder', lang)} placeholder={t.add_user_placeholder}
onkeydown={(e) => e.key === 'Enter' && (e.preventDefault(), addUser())} onkeydown={(e) => e.key === 'Enter' && (e.preventDefault(), addUser())}
/> />
<button type="button" onclick={addUser}>{t('add_user', lang)}</button> <button type="button" onclick={addUser}>{t.add_user}</button>
</div> </div>
{/if} {/if}
</div> </div>
+20
View File
@@ -0,0 +1,20 @@
/** Generated by scripts/split-i18n.ts. */
/** DE calendar UI strings — source of truth for the key set. */
export const de = {
today: "Heute",
calendar: "Liturgischer Kalender",
jumpToToday: "Zu heute",
prev: "Vorheriger Monat",
next: "Nächster Monat",
psalterWeek: "Psalterwoche",
cycle: "Lesejahr",
rite1969Long: "Römisches Messbuch 1969 (Ordentliche Form)",
rite1962Long: "Römisches Messbuch 1962 (Ausserordentliche Form)",
wipTitle: "In Arbeit",
wipBody: "Der Kalender für den alten Ritus (Messbuch 1962) ist noch nicht verfügbar. Bleib dran.",
rite1962DisclaimerTitle: "Genauigkeit wird noch geprüft",
rite1962DisclaimerBody: "Der Kalender für den Ritus von 1962 stammt aus divinum-officium-Daten und wird noch Tag für Tag mit maßgeblichen Quellen abgeglichen. Nur die in romcal enthaltenen Schweizer Diözesankalender werden angewendet; weitere Landes- oder Ortskalender sind noch nicht verfügbar.",
calendarVariant: "Kalender",
rite1969SwissNote: "romcal liefert für 1969 nur einen nationalen Schweizer Kalender — diözesane Unterkalender sind für diesen Ritus nicht verfügbar.",
} as const;
+18
View File
@@ -0,0 +1,18 @@
/** Generated by scripts/split-i18n.ts. */
/** DE calendar (1962) UI strings — source of truth for the key set. */
export const de = {
commemorations: "Kommemorationen",
octave: "Oktav",
octaveDay: "Tag",
vigilOf: "Vigil von",
transferredFrom: "Übertragen von",
source: "Quelle",
propers: "Messproprium",
stationChurch: "Stationskirche",
viewLatin: "Latein",
viewParallel: "Parallel",
viewVernacular: "Deutsch",
fallbackBadge: "Allioli",
fallbackHint: "Keine Übersetzung im Messbuch vorhanden. Text aus der Allioli-Bibelübersetzung an der angegebenen Stelle.",
} as const;
+20
View File
@@ -0,0 +1,20 @@
/** Generated by scripts/split-i18n.ts. */
import type { de } from './de';
export const en = {
today: "Today",
calendar: "Liturgical Calendar",
jumpToToday: "Jump to today",
prev: "Previous month",
next: "Next month",
psalterWeek: "Psalter week",
cycle: "Sunday cycle",
rite1969Long: "Roman Missal of 1969 (Ordinary Form)",
rite1962Long: "Roman Missal of 1962 (Extraordinary Form)",
wipTitle: "Work in progress",
wipBody: "The calendar for the older rite (1962 Missal) is not yet available. Stay tuned.",
rite1962DisclaimerTitle: "Accuracy still being verified",
rite1962DisclaimerBody: "The 1962 calendar is derived from divinum-officium data and is still being checked day-by-day against authoritative sources. Only the Swiss diocesan propers shipped by romcal are applied; other national/local calendars are not yet available.",
calendarVariant: "Calendar",
rite1969SwissNote: "romcal ships only a national Swiss calendar for 1969 — diocesan sub-calendars are not available for this rite.",
} as const satisfies Record<keyof typeof de, string>;
+18
View File
@@ -0,0 +1,18 @@
/** Generated by scripts/split-i18n.ts. */
import type { de } from './de_1962';
export const en = {
commemorations: "Commemorations",
octave: "Octave",
octaveDay: "day",
vigilOf: "Vigil of",
transferredFrom: "Transferred from",
source: "Source",
propers: "Mass propers",
stationChurch: "Station church",
viewLatin: "Latin",
viewParallel: "Parallel",
viewVernacular: "English",
fallbackBadge: "Douay-Rheims",
fallbackHint: "Translation not provided in the missal. Text taken from the Douay-Rheims Bible at the cited reference.",
} as const satisfies Record<keyof typeof de, string>;
+20
View File
@@ -0,0 +1,20 @@
/** Generated by scripts/split-i18n.ts. */
import type { de } from './de';
export const la = {
today: "Hodie",
calendar: "Calendarium Liturgicum",
jumpToToday: "Ad hodiernum",
prev: "Mensis praecedens",
next: "Mensis sequens",
psalterWeek: "Hebdomada psalterii",
cycle: "Cyclus dominicalis",
rite1969Long: "Missale Romanum 1969 (Forma Ordinaria)",
rite1962Long: "Missale Romanum 1962 (Forma Extraordinaria)",
wipTitle: "In opere",
wipBody: "Calendarium ritus antiqui (Missale 1962) nondum paratum est. Exspecta paulisper.",
rite1962DisclaimerTitle: "Accuratio adhuc probanda",
rite1962DisclaimerBody: "Calendarium anni 1962 ex datis divinum-officium ductum est et adhuc diebus singulis contra fontes fideles examinatur. Tantum calendaria propria dioecesium Helvetiae a romcal provisa adhibentur; cetera calendaria nationalia vel localia nondum praesto sunt.",
calendarVariant: "Calendarium",
rite1969SwissNote: "Pro anno 1969 romcal solum calendarium Helvetiae nationale praebet — calendaria dioecesana propria pro hoc ritu non adsunt.",
} as const satisfies Record<keyof typeof de, string>;
+18
View File
@@ -0,0 +1,18 @@
/** Generated by scripts/split-i18n.ts. */
import type { de } from './de_1962';
export const la = {
commemorations: "Commemorationes",
octave: "Octava",
octaveDay: "dies",
vigilOf: "Vigilia",
transferredFrom: "Translatum ex",
source: "Fons",
propers: "Propria Missæ",
stationChurch: "Statio",
viewLatin: "Latine",
viewParallel: "Parallelum",
viewVernacular: "Vernacula",
fallbackBadge: "Vulgata",
fallbackHint: "Interpretatio localis deest. Textus ex Biblia Sacra locis citatis.",
} as const satisfies Record<keyof typeof de, string>;
+237
View File
@@ -0,0 +1,237 @@
/** Generated by scripts/split-i18n.ts. */
/** DE cospend UI strings — source of truth for the key set. */
export const de = {
cospend_title: "Cospend - Ausgabenteilung",
all_payments_title: "Alle Zahlungen",
settle_title: "Schulden begleichen",
recurring_title: "Wiederkehrende Zahlungen",
shopping_list_title: "Einkaufsliste",
payment_details: "Zahlungsdetails",
cospend: "Cospend",
settle_debts: "Schulden begleichen",
monthly_expenses_chart: "Monatliche Ausgaben nach Kategorie",
loading_monthly: "Monatliche Ausgaben werden geladen...",
loading_recent: "Letzte Aktivitäten werden geladen...",
recent_activity: "Letzte Aktivität",
clear_filter: "Filter löschen",
no_recent_in: "Keine Aktivität in",
paid_by: "Bezahlt von",
payment: "Zahlung",
loading_payments: "Zahlungen werden geladen...",
no_payments_yet: "Noch keine Zahlungen",
start_first_expense: "Füge deine erste geteilte Ausgabe hinzu",
add_first_payment: "Erste Zahlung hinzufügen",
settlement: "Ausgleich",
split_details: "Aufteilung",
owes: "schuldet",
owed: "bekommt",
even: "ausgeglichen",
previous: "← Zurück",
next: "Weiter →",
load_more: "Mehr laden",
loading_ellipsis: "Laden...",
delete_payment_confirm: "Diese Zahlung wirklich löschen?",
date: "Datum:",
paid_by_label: "Bezahlt von:",
created_by: "Erstellt von:",
category_label: "Kategorie:",
split_method_label: "Aufteilungsart:",
description: "Beschreibung",
exchange_rate: "Wechselkurs",
receipt: "Beleg",
receipt_image: "Belegbild",
remove_image: "Bild entfernen",
replace_image: "Bild ersetzen",
upload_receipt: "Beleg hochladen",
uploading_image: "Bild wird hochgeladen...",
file_too_large: "Dateigrösse muss unter 5MB sein",
invalid_image: "Bitte eine gültige Bilddatei wählen (JPEG, PNG, WebP)",
you: "Du",
close: "Schliessen",
no_splits: "Keine Aufteilung",
split_equal: "Gleichmässig aufgeteilt auf",
paid_full_by: "Vollständig bezahlt von",
personal_equal: "Persönliche Beträge + Gleichverteilung auf",
custom_split: "Individuelle Aufteilung auf",
people: "Personen",
settle_subtitle: "Zahlungen erfassen, um offene Schulden auszugleichen",
loading_debts: "Schuldeninformationen werden geladen...",
all_settled: "Alles beglichen!",
no_debts_msg: "Keine offenen Schulden. Alle sind ausgeglichen!",
back_to_dashboard: "Zurück zum Dashboard",
available_settlements: "Mögliche Ausgleiche",
money_owed_to_you: "Geld, das du bekommst",
owes_you: "schuldet dir",
receive_payment: "Zahlung empfangen",
money_you_owe: "Geld, das du schuldest",
you_owe: "du schuldest",
make_payment: "Zahlung leisten",
settlement_details: "Ausgleichsdetails",
settlement_amount: "Ausgleichsbetrag",
record_settlement: "Ausgleich erfassen",
recording_settlement: "Ausgleich wird erfasst...",
cancel: "Abbrechen",
settlement_type: "Ausgleichsart",
select_settlement: "Ausgleichsart wählen",
receive_from: "Empfangen",
from: "von",
pay_to: "Zahlen",
to: "an",
from_user: "Von Benutzer",
select_payer: "Zahler wählen",
to_user: "An Benutzer",
select_recipient: "Empfänger wählen",
settlement_amount_chf: "Ausgleichsbetrag (CHF)",
error_select_settlement: "Bitte einen Ausgleich wählen und Betrag eingeben",
error_valid_amount: "Bitte einen gültigen positiven Betrag eingeben",
settlement_payment: "Ausgleichszahlung",
recurring_subtitle: "Automatisiere deine regelmässigen geteilten Ausgaben",
show_active_only: "Nur aktive anzeigen",
loading_recurring: "Wiederkehrende Zahlungen werden geladen...",
no_recurring: "Keine wiederkehrenden Zahlungen gefunden",
no_recurring_desc: "Erstelle deine erste wiederkehrende Zahlung für regelmässige Ausgaben wie Miete, Nebenkosten oder Abos.",
active: "Aktiv",
inactive: "Inaktiv",
frequency: "Häufigkeit:",
next_execution: "Nächste Ausführung:",
last_executed: "Zuletzt ausgeführt:",
ends: "Endet:",
split_between: "Aufgeteilt zwischen:",
gets: "bekommt",
edit: "Bearbeiten",
pause: "Pausieren",
activate: "Aktivieren",
delete_: "Löschen",
delete_recurring_confirm: "Wiederkehrende Zahlung wirklich löschen",
items_done: "erledigt",
add_item_placeholder: "Artikel hinzufügen...",
empty_list: "Die Einkaufsliste ist leer",
clear_checked: "Erledigte entfernen",
share: "Teilen",
shared_links: "Geteilte Links",
share_desc: "Jeder mit einem aktiven Link kann die Einkaufsliste bearbeiten.",
loading: "Laden...",
no_active_links: "Keine aktiven Links.",
remaining: "noch",
change: "Ändern",
copy_link: "Link kopieren",
create_new_link: "Neuen Link erstellen",
copied: "Kopiert",
expired: "abgelaufen",
ttl_1h: "1 Stunde",
ttl_6h: "6 Stunden",
ttl_24h: "24 Stunden",
ttl_3d: "3 Tage",
ttl_7d: "7 Tage",
kategorie: "Kategorie",
icon: "Icon",
search_icon: "Icon suchen...",
save: "Speichern",
saving: "Speichern...",
edit_name: "Name",
edit_qty: "Menge",
edit_qty_ph: "z.B. 3x, 500g, 1L",
your_balance: "Dein Saldo",
you_are_owed: "Du bekommst",
you_owe_balance: "Du schuldest",
all_even: "Alles ausgeglichen",
owes_you_balance: "schuldet dir",
you_owe_user: "du schuldest",
transaction: "Transaktion",
transactions: "Transaktionen",
debt_overview: "Schuldenübersicht",
loading_debt_breakdown: "Schuldenübersicht wird geladen...",
who_owes_you: "Wer dir schuldet",
you_owe_section: "Du schuldest",
total: "Gesamt",
freq_every_day: "Jeden Tag",
freq_every_week: "Jede Woche",
freq_every_month: "Jeden Monat",
freq_custom: "Benutzerdefiniert",
freq_unknown: "Unbekannte Häufigkeit",
today_at: "Heute um",
tomorrow_at: "Morgen um",
in_days_at: "In {days} Tagen um",
split_between_users: "Aufteilen zwischen",
predefined_note: "Aufteilung zwischen vordefinierten Benutzern:",
remove: "Entfernen",
add_user_placeholder: "Benutzer hinzufügen...",
add_user: "Benutzer hinzufügen",
split_method: "Aufteilungsmethode",
how_split: "Wie soll diese Zahlung aufgeteilt werden?",
split_5050: "50/50 teilen",
custom_split_amounts: "Individuelle Beträge",
personal_exceeds_total: "Warnung: Persönliche Beträge übersteigen den Gesamtbetrag!",
is_owed: "bekommt",
error_prefix: "Fehler",
cat_groceries: "Lebensmittel",
cat_shopping: "Einkauf",
cat_travel: "Reise",
cat_restaurant: "Restaurant",
cat_utilities: "Nebenkosten",
cat_fun: "Freizeit",
cat_settlement: "Ausgleich",
add_payment_title: "Neue Zahlung",
add_payment_subtitle: "Neue geteilte Ausgabe oder wiederkehrende Zahlung erstellen",
edit_payment_title: "Zahlung bearbeiten",
edit_payment_subtitle: "Zahlungsdetails und Beleg bearbeiten",
edit_recurring_title: "Wiederkehrende Zahlung bearbeiten",
payment_details_section: "Zahlungsdetails",
title_label: "Titel *",
title_placeholder: "z.B. Abendessen im Restaurant",
description_label: "Beschreibung",
description_placeholder: "Weitere Details...",
category_star: "Kategorie *",
amount_label: "Betrag *",
payment_date: "Zahlungsdatum",
paid_by_form: "Bezahlt von",
make_recurring: "Als wiederkehrende Zahlung einrichten",
recurring_section: "Wiederkehrende Zahlung",
recurring_schedule: "Wiederkehrender Zeitplan",
frequency_label: "Häufigkeit *",
freq_daily: "Täglich",
freq_weekly: "Wöchentlich",
freq_monthly: "Monatlich",
freq_quarterly: "Vierteljährlich",
freq_yearly: "Jährlich",
start_date: "Startdatum *",
end_date_optional: "Enddatum (optional)",
end_date_hint: "Leer lassen für unbefristete Wiederholung",
next_execution_preview: "Nächste Ausführung",
status_label: "Status",
create_payment: "Zahlung erstellen",
save_changes: "Änderungen speichern",
delete_payment: "Zahlung löschen",
deleting: "Löschen...",
split_config: "Aufteilungskonfiguration",
split_method_form: "Aufteilungsart:",
equal_split: "Gleichmässige Aufteilung",
personal_equal_split: "Persönliche Beträge + Gleichverteilung",
custom_proportions: "Individuelle Anteile",
personal_amounts: "Persönliche Beträge",
personal_amounts_desc: "Persönliche Beträge für jeden Benutzer eingeben. Der Rest wird gleichmässig aufgeteilt.",
total_personal: "Persönliche Summe",
remainder_to_split: "Rest zum Aufteilen",
personal_exceeds: "Persönliche Beträge übersteigen den Gesamtbetrag!",
split_preview: "Aufteilungsvorschau",
conversion_hint: "Betrag wird anhand des Wechselkurses am Zahlungstag in CHF umgerechnet",
fetching_rate: "Wechselkurs wird abgerufen...",
exchange_rate_date: "Wechselkurs wird für dieses Datum abgerufen",
paid_in_full: "Vollständig bezahlt",
paid_in_full_for: "Vollständig bezahlt für",
paid_in_full_by_you: "Vollständig von dir bezahlt",
paid_in_full_by: "Vollständig bezahlt von",
cat_fruits_veg: "Obst & Gemüse",
cat_meat_fish: "Fleisch & Fisch",
cat_dairy: "Milchprodukte",
cat_bakery: "Brot & Backwaren",
cat_grains: "Pasta, Reis & Getreide",
cat_spices: "Gewürze & Saucen",
cat_drinks: "Getränke",
cat_sweets: "Süßes & Snacks",
cat_frozen: "Tiefkühl",
cat_household: "Haushalt",
cat_hygiene: "Hygiene & Körperpflege",
cat_other: "Sonstiges",
} as const;
+237
View File
@@ -0,0 +1,237 @@
/** Generated by scripts/split-i18n.ts. */
import type { de } from './de';
export const en = {
cospend_title: "Expenses - Expense Sharing",
all_payments_title: "All Payments",
settle_title: "Settle Debts",
recurring_title: "Recurring Payments",
shopping_list_title: "Shopping List",
payment_details: "Payment Details",
cospend: "Expenses",
settle_debts: "Settle Debts",
monthly_expenses_chart: "Monthly Expenses by Category",
loading_monthly: "Loading monthly expenses chart...",
loading_recent: "Loading recent activity...",
recent_activity: "Recent Activity",
clear_filter: "Clear filter",
no_recent_in: "No recent activity in",
paid_by: "Paid by",
payment: "Payment",
loading_payments: "Loading payments...",
no_payments_yet: "No payments yet",
start_first_expense: "Start by adding your first shared expense",
add_first_payment: "Add Your First Payment",
settlement: "Settlement",
split_details: "Split Details",
owes: "owes",
owed: "owed",
even: "even",
previous: "← Previous",
next: "Next →",
load_more: "Load More",
loading_ellipsis: "Loading...",
delete_payment_confirm: "Are you sure you want to delete this payment?",
date: "Date:",
paid_by_label: "Paid by:",
created_by: "Created by:",
category_label: "Category:",
split_method_label: "Split method:",
description: "Description",
exchange_rate: "Exchange rate",
receipt: "Receipt",
receipt_image: "Receipt Image",
remove_image: "Remove Image",
replace_image: "Replace Image",
upload_receipt: "Upload Receipt Image",
uploading_image: "Uploading image...",
file_too_large: "File size must be less than 5MB",
invalid_image: "Please select a valid image file (JPEG, PNG, WebP)",
you: "You",
close: "Close",
no_splits: "No splits",
split_equal: "Split equally among",
paid_full_by: "Paid in full by",
personal_equal: "Personal amounts + equal split among",
custom_split: "Custom split among",
people: "people",
settle_subtitle: "Record payments to settle outstanding debts between users",
loading_debts: "Loading debt information...",
all_settled: "All Settled!",
no_debts_msg: "No outstanding debts to settle. Everyone is even!",
back_to_dashboard: "Back to Dashboard",
available_settlements: "Available Settlements",
money_owed_to_you: "Money You're Owed",
owes_you: "owes you",
receive_payment: "Receive Payment",
money_you_owe: "Money You Owe",
you_owe: "you owe",
make_payment: "Make Payment",
settlement_details: "Settlement Details",
settlement_amount: "Settlement Amount",
record_settlement: "Record Settlement",
recording_settlement: "Recording Settlement...",
cancel: "Cancel",
settlement_type: "Settlement Type",
select_settlement: "Select settlement type",
receive_from: "Receive",
from: "from",
pay_to: "Pay",
to: "to",
from_user: "From User",
select_payer: "Select payer",
to_user: "To User",
select_recipient: "Select recipient",
settlement_amount_chf: "Settlement Amount (CHF)",
error_select_settlement: "Please select a settlement and enter an amount",
error_valid_amount: "Please enter a valid positive amount",
settlement_payment: "Settlement Payment",
recurring_subtitle: "Automate your regular shared expenses",
show_active_only: "Show active only",
loading_recurring: "Loading recurring payments...",
no_recurring: "No recurring payments found",
no_recurring_desc: "Create your first recurring payment to automate regular expenses like rent, utilities, or subscriptions.",
active: "Active",
inactive: "Inactive",
frequency: "Frequency:",
next_execution: "Next execution:",
last_executed: "Last executed:",
ends: "Ends:",
split_between: "Split between:",
gets: "gets",
edit: "Edit",
pause: "Pause",
activate: "Activate",
delete_: "Delete",
delete_recurring_confirm: "Are you sure you want to delete the recurring payment",
items_done: "done",
add_item_placeholder: "Add item...",
empty_list: "The shopping list is empty",
clear_checked: "Remove checked",
share: "Share",
shared_links: "Shared Links",
share_desc: "Anyone with an active link can edit the shopping list.",
loading: "Loading...",
no_active_links: "No active links.",
remaining: "remaining",
change: "Change",
copy_link: "Copy link",
create_new_link: "Create new link",
copied: "Copied",
expired: "expired",
ttl_1h: "1 hour",
ttl_6h: "6 hours",
ttl_24h: "24 hours",
ttl_3d: "3 days",
ttl_7d: "7 days",
kategorie: "Category",
icon: "Icon",
search_icon: "Search icon...",
save: "Save",
saving: "Saving...",
edit_name: "Name",
edit_qty: "Amount",
edit_qty_ph: "e.g. 3x, 500g, 1L",
your_balance: "Your Balance",
you_are_owed: "You are owed",
you_owe_balance: "You owe",
all_even: "You're all even",
owes_you_balance: "owes you",
you_owe_user: "you owe",
transaction: "transaction",
transactions: "transactions",
debt_overview: "Debt Overview",
loading_debt_breakdown: "Loading debt breakdown...",
who_owes_you: "Who owes you",
you_owe_section: "You owe",
total: "Total",
freq_every_day: "Every day",
freq_every_week: "Every week",
freq_every_month: "Every month",
freq_custom: "Custom",
freq_unknown: "Unknown frequency",
today_at: "Today at",
tomorrow_at: "Tomorrow at",
in_days_at: "In {days} days at",
split_between_users: "Split Between Users",
predefined_note: "Splitting between predefined users:",
remove: "Remove",
add_user_placeholder: "Add user...",
add_user: "Add User",
split_method: "Split Method",
how_split: "How should this payment be split?",
split_5050: "Split 50/50",
custom_split_amounts: "Custom Split Amounts",
personal_exceeds_total: "Warning: Personal amounts exceed total payment amount!",
is_owed: "is owed",
error_prefix: "Error",
cat_groceries: "Groceries",
cat_shopping: "Shopping",
cat_travel: "Travel",
cat_restaurant: "Restaurant",
cat_utilities: "Utilities",
cat_fun: "Fun",
cat_settlement: "Settlement",
add_payment_title: "Add New Payment",
add_payment_subtitle: "Create a new shared expense or recurring payment",
edit_payment_title: "Edit Payment",
edit_payment_subtitle: "Modify payment details and receipt image",
edit_recurring_title: "Edit Recurring Payment",
payment_details_section: "Payment Details",
title_label: "Title *",
title_placeholder: "e.g., Dinner at restaurant",
description_label: "Description",
description_placeholder: "Additional details...",
category_star: "Category *",
amount_label: "Amount *",
payment_date: "Payment Date",
paid_by_form: "Paid by",
make_recurring: "Make this a recurring payment",
recurring_section: "Recurring Payment",
recurring_schedule: "Recurring Schedule",
frequency_label: "Frequency *",
freq_daily: "Daily",
freq_weekly: "Weekly",
freq_monthly: "Monthly",
freq_quarterly: "Quarterly",
freq_yearly: "Yearly",
start_date: "Start Date *",
end_date_optional: "End Date (optional)",
end_date_hint: "Leave empty for indefinite recurring",
next_execution_preview: "Next Execution",
status_label: "Status",
create_payment: "Create payment",
save_changes: "Save changes",
delete_payment: "Delete Payment",
deleting: "Deleting...",
split_config: "Split Configuration",
split_method_form: "Split Method:",
equal_split: "Equal Split",
personal_equal_split: "Personal + Equal Split",
custom_proportions: "Custom Proportions",
personal_amounts: "Personal Amounts",
personal_amounts_desc: "Enter personal amounts for each user. The remainder will be split equally.",
total_personal: "Total Personal",
remainder_to_split: "Remainder to Split",
personal_exceeds: "Personal amounts exceed total payment amount!",
split_preview: "Split Preview",
conversion_hint: "Amount will be converted to CHF using exchange rates for the payment date",
fetching_rate: "Fetching exchange rate...",
exchange_rate_date: "Exchange rate will be fetched for this date",
paid_in_full: "Paid in Full",
paid_in_full_for: "Paid in Full for",
paid_in_full_by_you: "Paid in Full by You",
paid_in_full_by: "Paid in Full by",
cat_fruits_veg: "Fruits & Vegetables",
cat_meat_fish: "Meat & Fish",
cat_dairy: "Dairy",
cat_bakery: "Bread & Bakery",
cat_grains: "Pasta, Rice & Grains",
cat_spices: "Spices & Sauces",
cat_drinks: "Beverages",
cat_sweets: "Sweets & Snacks",
cat_frozen: "Frozen",
cat_household: "Household",
cat_hygiene: "Hygiene & Body Care",
cat_other: "Other",
} as const satisfies Record<keyof typeof de, string>;
+26 -293
View File
@@ -1,24 +1,24 @@
/** Cospend route i18n — slug mappings and UI translations */ /** Cospend route i18n — slug mappings and UI translations */
/** Detect language from a cospend path by checking the root segment */ /** 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]; const first = pathname.split('/').filter(Boolean)[0];
return first === 'expenses' ? 'en' : 'de'; return first === 'expenses' ? 'en' : 'de';
} }
/** Convert a cospend path to the target language */ /** 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'; const targetRoot = targetLang === 'en' ? 'expenses' : 'cospend';
return pathname.replace(/^\/(cospend|expenses)/, `/${targetRoot}`); return pathname.replace(/^\/(cospend|expenses)/, `/${targetRoot}`);
} }
/** Get the root slug for a given language */ /** 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'; return lang === 'en' ? 'expenses' : 'cospend';
} }
/** Get translated nav labels */ /** Get translated nav labels */
export function cospendLabels(lang: 'en' | 'de') { export function cospendLabels(lang: CospendLang) {
return { return {
dash: lang === 'en' ? 'Dashboard' : 'Dashboard', dash: lang === 'en' ? 'Dashboard' : 'Dashboard',
list: lang === 'en' ? 'List' : 'Liste', list: lang === 'en' ? 'List' : 'Liste',
@@ -27,287 +27,16 @@ export function cospendLabels(lang: 'en' | 'de') {
}; };
} }
type Translations = Record<string, Record<string, string>>;
const translations: Translations = { import { de } from '$lib/i18n/cospend/de';
// Page titles import { en } from '$lib/i18n/cospend/en';
cospend_title: { en: 'Expenses - Expense Sharing', de: 'Cospend - Ausgabenteilung' },
all_payments_title: { en: 'All Payments', de: 'Alle Zahlungen' },
settle_title: { en: 'Settle Debts', de: 'Schulden begleichen' },
recurring_title: { en: 'Recurring Payments', de: 'Wiederkehrende Zahlungen' },
shopping_list_title: { en: 'Shopping List', de: 'Einkaufsliste' },
payment_details: { en: 'Payment Details', de: 'Zahlungsdetails' },
// Dashboard /** All cospend translations, keyed by locale. */
cospend: { en: 'Expenses', de: 'Cospend' }, export const m = { de, en } as const;
settle_debts: { en: 'Settle Debts', de: 'Schulden begleichen' },
monthly_expenses_chart: { en: 'Monthly Expenses by Category', de: 'Monatliche Ausgaben nach Kategorie' },
loading_monthly: { en: 'Loading monthly expenses chart...', de: 'Monatliche Ausgaben werden geladen...' },
loading_recent: { en: 'Loading recent activity...', de: 'Letzte Aktivitäten werden geladen...' },
recent_activity: { en: 'Recent Activity', de: 'Letzte Aktivität' },
clear_filter: { en: 'Clear filter', de: 'Filter löschen' },
no_recent_in: { en: 'No recent activity in', de: 'Keine Aktivität in' },
paid_by: { en: 'Paid by', de: 'Bezahlt von' },
payment: { en: 'Payment', de: 'Zahlung' },
// All Payments page export type CospendLang = keyof typeof m;
loading_payments: { en: 'Loading payments...', de: 'Zahlungen werden geladen...' }, export type CospendKey = keyof typeof de;
no_payments_yet: { en: 'No payments yet', de: 'Noch keine Zahlungen' },
start_first_expense: { en: 'Start by adding your first shared expense', de: 'Füge deine erste geteilte Ausgabe hinzu' },
add_first_payment: { en: 'Add Your First Payment', de: 'Erste Zahlung hinzufügen' },
settlement: { en: 'Settlement', de: 'Ausgleich' },
split_details: { en: 'Split Details', de: 'Aufteilung' },
owes: { en: 'owes', de: 'schuldet' },
owed: { en: 'owed', de: 'bekommt' },
even: { en: 'even', de: 'ausgeglichen' },
previous: { en: '← Previous', de: '← Zurück' },
next: { en: 'Next →', de: 'Weiter →' },
load_more: { en: 'Load More', de: 'Mehr laden' },
loading_ellipsis: { en: 'Loading...', de: 'Laden...' },
delete_payment_confirm: { en: 'Are you sure you want to delete this payment?', de: 'Diese Zahlung wirklich löschen?' },
// Payment detail labels
date: { en: 'Date:', de: 'Datum:' },
paid_by_label: { en: 'Paid by:', de: 'Bezahlt von:' },
created_by: { en: 'Created by:', de: 'Erstellt von:' },
category_label: { en: 'Category:', de: 'Kategorie:' },
split_method_label: { en: 'Split method:', de: 'Aufteilungsart:' },
description: { en: 'Description', de: 'Beschreibung' },
exchange_rate: { en: 'Exchange rate', de: 'Wechselkurs' },
receipt: { en: 'Receipt', de: 'Beleg' },
receipt_image: { en: 'Receipt Image', de: 'Belegbild' },
remove_image: { en: 'Remove Image', de: 'Bild entfernen' },
replace_image: { en: 'Replace Image', de: 'Bild ersetzen' },
upload_receipt: { en: 'Upload Receipt Image', de: 'Beleg hochladen' },
uploading_image: { en: 'Uploading image...', de: 'Bild wird hochgeladen...' },
file_too_large: { en: 'File size must be less than 5MB', de: 'Dateigrösse muss unter 5MB sein' },
invalid_image: { en: 'Please select a valid image file (JPEG, PNG, WebP)', de: 'Bitte eine gültige Bilddatei wählen (JPEG, PNG, WebP)' },
you: { en: 'You', de: 'Du' },
close: { en: 'Close', de: 'Schliessen' },
// Split descriptions
no_splits: { en: 'No splits', de: 'Keine Aufteilung' },
split_equal: { en: 'Split equally among', de: 'Gleichmässig aufgeteilt auf' },
paid_full_by: { en: 'Paid in full by', de: 'Vollständig bezahlt von' },
personal_equal: { en: 'Personal amounts + equal split among', de: 'Persönliche Beträge + Gleichverteilung auf' },
custom_split: { en: 'Custom split among', de: 'Individuelle Aufteilung auf' },
people: { en: 'people', de: 'Personen' },
// Settle page
settle_subtitle: { en: 'Record payments to settle outstanding debts between users', de: 'Zahlungen erfassen, um offene Schulden auszugleichen' },
loading_debts: { en: 'Loading debt information...', de: 'Schuldeninformationen werden geladen...' },
all_settled: { en: 'All Settled!', de: 'Alles beglichen!' },
no_debts_msg: { en: 'No outstanding debts to settle. Everyone is even!', de: 'Keine offenen Schulden. Alle sind ausgeglichen!' },
back_to_dashboard: { en: 'Back to Dashboard', de: 'Zurück zum Dashboard' },
available_settlements: { en: 'Available Settlements', de: 'Mögliche Ausgleiche' },
money_owed_to_you: { en: "Money You're Owed", de: 'Geld, das du bekommst' },
owes_you: { en: 'owes you', de: 'schuldet dir' },
receive_payment: { en: 'Receive Payment', de: 'Zahlung empfangen' },
money_you_owe: { en: 'Money You Owe', de: 'Geld, das du schuldest' },
you_owe: { en: 'you owe', de: 'du schuldest' },
make_payment: { en: 'Make Payment', de: 'Zahlung leisten' },
settlement_details: { en: 'Settlement Details', de: 'Ausgleichsdetails' },
settlement_amount: { en: 'Settlement Amount', de: 'Ausgleichsbetrag' },
record_settlement: { en: 'Record Settlement', de: 'Ausgleich erfassen' },
recording_settlement: { en: 'Recording Settlement...', de: 'Ausgleich wird erfasst...' },
cancel: { en: 'Cancel', de: 'Abbrechen' },
settlement_type: { en: 'Settlement Type', de: 'Ausgleichsart' },
select_settlement: { en: 'Select settlement type', de: 'Ausgleichsart wählen' },
receive_from: { en: 'Receive', de: 'Empfangen' },
from: { en: 'from', de: 'von' },
pay_to: { en: 'Pay', de: 'Zahlen' },
to: { en: 'to', de: 'an' },
from_user: { en: 'From User', de: 'Von Benutzer' },
select_payer: { en: 'Select payer', de: 'Zahler wählen' },
to_user: { en: 'To User', de: 'An Benutzer' },
select_recipient: { en: 'Select recipient', de: 'Empfänger wählen' },
settlement_amount_chf: { en: 'Settlement Amount (CHF)', de: 'Ausgleichsbetrag (CHF)' },
error_select_settlement: { en: 'Please select a settlement and enter an amount', de: 'Bitte einen Ausgleich wählen und Betrag eingeben' },
error_valid_amount: { en: 'Please enter a valid positive amount', de: 'Bitte einen gültigen positiven Betrag eingeben' },
settlement_payment: { en: 'Settlement Payment', de: 'Ausgleichszahlung' },
// Recurring page
recurring_subtitle: { en: 'Automate your regular shared expenses', de: 'Automatisiere deine regelmässigen geteilten Ausgaben' },
show_active_only: { en: 'Show active only', de: 'Nur aktive anzeigen' },
loading_recurring: { en: 'Loading recurring payments...', de: 'Wiederkehrende Zahlungen werden geladen...' },
no_recurring: { en: 'No recurring payments found', de: 'Keine wiederkehrenden Zahlungen gefunden' },
no_recurring_desc: { en: 'Create your first recurring payment to automate regular expenses like rent, utilities, or subscriptions.', de: 'Erstelle deine erste wiederkehrende Zahlung für regelmässige Ausgaben wie Miete, Nebenkosten oder Abos.' },
active: { en: 'Active', de: 'Aktiv' },
inactive: { en: 'Inactive', de: 'Inaktiv' },
frequency: { en: 'Frequency:', de: 'Häufigkeit:' },
next_execution: { en: 'Next execution:', de: 'Nächste Ausführung:' },
last_executed: { en: 'Last executed:', de: 'Zuletzt ausgeführt:' },
ends: { en: 'Ends:', de: 'Endet:' },
split_between: { en: 'Split between:', de: 'Aufgeteilt zwischen:' },
gets: { en: 'gets', de: 'bekommt' },
edit: { en: 'Edit', de: 'Bearbeiten' },
pause: { en: 'Pause', de: 'Pausieren' },
activate: { en: 'Activate', de: 'Aktivieren' },
delete_: { en: 'Delete', de: 'Löschen' },
delete_recurring_confirm: { en: 'Are you sure you want to delete the recurring payment', de: 'Wiederkehrende Zahlung wirklich löschen' },
// Shopping list
items_done: { en: 'done', de: 'erledigt' },
add_item_placeholder: { en: 'Add item...', de: 'Artikel hinzufügen...' },
empty_list: { en: 'The shopping list is empty', de: 'Die Einkaufsliste ist leer' },
clear_checked: { en: 'Remove checked', de: 'Erledigte entfernen' },
share: { en: 'Share', de: 'Teilen' },
// Share modal
shared_links: { en: 'Shared Links', de: 'Geteilte Links' },
share_desc: { en: 'Anyone with an active link can edit the shopping list.', de: 'Jeder mit einem aktiven Link kann die Einkaufsliste bearbeiten.' },
loading: { en: 'Loading...', de: 'Laden...' },
no_active_links: { en: 'No active links.', de: 'Keine aktiven Links.' },
remaining: { en: 'remaining', de: 'noch' },
change: { en: 'Change', de: 'Ändern' },
copy_link: { en: 'Copy link', de: 'Link kopieren' },
create_new_link: { en: 'Create new link', de: 'Neuen Link erstellen' },
copied: { en: 'Copied', de: 'Kopiert' },
expired: { en: 'expired', de: 'abgelaufen' },
// TTL
ttl_1h: { en: '1 hour', de: '1 Stunde' },
ttl_6h: { en: '6 hours', de: '6 Stunden' },
ttl_24h: { en: '24 hours', de: '24 Stunden' },
ttl_3d: { en: '3 days', de: '3 Tage' },
ttl_7d: { en: '7 days', de: '7 Tage' },
// Edit modal
kategorie: { en: 'Category', de: 'Kategorie' },
icon: { en: 'Icon', de: 'Icon' },
search_icon: { en: 'Search icon...', de: 'Icon suchen...' },
save: { en: 'Save', de: 'Speichern' },
saving: { en: 'Saving...', de: 'Speichern...' },
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) */ /** Category name translation map (German key → display name per language) */
const categoryDisplayNames: Record<string, Record<string, string>> = { const categoryDisplayNames: Record<string, Record<string, string>> = {
@@ -326,7 +55,7 @@ const categoryDisplayNames: Record<string, Record<string, string>> = {
}; };
/** Get translated category display name (shopping categories) */ /** 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; return categoryDisplayNames[category]?.[lang] ?? category;
} }
@@ -342,12 +71,12 @@ const paymentCategoryNames: Record<string, Record<string, string>> = {
}; };
/** Get translated payment category name */ /** 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; return paymentCategoryNames[category]?.[lang] ?? category;
} }
/** Get category options with translated labels */ /** Get category options with translated labels */
export function getCategoryOptionsI18n(lang: 'en' | 'de') { export function getCategoryOptionsI18n(lang: CospendLang) {
const emojis: Record<string, string> = { const emojis: Record<string, string> = {
groceries: '🛒', shopping: '🛍️', travel: '🚆', groceries: '🛒', shopping: '🛍️', travel: '🚆',
restaurant: '🍽️', utilities: '⚡', fun: '🎉', settlement: '🤝' 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 { * Get a translated string. Prefer `m[lang].key` directly in new code — this
return translations[key]?.[lang] ?? translations[key]?.en ?? key; * 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 */ /** 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(); const diff = new Date(expiresAt).getTime() - Date.now();
if (diff <= 0) return t('expired', lang); if (diff <= 0) return t('expired', lang);
const mins = Math.round(diff / 60000); 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 */ /** Get TTL options for the given language */
export function ttlOptions(lang: 'en' | 'de') { export function ttlOptions(lang: CospendLang) {
return [ return [
{ label: t('ttl_1h', lang), ms: 1 * 60 * 60 * 1000 }, { label: t('ttl_1h', lang), ms: 1 * 60 * 60 * 1000 },
{ label: t('ttl_6h', lang), ms: 6 * 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 */ /** 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'; return lang === 'en' ? 'en-CH' : 'de-CH';
} }
/** Build a split description string */ /** 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); if (!payment.splits || payment.splits.length === 0) return t('no_splits', lang);
const count = payment.splits.length; 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 */ /** 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) { switch (payment.frequency) {
case 'daily': return t('freq_every_day', lang); case 'daily': return t('freq_every_day', lang);
case 'weekly': return t('freq_every_week', 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 */ /** 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 loc = locale(lang);
const now = new Date(); const now = new Date();
const diffMs = date.getTime() - now.getTime(); const diffMs = date.getTime() - now.getTime();
@@ -14,10 +14,11 @@
import { formatCurrency } from '$lib/utils/formatters'; 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 let { data } = $props(); // Contains session data and balance from server
const lang = $derived(detectCospendLang(page.url.pathname)); const lang = $derived(detectCospendLang(page.url.pathname));
const t = $derived(m[lang]);
const root = $derived(cospendRoot(lang)); const root = $derived(cospendRoot(lang));
const loc = $derived(locale(lang)); const loc = $derived(locale(lang));
@@ -125,11 +126,11 @@
</script> </script>
<svelte:head> <svelte:head>
<title>{t('cospend_title', lang)}</title> <title>{t.cospend_title}</title>
</svelte:head> </svelte:head>
<main class="cospend-main"> <main class="cospend-main">
<h1 class="sr-only">{t('cospend', lang)}</h1> <h1 class="sr-only">{t.cospend}</h1>
<!-- Responsive layout for balance and chart --> <!-- Responsive layout for balance and chart -->
<div class="dashboard-layout"> <div class="dashboard-layout">
@@ -138,7 +139,7 @@
<div class="actions"> <div class="actions">
{#if balance.netBalance !== 0} {#if balance.netBalance !== 0}
<a href={resolve('/[cospendRoot=cospendRoot]/settle', { cospendRoot: root })} class="btn btn-settlement">{t('settle_debts', lang)}</a> <a href={resolve('/[cospendRoot=cospendRoot]/settle', { cospendRoot: root })} class="btn btn-settlement">{t.settle_debts}</a>
{/if} {/if}
</div> </div>
@@ -148,11 +149,11 @@
<!-- Monthly Expenses Chart --> <!-- Monthly Expenses Chart -->
<div class="chart-section"> <div class="chart-section">
{#if expensesLoading} {#if expensesLoading}
<div class="loading">{t('loading_monthly', lang)}</div> <div class="loading">{t.loading_monthly}</div>
{:else if monthlyExpensesData.datasets && monthlyExpensesData.datasets.length > 0} {:else if monthlyExpensesData.datasets && monthlyExpensesData.datasets.length > 0}
<BarChart <BarChart
data={monthlyExpensesData} data={monthlyExpensesData}
title={t('monthly_expenses_chart', lang)} title={t.monthly_expenses_chart}
height="400px" height="400px"
{lang} {lang}
onFilterChange={(/** @type {string[] | null} */ categories) => categoryFilter = categories} onFilterChange={(/** @type {string[] | null} */ categories) => categoryFilter = categories}
@@ -168,19 +169,19 @@
</div> </div>
{#if loading} {#if loading}
<div class="loading">{t('loading_recent', lang)}</div> <div class="loading">{t.loading_recent}</div>
{:else if error} {:else if error}
<div class="error">Error: {error}</div> <div class="error">Error: {error}</div>
{:else if balance.recentSplits && balance.recentSplits.length > 0} {:else if balance.recentSplits && balance.recentSplits.length > 0}
<div class="recent-activity"> <div class="recent-activity">
<div class="recent-activity-header"> <div class="recent-activity-header">
<h2>{t('recent_activity', lang)}{#if categoryFilter} <span class="filter-label">{categoryFilter.map((/** @type {any} */ c) => paymentCategoryName(c, lang)).join(', ')}</span>{/if}</h2> <h2>{t.recent_activity}{#if categoryFilter} <span class="filter-label">{categoryFilter.map((/** @type {any} */ c) => paymentCategoryName(c, lang)).join(', ')}</span>{/if}</h2>
{#if categoryFilter} {#if categoryFilter}
<button class="clear-filter" onclick={() => categoryFilter = null}>{t('clear_filter', lang)}</button> <button class="clear-filter" onclick={() => categoryFilter = null}>{t.clear_filter}</button>
{/if} {/if}
</div> </div>
{#if filteredSplits.length === 0} {#if filteredSplits.length === 0}
<p class="no-results">{t('no_recent_in', lang)} {categoryFilter ? categoryFilter.map((/** @type {any} */ c) => paymentCategoryName(c, lang)).join(', ') : ''}.</p> <p class="no-results">{t.no_recent_in} {categoryFilter ? categoryFilter.map((/** @type {any} */ c) => paymentCategoryName(c, lang)).join(', ') : ''}.</p>
{/if} {/if}
<div class="activity-dialog"> <div class="activity-dialog">
{#each filteredSplits as split} {#each filteredSplits as split}
@@ -226,9 +227,9 @@
<div class="user-info"> <div class="user-info">
<div class="payment-title-row"> <div class="payment-title-row">
<span class="category-emoji">{getCategoryEmoji(split.paymentId?.category || 'groceries')}</span> <span class="category-emoji">{getCategoryEmoji(split.paymentId?.category || 'groceries')}</span>
<strong class="payment-title">{split.paymentId?.title || t('payment', lang)}</strong> <strong class="payment-title">{split.paymentId?.title || t.payment}</strong>
</div> </div>
<span class="username">{t('paid_by', lang)} {split.paymentId?.paidBy || 'Unknown'}</span> <span class="username">{t.paid_by} {split.paymentId?.paidBy || 'Unknown'}</span>
<span class="category-name">{paymentCategoryName(split.paymentId?.category || 'groceries', lang)}</span> <span class="category-name">{paymentCategoryName(split.paymentId?.category || 'groceries', lang)}</span>
</div> </div>
<div class="activity-amount" <div class="activity-amount"
@@ -35,7 +35,7 @@
import Check from '@lucide/svelte/icons/check'; import Check from '@lucide/svelte/icons/check';
import { page } from '$app/state'; import { page } from '$app/state';
import { detectCospendLang, t, locale, categoryName, formatTTL as formatTTLi18n, ttlOptions } from '$lib/js/cospendI18n'; import { detectCospendLang, locale, categoryName, formatTTL as formatTTLi18n, ttlOptions, m } from '$lib/js/cospendI18n';
let { data } = $props(); let { data } = $props();
let user = $derived(data.session?.user?.nickname || 'guest'); let user = $derived(data.session?.user?.nickname || 'guest');
@@ -51,6 +51,7 @@
}); });
const lang = $derived(detectCospendLang(page.url.pathname)); const lang = $derived(detectCospendLang(page.url.pathname));
const t = $derived(m[lang]);
const loc = $derived(locale(lang)); const loc = $derived(locale(lang));
/** @type {Record<string, { icon: typeof Plus, color: string }>} */ /** @type {Record<string, { icon: typeof Plus, color: string }>} */
@@ -427,7 +428,7 @@
<div class="shopping-page"> <div class="shopping-page">
<header class="page-header"> <header class="page-header">
<div class="header-row"> <div class="header-row">
<h1 class="sr-only">{t('shopping_list_title', lang)}</h1> <h1 class="sr-only">{t.shopping_list_title}</h1>
<SyncIndicator status={sync.status} /> <SyncIndicator status={sync.status} />
{#if hasSupercard} {#if hasSupercard}
<button class="btn-card btn-card-coop" onclick={() => activeCard = 'supercard'} title="Coop Supercard" aria-label="Coop Supercard"> <button class="btn-card btn-card-coop" onclick={() => activeCard = 'supercard'} title="Coop Supercard" aria-label="Coop Supercard">
@@ -440,13 +441,13 @@
</button> </button>
{/if} {/if}
{#if !isGuest} {#if !isGuest}
<button class="btn-share" onclick={openShareModal} title={t('share', lang)}> <button class="btn-share" onclick={openShareModal} title={t.share}>
<Share2 size={16} /> <Share2 size={16} />
</button> </button>
{/if} {/if}
</div> </div>
{#if totalCount > 0} {#if totalCount > 0}
<p class="subtitle">{checkedCount} / {totalCount} {t('items_done', lang)}</p> <p class="subtitle">{checkedCount} / {totalCount} {t.items_done}</p>
{/if} {/if}
<div class="store-picker"> <div class="store-picker">
<Store size={13} /> <Store size={13} />
@@ -466,7 +467,7 @@
bind:value={newItemName} bind:value={newItemName}
onkeydown={onKeydown} onkeydown={onKeydown}
type="text" type="text"
placeholder={t('add_item_placeholder', lang)} placeholder={t.add_item_placeholder}
autocomplete="off" autocomplete="off"
/> />
<button class="btn-add" onclick={addItem} disabled={!newItemName.trim()}> <button class="btn-add" onclick={addItem} disabled={!newItemName.trim()}>
@@ -475,7 +476,7 @@
</div> </div>
{#if totalCount === 0} {#if totalCount === 0}
<p class="empty-state">{t('empty_list', lang)}</p> <p class="empty-state">{t.empty_list}</p>
{:else} {:else}
<div class="item-list"> <div class="item-list">
{#each groupedItems as group (group.category)} {#each groupedItems as group (group.category)}
@@ -528,7 +529,7 @@
{#if checkedCount > 0} {#if checkedCount > 0}
<button class="btn-clear-checked" onclick={() => sync.clearChecked()}> <button class="btn-clear-checked" onclick={() => sync.clearChecked()}>
<ListX size={16} /> <ListX size={16} />
{t('clear_checked', lang)} ({checkedCount}) {t.clear_checked} ({checkedCount})
</button> </button>
{/if} {/if}
{/if} {/if}
@@ -549,18 +550,18 @@
<div class="name-qty-row"> <div class="name-qty-row">
<div class="field name-field"> <div class="field name-field">
<!-- svelte-ignore a11y_label_has_associated_control --> <!-- svelte-ignore a11y_label_has_associated_control -->
<label class="edit-label">{t('edit_name', lang)}</label> <label class="edit-label">{t.edit_name}</label>
<input class="edit-input" type="text" bind:value={editName} /> <input class="edit-input" type="text" bind:value={editName} />
</div> </div>
<div class="field qty-field"> <div class="field qty-field">
<!-- svelte-ignore a11y_label_has_associated_control --> <!-- svelte-ignore a11y_label_has_associated_control -->
<label class="edit-label">{t('edit_qty', lang)}</label> <label class="edit-label">{t.edit_qty}</label>
<input class="edit-input" type="text" bind:value={editQty} placeholder={t('edit_qty_ph', lang)} /> <input class="edit-input" type="text" bind:value={editQty} placeholder={t.edit_qty_ph} />
</div> </div>
</div> </div>
<!-- svelte-ignore a11y_label_has_associated_control --> <!-- svelte-ignore a11y_label_has_associated_control -->
<label class="edit-label">{t('kategorie', lang)}</label> <label class="edit-label">{t.kategorie}</label>
<div class="category-picker"> <div class="category-picker">
{#each SHOPPING_CATEGORIES as cat} {#each SHOPPING_CATEGORIES as cat}
{@const meta = categoryMeta[cat] || categoryMeta['Sonstiges']} {@const meta = categoryMeta[cat] || categoryMeta['Sonstiges']}
@@ -578,10 +579,10 @@
</div> </div>
<!-- svelte-ignore a11y_label_has_associated_control --> <!-- svelte-ignore a11y_label_has_associated_control -->
<label class="edit-label">{t('icon', lang)}</label> <label class="edit-label">{t.icon}</label>
<div class="icon-search"> <div class="icon-search">
<Search size={14} /> <Search size={14} />
<input bind:value={iconSearch} type="text" placeholder={t('search_icon', lang)} /> <input bind:value={iconSearch} type="text" placeholder={t.search_icon} />
</div> </div>
<div class="icon-picker"> <div class="icon-picker">
{#each filteredIconGroups as [cat, icons]} {#each filteredIconGroups as [cat, icons]}
@@ -605,9 +606,9 @@
</div> </div>
<div class="edit-actions"> <div class="edit-actions">
<button class="btn-cancel" onclick={closeEdit}>{t('cancel', lang)}</button> <button class="btn-cancel" onclick={closeEdit}>{t.cancel}</button>
<button class="btn-save" onclick={saveEdit} disabled={editSaving}> <button class="btn-save" onclick={saveEdit} disabled={editSaving}>
{editSaving ? t('saving', lang) : t('save', lang)} {editSaving ? t.saving : t.save}
</button> </button>
</div> </div>
</div> </div>
@@ -622,17 +623,17 @@
<!-- svelte-ignore a11y_click_events_have_key_events --> <!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="edit-modal share-modal" onclick={(e) => e.stopPropagation()}> <div class="edit-modal share-modal" onclick={(e) => e.stopPropagation()}>
<div class="share-header"> <div class="share-header">
<h3>{t('shared_links', lang)}</h3> <h3>{t.shared_links}</h3>
<button class="close-button" onclick={() => { showShareModal = false; }}> <button class="close-button" onclick={() => { showShareModal = false; }}>
<X size={18} /> <X size={18} />
</button> </button>
</div> </div>
<p class="share-desc">{t('share_desc', lang)}</p> <p class="share-desc">{t.share_desc}</p>
{#if shareLoading} {#if shareLoading}
<p class="share-loading">{t('loading', lang)}</p> <p class="share-loading">{t.loading}</p>
{:else if shareTokens.length === 0} {:else if shareTokens.length === 0}
<p class="share-empty">{t('no_active_links', lang)}</p> <p class="share-empty">{t.no_active_links}</p>
{:else} {:else}
<div class="token-list"> <div class="token-list">
{#each shareTokens as tok (tok.id)} {#each shareTokens as tok (tok.id)}
@@ -642,7 +643,7 @@
<div class="token-expiry-row"> <div class="token-expiry-row">
<span class="token-ttl">{formatTTL(tok.expiresAt)}</span> <span class="token-ttl">{formatTTL(tok.expiresAt)}</span>
<select class="token-ttl-select" onchange={(e) => onTTLChange(tok.id, e)}> <select class="token-ttl-select" onchange={(e) => onTTLChange(tok.id, e)}>
<option value="" disabled selected>{t('change', lang)}</option> <option value="" disabled selected>{t.change}</option>
{#each TTL_OPTIONS as opt} {#each TTL_OPTIONS as opt}
<option value={opt.ms}>{opt.label}</option> <option value={opt.ms}>{opt.label}</option>
{/each} {/each}
@@ -650,10 +651,10 @@
</div> </div>
</div> </div>
<div class="token-actions"> <div class="token-actions">
<button class="btn-token-copy" onclick={() => copyTokenLink(tok)} title={t('copy_link', lang)}> <button class="btn-token-copy" onclick={() => copyTokenLink(tok)} title={t.copy_link}>
{#if copiedId === tok.id}<Check size={14} />{:else}<Copy size={14} />{/if} {#if copiedId === tok.id}<Check size={14} />{:else}<Copy size={14} />{/if}
</button> </button>
<button class="btn-token-delete" onclick={() => deleteToken(tok.id)} title={t('delete_', lang)}> <button class="btn-token-delete" onclick={() => deleteToken(tok.id)} title={t.delete_}>
<X size={14} /> <X size={14} />
</button> </button>
</div> </div>
@@ -664,7 +665,7 @@
<button class="btn-new-token" onclick={createNewToken}> <button class="btn-new-token" onclick={createNewToken}>
<Plus size={14} /> <Plus size={14} />
{t('create_new_link', lang)} {t.create_new_link}
</button> </button>
</div> </div>
</div> </div>
@@ -672,7 +673,7 @@
{#if showCopyToast} {#if showCopyToast}
<div class="copy-toast" transition:slide={{ duration: 150 }}> <div class="copy-toast" transition:slide={{ duration: 150 }}>
<Check size={14} /> {t('copied', lang)} <Check size={14} /> {t.copied}
</div> </div>
{/if} {/if}
@@ -9,12 +9,13 @@
import { confirm } from '$lib/js/confirmDialog.svelte'; import { confirm } from '$lib/js/confirmDialog.svelte';
import { isSettlementPayment, getSettlementIcon, getSettlementReceiver } from '$lib/utils/settlements'; import { isSettlementPayment, getSettlementIcon, getSettlementReceiver } from '$lib/utils/settlements';
import AddButton from '$lib/components/AddButton.svelte'; import AddButton from '$lib/components/AddButton.svelte';
import { detectCospendLang, cospendRoot, t, locale, splitDescription, paymentCategoryName } from '$lib/js/cospendI18n'; import { detectCospendLang, cospendRoot, locale, splitDescription, paymentCategoryName, m } from '$lib/js/cospendI18n';
import { formatCurrency } from '$lib/utils/formatters'; import { formatCurrency } from '$lib/utils/formatters';
let { data } = $props(); let { data } = $props();
const lang = $derived(detectCospendLang(page.url.pathname)); const lang = $derived(detectCospendLang(page.url.pathname));
const t = $derived(m[lang]);
const root = $derived(cospendRoot(lang)); const root = $derived(cospendRoot(lang));
const loc = $derived(locale(lang)); const loc = $derived(locale(lang));
@@ -84,7 +85,7 @@
} }
async function deletePayment(/** @type {string} */ paymentId) { async function deletePayment(/** @type {string} */ paymentId) {
if (!await confirm(t('delete_payment_confirm', lang))) { if (!await confirm(t.delete_payment_confirm)) {
return; return;
} }
@@ -127,18 +128,18 @@
</script> </script>
<svelte:head> <svelte:head>
<title>{t('all_payments_title', lang)} - {t('cospend', lang)}</title> <title>{t.all_payments_title} - {t.cospend}</title>
</svelte:head> </svelte:head>
<main class="payments-list"> <main class="payments-list">
<div class="header"> <div class="header">
<div class="header-content"> <div class="header-content">
<h1 class="sr-only">{t('all_payments_title', lang)}</h1> <h1 class="sr-only">{t.all_payments_title}</h1>
</div> </div>
</div> </div>
{#if loading && payments.length === 0} {#if loading && payments.length === 0}
<div class="loading">{t('loading_payments', lang)}</div> <div class="loading">{t.loading_payments}</div>
{:else if error} {:else if error}
<div class="error">Error: {error}</div> <div class="error">Error: {error}</div>
{:else if payments.length === 0} {:else if payments.length === 0}
@@ -147,9 +148,9 @@
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"> <svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/> <path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/>
</svg> </svg>
<h2>{t('no_payments_yet', lang)}</h2> <h2>{t.no_payments_yet}</h2>
<p>{t('start_first_expense', lang)}</p> <p>{t.start_first_expense}</p>
<a href={resolve('/[cospendRoot=cospendRoot]/payments/add', { cospendRoot: root })} class="btn btn-primary">{t('add_first_payment', lang)}</a> <a href={resolve('/[cospendRoot=cospendRoot]/payments/add', { cospendRoot: root })} class="btn btn-primary">{t.add_first_payment}</a>
</div> </div>
</div> </div>
{:else} {:else}
@@ -161,7 +162,7 @@
<div class="settlement-header"> <div class="settlement-header">
<div class="settlement-badge"> <div class="settlement-badge">
<span class="settlement-icon">💸</span> <span class="settlement-icon">💸</span>
<span class="settlement-label">{t('settlement', lang)}</span> <span class="settlement-label">{t.settlement}</span>
</div> </div>
<span class="settlement-date">{formatDate(payment.date)}</span> <span class="settlement-date">{formatDate(payment.date)}</span>
</div> </div>
@@ -218,29 +219,29 @@
<div class="payment-details"> <div class="payment-details">
<div class="detail-row"> <div class="detail-row">
<span class="label">{t('paid_by_label', lang)}</span> <span class="label">{t.paid_by_label}</span>
<span class="value">{payment.paidBy}</span> <span class="value">{payment.paidBy}</span>
</div> </div>
<div class="detail-row"> <div class="detail-row">
<span class="label">{t('split_method_label', lang)}</span> <span class="label">{t.split_method_label}</span>
<span class="value">{getSplitDescription(payment)}</span> <span class="value">{getSplitDescription(payment)}</span>
</div> </div>
</div> </div>
{#if payment.splits && payment.splits.length > 0} {#if payment.splits && payment.splits.length > 0}
<div class="splits-summary"> <div class="splits-summary">
<h4>{t('split_details', lang)}</h4> <h4>{t.split_details}</h4>
<div class="splits-list"> <div class="splits-list">
{#each payment.splits as split} {#each payment.splits as split}
<div class="split-item"> <div class="split-item">
<span class="split-user">{split.username}</span> <span class="split-user">{split.username}</span>
<span class="split-amount" class:positive={split.amount < 0} class:negative={split.amount > 0}> <span class="split-amount" class:positive={split.amount < 0} class:negative={split.amount > 0}>
{#if split.amount > 0} {#if split.amount > 0}
{t('owes', lang)} {formatCurrency(split.amount, 'CHF', loc)} {t.owes} {formatCurrency(split.amount, 'CHF', loc)}
{:else if split.amount < 0} {:else if split.amount < 0}
{t('owed', lang)} {formatCurrency(Math.abs(split.amount), 'CHF', loc)} {t.owed} {formatCurrency(Math.abs(split.amount), 'CHF', loc)}
{:else} {:else}
{t('owes', lang)} {formatCurrency(split.amount, 'CHF', loc)} {t.owes} {formatCurrency(split.amount, 'CHF', loc)}
{/if} {/if}
</span> </span>
</div> </div>
@@ -258,14 +259,14 @@
{#if data.currentOffset > 0} {#if data.currentOffset > 0}
<a href="?offset={Math.max(0, data.currentOffset - data.limit)}&limit={data.limit}" <a href="?offset={Math.max(0, data.currentOffset - data.limit)}&limit={data.limit}"
class="btn btn-secondary"> class="btn btn-secondary">
{t('previous', lang)} {t.previous}
</a> </a>
{/if} {/if}
{#if hasMore} {#if hasMore}
<a href="?offset={data.currentOffset + data.limit}&limit={data.limit}" <a href="?offset={data.currentOffset + data.limit}&limit={data.limit}"
class="btn btn-secondary"> class="btn btn-secondary">
{t('next', lang)} {t.next}
</a> </a>
{/if} {/if}
@@ -273,7 +274,7 @@
{#if hasMore} {#if hasMore}
<button class="btn btn-secondary js-only" onclick={loadMore} disabled={loading} <button class="btn btn-secondary js-only" onclick={loadMore} disabled={loading}
style="display: none;"> style="display: none;">
{loading ? t('loading_ellipsis', lang) : t('load_more', lang)} {loading ? t.loading_ellipsis : t.load_more}
</button> </button>
{/if} {/if}
</div> </div>
@@ -3,7 +3,7 @@
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { page } from '$app/state'; import { page } from '$app/state';
import { enhance } from '$app/forms'; import { enhance } from '$app/forms';
import { detectCospendLang, cospendRoot, locale, t, getCategoryOptionsI18n, frequencyDescription } from '$lib/js/cospendI18n'; import { detectCospendLang, cospendRoot, locale, getCategoryOptionsI18n, frequencyDescription, m } from '$lib/js/cospendI18n';
import { PREDEFINED_USERS, isPredefinedUsersMode } from '$lib/config/users'; import { PREDEFINED_USERS, isPredefinedUsersMode } from '$lib/config/users';
import { validateCronExpression, calculateNextExecutionDate } from '$lib/utils/recurring'; import { validateCronExpression, calculateNextExecutionDate } from '$lib/utils/recurring';
import ProfilePicture from '$lib/components/cospend/ProfilePicture.svelte'; import ProfilePicture from '$lib/components/cospend/ProfilePicture.svelte';
@@ -17,6 +17,7 @@
let { data, form } = $props(); let { data, form } = $props();
const lang = $derived(detectCospendLang(page.url.pathname)); const lang = $derived(detectCospendLang(page.url.pathname));
const t = $derived(m[lang]);
const root = $derived(cospendRoot(lang)); const root = $derived(cospendRoot(lang));
const loc = $derived(locale(lang)); const loc = $derived(locale(lang));
@@ -95,29 +96,29 @@
// No-JS fallback text - always generic // No-JS fallback text - always generic
if (!jsEnhanced) { if (!jsEnhanced) {
if (predefinedMode) { if (predefinedMode) {
return t('paid_in_full', lang); return t.paid_in_full;
} else { } else {
return t('paid_in_full', lang); return t.paid_in_full;
} }
} }
// JavaScript-enhanced reactive text // JavaScript-enhanced reactive text
if (!formData.paidBy) { if (!formData.paidBy) {
return t('paid_in_full', lang); return t.paid_in_full;
} }
// Special handling for 2-user predefined setup // Special handling for 2-user predefined setup
if (predefinedMode && users.length === 2) { if (predefinedMode && users.length === 2) {
const otherUser = users.find(user => user !== formData.paidBy); const otherUser = users.find(user => user !== formData.paidBy);
// Always show "for" the other user (who benefits) regardless of who pays // Always show "for" the other user (who benefits) regardless of who pays
return otherUser ? `${t('paid_in_full_for', lang)} ${otherUser}` : t('paid_in_full', lang); return otherUser ? `${t.paid_in_full_for} ${otherUser}` : t.paid_in_full;
} }
// General case with JS // General case with JS
if (formData.paidBy === data.currentUser) { if (formData.paidBy === data.currentUser) {
return t('paid_in_full_by_you', lang); return t.paid_in_full_by_you;
} else { } else {
return `${t('paid_in_full_by', lang)} ${formData.paidBy}`; return `${t.paid_in_full_by} ${formData.paidBy}`;
} }
}); });
@@ -363,44 +364,44 @@
</script> </script>
<svelte:head> <svelte:head>
<title>{t('add_payment_title', lang)} - {t('cospend', lang)}</title> <title>{t.add_payment_title} - {t.cospend}</title>
</svelte:head> </svelte:head>
<main class="add-payment"> <main class="add-payment">
<div class="header"> <div class="header">
<h1 class="sr-only">{t('add_payment_title', lang)}</h1> <h1 class="sr-only">{t.add_payment_title}</h1>
<p>{t('add_payment_subtitle', lang)}</p> <p>{t.add_payment_subtitle}</p>
</div> </div>
<form method="POST" use:enhance class="payment-form"> <form method="POST" use:enhance class="payment-form">
<div class="form-section"> <div class="form-section">
<h2>{t('payment_details_section', lang)}</h2> <h2>{t.payment_details_section}</h2>
<div class="form-group"> <div class="form-group">
<label for="title">{t('title_label', lang)}</label> <label for="title">{t.title_label}</label>
<input <input
type="text" type="text"
id="title" id="title"
name="title" name="title"
value={formData.title} value={formData.title}
required required
placeholder={t('title_placeholder', lang)} placeholder={t.title_placeholder}
/> />
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="description">{t('description_label', lang)}</label> <label for="description">{t.description_label}</label>
<textarea <textarea
id="description" id="description"
name="description" name="description"
value={formData.description} value={formData.description}
placeholder={t('description_placeholder', lang)} placeholder={t.description_placeholder}
rows="3" rows="3"
></textarea> ></textarea>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="category">{t('category_star', lang)}</label> <label for="category">{t.category_star}</label>
<select id="category" name="category" value={formData.category} required> <select id="category" name="category" value={formData.category} required>
{#each categoryOptions as option} {#each categoryOptions as option}
<option value={option.value}>{option.label}</option> <option value={option.value}>{option.label}</option>
@@ -410,7 +411,7 @@
<div class="form-row"> <div class="form-row">
<div class="form-group"> <div class="form-group">
<label for="amount">{t('amount_label', lang)}</label> <label for="amount">{t.amount_label}</label>
<div class="amount-currency"> <div class="amount-currency">
<input <input
type="number" type="number"
@@ -430,11 +431,11 @@
</div> </div>
{#if formData.currency !== 'CHF'} {#if formData.currency !== 'CHF'}
<div class="conversion-info"> <div class="conversion-info">
<small class="help-text">{t('conversion_hint', lang)}</small> <small class="help-text">{t.conversion_hint}</small>
{#if loadingExchangeRate} {#if loadingExchangeRate}
<div class="conversion-preview loading"> <div class="conversion-preview loading">
<small>🔄 {t('fetching_rate', lang)}</small> <small>🔄 {t.fetching_rate}</small>
</div> </div>
{:else if exchangeRateError} {:else if exchangeRateError}
<div class="conversion-preview error"> <div class="conversion-preview error">
@@ -454,17 +455,17 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="date">{t('payment_date', lang)}</label> <label for="date">{t.payment_date}</label>
<DatePicker bind:value={formData.date} {lang} /> <DatePicker bind:value={formData.date} {lang} />
<input type="hidden" name="date" value={formData.date} /> <input type="hidden" name="date" value={formData.date} />
{#if formData.currency !== 'CHF'} {#if formData.currency !== 'CHF'}
<small class="help-text">{t('exchange_rate_date', lang)}</small> <small class="help-text">{t.exchange_rate_date}</small>
{/if} {/if}
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="paidBy">{t('paid_by_form', lang)}</label> <label for="paidBy">{t.paid_by_form}</label>
<select id="paidBy" name="paidBy" bind:value={formData.paidBy} required> <select id="paidBy" name="paidBy" bind:value={formData.paidBy} required>
{#each users as user} {#each users as user}
<option value={user}>{user}</option> <option value={user}>{user}</option>
@@ -475,7 +476,7 @@
<div class="form-group"> <div class="form-group">
<label class="checkbox-label"> <label class="checkbox-label">
<Toggle bind:checked={formData.isRecurring} /> <Toggle bind:checked={formData.isRecurring} />
<span>{t('make_recurring', lang)}</span> <span>{t.make_recurring}</span>
<input type="hidden" name="isRecurring" value={formData.isRecurring ? 'true' : 'false'} /> <input type="hidden" name="isRecurring" value={formData.isRecurring ? 'true' : 'false'} />
</label> </label>
</div> </div>
@@ -483,24 +484,24 @@
{#if formData.isRecurring} {#if formData.isRecurring}
<div class="form-section"> <div class="form-section">
<h2>{t('recurring_section', lang)}</h2> <h2>{t.recurring_section}</h2>
<div class="recurring-options"> <div class="recurring-options">
<div class="form-row"> <div class="form-row">
<div class="form-group"> <div class="form-group">
<label for="frequency">{t('frequency_label', lang)}</label> <label for="frequency">{t.frequency_label}</label>
<select id="frequency" name="recurringFrequency" bind:value={recurringData.frequency} required> <select id="frequency" name="recurringFrequency" bind:value={recurringData.frequency} required>
<option value="daily">{t('freq_daily', lang)}</option> <option value="daily">{t.freq_daily}</option>
<option value="weekly">{t('freq_weekly', lang)}</option> <option value="weekly">{t.freq_weekly}</option>
<option value="monthly">{t('freq_monthly', lang)}</option> <option value="monthly">{t.freq_monthly}</option>
<option value="quarterly">{t('freq_quarterly', lang)}</option> <option value="quarterly">{t.freq_quarterly}</option>
<option value="yearly">{t('freq_yearly', lang)}</option> <option value="yearly">{t.freq_yearly}</option>
<option value="custom">{t('freq_custom', lang)}</option> <option value="custom">{t.freq_custom}</option>
</select> </select>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="recurringStartDate">{t('start_date', lang)}</label> <label for="recurringStartDate">{t.start_date}</label>
<DatePicker bind:value={recurringData.startDate} {lang} /> <DatePicker bind:value={recurringData.startDate} {lang} />
<input type="hidden" name="recurringStartDate" value={recurringData.startDate} /> <input type="hidden" name="recurringStartDate" value={recurringData.startDate} />
</div> </div>
@@ -535,16 +536,16 @@
{/if} {/if}
<div class="form-group"> <div class="form-group">
<label for="recurringEndDate">{t('end_date_optional', lang)}</label> <label for="recurringEndDate">{t.end_date_optional}</label>
<DatePicker bind:value={recurringData.endDate} min={recurringData.startDate} {lang} /> <DatePicker bind:value={recurringData.endDate} min={recurringData.startDate} {lang} />
<input type="hidden" name="recurringEndDate" value={recurringData.endDate} /> <input type="hidden" name="recurringEndDate" value={recurringData.endDate} />
<small class="help-text">{t('end_date_hint', lang)}</small> <small class="help-text">{t.end_date_hint}</small>
</div> </div>
{#if nextExecutionPreview} {#if nextExecutionPreview}
<div class="execution-preview"> <div class="execution-preview">
<h3>{t('next_execution_preview', lang)}</h3> <h3>{t.next_execution_preview}</h3>
<p class="next-execution">{nextExecutionPreview}</p> <p class="next-execution">{nextExecutionPreview}</p>
<p class="frequency-description">{frequencyDescription(/** @type {any} */ (recurringData), lang)}</p> <p class="frequency-description">{frequencyDescription(/** @type {any} */ (recurringData), lang)}</p>
</div> </div>
@@ -609,7 +610,7 @@
<div class="error">{error}</div> <div class="error">{error}</div>
{/if} {/if}
<SaveFab disabled={loading} label={t('create_payment', lang)} /> <SaveFab disabled={loading} label={t.create_payment} />
</form> </form>
</main> </main>
@@ -2,7 +2,7 @@
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { page } from '$app/state'; 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 { confirm } from '$lib/js/confirmDialog.svelte';
import FormSection from '$lib/components/FormSection.svelte'; import FormSection from '$lib/components/FormSection.svelte';
import ImageUpload from '$lib/components/ImageUpload.svelte'; import ImageUpload from '$lib/components/ImageUpload.svelte';
@@ -16,6 +16,7 @@
let { data } = $props(); let { data } = $props();
const lang = $derived(detectCospendLang(page.url.pathname)); const lang = $derived(detectCospendLang(page.url.pathname));
const t = $derived(m[lang]);
const root = $derived(cospendRoot(lang)); const root = $derived(cospendRoot(lang));
const loc = $derived(locale(lang)); const loc = $derived(locale(lang));
@@ -368,25 +369,25 @@
</script> </script>
<svelte:head> <svelte:head>
<title>{t('edit_payment_title', lang)} - {t('cospend', lang)}</title> <title>{t.edit_payment_title} - {t.cospend}</title>
</svelte:head> </svelte:head>
<main class="edit-payment"> <main class="edit-payment">
<div class="header"> <div class="header">
<h1 class="sr-only">{t('edit_payment_title', lang)}</h1> <h1 class="sr-only">{t.edit_payment_title}</h1>
<p>{t('edit_payment_subtitle', lang)}</p> <p>{t.edit_payment_subtitle}</p>
</div> </div>
{#if loading} {#if loading}
<div class="loading">{t('loading_payments', lang)}</div> <div class="loading">{t.loading_payments}</div>
{:else if error} {:else if error}
<div class="error">Error: {error}</div> <div class="error">Error: {error}</div>
{:else if payment} {:else if payment}
<form onsubmit={(e) => { e.preventDefault(); handleSubmit(); } <form onsubmit={(e) => { e.preventDefault(); handleSubmit(); }
} class="payment-form"> } class="payment-form">
<FormSection title={t('payment_details', lang)}> <FormSection title={t.payment_details}>
<div class="form-group"> <div class="form-group">
<label for="title">{t('title_label', lang)}</label> <label for="title">{t.title_label}</label>
<input <input
type="text" type="text"
id="title" id="title"
@@ -396,7 +397,7 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="description">{t('description_label', lang)}</label> <label for="description">{t.description_label}</label>
<textarea <textarea
id="description" id="description"
bind:value={payment.description} bind:value={payment.description}
@@ -405,7 +406,7 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="category">{t('category_star', lang)}</label> <label for="category">{t.category_star}</label>
<select id="category" bind:value={payment.category} required> <select id="category" bind:value={payment.category} required>
{#each categoryOptions as option} {#each categoryOptions as option}
<option value={option.value}>{option.label}</option> <option value={option.value}>{option.label}</option>
@@ -415,7 +416,7 @@
<div class="form-row"> <div class="form-row">
<div class="form-group"> <div class="form-group">
<label for="amount">{t('amount_label', lang)}</label> <label for="amount">{t.amount_label}</label>
<div class="amount-currency"> <div class="amount-currency">
{#if payment.originalAmount && payment.currency !== 'CHF'} {#if payment.originalAmount && payment.currency !== 'CHF'}
<!-- Show original amount for foreign currency --> <!-- Show original amount for foreign currency -->
@@ -478,13 +479,13 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="date">{t('date', lang)}</label> <label for="date">{t.date}</label>
<DatePicker bind:value={paymentDateStr} {lang} /> <DatePicker bind:value={paymentDateStr} {lang} />
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="paidBy">{t('paid_by_form', lang)}</label> <label for="paidBy">{t.paid_by_form}</label>
<input <input
type="text" type="text"
id="paidBy" id="paidBy"
@@ -507,18 +508,18 @@
/> />
{#if payment.splits && payment.splits.length > 0} {#if payment.splits && payment.splits.length > 0}
<FormSection title={t('split_config', lang)}> <FormSection title={t.split_config}>
<div class="split-method-info"> <div class="split-method-info">
<span class="label">{t('split_method_form', lang)}</span> <span class="label">{t.split_method_form}</span>
<span class="value"> <span class="value">
{#if payment.splitMethod === 'equal'} {#if payment.splitMethod === 'equal'}
{t('equal_split', lang)} {t.equal_split}
{:else if payment.splitMethod === 'full'} {:else if payment.splitMethod === 'full'}
{t('paid_in_full', lang)} {t.paid_in_full}
{:else if payment.splitMethod === 'personal_equal'} {:else if payment.splitMethod === 'personal_equal'}
{t('personal_equal_split', lang)} {t.personal_equal_split}
{:else if payment.splitMethod === 'proportional'} {:else if payment.splitMethod === 'proportional'}
{t('custom_proportions', lang)} {t.custom_proportions}
{:else} {:else}
{payment.splitMethod} {payment.splitMethod}
{/if} {/if}
@@ -527,8 +528,8 @@
{#if payment.splitMethod === 'personal_equal'} {#if payment.splitMethod === 'personal_equal'}
<div class="personal-amounts-editor"> <div class="personal-amounts-editor">
<h3>{t('personal_amounts', lang)}</h3> <h3>{t.personal_amounts}</h3>
<p class="description">{t('personal_amounts_desc', lang)}</p> <p class="description">{t.personal_amounts_desc}</p>
{#each payment.splits as split, index} {#each payment.splits as split, index}
<div class="personal-input"> <div class="personal-input">
<label for="personal_{split.username}">{split.username}</label> <label for="personal_{split.username}">{split.username}</label>
@@ -551,10 +552,10 @@
{@const remainder = Math.max(0, Number(payment.amount) - totalPersonal)} {@const remainder = Math.max(0, Number(payment.amount) - totalPersonal)}
{@const hasError = totalPersonal > Number(payment.amount)} {@const hasError = totalPersonal > Number(payment.amount)}
<div class="remainder-info" class:error={hasError}> <div class="remainder-info" class:error={hasError}>
<span>{t('total_personal', lang)}: CHF {totalPersonal.toFixed(2)}</span> <span>{t.total_personal}: CHF {totalPersonal.toFixed(2)}</span>
<span>{t('remainder_to_split', lang)}: CHF {remainder.toFixed(2)}</span> <span>{t.remainder_to_split}: CHF {remainder.toFixed(2)}</span>
{#if hasError} {#if hasError}
<div class="error-message">⚠️ {t('personal_exceeds', lang)}</div> <div class="error-message">⚠️ {t.personal_exceeds}</div>
{/if} {/if}
</div> </div>
{/if} {/if}
@@ -562,17 +563,17 @@
{/if} {/if}
<div class="splits-display"> <div class="splits-display">
<h3>{t('split_preview', lang)}</h3> <h3>{t.split_preview}</h3>
{#each payment.splits as split} {#each payment.splits as split}
<div class="split-item"> <div class="split-item">
<span class="split-username">{split.username}</span> <span class="split-username">{split.username}</span>
<span class="split-amount" class:positive={split.amount < 0} class:negative={split.amount > 0}> <span class="split-amount" class:positive={split.amount < 0} class:negative={split.amount > 0}>
{#if split.amount > 0} {#if split.amount > 0}
{t('owes', lang)} CHF {split.amount.toFixed(2)} {t.owes} CHF {split.amount.toFixed(2)}
{:else if split.amount < 0} {:else if split.amount < 0}
{t('owed', lang)} CHF {Math.abs(split.amount).toFixed(2)} {t.owed} CHF {Math.abs(split.amount).toFixed(2)}
{:else} {:else}
{t('owes', lang)} CHF {split.amount.toFixed(2)} {t.owes} CHF {split.amount.toFixed(2)}
{/if} {/if}
</span> </span>
</div> </div>
@@ -592,11 +593,11 @@
onclick={deletePayment} onclick={deletePayment}
disabled={deleting || saving} disabled={deleting || saving}
> >
{deleting ? t('deleting', lang) : t('delete_payment', lang)} {deleting ? t.deleting : t.delete_payment}
</button> </button>
</div> </div>
<SaveFab disabled={saving || deleting} label={t('save_changes', lang)} /> <SaveFab disabled={saving || deleting} label={t.save_changes} />
</form> </form>
{/if} {/if}
</main> </main>
@@ -6,7 +6,7 @@
import ProfilePicture from '$lib/components/cospend/ProfilePicture.svelte'; import ProfilePicture from '$lib/components/cospend/ProfilePicture.svelte';
import { getCategoryEmoji } from '$lib/utils/categories'; import { getCategoryEmoji } from '$lib/utils/categories';
import EditButton from '$lib/components/EditButton.svelte'; import EditButton from '$lib/components/EditButton.svelte';
import { detectCospendLang, cospendRoot, t, locale, splitDescription, paymentCategoryName } from '$lib/js/cospendI18n'; import { detectCospendLang, cospendRoot, locale, splitDescription, paymentCategoryName, m } from '$lib/js/cospendI18n';
import { formatCurrency } from '$lib/utils/formatters'; import { formatCurrency } from '$lib/utils/formatters';
@@ -14,6 +14,7 @@
let { data } = $props(); let { data } = $props();
const lang = $derived(detectCospendLang(page.url.pathname)); const lang = $derived(detectCospendLang(page.url.pathname));
const t = $derived(m[lang]);
const root = $derived(cospendRoot(lang)); const root = $derived(cospendRoot(lang));
const loc = $derived(locale(lang)); const loc = $derived(locale(lang));
@@ -54,13 +55,13 @@
</script> </script>
<svelte:head> <svelte:head>
<title>{payment ? payment.title : 'Payment'} - {t('cospend', lang)}</title> <title>{payment ? payment.title : 'Payment'} - {t.cospend}</title>
</svelte:head> </svelte:head>
<main class="payment-view"> <main class="payment-view">
{#if loading} {#if loading}
<div class="loading">{t('loading_payments', lang)}</div> <div class="loading">{t.loading_payments}</div>
{:else if error} {:else if error}
<div class="error">Error: {error}</div> <div class="error">Error: {error}</div>
{:else if payment} {:else if payment}
@@ -75,7 +76,7 @@
{formatAmountWithCurrency(payment)} {formatAmountWithCurrency(payment)}
{#if payment.currency !== 'CHF' && payment.exchangeRate} {#if payment.currency !== 'CHF' && payment.exchangeRate}
<div class="exchange-rate-info"> <div class="exchange-rate-info">
<small>{t('exchange_rate', lang)}: 1 {payment.currency} = {payment.exchangeRate.toFixed(4)} CHF</small> <small>{t.exchange_rate}: 1 {payment.currency} = {payment.exchangeRate.toFixed(4)} CHF</small>
</div> </div>
{/if} {/if}
</div> </div>
@@ -90,30 +91,30 @@
<div class="payment-info"> <div class="payment-info">
<div class="info-grid"> <div class="info-grid">
<div class="info-item"> <div class="info-item">
<span class="label">{t('date', lang)}</span> <span class="label">{t.date}</span>
<span class="value">{formatDate(payment.date)}</span> <span class="value">{formatDate(payment.date)}</span>
</div> </div>
<div class="info-item"> <div class="info-item">
<span class="label">{t('paid_by_label', lang)}</span> <span class="label">{t.paid_by_label}</span>
<span class="value">{payment.paidBy}</span> <span class="value">{payment.paidBy}</span>
</div> </div>
<div class="info-item"> <div class="info-item">
<span class="label">{t('created_by', lang)}</span> <span class="label">{t.created_by}</span>
<span class="value">{payment.createdBy}</span> <span class="value">{payment.createdBy}</span>
</div> </div>
<div class="info-item"> <div class="info-item">
<span class="label">{t('category_label', lang)}</span> <span class="label">{t.category_label}</span>
<span class="value">{paymentCategoryName(payment.category || 'groceries', lang)}</span> <span class="value">{paymentCategoryName(payment.category || 'groceries', lang)}</span>
</div> </div>
<div class="info-item"> <div class="info-item">
<span class="label">{t('split_method_label', lang)}</span> <span class="label">{t.split_method_label}</span>
<span class="value">{getSplitDescription(payment)}</span> <span class="value">{getSplitDescription(payment)}</span>
</div> </div>
</div> </div>
{#if payment.description} {#if payment.description}
<div class="description"> <div class="description">
<h3>{t('description', lang)}</h3> <h3>{t.description}</h3>
<p>{payment.description}</p> <p>{payment.description}</p>
</div> </div>
{/if} {/if}
@@ -121,7 +122,7 @@
{#if payment.splits && payment.splits.length > 0} {#if payment.splits && payment.splits.length > 0}
<div class="splits-section"> <div class="splits-section">
<h3>{t('split_details', lang)}</h3> <h3>{t.split_details}</h3>
<div class="splits-list"> <div class="splits-list">
{#each payment.splits as split} {#each payment.splits as split}
<div class="split-item" class:current-user={split.username === data.session?.user?.nickname}> <div class="split-item" class:current-user={split.username === data.session?.user?.nickname}>
@@ -130,17 +131,17 @@
<div class="user-info"> <div class="user-info">
<span class="username">{split.username}</span> <span class="username">{split.username}</span>
{#if split.username === data.session?.user?.nickname} {#if split.username === data.session?.user?.nickname}
<span class="you-badge">{t('you', lang)}</span> <span class="you-badge">{t.you}</span>
{/if} {/if}
</div> </div>
</div> </div>
<div class="split-amount" class:positive={split.amount < 0} class:negative={split.amount > 0}> <div class="split-amount" class:positive={split.amount < 0} class:negative={split.amount > 0}>
{#if split.amount > 0} {#if split.amount > 0}
{t('owes', lang)} {formatCurrency(split.amount, 'CHF', loc)} {t.owes} {formatCurrency(split.amount, 'CHF', loc)}
{:else if split.amount < 0} {:else if split.amount < 0}
{t('owed', lang)} {formatCurrency(split.amount, 'CHF', loc)} {t.owed} {formatCurrency(split.amount, 'CHF', loc)}
{:else} {:else}
{t('owes', lang)} {formatCurrency(split.amount, 'CHF', loc)} {t.owes} {formatCurrency(split.amount, 'CHF', loc)}
{/if} {/if}
</div> </div>
</div> </div>
@@ -9,11 +9,12 @@
import { formatCurrency } from '$lib/utils/formatters'; import { formatCurrency } from '$lib/utils/formatters';
import Toggle from '$lib/components/Toggle.svelte'; import Toggle from '$lib/components/Toggle.svelte';
import { page } from '$app/state'; import { page } from '$app/state';
import { detectCospendLang, cospendRoot, t, locale, paymentCategoryName, frequencyDescription, formatNextExecutionI18n } from '$lib/js/cospendI18n'; import { detectCospendLang, cospendRoot, locale, paymentCategoryName, frequencyDescription, formatNextExecutionI18n, m } from '$lib/js/cospendI18n';
let { data } = $props(); let { data } = $props();
const lang = $derived(detectCospendLang(page.url.pathname)); const lang = $derived(detectCospendLang(page.url.pathname));
const t = $derived(m[lang]);
const root = $derived(cospendRoot(lang)); const root = $derived(cospendRoot(lang));
const loc = $derived(locale(lang)); const loc = $derived(locale(lang));
@@ -67,7 +68,7 @@
} }
async function deleteRecurringPayment(/** @type {string} */ paymentId, /** @type {string} */ title) { async function deleteRecurringPayment(/** @type {string} */ paymentId, /** @type {string} */ title) {
if (!await confirm(`${t('delete_recurring_confirm', lang)} "${title}"?`)) { if (!await confirm(`${t.delete_recurring_confirm} "${title}"?`)) {
return; return;
} }
@@ -99,31 +100,31 @@
</script> </script>
<svelte:head> <svelte:head>
<title>{t('recurring_title', lang)} - {t('cospend', lang)}</title> <title>{t.recurring_title} - {t.cospend}</title>
</svelte:head> </svelte:head>
<main class="recurring-payments"> <main class="recurring-payments">
<div class="header"> <div class="header">
<h1 class="sr-only">{t('recurring_title', lang)}</h1> <h1 class="sr-only">{t.recurring_title}</h1>
<p>{t('recurring_subtitle', lang)}</p> <p>{t.recurring_subtitle}</p>
</div> </div>
<div class="filters"> <div class="filters">
<label> <label>
<Toggle bind:checked={showActiveOnly} /> <Toggle bind:checked={showActiveOnly} />
<span>{t('show_active_only', lang)}</span> <span>{t.show_active_only}</span>
</label> </label>
</div> </div>
{#if loading} {#if loading}
<div class="loading">{t('loading_recurring', lang)}</div> <div class="loading">{t.loading_recurring}</div>
{:else if error} {:else if error}
<div class="error">Error: {error}</div> <div class="error">Error: {error}</div>
{:else if recurringPayments.length === 0} {:else if recurringPayments.length === 0}
<div class="empty-state"> <div class="empty-state">
<h2>{t('no_recurring', lang)}</h2> <h2>{t.no_recurring}</h2>
<p>{t('no_recurring_desc', lang)}</p> <p>{t.no_recurring_desc}</p>
<a href={resolve('/[cospendRoot=cospendRoot]/payments/add', { cospendRoot: root })} class="btn btn-primary">{t('add_first_payment', lang)}</a> <a href={resolve('/[cospendRoot=cospendRoot]/payments/add', { cospendRoot: root })} class="btn btn-primary">{t.add_first_payment}</a>
</div> </div>
{:else} {:else}
<div class="payments-grid"> <div class="payments-grid">
@@ -134,7 +135,7 @@
<span class="category-emoji">{getCategoryEmoji(payment.category)}</span> <span class="category-emoji">{getCategoryEmoji(payment.category)}</span>
<h3>{payment.title}</h3> <h3>{payment.title}</h3>
<span class="status-badge" class:active={payment.isActive} class:inactive={!payment.isActive}> <span class="status-badge" class:active={payment.isActive} class:inactive={!payment.isActive}>
{payment.isActive ? t('active', lang) : t('inactive', lang)} {payment.isActive ? t.active : t.inactive}
</span> </span>
</div> </div>
<div class="payment-amount"> <div class="payment-amount">
@@ -148,17 +149,17 @@
<div class="payment-details"> <div class="payment-details">
<div class="detail-row"> <div class="detail-row">
<span class="label">{t('category_label', lang)}</span> <span class="label">{t.category_label}</span>
<span class="value">{paymentCategoryName(payment.category, lang)}</span> <span class="value">{paymentCategoryName(payment.category, lang)}</span>
</div> </div>
<div class="detail-row"> <div class="detail-row">
<span class="label">{t('frequency', lang)}</span> <span class="label">{t.frequency}</span>
<span class="value">{frequencyDescription(payment, lang)}</span> <span class="value">{frequencyDescription(payment, lang)}</span>
</div> </div>
<div class="detail-row"> <div class="detail-row">
<span class="label">{t('paid_by_label', lang)}</span> <span class="label">{t.paid_by_label}</span>
<div class="payer-info"> <div class="payer-info">
<ProfilePicture username={payment.paidBy} size={20} /> <ProfilePicture username={payment.paidBy} size={20} />
<span class="value">{payment.paidBy}</span> <span class="value">{payment.paidBy}</span>
@@ -166,7 +167,7 @@
</div> </div>
<div class="detail-row"> <div class="detail-row">
<span class="label">{t('next_execution', lang)}</span> <span class="label">{t.next_execution}</span>
<span class="value next-execution"> <span class="value next-execution">
{formatNextExecutionI18n(new Date(payment.nextExecutionDate), lang)} {formatNextExecutionI18n(new Date(payment.nextExecutionDate), lang)}
</span> </span>
@@ -174,21 +175,21 @@
{#if payment.lastExecutionDate} {#if payment.lastExecutionDate}
<div class="detail-row"> <div class="detail-row">
<span class="label">{t('last_executed', lang)}</span> <span class="label">{t.last_executed}</span>
<span class="value">{formatDate(payment.lastExecutionDate)}</span> <span class="value">{formatDate(payment.lastExecutionDate)}</span>
</div> </div>
{/if} {/if}
{#if payment.endDate} {#if payment.endDate}
<div class="detail-row"> <div class="detail-row">
<span class="label">{t('ends', lang)}</span> <span class="label">{t.ends}</span>
<span class="value">{formatDate(payment.endDate)}</span> <span class="value">{formatDate(payment.endDate)}</span>
</div> </div>
{/if} {/if}
</div> </div>
<div class="splits-preview"> <div class="splits-preview">
<h4>{t('split_between', lang)}</h4> <h4>{t.split_between}</h4>
<div class="splits-list"> <div class="splits-list">
{#each payment.splits as split} {#each payment.splits as split}
<div class="split-item"> <div class="split-item">
@@ -196,11 +197,11 @@
<span class="username">{split.username}</span> <span class="username">{split.username}</span>
<span class="split-amount" class:positive={split.amount < 0} class:negative={split.amount > 0}> <span class="split-amount" class:positive={split.amount < 0} class:negative={split.amount > 0}>
{#if split.amount > 0} {#if split.amount > 0}
{t('owes', lang)} {formatCurrency(split.amount, 'CHF', loc)} {t.owes} {formatCurrency(split.amount, 'CHF', loc)}
{:else if split.amount < 0} {:else if split.amount < 0}
{t('gets', lang)} {formatCurrency(Math.abs(split.amount), 'CHF', loc)} {t.gets} {formatCurrency(Math.abs(split.amount), 'CHF', loc)}
{:else} {:else}
{t('owes', lang)} {formatCurrency(split.amount, 'CHF', loc)} {t.owes} {formatCurrency(split.amount, 'CHF', loc)}
{/if} {/if}
</span> </span>
</div> </div>
@@ -210,7 +211,7 @@
<div class="card-actions"> <div class="card-actions">
<a href={resolve('/[cospendRoot=cospendRoot]/recurring/edit/[id]', { cospendRoot: root, id: payment._id })} class="btn btn-secondary btn-small"> <a href={resolve('/[cospendRoot=cospendRoot]/recurring/edit/[id]', { cospendRoot: root, id: payment._id })} class="btn btn-secondary btn-small">
{t('edit', lang)} {t.edit}
</a> </a>
<button <button
class="btn btn-small" class="btn btn-small"
@@ -218,13 +219,13 @@
class:btn-success={!payment.isActive} class:btn-success={!payment.isActive}
onclick={() => toggleActiveStatus(payment._id, payment.isActive)} onclick={() => toggleActiveStatus(payment._id, payment.isActive)}
> >
{payment.isActive ? t('pause', lang) : t('activate', lang)} {payment.isActive ? t.pause : t.activate}
</button> </button>
<button <button
class="btn btn-danger btn-small" class="btn btn-danger btn-small"
onclick={() => deleteRecurringPayment(payment._id, payment.title)} onclick={() => deleteRecurringPayment(payment._id, payment.title)}
> >
{t('delete_', lang)} {t.delete_}
</button> </button>
</div> </div>
</div> </div>
@@ -2,7 +2,7 @@
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { page } from '$app/state'; import { page } from '$app/state';
import { detectCospendLang, cospendRoot, locale, t, getCategoryOptionsI18n, frequencyDescription } from '$lib/js/cospendI18n'; import { detectCospendLang, cospendRoot, locale, getCategoryOptionsI18n, frequencyDescription, m } from '$lib/js/cospendI18n';
import { PREDEFINED_USERS, isPredefinedUsersMode } from '$lib/config/users'; import { PREDEFINED_USERS, isPredefinedUsersMode } from '$lib/config/users';
import { validateCronExpression, calculateNextExecutionDate } from '$lib/utils/recurring'; import { validateCronExpression, calculateNextExecutionDate } from '$lib/utils/recurring';
import ProfilePicture from '$lib/components/cospend/ProfilePicture.svelte'; import ProfilePicture from '$lib/components/cospend/ProfilePicture.svelte';
@@ -14,6 +14,7 @@
let { data } = $props(); let { data } = $props();
const lang = $derived(detectCospendLang(page.url.pathname)); const lang = $derived(detectCospendLang(page.url.pathname));
const t = $derived(m[lang]);
const root = $derived(cospendRoot(lang)); const root = $derived(cospendRoot(lang));
const loc = $derived(locale(lang)); const loc = $derived(locale(lang));
@@ -290,47 +291,47 @@
</script> </script>
<svelte:head> <svelte:head>
<title>{t('edit_recurring_title', lang)} - {t('cospend', lang)}</title> <title>{t.edit_recurring_title} - {t.cospend}</title>
</svelte:head> </svelte:head>
<main class="edit-recurring-payment"> <main class="edit-recurring-payment">
<div class="header"> <div class="header">
<h1 class="sr-only">{t('edit_recurring_title', lang)}</h1> <h1 class="sr-only">{t.edit_recurring_title}</h1>
</div> </div>
{#if loadingPayment} {#if loadingPayment}
<div class="loading">{t('loading_recurring', lang)}</div> <div class="loading">{t.loading_recurring}</div>
{:else if error && !formData.title} {:else if error && !formData.title}
<div class="error">Error: {error}</div> <div class="error">Error: {error}</div>
{:else} {:else}
<form onsubmit={(e) => { e.preventDefault(); handleSubmit(); } <form onsubmit={(e) => { e.preventDefault(); handleSubmit(); }
} class="payment-form"> } class="payment-form">
<div class="form-section"> <div class="form-section">
<h2>{t('payment_details_section', lang)}</h2> <h2>{t.payment_details_section}</h2>
<div class="form-group"> <div class="form-group">
<label for="title">{t('title_label', lang)}</label> <label for="title">{t.title_label}</label>
<input <input
type="text" type="text"
id="title" id="title"
bind:value={formData.title} bind:value={formData.title}
required required
placeholder={t('title_placeholder', lang)} placeholder={t.title_placeholder}
/> />
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="description">{t('description_label', lang)}</label> <label for="description">{t.description_label}</label>
<textarea <textarea
id="description" id="description"
bind:value={formData.description} bind:value={formData.description}
placeholder={t('description_placeholder', lang)} placeholder={t.description_placeholder}
rows="3" rows="3"
></textarea> ></textarea>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="category">{t('category_star', lang)}</label> <label for="category">{t.category_star}</label>
<select id="category" bind:value={formData.category} required> <select id="category" bind:value={formData.category} required>
{#each categoryOptions as option} {#each categoryOptions as option}
<option value={option.value}>{option.label}</option> <option value={option.value}>{option.label}</option>
@@ -340,7 +341,7 @@
<div class="form-row"> <div class="form-row">
<div class="form-group"> <div class="form-group">
<label for="amount">{t('amount_label', lang)}</label> <label for="amount">{t.amount_label}</label>
<div class="amount-currency"> <div class="amount-currency">
<input <input
type="number" type="number"
@@ -383,7 +384,7 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="paidBy">{t('paid_by_form', lang)}</label> <label for="paidBy">{t.paid_by_form}</label>
<select id="paidBy" bind:value={formData.paidBy} required> <select id="paidBy" bind:value={formData.paidBy} required>
{#each users as user} {#each users as user}
<option value={user}>{user}</option> <option value={user}>{user}</option>
@@ -393,30 +394,30 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="isActive">{t('status_label', lang)}</label> <label for="isActive">{t.status_label}</label>
<select id="isActive" bind:value={formData.isActive}> <select id="isActive" bind:value={formData.isActive}>
<option value={true}>{t('active', lang)}</option> <option value={true}>{t.active}</option>
<option value={false}>{t('inactive', lang)}</option> <option value={false}>{t.inactive}</option>
</select> </select>
</div> </div>
</div> </div>
<div class="form-section"> <div class="form-section">
<h2>{t('recurring_schedule', lang)}</h2> <h2>{t.recurring_schedule}</h2>
<div class="form-row"> <div class="form-row">
<div class="form-group"> <div class="form-group">
<label for="frequency">{t('frequency_label', lang)}</label> <label for="frequency">{t.frequency_label}</label>
<select id="frequency" bind:value={formData.frequency} required> <select id="frequency" bind:value={formData.frequency} required>
<option value="daily">{t('freq_daily', lang)}</option> <option value="daily">{t.freq_daily}</option>
<option value="weekly">{t('freq_weekly', lang)}</option> <option value="weekly">{t.freq_weekly}</option>
<option value="monthly">{t('freq_monthly', lang)}</option> <option value="monthly">{t.freq_monthly}</option>
<option value="custom">{t('freq_custom', lang)}</option> <option value="custom">{t.freq_custom}</option>
</select> </select>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="startDate">{t('start_date', lang)}</label> <label for="startDate">{t.start_date}</label>
<DatePicker bind:value={formData.startDate} {lang} /> <DatePicker bind:value={formData.startDate} {lang} />
</div> </div>
</div> </div>
@@ -449,14 +450,14 @@
{/if} {/if}
<div class="form-group"> <div class="form-group">
<label for="endDate">{t('end_date_optional', lang)}</label> <label for="endDate">{t.end_date_optional}</label>
<DatePicker bind:value={formData.endDate} {lang} /> <DatePicker bind:value={formData.endDate} {lang} />
<div class="help-text">{t('end_date_hint', lang)}</div> <div class="help-text">{t.end_date_hint}</div>
</div> </div>
{#if nextExecutionPreview} {#if nextExecutionPreview}
<div class="execution-preview"> <div class="execution-preview">
<h3>{t('next_execution_preview', lang)}</h3> <h3>{t.next_execution_preview}</h3>
<p class="next-execution">{nextExecutionPreview}</p> <p class="next-execution">{nextExecutionPreview}</p>
<p class="frequency-description">{frequencyDescription(/** @type {any} */ (formData), lang)}</p> <p class="frequency-description">{frequencyDescription(/** @type {any} */ (formData), lang)}</p>
</div> </div>
@@ -488,7 +489,7 @@
<div class="error">{error}</div> <div class="error">{error}</div>
{/if} {/if}
<SaveFab disabled={loading || cronError} label={t('save_changes', lang)} /> <SaveFab disabled={loading || cronError} label={t.save_changes} />
</form> </form>
{/if} {/if}
</main> </main>
@@ -5,13 +5,14 @@
import { page } from '$app/state'; import { page } from '$app/state';
import ProfilePicture from '$lib/components/cospend/ProfilePicture.svelte'; import ProfilePicture from '$lib/components/cospend/ProfilePicture.svelte';
import { PREDEFINED_USERS, isPredefinedUsersMode } from '$lib/config/users'; 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'; import { formatCurrency } from '$lib/utils/formatters';
let { data, form } = $props(); let { data, form } = $props();
const lang = $derived(detectCospendLang(page.url.pathname)); const lang = $derived(detectCospendLang(page.url.pathname));
const t = $derived(m[lang]);
const root = $derived(cospendRoot(lang)); const root = $derived(cospendRoot(lang));
const loc = $derived(locale(lang)); const loc = $derived(locale(lang));
@@ -44,7 +45,7 @@
from: debtData.whoOwesMe[0].username, from: debtData.whoOwesMe[0].username,
to: data.currentUser, to: data.currentUser,
amount: debtData.whoOwesMe[0].netAmount, 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) { if (!settlementAmount) {
settlementAmount = debtData.whoOwesMe[0].netAmount.toString(); settlementAmount = debtData.whoOwesMe[0].netAmount.toString();
@@ -55,7 +56,7 @@
from: data.currentUser, from: data.currentUser,
to: debtData.whoIOwe[0].username, to: debtData.whoIOwe[0].username,
amount: debtData.whoIOwe[0].netAmount, 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) { if (!settlementAmount) {
settlementAmount = debtData.whoIOwe[0].netAmount.toString(); settlementAmount = debtData.whoIOwe[0].netAmount.toString();
@@ -73,7 +74,7 @@
from: user, from: user,
to: currentUser, to: currentUser,
amount: amount, amount: amount,
description: `${t('settlement_payment', lang)}: ${user} → ${currentUser}` description: `${t.settlement_payment}: ${user} → ${currentUser}`
}; };
} else { } else {
selectedSettlement = { selectedSettlement = {
@@ -81,7 +82,7 @@
from: currentUser, from: currentUser,
to: user, to: user,
amount: amount, amount: amount,
description: `${t('settlement_payment', lang)}: ${currentUser} → ${user}` description: `${t.settlement_payment}: ${currentUser} → ${user}`
}; };
} }
settlementAmount = amount.toString(); settlementAmount = amount.toString();
@@ -89,13 +90,13 @@
async function processSettlement() { async function processSettlement() {
if (!selectedSettlement || !settlementAmount) { if (!selectedSettlement || !settlementAmount) {
error = t('error_select_settlement', lang); error = t.error_select_settlement;
return; return;
} }
const amount = parseFloat(/** @type {string} */ (settlementAmount)); const amount = parseFloat(/** @type {string} */ (settlementAmount));
if (isNaN(amount) || amount <= 0) { if (isNaN(amount) || amount <= 0) {
error = t('error_valid_amount', lang); error = t.error_valid_amount;
return; return;
} }
@@ -148,36 +149,36 @@
</script> </script>
<svelte:head> <svelte:head>
<title>{t('settle_title', lang)} - {t('cospend', lang)}</title> <title>{t.settle_title} - {t.cospend}</title>
</svelte:head> </svelte:head>
<main class="settle-main"> <main class="settle-main">
<div class="header-section"> <div class="header-section">
<h1 class="sr-only">{t('settle_title', lang)}</h1> <h1 class="sr-only">{t.settle_title}</h1>
<p>{t('settle_subtitle', lang)}</p> <p>{t.settle_subtitle}</p>
</div> </div>
{#if loading} {#if loading}
<div class="loading">{t('loading_debts', lang)}</div> <div class="loading">{t.loading_debts}</div>
{:else if error} {:else if error}
<div class="error">Error: {error}</div> <div class="error">Error: {error}</div>
{:else if debtData.whoOwesMe.length === 0 && debtData.whoIOwe.length === 0} {:else if debtData.whoOwesMe.length === 0 && debtData.whoIOwe.length === 0}
<div class="no-debts"> <div class="no-debts">
<h2>🎉 {t('all_settled', lang)}</h2> <h2>🎉 {t.all_settled}</h2>
<p>{t('no_debts_msg', lang)}</p> <p>{t.no_debts_msg}</p>
<div class="actions"> <div class="actions">
<a href={resolve('/[cospendRoot=cospendRoot]/dash', { cospendRoot: root })} class="btn btn-primary">{t('back_to_dashboard', lang)}</a> <a href={resolve('/[cospendRoot=cospendRoot]/dash', { cospendRoot: root })} class="btn btn-primary">{t.back_to_dashboard}</a>
</div> </div>
</div> </div>
{:else} {:else}
<div class="settlement-container"> <div class="settlement-container">
<!-- Available Settlements --> <!-- Available Settlements -->
<div class="available-settlements"> <div class="available-settlements">
<h2>{t('available_settlements', lang)}</h2> <h2>{t.available_settlements}</h2>
{#if debtData.whoOwesMe.length > 0} {#if debtData.whoOwesMe.length > 0}
<div class="settlement-section"> <div class="settlement-section">
<h3>{t('money_owed_to_you', lang)}</h3> <h3>{t.money_owed_to_you}</h3>
{#each debtData.whoOwesMe as debt} {#each debtData.whoOwesMe as debt}
<div class="settlement-option" <div class="settlement-option"
role="button" role="button"
@@ -190,11 +191,11 @@
<ProfilePicture username={debt.username} size={40} /> <ProfilePicture username={debt.username} size={40} />
<div class="user-details"> <div class="user-details">
<span class="username">{debt.username}</span> <span class="username">{debt.username}</span>
<span class="debt-amount">{t('owes_you', lang)} {formatCurrency(debt.netAmount, 'CHF', loc)}</span> <span class="debt-amount">{t.owes_you} {formatCurrency(debt.netAmount, 'CHF', loc)}</span>
</div> </div>
</div> </div>
<div class="settlement-action"> <div class="settlement-action">
<span class="action-text">{t('receive_payment', lang)}</span> <span class="action-text">{t.receive_payment}</span>
</div> </div>
</div> </div>
{/each} {/each}
@@ -203,7 +204,7 @@
{#if debtData.whoIOwe.length > 0} {#if debtData.whoIOwe.length > 0}
<div class="settlement-section"> <div class="settlement-section">
<h3>{t('money_you_owe', lang)}</h3> <h3>{t.money_you_owe}</h3>
{#each debtData.whoIOwe as debt} {#each debtData.whoIOwe as debt}
<div class="settlement-option" <div class="settlement-option"
role="button" role="button"
@@ -216,11 +217,11 @@
<ProfilePicture username={debt.username} size={40} /> <ProfilePicture username={debt.username} size={40} />
<div class="user-details"> <div class="user-details">
<span class="username">{debt.username}</span> <span class="username">{debt.username}</span>
<span class="debt-amount">{t('you_owe', lang)} {formatCurrency(debt.netAmount, 'CHF', loc)}</span> <span class="debt-amount">{t.you_owe} {formatCurrency(debt.netAmount, 'CHF', loc)}</span>
</div> </div>
</div> </div>
<div class="settlement-action"> <div class="settlement-action">
<span class="action-text">{t('make_payment', lang)}</span> <span class="action-text">{t.make_payment}</span>
</div> </div>
</div> </div>
{/each} {/each}
@@ -231,7 +232,7 @@
<!-- Settlement Details --> <!-- Settlement Details -->
{#if selectedSettlement} {#if selectedSettlement}
<div class="settlement-details"> <div class="settlement-details">
<h2>{t('settlement_details', lang)}</h2> <h2>{t.settlement_details}</h2>
<div class="settlement-summary"> <div class="settlement-summary">
<div class="settlement-flow"> <div class="settlement-flow">
@@ -239,7 +240,7 @@
<ProfilePicture username={selectedSettlement.from} size={48} /> <ProfilePicture username={selectedSettlement.from} size={48} />
<span class="username">{selectedSettlement.from}</span> <span class="username">{selectedSettlement.from}</span>
{#if selectedSettlement.from === data.currentUser} {#if selectedSettlement.from === data.currentUser}
<span class="you-badge">{t('you', lang)}</span> <span class="you-badge">{t.you}</span>
{/if} {/if}
</div> </div>
<div class="flow-arrow"></div> <div class="flow-arrow"></div>
@@ -247,13 +248,13 @@
<ProfilePicture username={selectedSettlement.to} size={48} /> <ProfilePicture username={selectedSettlement.to} size={48} />
<span class="username">{selectedSettlement.to}</span> <span class="username">{selectedSettlement.to}</span>
{#if selectedSettlement.to === data.currentUser} {#if selectedSettlement.to === data.currentUser}
<span class="you-badge">{t('you', lang)}</span> <span class="you-badge">{t.you}</span>
{/if} {/if}
</div> </div>
</div> </div>
<div class="settlement-amount-section"> <div class="settlement-amount-section">
<label for="amount">{t('settlement_amount', lang)}</label> <label for="amount">{t.settlement_amount}</label>
<div class="amount-input"> <div class="amount-input">
<span class="currency">CHF</span> <span class="currency">CHF</span>
<input <input
@@ -280,60 +281,60 @@
onclick={processSettlement} onclick={processSettlement}
disabled={submitting || !settlementAmount}> disabled={submitting || !settlementAmount}>
{#if submitting} {#if submitting}
{t('recording_settlement', lang)} {t.recording_settlement}
{:else} {:else}
{t('record_settlement', lang)} {t.record_settlement}
{/if} {/if}
</button> </button>
<button class="btn btn-secondary" onclick={() => selectedSettlement = null}> <button class="btn btn-secondary" onclick={() => selectedSettlement = null}>
{t('cancel', lang)} {t.cancel}
</button> </button>
</div> </div>
</div> </div>
{:else} {:else}
<!-- No-JS Fallback Form --> <!-- No-JS Fallback Form -->
<div class="settlement-details no-js-fallback"> <div class="settlement-details no-js-fallback">
<h2>{t('record_settlement', lang)}</h2> <h2>{t.record_settlement}</h2>
<form method="POST" action="?/settle" class="settlement-form"> <form method="POST" action="?/settle" class="settlement-form">
<div class="form-group"> <div class="form-group">
<label for="settlementType">{t('settlement_type', lang)}</label> <label for="settlementType">{t.settlement_type}</label>
<select id="settlementType" name="settlementType" required> <select id="settlementType" name="settlementType" required>
<option value="">{t('select_settlement', lang)}</option> <option value="">{t.select_settlement}</option>
{#each debtData.whoOwesMe as debt} {#each debtData.whoOwesMe as debt}
<option value="receive" data-from="{debt.username}" data-to="{data.currentUser}"> <option value="receive" data-from="{debt.username}" data-to="{data.currentUser}">
{t('receive_from', lang)} {formatCurrency(debt.netAmount, 'CHF', loc)} {t('from', lang)} {debt.username} {t.receive_from} {formatCurrency(debt.netAmount, 'CHF', loc)} {t.from} {debt.username}
</option> </option>
{/each} {/each}
{#each debtData.whoIOwe as debt} {#each debtData.whoIOwe as debt}
<option value="pay" data-from="{data.currentUser}" data-to="{debt.username}"> <option value="pay" data-from="{data.currentUser}" data-to="{debt.username}">
{t('pay_to', lang)} {formatCurrency(debt.netAmount, 'CHF', loc)} {t('to', lang)} {debt.username} {t.pay_to} {formatCurrency(debt.netAmount, 'CHF', loc)} {t.to} {debt.username}
</option> </option>
{/each} {/each}
</select> </select>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="fromUser">{t('from_user', lang)}</label> <label for="fromUser">{t.from_user}</label>
<select id="fromUser" name="fromUser" required> <select id="fromUser" name="fromUser" required>
<option value="">{t('select_payer', lang)}</option> <option value="">{t.select_payer}</option>
{#each [...debtData.whoOwesMe.map((/** @type {any} */ d) => d.username), data.currentUser].filter(Boolean) as user} {#each [...debtData.whoOwesMe.map((/** @type {any} */ d) => d.username), data.currentUser].filter(Boolean) as user}
<option value="{user}">{user}{user === data.currentUser ? ` (${t('you', lang)})` : ''}</option> <option value="{user}">{user}{user === data.currentUser ? ` (${t.you})` : ''}</option>
{/each} {/each}
</select> </select>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="toUser">{t('to_user', lang)}</label> <label for="toUser">{t.to_user}</label>
<select id="toUser" name="toUser" required> <select id="toUser" name="toUser" required>
<option value="">{t('select_recipient', lang)}</option> <option value="">{t.select_recipient}</option>
{#each [...debtData.whoIOwe.map((/** @type {any} */ d) => d.username), data.currentUser].filter(Boolean) as user} {#each [...debtData.whoIOwe.map((/** @type {any} */ d) => d.username), data.currentUser].filter(Boolean) as user}
<option value="{user}">{user}{user === data.currentUser ? ` (${t('you', lang)})` : ''}</option> <option value="{user}">{user}{user === data.currentUser ? ` (${t.you})` : ''}</option>
{/each} {/each}
</select> </select>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="fallback-amount">{t('settlement_amount_chf', lang)}</label> <label for="fallback-amount">{t.settlement_amount_chf}</label>
<input <input
id="fallback-amount" id="fallback-amount"
name="amount" name="amount"
@@ -348,10 +349,10 @@
<div class="settlement-actions"> <div class="settlement-actions">
<button type="submit" class="btn btn-settlement"> <button type="submit" class="btn btn-settlement">
{t('record_settlement', lang)} {t.record_settlement}
</button> </button>
<a href={resolve('/[cospendRoot=cospendRoot]/dash', { cospendRoot: root })} class="btn btn-secondary"> <a href={resolve('/[cospendRoot=cospendRoot]/dash', { cospendRoot: root })} class="btn btn-secondary">
{t('cancel', lang)} {t.cancel}
</a> </a>
</div> </div>
</form> </form>
@@ -1,14 +1,6 @@
<script lang="ts"> <script lang="ts">
import type { CalendarDay } from '$lib/calendarTypes'; import type { CalendarDay } from '$lib/calendarTypes';
import { import { formatLongDate, rankEmphasis, humanizePsalterWeek, humanizeSundayCycle, type CalendarLang, m, m1962 } from './calendarI18n';
formatLongDate,
rankEmphasis,
humanizePsalterWeek,
humanizeSundayCycle,
t,
t1962,
type CalendarLang
} from './calendarI18n';
import { litBg, litInk } from './calendarColors'; import { litBg, litInk } from './calendarColors';
let { let {
@@ -22,6 +14,8 @@
todayIso: string; todayIso: string;
href?: string; href?: string;
} = $props(); } = $props();
const t1962 = $derived(m1962[lang]);
const t = $derived(m[lang]);
const color = $derived(day.colorKeys[0] ?? 'GREEN'); const color = $derived(day.colorKeys[0] ?? 'GREEN');
const isToday = $derived(day.iso === todayIso); const isToday = $derived(day.iso === todayIso);
@@ -42,7 +36,7 @@
> >
<span class="hc-rank" aria-hidden="true">{rankNum}</span> <span class="hc-rank" aria-hidden="true">{rankNum}</span>
<div class="hc-date"> <div class="hc-date">
{#if isToday}{t('today', lang)} · {/if}{formatLongDate(day.iso, lang)} {#if isToday}{t.today} · {/if}{formatLongDate(day.iso, lang)}
</div> </div>
<h2 class="hc-name">{day.name}</h2> <h2 class="hc-name">{day.name}</h2>
<div class="hc-tags"> <div class="hc-tags">
@@ -56,17 +50,17 @@
<span class="hc-tag">{firstOr(day.seasonNames)}</span> <span class="hc-tag">{firstOr(day.seasonNames)}</span>
{/if} {/if}
{#if day.psalterWeek} {#if day.psalterWeek}
<span class="hc-tag">{t('psalterWeek', lang)}: {humanizePsalterWeek(day.psalterWeek, lang)}</span> <span class="hc-tag">{t.psalterWeek}: {humanizePsalterWeek(day.psalterWeek, lang)}</span>
{/if} {/if}
{#if day.sundayCycle} {#if day.sundayCycle}
<span class="hc-tag">{t('cycle', lang)}: {humanizeSundayCycle(day.sundayCycle)}</span> <span class="hc-tag">{t.cycle}: {humanizeSundayCycle(day.sundayCycle)}</span>
{/if} {/if}
</div> </div>
{#if day.rite1962 && day.rite1962.commemorations.length} {#if day.rite1962 && day.rite1962.commemorations.length}
<div class="hc-commems"> <div class="hc-commems">
<div class="hc-commems-head"> <div class="hc-commems-head">
<svg class="hc-commems-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12h14"/><path d="M12 5v14"/></svg> <svg class="hc-commems-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12h14"/><path d="M12 5v14"/></svg>
<span class="hc-commems-title">{t1962('commemorations', lang)}</span> <span class="hc-commems-title">{t1962.commemorations}</span>
</div> </div>
<div class="hc-commems-list"> <div class="hc-commems-list">
{#each day.rite1962.commemorations as c (c.id)} {#each day.rite1962.commemorations as c (c.id)}
@@ -79,7 +73,7 @@
<div class="hc-stations"> <div class="hc-stations">
<svg class="hc-stations-label" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10 9h4"/><path d="M12 7v5"/><path d="M14 22v-4a2 2 0 0 0-4 0v4"/><path d="M18 22V5l-6-3-6 3v17"/><path d="M4 10.5V22"/><path d="M20 10.5V22"/><path d="M22 22H2"/></svg> <svg class="hc-stations-label" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10 9h4"/><path d="M12 7v5"/><path d="M14 22v-4a2 2 0 0 0-4 0v4"/><path d="M18 22V5l-6-3-6 3v17"/><path d="M4 10.5V22"/><path d="M20 10.5V22"/><path d="M22 22H2"/></svg>
<span class="hc-stations-text"> <span class="hc-stations-text">
<span class="hc-stations-title">{t1962('stationChurch', lang)}:</span> <span class="hc-stations-title">{t1962.stationChurch}:</span>
{#each day.rite1962.stationChurches as s, i (s.key + (s.mass ?? ''))} {#each day.rite1962.stationChurches as s, i (s.key + (s.mass ?? ''))}
{#if i > 0}<span class="hc-stations-sep"> · </span>{/if}<span class="hc-station-name">{s.name}</span>{#if s.mass}<span class="hc-station-mass"> ({s.mass.replace(/_/g, ' ')})</span>{/if} {#if i > 0}<span class="hc-stations-sep"> · </span>{/if}<span class="hc-station-name">{s.name}</span>{#if s.mass}<span class="hc-station-mass"> ({s.mass.replace(/_/g, ' ')})</span>{/if}
{/each} {/each}
@@ -4,18 +4,7 @@
import { page } from '$app/state'; import { page } from '$app/state';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { import { getMonthName, getWeekdayShort, rankEmphasis, dioceseLabel, DIOCESES_1962, DIOCESES_1969, DEFAULT_DIOCESE_1962, DEFAULT_DIOCESE_1969, type CalendarLang, m } from '../../../../calendarI18n';
getMonthName,
getWeekdayShort,
rankEmphasis,
t,
dioceseLabel,
DIOCESES_1962,
DIOCESES_1969,
DEFAULT_DIOCESE_1962,
DEFAULT_DIOCESE_1969,
type CalendarLang
} from '../../../../calendarI18n';
import { litBg, litInk, LIT_COLOR_VAR } from '../../../../calendarColors'; import { litBg, litInk, LIT_COLOR_VAR } from '../../../../calendarColors';
import RingView from './RingView.svelte'; import RingView from './RingView.svelte';
import HeroCard from '../../../../HeroCard.svelte'; import HeroCard from '../../../../HeroCard.svelte';
@@ -23,6 +12,7 @@
let { data }: { data: PageData } = $props(); let { data }: { data: PageData } = $props();
const lang = $derived(data.lang as CalendarLang); const lang = $derived(data.lang as CalendarLang);
const t = $derived(m[lang]);
const year = $derived(data.year); const year = $derived(data.year);
const liturgicalYear = $derived(data.liturgicalYear); const liturgicalYear = $derived(data.liturgicalYear);
@@ -67,7 +57,7 @@
const rite = $derived(data.rite); const rite = $derived(data.rite);
const wip = $derived(data.wip); const wip = $derived(data.wip);
const riteSubtitle = $derived(t(rite === 'vetus' ? 'rite1962Long' : 'rite1969Long', lang)); const riteSubtitle = $derived(t[rite === 'vetus' ? 'rite1962Long' : 'rite1969Long']);
function pad(n: number) { function pad(n: number) {
return String(n).padStart(2, '0'); return String(n).padStart(2, '0');
@@ -144,7 +134,7 @@
}) + dioceseQuery; }) + dioceseQuery;
}); });
const pageTitle = $derived(t('calendar', lang)); const pageTitle = $derived(t.calendar);
// When switching rites we drop ?diocese because the ID spaces differ (1962 has // When switching rites we drop ?diocese because the ID spaces differ (1962 has
// diocesan calendars, 1969 only "general" or "switzerland"). The server // diocesan calendars, 1969 only "general" or "switzerland"). The server
@@ -202,31 +192,31 @@
</a> </a>
</div> </div>
<label class="diocese-picker"> <label class="diocese-picker">
<span class="diocese-label">{t('calendarVariant', lang)}</span> <span class="diocese-label">{t.calendarVariant}</span>
<select value={diocese} onchange={onDioceseChange} aria-label={t('calendarVariant', lang)}> <select value={diocese} onchange={onDioceseChange} aria-label={t.calendarVariant}>
{#each dioceseOptions as d (d)} {#each dioceseOptions as d (d)}
<option value={d}>{dioceseLabel(d, lang)}</option> <option value={d}>{dioceseLabel(d, lang)}</option>
{/each} {/each}
</select> </select>
</label> </label>
{#if rite === 'novus'} {#if rite === 'novus'}
<p class="diocese-note">{t('rite1969SwissNote', lang)}</p> <p class="diocese-note">{t.rite1969SwissNote}</p>
{/if} {/if}
</header> </header>
{#if wip} {#if wip}
<section class="wip"> <section class="wip">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 8v4"/><path d="M12 16h.01"/><circle cx="12" cy="12" r="10"/></svg> <svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 8v4"/><path d="M12 16h.01"/><circle cx="12" cy="12" r="10"/></svg>
<h2>{t('wipTitle', lang)}</h2> <h2>{t.wipTitle}</h2>
<p>{t('wipBody', lang)}</p> <p>{t.wipBody}</p>
</section> </section>
{:else} {:else}
{#if rite === 'vetus'} {#if rite === 'vetus'}
<aside class="disclaimer" role="note"> <aside class="disclaimer" role="note">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 9v4"/><path d="M12 17h.01"/><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/></svg> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 9v4"/><path d="M12 17h.01"/><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/></svg>
<div> <div>
<strong>{t('rite1962DisclaimerTitle', lang)}</strong> <strong>{t.rite1962DisclaimerTitle}</strong>
<p>{t('rite1962DisclaimerBody', lang)}</p> <p>{t.rite1962DisclaimerBody}</p>
</div> </div>
</aside> </aside>
{/if} {/if}
@@ -241,7 +231,7 @@
<!-- Color legend + view switcher --> <!-- Color legend + view switcher -->
<div class="overview-controls"> <div class="overview-controls">
<div class="view-switcher" role="tablist" aria-label={t('calendar', lang)}> <div class="view-switcher" role="tablist" aria-label={t.calendar}>
<button <button
class:active={view === 'ring'} class:active={view === 'ring'}
role="tab" role="tab"
@@ -281,7 +271,7 @@
data-sveltekit-replacestate data-sveltekit-replacestate
> >
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
{t('jumpToToday', lang)} {t.jumpToToday}
</a> </a>
</div> </div>
</div> </div>
@@ -309,7 +299,7 @@
<a <a
class="nav-btn" class="nav-btn"
href={monthHref(prevMonth.y, prevMonth.m)} href={monthHref(prevMonth.y, prevMonth.m)}
aria-label={t('prev', lang)} aria-label={t.prev}
data-sveltekit-noscroll data-sveltekit-noscroll
> >
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="15 18 9 12 15 6"/></svg> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="15 18 9 12 15 6"/></svg>
@@ -321,7 +311,7 @@
<a <a
class="nav-btn" class="nav-btn"
href={monthHref(nextMonth.y, nextMonth.m)} href={monthHref(nextMonth.y, nextMonth.m)}
aria-label={t('next', lang)} aria-label={t.next}
data-sveltekit-noscroll data-sveltekit-noscroll
> >
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="9 18 15 12 9 6"/></svg> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="9 18 15 12 9 6"/></svg>
@@ -3,19 +3,14 @@
import type { PageData } from './$types'; import type { PageData } from './$types';
import { page } from '$app/state'; import { page } from '$app/state';
import { browser } from '$app/environment'; import { browser } from '$app/environment';
import { import { formatLongDate, getMonthName, properLabel, type CalendarLang, m, m1962 } from '../../../../../calendarI18n';
formatLongDate,
getMonthName,
properLabel,
t,
t1962,
type CalendarLang
} from '../../../../../calendarI18n';
import HeroCard from '../../../../../HeroCard.svelte'; import HeroCard from '../../../../../HeroCard.svelte';
let { data }: { data: PageData } = $props(); let { data }: { data: PageData } = $props();
const lang = $derived(data.lang as CalendarLang); const lang = $derived(data.lang as CalendarLang);
const t1962 = $derived(m1962[lang]);
const t = $derived(m[lang]);
const rite = $derived(data.rite); const rite = $derived(data.rite);
const day = $derived(data.day1); const day = $derived(data.day1);
const year = $derived(data.year); const year = $derived(data.year);
@@ -97,10 +92,10 @@
<span>{monthTitle}</span> <span>{monthTitle}</span>
</a> </a>
<div class="day-nav"> <div class="day-nav">
<a class="nav-btn" href={prevHref} aria-label={t('prev', lang)} data-sveltekit-noscroll> <a class="nav-btn" href={prevHref} aria-label={t.prev} data-sveltekit-noscroll>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="15 18 9 12 15 6"/></svg> <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="15 18 9 12 15 6"/></svg>
</a> </a>
<a class="nav-btn" href={nextHref} aria-label={t('next', lang)} data-sveltekit-noscroll> <a class="nav-btn" href={nextHref} aria-label={t.next} data-sveltekit-noscroll>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="9 18 15 12 9 6"/></svg> <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="9 18 15 12 9 6"/></svg>
</a> </a>
</div> </div>
@@ -114,19 +109,19 @@
<dl class="detail-extras"> <dl class="detail-extras">
{#if d.vigilOf} {#if d.vigilOf}
<div> <div>
<dt>{t1962('vigilOf', lang)}</dt> <dt>{t1962['vigilOf']}</dt>
<dd>{d.vigilOf}</dd> <dd>{d.vigilOf}</dd>
</div> </div>
{/if} {/if}
{#if d.octave} {#if d.octave}
<div> <div>
<dt>{t1962('octave', lang)}</dt> <dt>{t1962.octave}</dt>
<dd>{d.octave.ofId} · {t1962('octaveDay', lang)} {d.octave.day}</dd> <dd>{d.octave.ofId} · {t1962['octaveDay']} {d.octave.day}</dd>
</div> </div>
{/if} {/if}
{#if d.transferredFrom} {#if d.transferredFrom}
<div> <div>
<dt>{t1962('transferredFrom', lang)}</dt> <dt>{t1962['transferredFrom']}</dt>
<dd>{d.transferredFrom}</dd> <dd>{d.transferredFrom}</dd>
</div> </div>
{/if} {/if}
@@ -135,9 +130,9 @@
{#if d.propers.length} {#if d.propers.length}
<section class="propers"> <section class="propers">
<div class="propers-head"> <div class="propers-head">
<h4>{t1962('propers', lang)}</h4> <h4>{t1962.propers}</h4>
{#if lang !== 'la'} {#if lang !== 'la'}
<div class="view-toggle" role="group" aria-label={t1962('propers', lang)}> <div class="view-toggle" role="group" aria-label={t1962.propers}>
<button <button
type="button" type="button"
class="view-btn" class="view-btn"
@@ -145,7 +140,7 @@
aria-pressed={propersView === 'la'} aria-pressed={propersView === 'la'}
onclick={() => (propersView = 'la')} onclick={() => (propersView = 'la')}
> >
{t1962('viewLatin', lang)} {t1962['viewLatin']}
</button> </button>
<button <button
type="button" type="button"
@@ -154,7 +149,7 @@
aria-pressed={propersView === 'parallel'} aria-pressed={propersView === 'parallel'}
onclick={() => (propersView = 'parallel')} onclick={() => (propersView = 'parallel')}
> >
{t1962('viewParallel', lang)} {t1962['viewParallel']}
</button> </button>
<button <button
type="button" type="button"
@@ -163,7 +158,7 @@
aria-pressed={propersView === 'local'} aria-pressed={propersView === 'local'}
onclick={() => (propersView = 'local')} onclick={() => (propersView = 'local')}
> >
{t1962('viewVernacular', lang)} {t1962['viewVernacular']}
</button> </button>
</div> </div>
{/if} {/if}
@@ -177,9 +172,9 @@
<span class="proper-ref">({section.refLabel})</span> <span class="proper-ref">({section.refLabel})</span>
{/if} {/if}
{#if section.localFromBible && lang !== 'la' && propersView !== 'la'} {#if section.localFromBible && lang !== 'la' && propersView !== 'la'}
<span class="proper-fallback" title={t1962('fallbackHint', lang)}> <span class="proper-fallback" title={t1962['fallbackHint']}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 9v4"/><path d="M12 17h.01"/><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0Z"/></svg> <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 9v4"/><path d="M12 17h.01"/><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0Z"/></svg>
{t1962('fallbackBadge', lang)} {t1962['fallbackBadge']}
</span> </span>
{/if} {/if}
</header> </header>
@@ -91,58 +91,22 @@ export function humanizeSundayCycle(raw: string | null): string | null {
return m ? m[1] : raw; return m ? m[1] : raw;
} }
export const ui = { import { de as ui_de } from '$lib/i18n/calendar/de';
today: { en: 'Today', de: 'Heute', la: 'Hodie' }, import { en as ui_en } from '$lib/i18n/calendar/en';
calendar: { en: 'Liturgical Calendar', de: 'Liturgischer Kalender', la: 'Calendarium Liturgicum' }, import { la as ui_la } from '$lib/i18n/calendar/la';
jumpToToday: { en: 'Jump to today', de: 'Zu heute', la: 'Ad hodiernum' },
prev: { en: 'Previous month', de: 'Vorheriger Monat', la: 'Mensis praecedens' },
next: { en: 'Next month', de: 'Nächster Monat', la: 'Mensis sequens' },
psalterWeek: { en: 'Psalter week', de: 'Psalterwoche', la: 'Hebdomada psalterii' },
cycle: { en: 'Sunday cycle', de: 'Lesejahr', la: 'Cyclus dominicalis' },
rite1969Long: {
en: 'Roman Missal of 1969 (Ordinary Form)',
de: 'Römisches Messbuch 1969 (Ordentliche Form)',
la: 'Missale Romanum 1969 (Forma Ordinaria)'
},
rite1962Long: {
en: 'Roman Missal of 1962 (Extraordinary Form)',
de: 'Römisches Messbuch 1962 (Ausserordentliche Form)',
la: 'Missale Romanum 1962 (Forma Extraordinaria)'
},
wipTitle: {
en: 'Work in progress',
de: 'In Arbeit',
la: 'In opere'
},
wipBody: {
en: 'The calendar for the older rite (1962 Missal) is not yet available. Stay tuned.',
de: 'Der Kalender für den alten Ritus (Messbuch 1962) ist noch nicht verfügbar. Bleib dran.',
la: 'Calendarium ritus antiqui (Missale 1962) nondum paratum est. Exspecta paulisper.'
},
rite1962DisclaimerTitle: {
en: 'Accuracy still being verified',
de: 'Genauigkeit wird noch geprüft',
la: 'Accuratio adhuc probanda'
},
rite1962DisclaimerBody: {
en: '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.',
de: '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.',
la: '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: {
en: 'Calendar',
de: 'Kalender',
la: 'Calendarium'
},
rite1969SwissNote: {
en: 'romcal ships only a national Swiss calendar for 1969 — diocesan sub-calendars are not available for this rite.',
de: 'romcal liefert für 1969 nur einen nationalen Schweizer Kalender — diözesane Unterkalender sind für diesen Ritus nicht verfügbar.',
la: 'Pro anno 1969 romcal solum calendarium Helvetiae nationale praebet — calendaria dioecesana propria pro hoc ritu non adsunt.'
}
};
export function t(key: keyof typeof ui, lang: CalendarLang): string { /** Calendar UI translations keyed by locale. de.ts is the source of truth. */
return ui[key][lang] ?? ui[key].en; export const m = { de: ui_de, en: ui_en, la: ui_la } as const;
export type CalendarKey = keyof typeof ui_de;
/**
* Get a translated UI string. Prefer `m[lang].key` directly in new code
* this helper is kept for the existing call sites and falls back to English
* if the requested locale is missing.
*/
export function t(key: CalendarKey, lang: CalendarLang): string {
return m[lang][key] ?? m.en[key];
} }
export type Rite = 'novus' | 'vetus'; export type Rite = 'novus' | 'vetus';
@@ -280,29 +244,21 @@ export function colorLabel1962(colorKey: string, lang: CalendarLang): string {
return COLOR_LABEL_1962[colorKey]?.[lang] ?? colorKey; return COLOR_LABEL_1962[colorKey]?.[lang] ?? colorKey;
} }
export const ui1962 = { import { de as ui1962_de } from '$lib/i18n/calendar/de_1962';
commemorations: { en: 'Commemorations', de: 'Kommemorationen', la: 'Commemorationes' }, import { en as ui1962_en } from '$lib/i18n/calendar/en_1962';
octave: { en: 'Octave', de: 'Oktav', la: 'Octava' }, import { la as ui1962_la } from '$lib/i18n/calendar/la_1962';
octaveDay: { en: 'day', de: 'Tag', la: 'dies' },
vigilOf: { en: 'Vigil of', de: 'Vigil von', la: 'Vigilia' },
transferredFrom: { en: 'Transferred from', de: 'Übertragen von', la: 'Translatum ex' },
source: { en: 'Source', de: 'Quelle', la: 'Fons' },
propers: { en: 'Mass propers', de: 'Messproprium', la: 'Propria Missæ' },
stationChurch: { en: 'Station church', de: 'Stationskirche', la: 'Statio' },
viewLatin: { en: 'Latin', de: 'Latein', la: 'Latine' },
viewParallel: { en: 'Parallel', de: 'Parallel', la: 'Parallelum' },
viewVernacular: { en: 'English', de: 'Deutsch', la: 'Vernacula' },
fallbackBadge: { en: 'Douay-Rheims', de: 'Allioli', la: 'Vulgata' },
fallbackHint: {
en: 'Translation not provided in the missal. Text taken from the Douay-Rheims Bible at the cited reference.',
de: 'Keine Übersetzung im Messbuch vorhanden. Text aus der Allioli-Bibelübersetzung an der angegebenen Stelle.',
la: 'Interpretatio localis deest. Textus ex Biblia Sacra locis citatis.'
}
} as const;
export function t1962(key: keyof typeof ui1962, lang: CalendarLang): string { /** 1962-rite-only UI translations keyed by locale. de_1962.ts is the source of truth. */
const entry = ui1962[key] as Record<string, string | undefined>; export const m1962 = { de: ui1962_de, en: ui1962_en, la: ui1962_la } as const;
return entry[lang] ?? entry.en ?? '';
export type Calendar1962Key = keyof typeof ui1962_de;
/**
* Get a translated 1962-rite UI string. Prefer `m1962[lang].key` directly
* in new code this helper is kept for the existing call sites.
*/
export function t1962(key: Calendar1962Key, lang: CalendarLang): string {
return m1962[lang][key] ?? m1962.en[key] ?? '';
} }
const PROPER_LABEL: Record<string, Record<CalendarLang, string>> = { const PROPER_LABEL: Record<string, Record<CalendarLang, string>> = {