refactor(fitness): adopt t.key / t[expr] syntax across fitness pages
22 files migrated from t('key', lang) function calls to direct lookups
on a derived dictionary alias: const t = $derived(m[lang]) once per
file, then t.start_period or t[card.labelKey] at the call sites.
Cleaner read at the point of use, one less argument threaded through,
and TypeScript narrows on every key access (so a typo in a literal
key now errors at the call site, not silently falls back to the key
string).
The codemod handles both ways `lang` can enter scope — derived from
the URL via detectFitnessLang, or destructured from $props() (single
or multi-line). One file aliased the i18n table to `messages` to
avoid collision with a local `const m = data.measurement`.
The deprecated `t(key, lang)` function still exists in fitnessI18n.ts
for any remaining out-of-tree call sites — can be deleted once
nothing imports it.
This commit is contained in:
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "homepage",
|
||||
"version": "1.54.0",
|
||||
"version": "1.54.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* 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`);
|
||||
@@ -10,9 +10,10 @@
|
||||
import Shapes from '@lucide/svelte/icons/shapes';
|
||||
import Weight from '@lucide/svelte/icons/weight';
|
||||
import { page } from '$app/state';
|
||||
import { detectFitnessLang, t } from '$lib/js/fitnessI18n';
|
||||
import { detectFitnessLang, m } from '$lib/js/fitnessI18n';
|
||||
|
||||
const lang = $derived(detectFitnessLang(page.url.pathname));
|
||||
const t = $derived(m[lang]);
|
||||
const isEn = $derived(lang === 'en');
|
||||
|
||||
/**
|
||||
@@ -81,7 +82,7 @@
|
||||
<div class="picker-backdrop" onclick={onClose}></div>
|
||||
<div class="picker-panel">
|
||||
<div class="picker-header">
|
||||
<h2>{t('picker_title', lang)}</h2>
|
||||
<h2>{t.picker_title}</h2>
|
||||
<button class="close-btn" onclick={onClose} aria-label="Close">
|
||||
<X size={20} />
|
||||
</button>
|
||||
@@ -91,7 +92,7 @@
|
||||
<Search size={16} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('search_exercises', lang)}
|
||||
placeholder={t.search_exercises}
|
||||
bind:value={query}
|
||||
/>
|
||||
</div>
|
||||
@@ -154,7 +155,7 @@
|
||||
</li>
|
||||
{/each}
|
||||
{#if filtered.length === 0}
|
||||
<li class="no-results">{t('no_exercises_found', lang)}</li>
|
||||
<li class="no-results">{t.no_exercises_found}</li>
|
||||
{/if}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
import ExternalLink from '@lucide/svelte/icons/external-link';
|
||||
import ScanBarcode from '@lucide/svelte/icons/scan-barcode';
|
||||
import X from '@lucide/svelte/icons/x';
|
||||
import { detectFitnessLang, fitnessSlugs, t } from '$lib/js/fitnessI18n';
|
||||
import { detectFitnessLang, fitnessSlugs, m } from '$lib/js/fitnessI18n';
|
||||
import MacroBreakdown from './MacroBreakdown.svelte';
|
||||
|
||||
/**
|
||||
@@ -51,9 +51,10 @@
|
||||
} = $props();
|
||||
|
||||
const lang = $derived(detectFitnessLang(page.url.pathname));
|
||||
const t = $derived(m[lang]);
|
||||
const s = $derived(fitnessSlugs(lang));
|
||||
const isEn = $derived(lang === 'en');
|
||||
const btnLabel = $derived(confirmLabel ?? t('log_food', lang));
|
||||
const btnLabel = $derived(confirmLabel ?? t.log_food);
|
||||
|
||||
// --- Search state ---
|
||||
let query = $state('');
|
||||
@@ -434,7 +435,7 @@
|
||||
<input
|
||||
type="text"
|
||||
class="fs-search-input"
|
||||
placeholder={t('search_food', lang)}
|
||||
placeholder={t.search_food}
|
||||
bind:value={query}
|
||||
oninput={doSearch}
|
||||
autofocus={autofocus}
|
||||
@@ -455,7 +456,7 @@
|
||||
<p class="fs-scan-error">{scanError}</p>
|
||||
{/if}
|
||||
{#if loading}
|
||||
<p class="fs-status">{t('loading', lang)}</p>
|
||||
<p class="fs-status">{t.loading}</p>
|
||||
{/if}
|
||||
{#if displayResults.length > 0}
|
||||
<div class="fs-results">
|
||||
@@ -487,7 +488,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
{#if oncancel}
|
||||
<button class="fs-btn-cancel" onclick={oncancel}>{t('cancel', lang)}</button>
|
||||
<button class="fs-btn-cancel" onclick={oncancel}>{t.cancel}</button>
|
||||
{/if}
|
||||
{:else}
|
||||
<!-- Selected food — detail & amount -->
|
||||
@@ -546,7 +547,7 @@
|
||||
{/if}
|
||||
|
||||
<div class="fs-actions">
|
||||
<button class="fs-btn-cancel" onclick={() => { selected = null; }}>{t('cancel', lang)}</button>
|
||||
<button class="fs-btn-cancel" onclick={() => { selected = null; }}>{t.cancel}</button>
|
||||
<button class="fs-btn-confirm" onclick={confirm}>{btnLabel}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import Sun from '@lucide/svelte/icons/sun';
|
||||
import Moon from '@lucide/svelte/icons/moon';
|
||||
import Cookie from '@lucide/svelte/icons/cookie';
|
||||
import { t } from '$lib/js/fitnessI18n';
|
||||
import { m } from '$lib/js/fitnessI18n';
|
||||
|
||||
/** @type {{ value?: 'breakfast' | 'lunch' | 'dinner' | 'snack', lang?: 'en' | 'de', onchange?: (meal: 'breakfast' | 'lunch' | 'dinner' | 'snack') => void }} */
|
||||
let {
|
||||
@@ -11,6 +11,7 @@
|
||||
lang = 'de',
|
||||
onchange = () => {},
|
||||
} = $props();
|
||||
const t = $derived(m[lang]);
|
||||
|
||||
/** @type {Array<'breakfast' | 'lunch' | 'dinner' | 'snack'>} */
|
||||
const mealTypes = ['breakfast', 'lunch', 'dinner', 'snack'];
|
||||
@@ -32,7 +33,7 @@
|
||||
class:active={value === meal}
|
||||
style="--mc: {meta.color}"
|
||||
onclick={() => { onchange(meal); }}
|
||||
title={t(meal, lang)}
|
||||
title={t[meal]}
|
||||
>
|
||||
<MealIcon size={14} />
|
||||
</button>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script>
|
||||
import { t } from '$lib/js/fitnessI18n';
|
||||
import { m } from '$lib/js/fitnessI18n';
|
||||
import Trash2 from '@lucide/svelte/icons/trash-2';
|
||||
import Plus from '@lucide/svelte/icons/plus';
|
||||
import Pencil from '@lucide/svelte/icons/pencil';
|
||||
@@ -17,6 +17,7 @@
|
||||
* @type {{ periods: any[], lang: 'en' | 'de', sharedWith?: string[], readOnly?: boolean, ownerName?: string, mode?: 'entry' | 'projection' | 'full' }}
|
||||
*/
|
||||
let { periods: initialPeriods = [], lang = 'en', sharedWith: initialSharedWith = [], readOnly = false, ownerName = '', mode = 'full' } = $props();
|
||||
const t = $derived(m[lang]);
|
||||
const showEntry = $derived(mode !== 'projection');
|
||||
const showProjection = $derived(mode !== 'entry');
|
||||
|
||||
@@ -116,7 +117,7 @@
|
||||
const startDay = start.toLocaleDateString(locale, { weekday: 'long' });
|
||||
const endDay = end.toLocaleDateString(locale, { weekday: 'long' });
|
||||
const diffDays = Math.round((midnight(start) - todayMidnight) / 86400000);
|
||||
const toWord = t('to', lang);
|
||||
const toWord = t.to;
|
||||
|
||||
if (diffDays >= 0 && diffDays < 7) {
|
||||
return lang === 'de'
|
||||
@@ -653,7 +654,7 @@
|
||||
|
||||
/** @param {string} id */
|
||||
async function deletePeriod(id) {
|
||||
if (!await confirm(t('delete_period_confirm', lang))) return;
|
||||
if (!await confirm(t.delete_period_confirm)) return;
|
||||
try {
|
||||
const res = await fetch(`/api/fitness/period/${id}`, { method: 'DELETE' });
|
||||
if (res.ok) {
|
||||
@@ -709,7 +710,7 @@
|
||||
{ownerName}
|
||||
</span>
|
||||
{:else}
|
||||
{t('period_tracker', lang)}
|
||||
{t.period_tracker}
|
||||
{/if}
|
||||
</h2>
|
||||
|
||||
@@ -718,29 +719,29 @@
|
||||
{#if ongoing}
|
||||
<div class="status-split">
|
||||
<div class="status-main">
|
||||
<span class="status-pill period-pill">{t('current_period', lang)}</span>
|
||||
<span class="status-hero ongoing-hero">{t('period_day', lang)} {ongoingDay}</span>
|
||||
<span class="status-pill period-pill">{t.current_period}</span>
|
||||
<span class="status-hero ongoing-hero">{t.period_day} {ongoingDay}</span>
|
||||
{#if showProjection && predictions.predictedEndOfOngoing}
|
||||
<span class="status-detail">{t('predicted_end', lang)}</span>
|
||||
<span class="status-detail">{t.predicted_end}</span>
|
||||
<span class="status-relative">{relativeDate(predictions.predictedEndOfOngoing)}</span>
|
||||
<span class="status-date">{formatDate(predictions.predictedEndOfOngoing)}</span>
|
||||
{/if}
|
||||
{#if showEntry && !readOnly}
|
||||
<button class="end-btn" onclick={endPeriod} disabled={loading}>
|
||||
<span class="end-btn-icon"><Check size={18} strokeWidth={2.5} /></span>
|
||||
<span class="end-btn-label">{t('end_period', lang)}</span>
|
||||
<span class="end-btn-label">{t.end_period}</span>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if showProjection && nextCycle}
|
||||
<div class="status-side">
|
||||
<div class="status-side-item ovulation-accent">
|
||||
<span class="status-side-label">{t('ovulation', lang)}</span>
|
||||
<span class="status-side-label">{t.ovulation}</span>
|
||||
<span class="status-side-relative">{relativeDate(nextCycle.fertileEnd)}</span>
|
||||
<span class="status-side-date">{formatDate(nextCycle.fertileEnd)}</span>
|
||||
</div>
|
||||
<div class="status-side-item fertile-accent">
|
||||
<span class="status-side-label">{t('fertile', lang)}</span>
|
||||
<span class="status-side-label">{t.fertile}</span>
|
||||
<span class="status-side-date">{formatDate(nextCycle.fertileStart)} — {formatDate(nextCycle.fertileEnd)}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -749,33 +750,33 @@
|
||||
{:else if showProjection && nextCycle}
|
||||
<div class="status-split">
|
||||
<div class="status-main">
|
||||
<span class="status-pill period-pill">{t('next_period', lang)}</span>
|
||||
<span class="status-pill period-pill">{t.next_period}</span>
|
||||
<span class="status-hero">{relativeRange(nextCycle.start, nextCycle.end)}</span>
|
||||
<span class="status-date">{formatDate(nextCycle.start)} — {formatDate(nextCycle.end)}</span>
|
||||
{#if showEntry && !readOnly}
|
||||
<button class="start-btn" onclick={startPeriod} disabled={loading}>
|
||||
{t('start_period', lang)}
|
||||
{t.start_period}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="status-side">
|
||||
<div class="status-side-item ovulation-accent">
|
||||
<span class="status-side-label">{t('ovulation', lang)}</span>
|
||||
<span class="status-side-label">{t.ovulation}</span>
|
||||
<span class="status-side-relative">{relativeDate(nextCycle.fertileEnd)}</span>
|
||||
<span class="status-side-date">{formatDate(nextCycle.fertileEnd)}</span>
|
||||
</div>
|
||||
<div class="status-side-item fertile-accent">
|
||||
<span class="status-side-label">{t('fertile', lang)}</span>
|
||||
<span class="status-side-label">{t.fertile}</span>
|
||||
<span class="status-side-date">{formatDate(nextCycle.fertileStart)} — {formatDate(nextCycle.fertileEnd)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else if showEntry}
|
||||
<div class="status-block">
|
||||
<span class="status-empty">{sorted.length === 0 ? t('no_period_data', lang) : t('no_active_period', lang)}</span>
|
||||
<span class="status-empty">{sorted.length === 0 ? t.no_period_data : t.no_active_period}</span>
|
||||
{#if !readOnly}
|
||||
<button class="start-btn" onclick={startPeriod} disabled={loading}>
|
||||
{t('start_period', lang)}
|
||||
{t.start_period}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -817,10 +818,10 @@
|
||||
<div class="cal-legend">
|
||||
<span class="legend-item"><span class="legend-dot period"></span> {lang === 'de' ? 'Periode' : 'Period'}</span>
|
||||
<span class="legend-item"><span class="legend-dot predicted"></span> {lang === 'de' ? 'Prognose' : 'Predicted'}</span>
|
||||
<span class="legend-item"><span class="legend-dot fertile"></span> {t('fertile', lang)}</span>
|
||||
<span class="legend-item"><span class="legend-dot peak-fertile"></span> {t('peak_fertility', lang)}</span>
|
||||
<span class="legend-item"><span class="legend-dot ovulation"></span> {t('ovulation', lang)}</span>
|
||||
<span class="legend-item"><span class="legend-dot luteal"></span> {t('luteal_phase', lang)}</span>
|
||||
<span class="legend-item"><span class="legend-dot fertile"></span> {t.fertile}</span>
|
||||
<span class="legend-item"><span class="legend-dot peak-fertile"></span> {t.peak_fertility}</span>
|
||||
<span class="legend-item"><span class="legend-dot ovulation"></span> {t.ovulation}</span>
|
||||
<span class="legend-item"><span class="legend-dot luteal"></span> {t.luteal_phase}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -829,17 +830,17 @@
|
||||
{#if showProjection && completed.length >= 2}
|
||||
<div class="cycle-stats">
|
||||
<div class="cycle-stat">
|
||||
<span class="cycle-stat-label">{t('cycle_length', lang)}</span>
|
||||
<span class="cycle-stat-value">{Math.round(predictions.avgCycle)} {t('days', lang)}</span>
|
||||
<span class="cycle-stat-label">{t.cycle_length}</span>
|
||||
<span class="cycle-stat-value">{Math.round(predictions.avgCycle)} {t.days}</span>
|
||||
{#if predictions.cycleVariance > 0}
|
||||
<span class="cycle-stat-variance">± {predictions.cycleVariance} {t('days', lang)} (95% CI)</span>
|
||||
<span class="cycle-stat-variance">± {predictions.cycleVariance} {t.days} (95% CI)</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="cycle-stat">
|
||||
<span class="cycle-stat-label">{t('period_length', lang)}</span>
|
||||
<span class="cycle-stat-value">{Math.round(predictions.avgPeriod)} {t('days', lang)}</span>
|
||||
<span class="cycle-stat-label">{t.period_length}</span>
|
||||
<span class="cycle-stat-value">{Math.round(predictions.avgPeriod)} {t.days}</span>
|
||||
{#if predictions.periodVariance > 0}
|
||||
<span class="cycle-stat-variance">± {predictions.periodVariance} {t('days', lang)} (95% CI)</span>
|
||||
<span class="cycle-stat-variance">± {predictions.periodVariance} {t.days} (95% CI)</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -851,13 +852,13 @@
|
||||
<div class="history">
|
||||
<div class="history-share-row">
|
||||
<button class="history-toggle" onclick={() => showHistory = !showHistory}>
|
||||
<h3>{t('history', lang)}</h3>
|
||||
<h3>{t.history}</h3>
|
||||
<ChevronRight size={14} class={showHistory ? 'chevron open' : 'chevron'} />
|
||||
</button>
|
||||
<div class="share-bar">
|
||||
{#if shareList.length > 0}
|
||||
<div class="shared-avatars">
|
||||
<span class="shared-label">{t('shared_with', lang)}</span>
|
||||
<span class="shared-label">{t.shared_with}</span>
|
||||
{#each shareList as user}
|
||||
<div class="shared-avatar" title={user}>
|
||||
<ProfilePicture username={user} size={28} />
|
||||
@@ -865,7 +866,7 @@
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<button class="share-btn" onclick={() => showShare = true} aria-label={t('share', lang)}>
|
||||
<button class="share-btn" onclick={() => showShare = true} aria-label={t.share}>
|
||||
<UserPlus size={16} />
|
||||
</button>
|
||||
</div>
|
||||
@@ -875,7 +876,7 @@
|
||||
<div class="history-header">
|
||||
<button class="add-past-btn" onclick={() => showAddForm = !showAddForm}>
|
||||
<Plus size={14} />
|
||||
{t('add_past_period', lang)}
|
||||
{t.add_past_period}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -883,20 +884,20 @@
|
||||
<div class="add-form">
|
||||
<div class="add-row">
|
||||
<label>
|
||||
{t('period_start', lang)}
|
||||
{t.period_start}
|
||||
<DatePicker bind:value={addStart} max={todayStr} {lang} />
|
||||
</label>
|
||||
<label>
|
||||
{t('period_end', lang)}
|
||||
{t.period_end}
|
||||
<DatePicker bind:value={addEnd} min={addStart} max={todayStr} {lang} />
|
||||
</label>
|
||||
</div>
|
||||
<div class="add-actions">
|
||||
<button class="save-btn" onclick={addPastPeriod} disabled={!addStart || loading}>
|
||||
{t('save', lang)}
|
||||
{t.save}
|
||||
</button>
|
||||
<button class="cancel-btn" onclick={() => { showAddForm = false; addStart = ''; addEnd = ''; }}>
|
||||
{t('cancel', lang)}
|
||||
{t.cancel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -908,20 +909,20 @@
|
||||
<div class="history-item editing">
|
||||
<div class="add-row">
|
||||
<label>
|
||||
{t('period_start', lang)}
|
||||
{t.period_start}
|
||||
<DatePicker bind:value={editStart} {lang} />
|
||||
</label>
|
||||
<label>
|
||||
{t('period_end', lang)}
|
||||
{t.period_end}
|
||||
<DatePicker bind:value={editEnd} min={editStart} {lang} />
|
||||
</label>
|
||||
</div>
|
||||
<div class="add-actions">
|
||||
<button class="save-btn" onclick={saveEdit} disabled={!editStart || loading}>
|
||||
{t('save', lang)}
|
||||
{t.save}
|
||||
</button>
|
||||
<button class="cancel-btn" onclick={cancelEdit}>
|
||||
{t('cancel', lang)}
|
||||
{t.cancel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -933,12 +934,12 @@
|
||||
{#if p.endDate}
|
||||
— {formatDate(p.endDate)}
|
||||
{:else}
|
||||
<span class="ongoing-badge">{t('ongoing', lang)}</span>
|
||||
<span class="ongoing-badge">{t.ongoing}</span>
|
||||
{/if}
|
||||
</span>
|
||||
{#if p.endDate}
|
||||
{@const dur = Math.round((new Date(p.endDate).getTime() - new Date(p.startDate).getTime()) / 86400000) + 1}
|
||||
<span class="history-dur">{dur} {t('days', lang)}</span>
|
||||
<span class="history-dur">{dur} {t.days}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="history-actions">
|
||||
@@ -958,33 +959,33 @@
|
||||
{:else}
|
||||
<div class="empty-state">
|
||||
<div class="share-bar">
|
||||
<p>{t('no_period_data', lang)}</p>
|
||||
<button class="share-btn" onclick={() => showShare = true} aria-label={t('share', lang)}>
|
||||
<p>{t.no_period_data}</p>
|
||||
<button class="share-btn" onclick={() => showShare = true} aria-label={t.share}>
|
||||
<UserPlus size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<button class="add-past-btn" onclick={() => showAddForm = !showAddForm}>
|
||||
<Plus size={14} />
|
||||
{t('add_past_period', lang)}
|
||||
{t.add_past_period}
|
||||
</button>
|
||||
{#if showAddForm}
|
||||
<div class="add-form">
|
||||
<div class="add-row">
|
||||
<label>
|
||||
{t('period_start', lang)}
|
||||
{t.period_start}
|
||||
<DatePicker bind:value={addStart} max={todayStr} {lang} />
|
||||
</label>
|
||||
<label>
|
||||
{t('period_end', lang)}
|
||||
{t.period_end}
|
||||
<DatePicker bind:value={addEnd} min={addStart} max={todayStr} {lang} />
|
||||
</label>
|
||||
</div>
|
||||
<div class="add-actions">
|
||||
<button class="save-btn" onclick={addPastPeriod} disabled={!addStart || loading}>
|
||||
{t('save', lang)}
|
||||
{t.save}
|
||||
</button>
|
||||
<button class="cancel-btn" onclick={() => { showAddForm = false; addStart = ''; addEnd = ''; }}>
|
||||
{t('cancel', lang)}
|
||||
{t.cancel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1002,7 +1003,7 @@
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events -->
|
||||
<div class="share-modal" role="presentation" onclick={(e) => e.stopPropagation()}>
|
||||
<div class="share-modal-header">
|
||||
<h3>{t('share', lang)}</h3>
|
||||
<h3>{t.share}</h3>
|
||||
<button class="share-modal-close" onclick={() => showShare = false}>
|
||||
<X size={16} />
|
||||
</button>
|
||||
@@ -1020,13 +1021,13 @@
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<span class="share-empty">{t('no_shared', lang)}</span>
|
||||
<span class="share-empty">{t.no_shared}</span>
|
||||
{/if}
|
||||
<form class="share-add" onsubmit={(e) => { e.preventDefault(); addShareUser(); }}>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={shareInput}
|
||||
placeholder={t('add_user', lang)}
|
||||
placeholder={t.add_user}
|
||||
disabled={shareSaving}
|
||||
/>
|
||||
<button type="submit" class="share-add-btn" disabled={!shareInput.trim() || shareSaving}>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
import Wheat from '@lucide/svelte/icons/wheat';
|
||||
import { untrack } from 'svelte';
|
||||
import { toast } from '$lib/js/toast.svelte';
|
||||
import { t } from '$lib/js/fitnessI18n';
|
||||
import { m } from '$lib/js/fitnessI18n';
|
||||
import MealTypePicker from '$lib/components/fitness/MealTypePicker.svelte';
|
||||
/** @typedef {import('$lib/server/roundOffScoring').ComboSuggestion} ComboSuggestion */
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
initialSuggestions = null,
|
||||
onlogged = () => {},
|
||||
} = $props();
|
||||
const t = $derived(m[lang]);
|
||||
|
||||
const isEn = $derived(lang === 'en');
|
||||
|
||||
@@ -157,7 +158,7 @@
|
||||
<div class="round-off-header">
|
||||
<Sparkles size={16} />
|
||||
<h3>{isEn ? 'Round off this day' : 'Tag abrunden'}</h3>
|
||||
<span class="round-off-remaining">{Math.round(remainingKcal)} kcal {t('remaining', lang)}</span>
|
||||
<span class="round-off-remaining">{Math.round(remainingKcal)} kcal {t.remaining}</span>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
|
||||
@@ -6,9 +6,10 @@
|
||||
import { METRIC_LABELS } from '$lib/data/exercises';
|
||||
import RestTimer from './RestTimer.svelte';
|
||||
import { page } from '$app/state';
|
||||
import { detectFitnessLang, t } from '$lib/js/fitnessI18n';
|
||||
import { detectFitnessLang, m } from '$lib/js/fitnessI18n';
|
||||
|
||||
const lang = $derived(detectFitnessLang(page.url.pathname));
|
||||
const t = $derived(m[lang]);
|
||||
|
||||
/**
|
||||
* @type {{
|
||||
@@ -97,16 +98,16 @@
|
||||
{#if editable && onRemove}
|
||||
<th class="col-remove"></th>
|
||||
{/if}
|
||||
<th class="col-set">{t('set_header', lang)}</th>
|
||||
<th class="col-set">{t.set_header}</th>
|
||||
{#if previousSets}
|
||||
<th class="col-prev">{t('prev_header', lang)}</th>
|
||||
<th class="col-prev">{t.prev_header}</th>
|
||||
{/if}
|
||||
{#each mainMetrics as metric (metric)}
|
||||
<th class="col-metric">{timedHold && metric === 'duration' ? 'SEC' : METRIC_LABELS[metric]}</th>
|
||||
{/each}
|
||||
{#if editable && hasRpe}
|
||||
<th class="col-at"></th>
|
||||
<th class="col-rpe">{t('rpe', lang)}</th>
|
||||
<th class="col-rpe">{t.rpe}</th>
|
||||
{/if}
|
||||
{#if editable}
|
||||
<th class="col-check"></th>
|
||||
|
||||
@@ -3,9 +3,10 @@
|
||||
import EllipsisVertical from '@lucide/svelte/icons/ellipsis-vertical';
|
||||
import MapPin from '@lucide/svelte/icons/map-pin';
|
||||
import { page } from '$app/state';
|
||||
import { detectFitnessLang, t } from '$lib/js/fitnessI18n';
|
||||
import { detectFitnessLang, m } from '$lib/js/fitnessI18n';
|
||||
|
||||
const lang = $derived(detectFitnessLang(page.url.pathname));
|
||||
const t = $derived(m[lang]);
|
||||
|
||||
/**
|
||||
* @type {{
|
||||
@@ -23,8 +24,8 @@
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - d.getTime();
|
||||
const diffDays = Math.floor(diffMs / 86400000);
|
||||
if (diffDays === 0) return t('today', lang);
|
||||
if (diffDays === 1) return t('yesterday', lang);
|
||||
if (diffDays === 0) return t.today;
|
||||
if (diffDays === 1) return t.yesterday;
|
||||
if (diffDays < 7) return lang === 'en' ? `${diffDays} days ago` : `vor ${diffDays} Tagen`;
|
||||
return d.toLocaleDateString(lang === 'en' ? 'en' : 'de', { month: 'short', day: 'numeric' });
|
||||
}
|
||||
@@ -52,12 +53,12 @@
|
||||
<li>{ex.sets.length} × {exercise?.localName ?? ex.exerciseId}</li>
|
||||
{/each}
|
||||
{#if template.exercises.length > 4}
|
||||
<li class="more">+{template.exercises.length - 4} {t('more', lang)}</li>
|
||||
<li class="more">+{template.exercises.length - 4} {t.more}</li>
|
||||
{/if}
|
||||
</ul>
|
||||
{/if}
|
||||
{#if lastUsed}
|
||||
<p class="last-used">{t('last_performed', lang)} {formatDate(lastUsed)}</p>
|
||||
<p class="last-used">{t.last_performed} {formatDate(lastUsed)}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -5,9 +5,10 @@ import Pause from '@lucide/svelte/icons/pause';
|
||||
import ChevronRight from '@lucide/svelte/icons/chevron-right';
|
||||
import SyncIndicator from '$lib/components/fitness/SyncIndicator.svelte';
|
||||
import { page } from '$app/state';
|
||||
import { detectFitnessLang, t } from '$lib/js/fitnessI18n';
|
||||
import { detectFitnessLang, m } from '$lib/js/fitnessI18n';
|
||||
|
||||
const lang = $derived(detectFitnessLang(page.url.pathname));
|
||||
const t = $derived(m[lang]);
|
||||
|
||||
let { href, elapsed = '0:00', paused = false, syncStatus = 'idle', onPauseToggle,
|
||||
restSeconds = 0, restTotal = 0, onRestAdjust = null, onRestSkip = null } = $props();
|
||||
@@ -28,7 +29,7 @@ const restProgress = $derived(restTotal > 0 ? restSeconds / restTotal : 0);
|
||||
class:rest-active={restActive}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-label={t('active_workout', lang)}
|
||||
aria-label={t.active_workout}
|
||||
onclick={() => goto(href)}
|
||||
onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); goto(href); } }}
|
||||
>
|
||||
@@ -54,7 +55,7 @@ const restProgress = $derived(restTotal > 0 ? restSeconds / restTotal : 0);
|
||||
<button class="rest-adj" onclick={(e) => { e.stopPropagation(); onRestAdjust?.(30); }} aria-label="Add 30 seconds">+30s</button>
|
||||
</div>
|
||||
{:else}
|
||||
<span class="fab-label">{t('active_workout', lang)}</span>
|
||||
<span class="fab-label">{t.active_workout}</span>
|
||||
<ChevronRight size={14} strokeWidth={2.4} class="fab-chevron" />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
import Check from '@lucide/svelte/icons/check';
|
||||
import UserCog from '@lucide/svelte/icons/user-cog';
|
||||
import Sparkles from '@lucide/svelte/icons/sparkles';
|
||||
import { detectFitnessLang, t } from '$lib/js/fitnessI18n';
|
||||
import { detectFitnessLang, m } from '$lib/js/fitnessI18n';
|
||||
/** @typedef {import('$lib/js/fitnessI18n').FitnessKey} FitnessKey */
|
||||
import { toast } from '$lib/js/toast.svelte';
|
||||
import { confirm } from '$lib/js/confirmDialog.svelte';
|
||||
@@ -30,6 +30,7 @@
|
||||
);
|
||||
|
||||
const lang = $derived(detectFitnessLang(page.url.pathname));
|
||||
const t = $derived(m[lang]);
|
||||
const checkinSlug = $derived(lang === 'en' ? 'check-in' : 'erfassung');
|
||||
import { getWorkout } from '$lib/js/workout.svelte';
|
||||
import PeriodTracker from '$lib/components/fitness/PeriodTracker.svelte';
|
||||
@@ -107,19 +108,19 @@
|
||||
|
||||
const latestBp = $derived(latest.measurements?.value ?? {});
|
||||
const bodyPartFields = $derived([
|
||||
{ label: t('neck', lang), key: 'neck', value: latestBp.neck },
|
||||
{ label: t('shoulders', lang), key: 'shoulders', value: latestBp.shoulders },
|
||||
{ label: t('chest', lang), key: 'chest', value: latestBp.chest },
|
||||
{ label: t('l_bicep', lang), key: 'leftBicep', value: latestBp.leftBicep },
|
||||
{ label: t('r_bicep', lang), key: 'rightBicep', value: latestBp.rightBicep },
|
||||
{ label: t('l_forearm', lang), key: 'leftForearm', value: latestBp.leftForearm },
|
||||
{ label: t('r_forearm', lang), key: 'rightForearm', value: latestBp.rightForearm },
|
||||
{ label: t('waist', lang), key: 'waist', value: latestBp.waist },
|
||||
{ label: t('hips', lang), key: 'hips', value: latestBp.hips },
|
||||
{ label: t('l_thigh', lang), key: 'leftThigh', value: latestBp.leftThigh },
|
||||
{ label: t('r_thigh', lang), key: 'rightThigh', value: latestBp.rightThigh },
|
||||
{ label: t('l_calf', lang), key: 'leftCalf', value: latestBp.leftCalf },
|
||||
{ label: t('r_calf', lang), key: 'rightCalf', value: latestBp.rightCalf }
|
||||
{ label: t.neck, key: 'neck', value: latestBp.neck },
|
||||
{ label: t.shoulders, key: 'shoulders', value: latestBp.shoulders },
|
||||
{ label: t.chest, key: 'chest', value: latestBp.chest },
|
||||
{ label: t.l_bicep, key: 'leftBicep', value: latestBp.leftBicep },
|
||||
{ label: t.r_bicep, key: 'rightBicep', value: latestBp.rightBicep },
|
||||
{ label: t.l_forearm, key: 'leftForearm', value: latestBp.leftForearm },
|
||||
{ label: t.r_forearm, key: 'rightForearm', value: latestBp.rightForearm },
|
||||
{ label: t.waist, key: 'waist', value: latestBp.waist },
|
||||
{ label: t.hips, key: 'hips', value: latestBp.hips },
|
||||
{ label: t.l_thigh, key: 'leftThigh', value: latestBp.leftThigh },
|
||||
{ label: t.r_thigh, key: 'rightThigh', value: latestBp.rightThigh },
|
||||
{ label: t.l_calf, key: 'leftCalf', value: latestBp.leftCalf },
|
||||
{ label: t.r_calf, key: 'rightCalf', value: latestBp.rightCalf }
|
||||
]);
|
||||
|
||||
async function loadMore() {
|
||||
@@ -145,7 +146,7 @@
|
||||
|
||||
/** @param {string} id */
|
||||
async function deleteMeasurement(id) {
|
||||
if (!await confirm(t('delete_measurement_confirm', lang))) return;
|
||||
if (!await confirm(t.delete_measurement_confirm)) return;
|
||||
try {
|
||||
const res = await fetch(`/api/fitness/measurements/${id}`, { method: 'DELETE' });
|
||||
if (res.ok) {
|
||||
@@ -182,7 +183,7 @@
|
||||
const de = bpCount === 1 ? '1 Körperteil' : `${bpCount} Körperteile`;
|
||||
parts.push(lang === 'en' ? en : de);
|
||||
}
|
||||
return parts.join(' · ') || t('no_measurements_yet', lang);
|
||||
return parts.join(' · ') || t.no_measurements_yet;
|
||||
}
|
||||
|
||||
// --- New measurement form ---
|
||||
@@ -199,7 +200,7 @@
|
||||
const hasAnyBodyPart = $derived(bodyPartFields.some((f) => f.value != null));
|
||||
const latestBpDate = $derived(latest.measurements?.date ?? null);
|
||||
function formatLatestBp() {
|
||||
if (!latestBpDate) return t('no_measurements_yet', lang);
|
||||
if (!latestBpDate) return t.no_measurements_yet;
|
||||
const d = new Date(latestBpDate);
|
||||
const now = new Date();
|
||||
const diffDays = Math.floor((now.getTime() - d.getTime()) / 86400000);
|
||||
@@ -208,7 +209,7 @@
|
||||
if (diffDays < 30) rel = rtf.format(-diffDays, 'day');
|
||||
else if (diffDays < 365) rel = rtf.format(-Math.floor(diffDays / 30), 'month');
|
||||
else rel = rtf.format(-Math.floor(diffDays / 365), 'year');
|
||||
return `${t('last_measured', lang)} · ${rel}`;
|
||||
return `${t.last_measured} · ${rel}`;
|
||||
}
|
||||
|
||||
/** @typedef {{ key: string, filled: boolean } & ({ shape: 'dot', x: number, y: number } | { shape: 'band', x1: number, x2: number, y: number })} BpMarker */
|
||||
@@ -361,7 +362,7 @@
|
||||
else if (c.key === 'notes') { label = lang === 'en' ? 'Notes' : 'Notizen'; }
|
||||
else if (c.key.startsWith('measurements.')) {
|
||||
const part = c.key.slice('measurements.'.length);
|
||||
label = t(/** @type {FitnessKey} */ (partKeyMap[part] ?? part), lang);
|
||||
label = t[/** @type {FitnessKey} */ (partKeyMap[part] ?? part)];
|
||||
unit = ' cm';
|
||||
}
|
||||
return `${label} (${c.oldVal}${unit} → ${c.newVal}${unit})`;
|
||||
@@ -382,11 +383,11 @@
|
||||
if (res.status === 409) {
|
||||
const { conflicts } = await res.json();
|
||||
const ok = await confirm(
|
||||
t('overwrite_message', lang).replace('{fields}', formatConflicts(conflicts)),
|
||||
t.overwrite_message.replace('{fields}', formatConflicts(conflicts)),
|
||||
{
|
||||
title: t('overwrite_title', lang),
|
||||
confirmText: t('overwrite_confirm', lang),
|
||||
cancelText: t('cancel', lang),
|
||||
title: t.overwrite_title,
|
||||
confirmText: t.overwrite_confirm,
|
||||
cancelText: t.cancel,
|
||||
destructive: true
|
||||
}
|
||||
);
|
||||
@@ -420,16 +421,16 @@
|
||||
<svelte:head><title>{lang === 'en' ? 'Measure' : 'Messen'} - Bocken</title></svelte:head>
|
||||
|
||||
<div class="measure-page">
|
||||
<h1 class="sr-only">{t('measure_title', lang)}</h1>
|
||||
<h1 class="sr-only">{t.measure_title}</h1>
|
||||
|
||||
{#if showSetupBanner}
|
||||
<div class="setup-banner" role="status">
|
||||
<span class="setup-banner-icon" aria-hidden="true"><Sparkles size={18} /></span>
|
||||
<span class="setup-banner-text">{t('profile_setup_cta', lang)}</span>
|
||||
<span class="setup-banner-text">{t.profile_setup_cta}</span>
|
||||
<button type="button" class="setup-banner-cta" onclick={openProfileEdit}>
|
||||
{t('set_up_profile', lang)}
|
||||
{t.set_up_profile}
|
||||
</button>
|
||||
<button type="button" class="setup-banner-dismiss" onclick={() => bannerDismissed = true} aria-label={t('dismiss', lang)}>
|
||||
<button type="button" class="setup-banner-dismiss" onclick={() => bannerDismissed = true} aria-label={t.dismiss}>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
@@ -437,32 +438,32 @@
|
||||
|
||||
{#if profileEditing}
|
||||
<div class="profile-fields" bind:this={profileFormEl}>
|
||||
<button type="button" class="profile-close-btn" onclick={() => profileEditing = false} aria-label={t('cancel', lang)}>
|
||||
<button type="button" class="profile-close-btn" onclick={() => profileEditing = false} aria-label={t.cancel}>
|
||||
<X size={14} />
|
||||
</button>
|
||||
<div class="form-group">
|
||||
<!-- svelte-ignore a11y_label_has_associated_control -->
|
||||
<label>{t('sex', lang)}</label>
|
||||
<label>{t.sex}</label>
|
||||
<div class="sex-pills">
|
||||
<button class="sex-pill" class:active={profileSex === 'male'} onclick={() => profileSex = 'male'}>
|
||||
<Mars size={14} /> {t('male', lang)}
|
||||
<Mars size={14} /> {t.male}
|
||||
</button>
|
||||
<button class="sex-pill" class:active={profileSex === 'female'} onclick={() => profileSex = 'female'}>
|
||||
<Venus size={14} /> {t('female', lang)}
|
||||
<Venus size={14} /> {t.female}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="p-height">{t('height', lang)}</label>
|
||||
<label for="p-height">{t.height}</label>
|
||||
<input id="p-height" type="number" min="100" max="250" placeholder="175" bind:value={profileHeight} />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="p-birthyear">{t('birth_year', lang)}</label>
|
||||
<label for="p-birthyear">{t.birth_year}</label>
|
||||
<input id="p-birthyear" type="number" min="1900" max="2020" placeholder="1990" bind:value={profileBirthYear} />
|
||||
</div>
|
||||
{#if profileDirty}
|
||||
<button class="profile-save-btn" onclick={saveProfile} disabled={profileSaving}>
|
||||
{profileSaving ? t('saving', lang) : t('save', lang)}
|
||||
{profileSaving ? t.saving : t.save}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -472,7 +473,7 @@
|
||||
<div class="main-col">
|
||||
<button type="button" class="edit-profile-link top" onclick={openProfileEdit}>
|
||||
<UserCog size={14} />
|
||||
<span>{t('edit_profile', lang)}</span>
|
||||
<span>{t.edit_profile}</span>
|
||||
</button>
|
||||
<form class="add-form" onsubmit={(e) => { e.preventDefault(); saveMeasurement(); }}>
|
||||
<div class="date-row">
|
||||
@@ -573,19 +574,19 @@
|
||||
</svg>
|
||||
</div>
|
||||
<div class="bp-content">
|
||||
<span class="bp-eyebrow">{t('body_parts', lang)}</span>
|
||||
<span class="bp-title">{t('measure_body_parts', lang)}</span>
|
||||
<span class="bp-eyebrow">{t.body_parts}</span>
|
||||
<span class="bp-title">{t.measure_body_parts}</span>
|
||||
<span class="bp-meta">
|
||||
<span class="bp-count"><b>{filledCount}</b>/{totalParts}</span>
|
||||
<span class="bp-dot-sep">·</span>
|
||||
<span class="bp-sub">{hasAnyBodyPart ? formatLatestBp() : t('measure_body_parts_sub', lang)}</span>
|
||||
<span class="bp-sub">{hasAnyBodyPart ? formatLatestBp() : t.measure_body_parts_sub}</span>
|
||||
</span>
|
||||
</div>
|
||||
<ChevronRight size={18} class="bp-chevron" />
|
||||
</a>
|
||||
|
||||
{#if formDirty && !workout.active}
|
||||
<SaveFab disabled={saving} label={t('save_measurement', lang)} />
|
||||
<SaveFab disabled={saving} label={t.save_measurement} />
|
||||
{/if}
|
||||
</form>
|
||||
|
||||
@@ -599,7 +600,7 @@
|
||||
{#if measurements.length > 0}
|
||||
<section class="history-section">
|
||||
<button class="history-toggle" onclick={() => showWeightHistory = !showWeightHistory}>
|
||||
<h2>{t('past_measurements', lang)}</h2>
|
||||
<h2>{t.past_measurements}</h2>
|
||||
<ChevronRight size={14} class={showWeightHistory ? 'chevron open' : 'chevron'} />
|
||||
</button>
|
||||
<div class="history-list" class:collapsed={!showWeightHistory}>
|
||||
@@ -635,15 +636,15 @@
|
||||
<span class="edit-unit">%</span>
|
||||
</div>
|
||||
<div class="edit-actions">
|
||||
<a class="edit-more" href={resolve('/fitness/[checkin=fitnessCheckIn]/edit/[id]', { checkin: checkinSlug, id: m._id })} aria-label={t('edit_measurement', lang)}>
|
||||
<a class="edit-more" href={resolve('/fitness/[checkin=fitnessCheckIn]/edit/[id]', { checkin: checkinSlug, id: m._id })} aria-label={t.edit_measurement}>
|
||||
<Pencil size={11} />
|
||||
<span class="edit-more-label">{lang === 'en' ? 'Edit all fields' : 'Alle Felder bearbeiten'}</span>
|
||||
<ChevronRight size={11} />
|
||||
</a>
|
||||
<button type="button" class="edit-btn cancel" onclick={cancelEdit} aria-label={t('cancel', lang)}>
|
||||
<button type="button" class="edit-btn cancel" onclick={cancelEdit} aria-label={t.cancel}>
|
||||
<X size={14} />
|
||||
</button>
|
||||
<button type="button" class="edit-btn save" onclick={() => saveEdit(m)} disabled={editSaving} aria-label={t('save', lang)}>
|
||||
<button type="button" class="edit-btn save" onclick={() => saveEdit(m)} disabled={editSaving} aria-label={t.save}>
|
||||
<Check size={14} />
|
||||
</button>
|
||||
</div>
|
||||
@@ -669,7 +670,7 @@
|
||||
</div>
|
||||
{#if measurements.length < measurementsTotal}
|
||||
<button type="button" class="show-more" class:collapsed={!showWeightHistory} onclick={loadMore} disabled={loadingMore}>
|
||||
{loadingMore ? t('saving', lang) : t('show_more', lang)}
|
||||
{loadingMore ? t.saving : t.show_more}
|
||||
<span class="show-more-count">({measurements.length}/{measurementsTotal})</span>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
import History from '@lucide/svelte/icons/history';
|
||||
import { fly, fade } from 'svelte/transition';
|
||||
import { cubicOut } from 'svelte/easing';
|
||||
import { detectFitnessLang, t } from '$lib/js/fitnessI18n';
|
||||
import { detectFitnessLang, m } from '$lib/js/fitnessI18n';
|
||||
/** @typedef {import('$lib/js/fitnessI18n').FitnessKey} FitnessKey */
|
||||
import { toast } from '$lib/js/toast.svelte';
|
||||
import { confirm } from '$lib/js/confirmDialog.svelte';
|
||||
@@ -25,6 +25,7 @@
|
||||
let { data } = $props();
|
||||
|
||||
const lang = $derived(detectFitnessLang(page.url.pathname));
|
||||
const t = $derived(m[lang]);
|
||||
const checkinSlug = $derived(lang === 'en' ? 'check-in' : 'erfassung');
|
||||
|
||||
/** @typedef {{ key: FitnessKey, labelKey: FitnessKey, img: string | null, paired: boolean, tipKey: FitnessKey, dbSingle?: string, dbLeft?: string, dbRight?: string }} Step */
|
||||
@@ -44,8 +45,8 @@
|
||||
|
||||
/** @param {Step} s */
|
||||
function stepLabel(s) {
|
||||
if (!s.paired) return t(s.labelKey, lang);
|
||||
return t(s.key, lang);
|
||||
if (!s.paired) return t[s.labelKey];
|
||||
return t[s.key];
|
||||
}
|
||||
|
||||
/** Sorted-ascending clean list of past body-part records */
|
||||
@@ -208,7 +209,7 @@
|
||||
}
|
||||
}
|
||||
if (Object.keys(ms).length === 0) {
|
||||
toast.error(t('no_body_parts_selected', lang));
|
||||
toast.error(t.no_body_parts_selected);
|
||||
return;
|
||||
}
|
||||
saving = true;
|
||||
@@ -233,16 +234,16 @@
|
||||
/** @param {{ key: string, oldVal: unknown, newVal: unknown }} c */
|
||||
const fmtConflict = (c) => {
|
||||
const part = c.key.startsWith('measurements.') ? c.key.slice('measurements.'.length) : c.key;
|
||||
const label = t(/** @type {FitnessKey} */ (partKeyMap[part] ?? part), lang);
|
||||
const label = t[/** @type {FitnessKey} */ (partKeyMap[part] ?? part)];
|
||||
return `${label} (${c.oldVal} cm → ${c.newVal} cm)`;
|
||||
};
|
||||
const fields = conflicts.map(fmtConflict).join(', ');
|
||||
const ok = await confirm(
|
||||
t('overwrite_message', lang).replace('{fields}', fields),
|
||||
t.overwrite_message.replace('{fields}', fields),
|
||||
{
|
||||
title: t('overwrite_title', lang),
|
||||
confirmText: t('overwrite_confirm', lang),
|
||||
cancelText: t('cancel', lang),
|
||||
title: t.overwrite_title,
|
||||
confirmText: t.overwrite_confirm,
|
||||
cancelText: t.cancel,
|
||||
destructive: true
|
||||
}
|
||||
);
|
||||
@@ -368,12 +369,12 @@
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={onkey} />
|
||||
<svelte:head><title>{t('body_parts', lang)} - Bocken</title></svelte:head>
|
||||
<svelte:head><title>{t.body_parts} - Bocken</title></svelte:head>
|
||||
|
||||
<div class="shell" class:is-done={done} data-fitness-fullbleed>
|
||||
<aside class="rail" aria-label={t('body_parts', lang)}>
|
||||
<aside class="rail" aria-label={t.body_parts}>
|
||||
<div class="rail-header">
|
||||
<span class="rail-eyebrow">{t('body_parts', lang)}</span>
|
||||
<span class="rail-eyebrow">{t.body_parts}</span>
|
||||
<span class="rail-count">{steps.filter(isFilled).length}/{total}</span>
|
||||
</div>
|
||||
<ol class="rail-list">
|
||||
@@ -403,7 +404,7 @@
|
||||
onclick={() => { direction = 1; idx = total; }}
|
||||
>
|
||||
<span class="rail-dot" aria-hidden="true"><Check size={11} strokeWidth={3} /></span>
|
||||
<span class="rail-label">{t('review_save', lang)}</span>
|
||||
<span class="rail-label">{t.review_save}</span>
|
||||
<span class="rail-value"></span>
|
||||
</button>
|
||||
</li>
|
||||
@@ -416,7 +417,7 @@
|
||||
<span class="dot" class:active={i === idx && !done} class:past={i < idx || done}></span>
|
||||
{/each}
|
||||
</div>
|
||||
<button class="icon-x" aria-label={t('exit', lang)} onclick={exit}>
|
||||
<button class="icon-x" aria-label={t.exit} onclick={exit}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</header>
|
||||
@@ -442,9 +443,9 @@
|
||||
</div>
|
||||
|
||||
<div class="meta">
|
||||
<span class="eyebrow">{fmt(t('step_n_of_m', lang), { n: idx + 1, m: total })}</span>
|
||||
<span class="eyebrow">{fmt(t.step_n_of_m, { n: idx + 1, m: total })}</span>
|
||||
<h1 class="title">{stepLabel(step)}</h1>
|
||||
<p class="tip">{t(step.tipKey, lang)}</p>
|
||||
<p class="tip">{t[step.tipKey]}</p>
|
||||
</div>
|
||||
|
||||
{#if step.paired}
|
||||
@@ -488,20 +489,20 @@
|
||||
</div>
|
||||
<button type="button" class="copy-btn" onclick={() => copyLtoR(step.key)} disabled={!pv.left}>
|
||||
<CopyPlus size={15} />
|
||||
<span>{t('copy_l_to_r_before', lang)}</span>
|
||||
<span>{t.copy_l_to_r_before}</span>
|
||||
<ArrowRight size={14} />
|
||||
<span>{t('copy_l_to_r_after', lang)}</span>
|
||||
<span>{t.copy_l_to_r_after}</span>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{#if lastForStep?.left != null || lastForStep?.right != null}
|
||||
<button type="button" class="same-value-btn" onclick={() => { useLastValue(step.key); next(); }}>
|
||||
<History size={15} />
|
||||
<span>{t('same_as_last', lang)}</span>
|
||||
<span>{t.same_as_last}</span>
|
||||
</button>
|
||||
{/if}
|
||||
<div class="same-toggle">
|
||||
<Toggle bind:checked={pv.same} label={t('same_both_sides', lang)} />
|
||||
<Toggle bind:checked={pv.same} label={t.same_both_sides} />
|
||||
</div>
|
||||
{:else}
|
||||
<div class="stepper" onwheel={(e) => onWheel(e, step.key, null)}>
|
||||
@@ -519,7 +520,7 @@
|
||||
{#if lastForStep?.value != null}
|
||||
<button type="button" class="same-value-btn" onclick={() => { useLastValue(step.key); next(); }}>
|
||||
<History size={15} />
|
||||
<span>{t('same_as_last', lang)}</span>
|
||||
<span>{t.same_as_last}</span>
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
@@ -530,8 +531,8 @@
|
||||
<div class="check-halo">
|
||||
<Check size={42} strokeWidth={2.4} />
|
||||
</div>
|
||||
<h1 class="title">{t('ready_to_save', lang)}</h1>
|
||||
<p class="tip">{t('review_numbers', lang)}</p>
|
||||
<h1 class="title">{t.ready_to_save}</h1>
|
||||
<p class="tip">{t.review_numbers}</p>
|
||||
|
||||
<div class="date-row">
|
||||
<DatePicker bind:value={formDate} {lang} />
|
||||
@@ -548,7 +549,7 @@
|
||||
|
||||
<div class="summary-actions">
|
||||
<button type="button" class="ghost" onclick={() => { idx = 0; direction = -1; }}>
|
||||
<ArrowLeft size={14} /> {t('edit_again', lang)}
|
||||
<ArrowLeft size={14} /> {t.edit_again}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
@@ -561,15 +562,15 @@
|
||||
<div class="panel-section chart-section" in:fade={{ duration: 180 }}>
|
||||
<div class="panel-head">
|
||||
<TrendingUp size={14} />
|
||||
<h3 class="panel-title">{fmt(t('over_time', lang), { label: stepLabel(step) })}</h3>
|
||||
<h3 class="panel-title">{fmt(t.over_time, { label: stepLabel(step) })}</h3>
|
||||
</div>
|
||||
{#if chart.empty}
|
||||
<div class="chart-empty">
|
||||
<span class="chart-empty-dot"></span>
|
||||
{t('first_measurement_hint', lang)}
|
||||
{t.first_measurement_hint}
|
||||
</div>
|
||||
{:else}
|
||||
<svg class="chart" viewBox="0 0 {CHART_W} {CHART_H}" role="img" aria-label={fmt(t('over_time', lang), { label: stepLabel(step) })}>
|
||||
<svg class="chart" viewBox="0 0 {CHART_W} {CHART_H}" role="img" aria-label={fmt(t.over_time, { label: stepLabel(step) })}>
|
||||
<defs>
|
||||
<linearGradient id="area-grad-primary" x1="0" x2="0" y1="0" y2="1">
|
||||
<stop offset="0%" stop-color="var(--color-primary)" stop-opacity="0.25" />
|
||||
@@ -598,7 +599,7 @@
|
||||
{#if chart.axis.lastDate}
|
||||
<text x={CHART_PAD.l} y={CHART_H - 4} text-anchor="start" class="axis-label">{shortDate(chart.axis.lastDate)}</text>
|
||||
{/if}
|
||||
<text x={CHART_W - CHART_PAD.r} y={CHART_H - 4} text-anchor="end" class="axis-label accent">{t('today_short', lang)}</text>
|
||||
<text x={CHART_W - CHART_PAD.r} y={CHART_H - 4} text-anchor="end" class="axis-label accent">{t.today_short}</text>
|
||||
{/if}
|
||||
</svg>
|
||||
{#if chart.series.length > 1}
|
||||
@@ -618,7 +619,7 @@
|
||||
<span class="legend-item">
|
||||
<span class="swatch" style:background={chart.series[0].color}></span>
|
||||
<span class="legend-val">{chart.series[0].pending.value.toFixed(1)} cm</span>
|
||||
<span class="legend-tag">{t('today_short', lang)}</span>
|
||||
<span class="legend-tag">{t.today_short}</span>
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -631,31 +632,31 @@
|
||||
|
||||
<footer class="bottombar">
|
||||
{#if !done}
|
||||
<button type="button" class="ghost" onclick={skip}>{t('skip', lang)}</button>
|
||||
<button type="button" class="ghost" onclick={skip}>{t.skip}</button>
|
||||
{#if showShortcuts}
|
||||
<div class="kbd-legend" aria-hidden="true">
|
||||
<span><kbd>←</kbd><kbd>→</kbd> {t('kbd_nav', lang)}</span>
|
||||
<span><kbd>↵</kbd> {t('kbd_next', lang)}</span>
|
||||
<span><kbd>S</kbd> {t('kbd_skip', lang)}</span>
|
||||
<span><kbd>scroll</kbd> {t('kbd_wheel', lang)}</span>
|
||||
<span><kbd>←</kbd><kbd>→</kbd> {t.kbd_nav}</span>
|
||||
<span><kbd>↵</kbd> {t.kbd_next}</span>
|
||||
<span><kbd>S</kbd> {t.kbd_skip}</span>
|
||||
<span><kbd>scroll</kbd> {t.kbd_wheel}</span>
|
||||
</div>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
class="kbd-hint"
|
||||
onclick={() => showShortcuts = true}
|
||||
aria-label={t('kbd_hint', lang)}
|
||||
title={t('kbd_hint', lang)}
|
||||
aria-label={t.kbd_hint}
|
||||
title={t.kbd_hint}
|
||||
>
|
||||
<kbd>?</kbd>
|
||||
</button>
|
||||
{/if}
|
||||
<div class="nav-pair">
|
||||
<button type="button" class="nav-btn" onclick={back} disabled={idx === 0} aria-label={t('back', lang)}>
|
||||
<button type="button" class="nav-btn" onclick={back} disabled={idx === 0} aria-label={t.back}>
|
||||
<ArrowLeft size={16} />
|
||||
</button>
|
||||
<button type="button" class="nav-btn primary" onclick={next}>
|
||||
{idx === total - 1 ? t('review', lang) : t('next', lang)}
|
||||
{idx === total - 1 ? t.review : t.next}
|
||||
<ArrowRight size={16} />
|
||||
</button>
|
||||
</div>
|
||||
@@ -665,7 +666,7 @@
|
||||
</footer>
|
||||
|
||||
{#if done}
|
||||
<SaveFab type="button" onclick={save} disabled={saving} label={saving ? t('saving', lang) : t('save_measurement', lang)} />
|
||||
<SaveFab type="button" onclick={save} disabled={saving} label={saving ? t.saving : t.save_measurement} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script>
|
||||
import { page } from '$app/state';
|
||||
import { goto } from '$app/navigation';
|
||||
import { detectFitnessLang, t } from '$lib/js/fitnessI18n';
|
||||
import { detectFitnessLang, m as messages } from '$lib/js/fitnessI18n';
|
||||
import { toast } from '$lib/js/toast.svelte';
|
||||
import { confirm } from '$lib/js/confirmDialog.svelte';
|
||||
import Trash2 from '@lucide/svelte/icons/trash-2';
|
||||
@@ -9,6 +9,7 @@
|
||||
import DatePicker from '$lib/components/DatePicker.svelte';
|
||||
|
||||
const lang = $derived(detectFitnessLang(page.url.pathname));
|
||||
const t = $derived(messages[lang]);
|
||||
const checkinSlug = $derived(lang === 'en' ? 'check-in' : 'erfassung');
|
||||
|
||||
let { data } = $props();
|
||||
@@ -83,7 +84,7 @@
|
||||
}
|
||||
|
||||
async function deleteMeasurement() {
|
||||
if (!await confirm(t('delete_measurement_confirm', lang))) return;
|
||||
if (!await confirm(t.delete_measurement_confirm)) return;
|
||||
deleting = true;
|
||||
try {
|
||||
const res = await fetch(`/api/fitness/measurements/${m._id}`, { method: 'DELETE' });
|
||||
@@ -98,67 +99,67 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>{t('edit_measurement', lang)} - Bocken</title></svelte:head>
|
||||
<svelte:head><title>{t.edit_measurement} - Bocken</title></svelte:head>
|
||||
|
||||
<div class="measure-edit">
|
||||
<h1>{t('edit_measurement', lang)}</h1>
|
||||
<h1>{t.edit_measurement}</h1>
|
||||
|
||||
{#if !m}
|
||||
<p>Measurement not found.</p>
|
||||
{:else}
|
||||
<form onsubmit={(e) => { e.preventDefault(); saveMeasurement(); }}>
|
||||
<div class="form-group">
|
||||
<label for="m-date">{t('date', lang)}</label>
|
||||
<label for="m-date">{t.date}</label>
|
||||
<DatePicker bind:value={formDate} {lang} />
|
||||
</div>
|
||||
|
||||
<h3>{t('general', lang)}</h3>
|
||||
<h3>{t.general}</h3>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="m-weight">{t('weight_kg', lang)}</label>
|
||||
<label for="m-weight">{t.weight_kg}</label>
|
||||
<input id="m-weight" type="number" step="0.1" bind:value={formWeight} placeholder="--" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="m-bf">{t('body_fat_pct', lang)}</label>
|
||||
<label for="m-bf">{t.body_fat_pct}</label>
|
||||
<input id="m-bf" type="number" step="0.1" bind:value={formBodyFat} placeholder="--" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3>{t('body_parts_cm', lang)}</h3>
|
||||
<h3>{t.body_parts_cm}</h3>
|
||||
<div class="form-row">
|
||||
<div class="form-group"><label for="m-neck">{t('neck', lang)}</label><input id="m-neck" type="number" step="0.1" bind:value={formNeck} placeholder="--" /></div>
|
||||
<div class="form-group"><label for="m-shoulders">{t('shoulders', lang)}</label><input id="m-shoulders" type="number" step="0.1" bind:value={formShoulders} placeholder="--" /></div>
|
||||
<div class="form-group"><label for="m-chest">{t('chest', lang)}</label><input id="m-chest" type="number" step="0.1" bind:value={formChest} placeholder="--" /></div>
|
||||
<div class="form-group"><label for="m-neck">{t.neck}</label><input id="m-neck" type="number" step="0.1" bind:value={formNeck} placeholder="--" /></div>
|
||||
<div class="form-group"><label for="m-shoulders">{t.shoulders}</label><input id="m-shoulders" type="number" step="0.1" bind:value={formShoulders} placeholder="--" /></div>
|
||||
<div class="form-group"><label for="m-chest">{t.chest}</label><input id="m-chest" type="number" step="0.1" bind:value={formChest} placeholder="--" /></div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group"><label for="m-bl">{t('l_bicep', lang)}</label><input id="m-bl" type="number" step="0.1" bind:value={formBicepsL} placeholder="--" /></div>
|
||||
<div class="form-group"><label for="m-br">{t('r_bicep', lang)}</label><input id="m-br" type="number" step="0.1" bind:value={formBicepsR} placeholder="--" /></div>
|
||||
<div class="form-group"><label for="m-bl">{t.l_bicep}</label><input id="m-bl" type="number" step="0.1" bind:value={formBicepsL} placeholder="--" /></div>
|
||||
<div class="form-group"><label for="m-br">{t.r_bicep}</label><input id="m-br" type="number" step="0.1" bind:value={formBicepsR} placeholder="--" /></div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group"><label for="m-fl">{t('l_forearm', lang)}</label><input id="m-fl" type="number" step="0.1" bind:value={formForearmsL} placeholder="--" /></div>
|
||||
<div class="form-group"><label for="m-fr">{t('r_forearm', lang)}</label><input id="m-fr" type="number" step="0.1" bind:value={formForearmsR} placeholder="--" /></div>
|
||||
<div class="form-group"><label for="m-fl">{t.l_forearm}</label><input id="m-fl" type="number" step="0.1" bind:value={formForearmsL} placeholder="--" /></div>
|
||||
<div class="form-group"><label for="m-fr">{t.r_forearm}</label><input id="m-fr" type="number" step="0.1" bind:value={formForearmsR} placeholder="--" /></div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group"><label for="m-waist">{t('waist', lang)}</label><input id="m-waist" type="number" step="0.1" bind:value={formWaist} placeholder="--" /></div>
|
||||
<div class="form-group"><label for="m-hips">{t('hips', lang)}</label><input id="m-hips" type="number" step="0.1" bind:value={formHips} placeholder="--" /></div>
|
||||
<div class="form-group"><label for="m-waist">{t.waist}</label><input id="m-waist" type="number" step="0.1" bind:value={formWaist} placeholder="--" /></div>
|
||||
<div class="form-group"><label for="m-hips">{t.hips}</label><input id="m-hips" type="number" step="0.1" bind:value={formHips} placeholder="--" /></div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group"><label for="m-tl">{t('l_thigh', lang)}</label><input id="m-tl" type="number" step="0.1" bind:value={formThighsL} placeholder="--" /></div>
|
||||
<div class="form-group"><label for="m-tr">{t('r_thigh', lang)}</label><input id="m-tr" type="number" step="0.1" bind:value={formThighsR} placeholder="--" /></div>
|
||||
<div class="form-group"><label for="m-tl">{t.l_thigh}</label><input id="m-tl" type="number" step="0.1" bind:value={formThighsL} placeholder="--" /></div>
|
||||
<div class="form-group"><label for="m-tr">{t.r_thigh}</label><input id="m-tr" type="number" step="0.1" bind:value={formThighsR} placeholder="--" /></div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group"><label for="m-cl">{t('l_calf', lang)}</label><input id="m-cl" type="number" step="0.1" bind:value={formCalvesL} placeholder="--" /></div>
|
||||
<div class="form-group"><label for="m-cr">{t('r_calf', lang)}</label><input id="m-cr" type="number" step="0.1" bind:value={formCalvesR} placeholder="--" /></div>
|
||||
<div class="form-group"><label for="m-cl">{t.l_calf}</label><input id="m-cl" type="number" step="0.1" bind:value={formCalvesL} placeholder="--" /></div>
|
||||
<div class="form-group"><label for="m-cr">{t.r_calf}</label><input id="m-cr" type="number" step="0.1" bind:value={formCalvesR} placeholder="--" /></div>
|
||||
</div>
|
||||
|
||||
<div class="delete-actions">
|
||||
<button type="button" class="btn-danger" onclick={deleteMeasurement} disabled={deleting || saving}>
|
||||
<Trash2 size={14} />
|
||||
{deleting ? t('saving', lang) : t('delete_', lang)}
|
||||
{deleting ? t.saving : t.delete_}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<SaveFab disabled={saving || deleting} label={t('update_measurement', lang)} />
|
||||
<SaveFab disabled={saving || deleting} label={t.update_measurement} />
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -12,11 +12,12 @@
|
||||
import Layers from '@lucide/svelte/icons/layers';
|
||||
import { getFilterOptionsAll, searchAllExercises, isStretchType } from '$lib/data/exercisedb';
|
||||
import { translateTerm } from '$lib/data/exercises';
|
||||
import { detectFitnessLang, fitnessSlugs, t } from '$lib/js/fitnessI18n';
|
||||
import { detectFitnessLang, fitnessSlugs, m } from '$lib/js/fitnessI18n';
|
||||
import { MUSCLE_GROUPS, MUSCLE_GROUP_DE } from '$lib/data/muscleMap';
|
||||
import MuscleFilter from '$lib/components/fitness/MuscleFilter.svelte';
|
||||
|
||||
const lang = $derived(detectFitnessLang(page.url.pathname));
|
||||
const t = $derived(m[lang]);
|
||||
const isEn = $derived(lang === 'en');
|
||||
const sl = $derived(fitnessSlugs(lang));
|
||||
|
||||
@@ -117,7 +118,7 @@
|
||||
<svelte:head><title>{lang === 'en' ? 'Exercises' : 'Übungen'} - Bocken</title></svelte:head>
|
||||
|
||||
<div class="exercises-page">
|
||||
<h1 class="sr-only">{t('exercises_title', lang)}</h1>
|
||||
<h1 class="sr-only">{t.exercises_title}</h1>
|
||||
|
||||
<aside class="muscle-card" aria-label={isEn ? 'Filter by muscle' : 'Nach Muskel filtern'}>
|
||||
<MuscleFilter bind:selectedGroups={muscleGroups} {lang} />
|
||||
@@ -126,7 +127,7 @@
|
||||
<div class="exercises-content">
|
||||
<div class="search-bar">
|
||||
<Search size={16} />
|
||||
<input type="text" placeholder={t('search_exercises', lang)} bind:value={query} />
|
||||
<input type="text" placeholder={t.search_exercises} bind:value={query} />
|
||||
</div>
|
||||
|
||||
<div class="type-toggle" role="tablist" aria-label={isEn ? 'Exercise type filter' : 'Filter nach Übungsart'}>
|
||||
@@ -138,7 +139,7 @@
|
||||
onclick={() => typeFilter = 'all'}
|
||||
>
|
||||
<Layers size={14} strokeWidth={2.2} />
|
||||
<span>{t('type_any', lang)}</span>
|
||||
<span>{t.type_any}</span>
|
||||
</button>
|
||||
<button
|
||||
role="tab"
|
||||
@@ -148,7 +149,7 @@
|
||||
onclick={() => typeFilter = 'non-stretch'}
|
||||
>
|
||||
<BicepsFlexed size={14} strokeWidth={2.2} />
|
||||
<span>{t('type_weights', lang)}</span>
|
||||
<span>{t.type_weights}</span>
|
||||
</button>
|
||||
<button
|
||||
role="tab"
|
||||
@@ -158,7 +159,7 @@
|
||||
onclick={() => typeFilter = 'stretch'}
|
||||
>
|
||||
<PersonStanding size={14} strokeWidth={2.2} />
|
||||
<span>{t('type_stretches', lang)}</span>
|
||||
<span>{t.type_stretches}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -218,7 +219,7 @@
|
||||
<span class="exercise-name">
|
||||
{exercise.localName}
|
||||
{#if isStretchType(exercise.exerciseType)}
|
||||
<span class="stretch-badge">{t('stretch_pill', lang)}</span>
|
||||
<span class="stretch-badge">{t.stretch_pill}</span>
|
||||
{/if}
|
||||
</span>
|
||||
<span class="exercise-meta">{exercise.localBodyPart} · {exercise.localEquipment}</span>
|
||||
@@ -227,7 +228,7 @@
|
||||
</li>
|
||||
{/each}
|
||||
{#if filtered.length === 0}
|
||||
<li class="no-results">{t('no_exercises_match', lang)}</li>
|
||||
<li class="no-results">{t.no_exercises_match}</li>
|
||||
{/if}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -8,21 +8,22 @@
|
||||
switch (type) {
|
||||
case 'STRETCHING':
|
||||
case 'YOGA':
|
||||
return { key: 'stretch', label: t('stretch_pill', lang) };
|
||||
return { key: 'stretch', label: t.stretch_pill };
|
||||
case 'STRENGTH':
|
||||
case 'WEIGHTLIFTING':
|
||||
return { key: 'strength', label: t('strength_pill', lang) };
|
||||
return { key: 'strength', label: t.strength_pill };
|
||||
case 'CARDIO':
|
||||
return { key: 'cardio', label: t('cardio_pill', lang) };
|
||||
return { key: 'cardio', label: t.cardio_pill };
|
||||
case 'PLYOMETRICS':
|
||||
return { key: 'plyo', label: t('plyo_pill', lang) };
|
||||
return { key: 'plyo', label: t.plyo_pill };
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
import { detectFitnessLang, fitnessSlugs, t } from '$lib/js/fitnessI18n';
|
||||
import { detectFitnessLang, fitnessSlugs, m } from '$lib/js/fitnessI18n';
|
||||
import ChevronRight from '@lucide/svelte/icons/chevron-right';
|
||||
const lang = $derived(detectFitnessLang(page.url.pathname));
|
||||
const t = $derived(m[lang]);
|
||||
const s = $derived(fitnessSlugs(lang));
|
||||
import FitnessChart from '$lib/components/fitness/FitnessChart.svelte';
|
||||
import MuscleMap from '$lib/components/fitness/MuscleMap.svelte';
|
||||
@@ -141,7 +142,7 @@
|
||||
class:active={activeTab === tab}
|
||||
onclick={() => activeTab = tab}
|
||||
>
|
||||
{{ about: t('about', lang), history: t('history_tab', lang), charts: t('charts', lang), records: t('records', lang) }[tab]}
|
||||
{{ about: t.about, history: t.history_tab, charts: t.charts, records: t.records }[tab]}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -185,7 +186,7 @@
|
||||
|
||||
<!-- Instructions -->
|
||||
{#if exercise?.localInstructions?.length}
|
||||
<h3>{t('instructions', lang)}</h3>
|
||||
<h3>{t.instructions}</h3>
|
||||
<ol class="instructions">
|
||||
{#each exercise.localInstructions as step}
|
||||
<li>{step}</li>
|
||||
@@ -234,7 +235,7 @@
|
||||
{:else if activeTab === 'history'}
|
||||
<div class="tab-content">
|
||||
{#if history.length === 0}
|
||||
<p class="empty">{t('no_history_yet', lang)}</p>
|
||||
<p class="empty">{t.no_history_yet}</p>
|
||||
{:else}
|
||||
{#each history as entry (entry.sessionId)}
|
||||
<div class="history-session">
|
||||
@@ -244,7 +245,7 @@
|
||||
</div>
|
||||
<table class="history-sets">
|
||||
<thead>
|
||||
<tr><th>{t('set', lang)}</th><th>{t('kg', lang)}</th><th>{t('reps', lang)}</th><th>{t('est_1rm', lang)}</th></tr>
|
||||
<tr><th>{t.set}</th><th>{t.kg}</th><th>{t.reps}</th><th>{t.est_1rm}</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each entry.sets as set, i (i)}
|
||||
@@ -264,11 +265,11 @@
|
||||
{:else if activeTab === 'charts'}
|
||||
<div class="tab-content charts-grid">
|
||||
{#if (charts.est1rmOverTime?.length ?? 0) > 0}
|
||||
<FitnessChart data={est1rmChartData} title={t('best_set_1rm', lang)} yUnit=" kg" />
|
||||
<FitnessChart data={maxWeightChartData} title={t('best_set_max', lang)} yUnit=" kg" />
|
||||
<FitnessChart data={volumeChartData} title={t('total_volume', lang)} yUnit=" kg" />
|
||||
<FitnessChart data={est1rmChartData} title={t.best_set_1rm} yUnit=" kg" />
|
||||
<FitnessChart data={maxWeightChartData} title={t.best_set_max} yUnit=" kg" />
|
||||
<FitnessChart data={volumeChartData} title={t.total_volume} yUnit=" kg" />
|
||||
{:else}
|
||||
<p class="empty">{t('not_enough_data', lang)}</p>
|
||||
<p class="empty">{t.not_enough_data}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if activeTab === 'records'}
|
||||
@@ -276,29 +277,29 @@
|
||||
<div class="records-summary">
|
||||
{#if prs.estimatedOneRepMax}
|
||||
<div class="record-card">
|
||||
<span class="record-label">{t('estimated_1rm', lang)}</span>
|
||||
<span class="record-label">{t.estimated_1rm}</span>
|
||||
<span class="record-value">{prs.estimatedOneRepMax} kg</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if prs.maxVolume}
|
||||
<div class="record-card">
|
||||
<span class="record-label">{t('max_volume', lang)}</span>
|
||||
<span class="record-label">{t.max_volume}</span>
|
||||
<span class="record-value">{prs.maxVolume} kg</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if prs.maxWeight}
|
||||
<div class="record-card">
|
||||
<span class="record-label">{t('max_weight', lang)}</span>
|
||||
<span class="record-label">{t.max_weight}</span>
|
||||
<span class="record-value">{prs.maxWeight} kg</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if records.length}
|
||||
<h3>{t('rep_records', lang)}</h3>
|
||||
<h3>{t.rep_records}</h3>
|
||||
<table class="records-table">
|
||||
<thead>
|
||||
<tr><th>{t('reps', lang)}</th><th>{t('best_performance', lang)}</th><th>{t('est_1rm', lang)}</th></tr>
|
||||
<tr><th>{t.reps}</th><th>{t.best_performance}</th><th>{t.est_1rm}</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each records as rec (rec.reps)}
|
||||
|
||||
@@ -4,9 +4,10 @@
|
||||
import ChevronLeft from '@lucide/svelte/icons/chevron-left';
|
||||
import ChevronRight from '@lucide/svelte/icons/chevron-right';
|
||||
import SessionCard from '$lib/components/fitness/SessionCard.svelte';
|
||||
import { detectFitnessLang, fitnessSlugs, t } from '$lib/js/fitnessI18n';
|
||||
import { detectFitnessLang, fitnessSlugs, m } from '$lib/js/fitnessI18n';
|
||||
|
||||
const lang = $derived(detectFitnessLang(appPage.url.pathname));
|
||||
const t = $derived(m[lang]);
|
||||
const s = $derived(fitnessSlugs(lang));
|
||||
|
||||
let { data } = $props();
|
||||
@@ -60,17 +61,17 @@
|
||||
const recentHref = $derived(resolve('/fitness/[history=fitnessHistory]', { history: s.history }));
|
||||
</script>
|
||||
|
||||
<svelte:head><title>{t('history_title', lang)} - Bocken</title></svelte:head>
|
||||
<svelte:head><title>{t.history_title} - Bocken</title></svelte:head>
|
||||
|
||||
<div class="history-page">
|
||||
<h1 class="sr-only">{t('history_title', lang)}</h1>
|
||||
<h1 class="sr-only">{t.history_title}</h1>
|
||||
|
||||
{#if sessions.length === 0}
|
||||
<p class="empty">{t('no_workouts_yet', lang)}</p>
|
||||
<p class="empty">{t.no_workouts_yet}</p>
|
||||
{:else}
|
||||
{#each Object.entries(grouped) as [month, monthSessions] (month)}
|
||||
<section class="month-group">
|
||||
<h2 class="month-header">{month} — {monthSessions.length} {monthSessions.length !== 1 ? t('workouts_plural', lang) : t('workout_singular', lang)}</h2>
|
||||
<h2 class="month-header">{month} — {monthSessions.length} {monthSessions.length !== 1 ? t.workouts_plural : t.workout_singular}</h2>
|
||||
<div class="session-list">
|
||||
{#each monthSessions as session (session._id)}
|
||||
<SessionCard {session} />
|
||||
|
||||
@@ -16,11 +16,12 @@
|
||||
import Flame from '@lucide/svelte/icons/flame';
|
||||
import Info from '@lucide/svelte/icons/info';
|
||||
import Mountain from '@lucide/svelte/icons/mountain';
|
||||
import { detectFitnessLang, fitnessSlugs, t } from '$lib/js/fitnessI18n';
|
||||
import { detectFitnessLang, fitnessSlugs, m } from '$lib/js/fitnessI18n';
|
||||
import { confirm } from '$lib/js/confirmDialog.svelte';
|
||||
import { toast } from '$lib/js/toast.svelte';
|
||||
|
||||
const lang = $derived(detectFitnessLang(page.url.pathname));
|
||||
const t = $derived(m[lang]);
|
||||
const sl = $derived(fitnessSlugs(lang));
|
||||
import { getExerciseById, getExerciseMetrics, METRIC_LABELS } from '$lib/data/exercises';
|
||||
import { formatPaceRangeLabel, formatPaceValue } from '$lib/data/cardioPrRanges';
|
||||
@@ -232,7 +233,7 @@
|
||||
}
|
||||
|
||||
async function deleteSession() {
|
||||
if (!await confirm(t('delete_session_confirm', lang))) return;
|
||||
if (!await confirm(t.delete_session_confirm)) return;
|
||||
deleting = true;
|
||||
try {
|
||||
const res = await fetch(`/api/fitness/sessions/${session._id}`, { method: 'DELETE' });
|
||||
@@ -475,7 +476,7 @@
|
||||
return {
|
||||
labels: filtered.map(s => s.dist.toFixed(2)),
|
||||
datasets: [{
|
||||
label: t('elevation', lang),
|
||||
label: t.elevation,
|
||||
data: filtered.map(s => Math.round(s.altitude)),
|
||||
borderColor: color,
|
||||
backgroundColor: fill,
|
||||
@@ -516,7 +517,7 @@
|
||||
return {
|
||||
labels: filtered.map(s => s.dist.toFixed(2)),
|
||||
datasets: [{
|
||||
label: t('cadence', lang),
|
||||
label: t.cadence,
|
||||
data: filtered.map(s => s.cadence),
|
||||
borderColor: color,
|
||||
backgroundColor: fill,
|
||||
@@ -564,7 +565,7 @@
|
||||
|
||||
/** @param {number} exIdx */
|
||||
async function removeGpx(exIdx) {
|
||||
if (!await confirm(t('remove_gps_confirm', lang))) return;
|
||||
if (!await confirm(t.remove_gps_confirm)) return;
|
||||
try {
|
||||
const res = await fetch(`/api/fitness/sessions/${session._id}/gpx`, {
|
||||
method: 'DELETE',
|
||||
@@ -598,13 +599,13 @@
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
{#if editing}
|
||||
<button class="recalc-btn" onclick={recalculate} disabled={recalculating} title={t('recalc_title', lang)}>
|
||||
<button class="recalc-btn" onclick={recalculate} disabled={recalculating} title={t.recalc_title}>
|
||||
<RefreshCw size={14} class={recalculating ? 'spinning' : ''} />
|
||||
</button>
|
||||
<button class="save-btn" onclick={saveEdit} disabled={saving}>
|
||||
{saving ? t('saving', lang).toUpperCase() : t('save', lang).toUpperCase()}
|
||||
{saving ? t.saving.toUpperCase() : t.save.toUpperCase()}
|
||||
</button>
|
||||
<button class="cancel-edit-btn" onclick={cancelEdit}>{t('cancel', lang)}</button>
|
||||
<button class="cancel-edit-btn" onclick={cancelEdit}>{t.cancel}</button>
|
||||
{:else}
|
||||
<button class="edit-btn" onclick={startEdit} aria-label="Edit session">
|
||||
<Pencil size={16} />
|
||||
@@ -619,20 +620,20 @@
|
||||
{#if editing}
|
||||
<div class="edit-meta">
|
||||
<div class="meta-row">
|
||||
<label for="edit-date">{t('date', lang)}</label>
|
||||
<label for="edit-date">{t.date}</label>
|
||||
<DatePicker bind:value={editData.date} {lang} />
|
||||
</div>
|
||||
<div class="meta-row">
|
||||
<label for="edit-time">{t('time', lang)}</label>
|
||||
<label for="edit-time">{t.time}</label>
|
||||
<input id="edit-time" type="time" bind:value={editData.time} />
|
||||
</div>
|
||||
<div class="meta-row">
|
||||
<label for="edit-duration">{t('duration_min', lang)}</label>
|
||||
<label for="edit-duration">{t.duration_min}</label>
|
||||
<input id="edit-duration" type="number" min="0" bind:value={editData.duration} />
|
||||
</div>
|
||||
<div class="meta-row">
|
||||
<label for="edit-notes">{t('notes', lang)}</label>
|
||||
<textarea id="edit-notes" bind:value={editData.notes} rows="2" placeholder={t('notes_placeholder', lang)}></textarea>
|
||||
<label for="edit-notes">{t.notes}</label>
|
||||
<textarea id="edit-notes" bind:value={editData.notes} rows="2" placeholder={t.notes_placeholder}></textarea>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
@@ -701,7 +702,7 @@
|
||||
{@const exData = session.exercises[exIdx]}
|
||||
<div class="gps-indicator">
|
||||
<Route size={14} />
|
||||
<span>{t('gps_track_stored', lang)}{exData.totalDistance ? ` · ${exData.totalDistance.toFixed(2)} km` : ''}</span>
|
||||
<span>{t.gps_track_stored}{exData.totalDistance ? ` · ${exData.totalDistance.toFixed(2)} km` : ''}</span>
|
||||
<button class="gpx-remove-btn" onclick={() => removeGpx(exIdx)} aria-label="Remove GPS track">
|
||||
<X size={14} />
|
||||
</button>
|
||||
@@ -723,13 +724,13 @@
|
||||
/>
|
||||
|
||||
<button class="add-set-btn" onclick={() => addSetToEdit(exIdx)}>
|
||||
{t('add_set', lang)}
|
||||
{t.add_set}
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<button class="add-exercise-btn" onclick={() => showPicker = true}>
|
||||
<Plus size={18} /> {t('add_exercise', lang)}
|
||||
<Plus size={18} /> {t.add_exercise}
|
||||
</button>
|
||||
{:else}
|
||||
{#each session.exercises as ex, exIdx (ex.exerciseId + '-' + exIdx)}
|
||||
@@ -744,13 +745,13 @@
|
||||
<table class="sets-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('set_header', lang)}</th>
|
||||
<th>{t.set_header}</th>
|
||||
{#each mainMetrics as metric (metric)}
|
||||
<th>{METRIC_LABELS[metric]}</th>
|
||||
{/each}
|
||||
<th>RPE</th>
|
||||
{#if showEst1rm}
|
||||
<th>{t('est_1rm', lang)}</th>
|
||||
<th>{t.est_1rm}</th>
|
||||
{/if}
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -783,8 +784,8 @@
|
||||
<span class="gps-stat accent"><Gauge size={14} /> {formatPace(pace)}</span>
|
||||
{/if}
|
||||
{#if elevStats}
|
||||
<span class="gps-stat elev-gain"><Mountain size={14} /> +{elevStats.gain}{t('elevation_unit', lang)}</span>
|
||||
<span class="gps-stat elev-loss">-{elevStats.loss}{t('elevation_unit', lang)}</span>
|
||||
<span class="gps-stat elev-gain"><Mountain size={14} /> +{elevStats.gain}{t.elevation_unit}</span>
|
||||
<span class="gps-stat elev-loss">-{elevStats.loss}{t.elevation_unit}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="track-map" {@attach renderMap(ex.gpsTrack, exIdx)}></div>
|
||||
@@ -797,7 +798,7 @@
|
||||
<div class="chart-section">
|
||||
<FitnessChart
|
||||
data={buildElevationChartData(elevSamples)}
|
||||
title="{t('elevation', lang)} ({t('elevation_unit', lang)})"
|
||||
title="{t.elevation} ({t.elevation_unit})"
|
||||
height="160px"
|
||||
yUnit="m"
|
||||
/>
|
||||
@@ -820,7 +821,7 @@
|
||||
<div class="chart-section">
|
||||
<FitnessChart
|
||||
data={buildCadenceChartData(cadenceSamples)}
|
||||
title="{t('cadence', lang)} ({t('cadence_unit', lang)}) · {t('avg', lang)} {avgCadence}"
|
||||
title="{t.cadence} ({t.cadence_unit}) · {t.avg} {avgCadence}"
|
||||
height="160px"
|
||||
yUnit=" spm"
|
||||
/>
|
||||
@@ -830,12 +831,12 @@
|
||||
{#if splits.length > 1}
|
||||
{@const avgPace = splits.reduce((a, s) => a + s.pace, 0) / splits.length}
|
||||
<div class="splits-section">
|
||||
<h4>{t('splits', lang)}</h4>
|
||||
<h4>{t.splits}</h4>
|
||||
<table class="splits-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>KM</th>
|
||||
<th>{t('pace', lang)}</th>
|
||||
<th>{t.pace}</th>
|
||||
<th>TIME</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -857,13 +858,13 @@
|
||||
{/if}
|
||||
<button class="gpx-download-btn" onclick={() => downloadGpx(exIdx)}>
|
||||
<Download size={14} />
|
||||
{t('download_gpx', lang)}
|
||||
{t.download_gpx}
|
||||
</button>
|
||||
</div>
|
||||
{:else if isCardio(ex.exerciseId)}
|
||||
<button class="gpx-upload-btn" onclick={() => uploadGpx(exIdx)} disabled={uploading === exIdx}>
|
||||
<Upload size={14} />
|
||||
{uploading === exIdx ? t('uploading', lang) : t('upload_gpx', lang)}
|
||||
{uploading === exIdx ? t.uploading : t.upload_gpx}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -872,7 +873,7 @@
|
||||
|
||||
{#if !editing && session.prs?.length > 0}
|
||||
<div class="prs-section">
|
||||
<h2>{t('personal_records', lang)}</h2>
|
||||
<h2>{t.personal_records}</h2>
|
||||
<div class="pr-list">
|
||||
{#each session.prs as pr (pr.exerciseId + pr.type)}
|
||||
{@const exercise = getExerciseById(pr.exerciseId, lang)}
|
||||
@@ -901,7 +902,7 @@
|
||||
|
||||
{#if !editing && session.notes}
|
||||
<div class="notes-section">
|
||||
<h2>{t('notes', lang)}</h2>
|
||||
<h2>{t.notes}</h2>
|
||||
<p>{session.notes}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
import Beef from '@lucide/svelte/icons/beef';
|
||||
import Droplet from '@lucide/svelte/icons/droplet';
|
||||
import Wheat from '@lucide/svelte/icons/wheat';
|
||||
import { detectFitnessLang, fitnessSlugs, t } from '$lib/js/fitnessI18n';
|
||||
import { detectFitnessLang, fitnessSlugs, m } from '$lib/js/fitnessI18n';
|
||||
import AddButton from '$lib/components/AddButton.svelte';
|
||||
import FoodSearch from '$lib/components/fitness/FoodSearch.svelte';
|
||||
import MacroBreakdown from '$lib/components/fitness/MacroBreakdown.svelte';
|
||||
@@ -71,6 +71,7 @@
|
||||
*/
|
||||
|
||||
const lang = $derived(detectFitnessLang(page.url.pathname));
|
||||
const t = $derived(m[lang]);
|
||||
const s = $derived(fitnessSlugs(lang));
|
||||
const isEn = $derived(lang === 'en');
|
||||
|
||||
@@ -1054,7 +1055,7 @@
|
||||
|
||||
/** @param {string} id */
|
||||
async function deleteEntry(id) {
|
||||
if (!await confirm(t('delete_entry_confirm', lang))) return;
|
||||
if (!await confirm(t.delete_entry_confirm)) return;
|
||||
try {
|
||||
const res = await fetch(`/api/fitness/food-log/${id}`, { method: 'DELETE' });
|
||||
if (res.ok) {
|
||||
@@ -1262,7 +1263,7 @@
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{t('nutrition_title', lang)} — Fitness</title>
|
||||
<title>{t.nutrition_title} — Fitness</title>
|
||||
</svelte:head>
|
||||
|
||||
{#snippet cmDetailScreen(/** @type {CustomMeal} */ meal, /** @type {(m: CustomMeal, grams?: number | null) => void} */ logFn)}
|
||||
@@ -1270,7 +1271,7 @@
|
||||
<div class="cm-detail">
|
||||
<div class="cm-detail-header">
|
||||
<span class="cm-detail-name">{meal.name}</span>
|
||||
<span class="cm-detail-sub">{meal.ingredients.length} {t('ingredients', lang)} · {Math.round(preview.totalGrams)}g {isEn ? 'base' : 'Basis'}</span>
|
||||
<span class="cm-detail-sub">{meal.ingredients.length} {t.ingredients} · {Math.round(preview.totalGrams)}g {isEn ? 'base' : 'Basis'}</span>
|
||||
</div>
|
||||
|
||||
<!-- Amount selector -->
|
||||
@@ -1314,8 +1315,8 @@
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="cm-detail-actions">
|
||||
<button class="cm-detail-btn-cancel" onclick={deselectCmMeal}>{t('cancel', lang)}</button>
|
||||
<button class="cm-detail-btn-confirm" onclick={() => logFn(meal, cmResolvedGrams(meal))}>{t('log_meal', lang)}</button>
|
||||
<button class="cm-detail-btn-cancel" onclick={deselectCmMeal}>{t.cancel}</button>
|
||||
<button class="cm-detail-btn-confirm" onclick={() => logFn(meal, cmResolvedGrams(meal))}>{t.log_meal}</button>
|
||||
</div>
|
||||
</div>
|
||||
{/snippet}
|
||||
@@ -1323,7 +1324,7 @@
|
||||
{#snippet favoritesTab(/** @type {(food: FoodSelection) => void} */ logFn)}
|
||||
<div class="fav-tab-list">
|
||||
{#if !favTabLoaded}
|
||||
<p class="meals-empty">{t('loading', lang)}</p>
|
||||
<p class="meals-empty">{t.loading}</p>
|
||||
{:else if favTabItems.length === 0}
|
||||
<p class="meals-empty">{isEn ? 'No favorites yet. Tap the heart on foods to add them here.' : 'Noch keine Favoriten. Tippe auf das Herz bei Lebensmitteln.'}</p>
|
||||
{:else}
|
||||
@@ -1342,13 +1343,13 @@
|
||||
bind:value={cmFilter} />
|
||||
{/if}
|
||||
{#if customMeals.length === 0}
|
||||
<p class="meals-empty">{t('no_custom_meals', lang)}</p>
|
||||
<p class="meals-empty">{t.no_custom_meals}</p>
|
||||
{/if}
|
||||
{#each filteredCustomMeals as cm}
|
||||
<div class="custom-meal-card" role="button" tabindex="0" onclick={() => selectCmMeal(cm)} onkeydown={e => e.key === 'Enter' && selectCmMeal(cm)}>
|
||||
<div class="custom-meal-info">
|
||||
<span class="custom-meal-name">{cm.name}</span>
|
||||
<span class="custom-meal-detail">{cm.ingredients.length} {t('ingredients', lang)} · {fmtCal(mealTotalCal(cm))} kcal · {Math.round(mealTotalGrams(cm))}g</span>
|
||||
<span class="custom-meal-detail">{cm.ingredients.length} {t.ingredients} · {fmtCal(mealTotalCal(cm))} kcal · {Math.round(mealTotalGrams(cm))}g</span>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
@@ -1404,13 +1405,13 @@
|
||||
</a>
|
||||
<span class="date-display" class:is-today={isToday}>
|
||||
{displayDate}
|
||||
{#if isToday}<span class="today-badge">{t('today', lang)}</span>{/if}
|
||||
{#if isToday}<span class="today-badge">{t.today}</span>{/if}
|
||||
</span>
|
||||
<a class="date-btn" href={nextHref} aria-label="Next day" data-sveltekit-replacestate data-sveltekit-noscroll>
|
||||
<ChevronRight size={20} />
|
||||
</a>
|
||||
{#if !isToday}
|
||||
<a class="go-today-btn" href={todayHref} data-sveltekit-replacestate data-sveltekit-noscroll>{t('today', lang)}</a>
|
||||
<a class="go-today-btn" href={todayHref} data-sveltekit-replacestate data-sveltekit-noscroll>{t.today}</a>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1482,7 +1483,7 @@
|
||||
{/if}
|
||||
</span>
|
||||
{#if !hasBmrData}
|
||||
<div class="bmr-hint">{isEn ? 'Set profile in' : 'Profil unter'} <a href={resolve('/fitness/[checkin=fitnessCheckIn]', { checkin: s.measure })}>{t('measure_title', lang)}</a></div>
|
||||
<div class="bmr-hint">{isEn ? 'Set profile in' : 'Profil unter'} <a href={resolve('/fitness/[checkin=fitnessCheckIn]', { checkin: s.measure })}>{t.measure_title}</a></div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1490,9 +1491,9 @@
|
||||
<!-- Macro progress bars -->
|
||||
<div class="macro-bars">
|
||||
{#each [
|
||||
{ value: dayTotals.protein, goal: proteinGoalGrams, label: t('protein', lang), color: 'var(--nord14)', icon: Beef },
|
||||
{ value: dayTotals.fat, goal: fatGoalGrams, label: t('fat', lang), color: 'var(--nord12)', icon: Droplet },
|
||||
{ value: dayTotals.carbs, goal: carbGoalGrams, label: t('carbs', lang), color: 'var(--nord9)', icon: Wheat },
|
||||
{ value: dayTotals.protein, goal: proteinGoalGrams, label: t.protein, color: 'var(--nord14)', icon: Beef },
|
||||
{ value: dayTotals.fat, goal: fatGoalGrams, label: t.fat, color: 'var(--nord12)', icon: Droplet },
|
||||
{ value: dayTotals.carbs, goal: carbGoalGrams, label: t.carbs, color: 'var(--nord9)', icon: Wheat },
|
||||
] as macro}
|
||||
{@const pct = macro.goal ? macro.value / macro.goal * 100 : 0}
|
||||
{@const over = pct > 100}
|
||||
@@ -1509,7 +1510,7 @@
|
||||
</div>
|
||||
{#if macro.goal}
|
||||
<span class="macro-bar-info" class:over>
|
||||
{remaining >= 0 ? `${fmt(remaining)}/${fmt(macro.goal)}g ${t('remaining', lang)}` : `${fmt(-remaining)}g ${t('over', lang)} ${fmt(macro.goal)}g`}
|
||||
{remaining >= 0 ? `${fmt(remaining)}/${fmt(macro.goal)}g ${t.remaining}` : `${fmt(-remaining)}g ${t.over} ${fmt(macro.goal)}g`}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="macro-bar-info">{fmt(macro.value)}g</span>
|
||||
@@ -1523,7 +1524,7 @@
|
||||
<div class="details-toggle-row">
|
||||
<button class="details-toggle" onclick={() => showMicros = !showMicros}>
|
||||
<ChevronDown size={14} style={showMicros ? 'transform: rotate(180deg)' : ''} />
|
||||
{t('micro_details', lang)}
|
||||
{t.micro_details}
|
||||
</button>
|
||||
</div>
|
||||
{@render microPanel()}
|
||||
@@ -1533,8 +1534,8 @@
|
||||
{:else}
|
||||
<div class="no-goal">
|
||||
<div class="no-goal-icon"><Utensils size={32} /></div>
|
||||
<p>{t('set_goal_prompt', lang)}</p>
|
||||
<button class="btn-primary" onclick={openGoalEditor}>{t('set_goal', lang)}</button>
|
||||
<p>{t.set_goal_prompt}</p>
|
||||
<button class="btn-primary" onclick={openGoalEditor}>{t.set_goal}</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -1542,14 +1543,14 @@
|
||||
{#if goalCalories && !showGoalEditor}
|
||||
<button class="goal-edit-btn" onclick={openGoalEditor}>
|
||||
<Settings size={14} />
|
||||
{t('daily_goal', lang)}
|
||||
{t.daily_goal}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<!-- Goal Editor (Stepped Wizard) -->
|
||||
{#if showGoalEditor}
|
||||
<div class="goal-editor">
|
||||
<h3>{t('daily_goal', lang)}</h3>
|
||||
<h3>{t.daily_goal}</h3>
|
||||
|
||||
<!-- Step indicators -->
|
||||
<div class="wizard-steps">
|
||||
@@ -1610,14 +1611,14 @@
|
||||
<span>{isEn
|
||||
? 'Your TDEE (Total Daily Energy Expenditure) is the calories you burn per day. Set weight, height, and birth year under'
|
||||
: 'Dein TDEE (Gesamtenergieumsatz) sind die Kalorien, die du pro Tag verbrauchst. Gewicht, Größe und Geburtsjahr einstellen unter'}
|
||||
<a href={resolve('/fitness/[checkin=fitnessCheckIn]', { checkin: s.measure })}>{t('measure_title', lang)}</a>
|
||||
<a href={resolve('/fitness/[checkin=fitnessCheckIn]', { checkin: s.measure })}>{t.measure_title}</a>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="goal-field">
|
||||
<label for="goal-calories">{t('calorie_target', lang)}</label>
|
||||
<label for="goal-calories">{t.calorie_target}</label>
|
||||
<div class="calorie-input-row">
|
||||
<input id="goal-calories" type="number" bind:value={editCalories} min="500" max="10000" />
|
||||
{#if hasBmrData}
|
||||
@@ -1715,19 +1716,19 @@
|
||||
<text x="60" y="70" text-anchor="middle" class="ring-cal-sub">kcal</text>
|
||||
</svg>
|
||||
<div class="macro-ring-legend">
|
||||
<span class="mrl-item"><span class="mrl-dot" style="background: var(--nord14)"></span><Beef size={12} /> {t('protein', lang)} {editMacroRing.prot}%</span>
|
||||
<span class="mrl-item"><span class="mrl-dot" style="background: var(--nord12)"></span><Droplet size={12} /> {t('fat', lang)} {editMacroRing.fat}%</span>
|
||||
<span class="mrl-item"><span class="mrl-dot" style="background: var(--nord9)"></span><Wheat size={12} /> {t('carbs', lang)} {editMacroRing.carb}%</span>
|
||||
<span class="mrl-item"><span class="mrl-dot" style="background: var(--nord14)"></span><Beef size={12} /> {t.protein} {editMacroRing.prot}%</span>
|
||||
<span class="mrl-item"><span class="mrl-dot" style="background: var(--nord12)"></span><Droplet size={12} /> {t.fat} {editMacroRing.fat}%</span>
|
||||
<span class="mrl-item"><span class="mrl-dot" style="background: var(--nord9)"></span><Wheat size={12} /> {t.carbs} {editMacroRing.carb}%</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="goal-field">
|
||||
<label for="goal-protein-mode">{t('protein_goal', lang)}</label>
|
||||
<label for="goal-protein-mode">{t.protein_goal}</label>
|
||||
<div class="protein-mode">
|
||||
<select id="goal-protein-mode" bind:value={editProteinMode}>
|
||||
<option value="fixed">{t('protein_fixed', lang)}</option>
|
||||
<option value="per_kg">{t('protein_per_kg', lang)}</option>
|
||||
<option value="fixed">{t.protein_fixed}</option>
|
||||
<option value="per_kg">{t.protein_per_kg}</option>
|
||||
</select>
|
||||
<input id="goal-protein-target" type="number" bind:value={editProteinTarget} min="0" step="0.1"
|
||||
placeholder={editProteinMode === 'per_kg' ? 'g/kg' : 'g'} />
|
||||
@@ -1735,11 +1736,11 @@
|
||||
</div>
|
||||
<div class="goal-row">
|
||||
<div class="goal-field">
|
||||
<label for="goal-fat">{t('fat_percent', lang)} <span class="macro-actual">→ {editMacroRing.fat}%</span></label>
|
||||
<label for="goal-fat">{t.fat_percent} <span class="macro-actual">→ {editMacroRing.fat}%</span></label>
|
||||
<input id="goal-fat" type="number" bind:value={editFatPercent} min="0" max="100" />
|
||||
</div>
|
||||
<div class="goal-field">
|
||||
<label for="goal-carbs">{t('carb_percent', lang)} <span class="macro-actual">→ {editMacroRing.carb}%</span></label>
|
||||
<label for="goal-carbs">{t.carb_percent} <span class="macro-actual">→ {editMacroRing.carb}%</span></label>
|
||||
<input id="goal-carbs" type="number" bind:value={editCarbPercent} min="0" max="100" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -1747,9 +1748,9 @@
|
||||
<div class="wizard-nav">
|
||||
<button class="btn-secondary" type="button" onclick={() => goalStep = 2}>← {isEn ? 'Back' : 'Zurück'}</button>
|
||||
<div class="goal-actions-final">
|
||||
<button class="btn-secondary" onclick={() => showGoalEditor = false}>{t('cancel', lang)}</button>
|
||||
<button class="btn-secondary" onclick={() => showGoalEditor = false}>{t.cancel}</button>
|
||||
<button class="btn-primary" onclick={saveGoals} disabled={goalSaving}>
|
||||
{goalSaving ? t('saving', lang) : t('save', lang)}
|
||||
{goalSaving ? t.saving : t.save}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1855,7 +1856,7 @@
|
||||
{#if goalCalories}
|
||||
<!-- Micros card (desktop) -->
|
||||
<div class="micro-card">
|
||||
<h3 class="micro-card-title">{t('micro_details', lang)}</h3>
|
||||
<h3 class="micro-card-title">{t.micro_details}</h3>
|
||||
{@render microPanel()}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1883,17 +1884,17 @@
|
||||
ondragleave={(ev) => onMealDragLeave(ev, meal)}
|
||||
ondrop={(ev) => onMealDrop(ev, meal)}
|
||||
role="region"
|
||||
aria-label={t(meal, lang)}
|
||||
aria-label={t[meal]}
|
||||
>
|
||||
<div class="meal-header">
|
||||
<div class="meal-title">
|
||||
<div class="meal-icon">
|
||||
<MealSectionIcon size={15} />
|
||||
</div>
|
||||
<h3>{t(meal, lang)}</h3>
|
||||
<h3>{t[meal]}</h3>
|
||||
</div>
|
||||
{#if mealEntries.length > 0}
|
||||
<span class="meal-cal">{fmtCal(mealCal)} {t('kcal', lang)}</span>
|
||||
<span class="meal-cal">{fmtCal(mealCal)} {t.kcal}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1940,7 +1941,7 @@
|
||||
onclick={() => { editingMeal = m; }}
|
||||
>
|
||||
<MPIcon size={11} />
|
||||
<span>{t(m, lang)}</span>
|
||||
<span>{t[m]}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -1959,7 +1960,7 @@
|
||||
<Pencil size={14} />
|
||||
{/if}
|
||||
</button>
|
||||
<button class="food-card-action delete" onclick={() => deleteEntry(entry._id)} aria-label={t('delete_', lang)}>
|
||||
<button class="food-card-action delete" onclick={() => deleteEntry(entry._id)} aria-label={t.delete_}>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
@@ -1973,7 +1974,7 @@
|
||||
<div class="fab-tabs">
|
||||
<button class="fab-tab" class:active={inlineTab === 'search'} onclick={() => inlineTab = 'search'}>
|
||||
<Search size={13} />
|
||||
{t('search_food', lang).replace('…', '')}
|
||||
{t.search_food.replace('…', '')}
|
||||
</button>
|
||||
<button class="fab-tab" class:active={inlineTab === 'favorites'} onclick={() => { inlineTab = 'favorites'; loadFavTab(); }}>
|
||||
<Heart size={13} />
|
||||
@@ -1981,7 +1982,7 @@
|
||||
</button>
|
||||
<button class="fab-tab" class:active={inlineTab === 'meals'} onclick={() => { inlineTab = 'meals'; loadCustomMeals(); }}>
|
||||
<UtensilsCrossed size={13} />
|
||||
{t('custom_meals', lang)}
|
||||
{t.custom_meals}
|
||||
</button>
|
||||
</div>
|
||||
<button class="fab-close" onclick={cancelAdd}><Plus size={18} style="transform: rotate(45deg)" /></button>
|
||||
@@ -1998,7 +1999,7 @@
|
||||
{:else}
|
||||
<button class="add-food-btn" onclick={() => startAdd(meal)}>
|
||||
<Plus size={14} />
|
||||
{t('add_food', lang)}
|
||||
{t.add_food}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -2071,7 +2072,7 @@
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events -->
|
||||
<div class="fab-modal" onclick={(e) => e.stopPropagation()} onkeydown={(e) => e.stopPropagation()}>
|
||||
<div class="fab-modal-header">
|
||||
<h3>{t('add_food', lang)}</h3>
|
||||
<h3>{t.add_food}</h3>
|
||||
<button class="fab-close" onclick={closeFabModal}><Plus size={18} style="transform: rotate(45deg)" /></button>
|
||||
</div>
|
||||
|
||||
@@ -2087,7 +2088,7 @@
|
||||
onclick={() => fabMealType = meal}
|
||||
>
|
||||
<MealIcon size={14} />
|
||||
<span>{t(meal, lang)}</span>
|
||||
<span>{t[meal]}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -2096,7 +2097,7 @@
|
||||
<div class="fab-tabs">
|
||||
<button class="fab-tab" class:active={fabTab === 'search'} onclick={() => fabTab = 'search'}>
|
||||
<Search size={13} />
|
||||
{t('search_food', lang).replace('…', '')}
|
||||
{t.search_food.replace('…', '')}
|
||||
</button>
|
||||
<button class="fab-tab" class:active={fabTab === 'favorites'} onclick={() => { fabTab = 'favorites'; loadFavTab(); }}>
|
||||
<Heart size={13} />
|
||||
@@ -2104,7 +2105,7 @@
|
||||
</button>
|
||||
<button class="fab-tab" class:active={fabTab === 'meals'} onclick={() => { fabTab = 'meals'; loadCustomMeals(); }}>
|
||||
<UtensilsCrossed size={13} />
|
||||
{t('custom_meals', lang)}
|
||||
{t.custom_meals}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -8,13 +8,14 @@
|
||||
import Droplet from '@lucide/svelte/icons/droplet';
|
||||
import Wheat from '@lucide/svelte/icons/wheat';
|
||||
import { toast } from '$lib/js/toast.svelte';
|
||||
import { detectFitnessLang, fitnessSlugs, t } from '$lib/js/fitnessI18n';
|
||||
import { detectFitnessLang, fitnessSlugs, m } from '$lib/js/fitnessI18n';
|
||||
import { NUTRIENT_META } from '$lib/data/dailyReferenceIntake';
|
||||
import RingGraph from '$lib/components/fitness/RingGraph.svelte';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
const lang = $derived(detectFitnessLang(page.url.pathname));
|
||||
const t = $derived(m[lang]);
|
||||
const s = $derived(fitnessSlugs(lang));
|
||||
const isEn = $derived(lang === 'en');
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import Pencil from '@lucide/svelte/icons/pencil';
|
||||
import UtensilsCrossed from '@lucide/svelte/icons/utensils-crossed';
|
||||
import X from '@lucide/svelte/icons/x';
|
||||
import { detectFitnessLang, fitnessSlugs, t } from '$lib/js/fitnessI18n';
|
||||
import { detectFitnessLang, fitnessSlugs, m } from '$lib/js/fitnessI18n';
|
||||
import { toast } from '$lib/js/toast.svelte';
|
||||
import { confirm } from '$lib/js/confirmDialog.svelte';
|
||||
import FoodSearch from '$lib/components/fitness/FoodSearch.svelte';
|
||||
@@ -15,6 +15,7 @@
|
||||
/** @typedef {import('$models/CustomMeal').ICustomMealIngredient} MealIngredient */
|
||||
|
||||
const lang = $derived(detectFitnessLang(page.url.pathname));
|
||||
const t = $derived(m[lang]);
|
||||
const s = $derived(fitnessSlugs(lang));
|
||||
const isEn = $derived(lang === 'en');
|
||||
|
||||
@@ -148,7 +149,7 @@
|
||||
|
||||
/** @param {Meal} meal */
|
||||
async function deleteMeal(meal) {
|
||||
if (!await confirm(t('delete_meal_confirm', lang))) return;
|
||||
if (!await confirm(t.delete_meal_confirm)) return;
|
||||
try {
|
||||
const res = await fetch(`/api/fitness/custom-meals/${meal._id}`, { method: 'DELETE' });
|
||||
if (res.ok) {
|
||||
@@ -167,42 +168,42 @@
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{t('custom_meals', lang)} — Fitness</title>
|
||||
<title>{t.custom_meals} — Fitness</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="meals-page">
|
||||
<!-- Header -->
|
||||
<div class="header">
|
||||
<h1 class="page-title">{t('custom_meals', lang)}</h1>
|
||||
<h1 class="page-title">{t.custom_meals}</h1>
|
||||
{#if !editing}
|
||||
<button class="create-btn" onclick={startCreate}>
|
||||
<Plus size={18} />
|
||||
<span>{t('new_meal', lang)}</span>
|
||||
<span>{t.new_meal}</span>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="loading-state">
|
||||
<p>{t('loading', lang)}</p>
|
||||
<p>{t.loading}</p>
|
||||
</div>
|
||||
{:else if editing}
|
||||
<!-- Create/Edit Form -->
|
||||
<div class="form-card">
|
||||
<h2 class="form-title">{editingId ? t('edit', lang) : t('new_meal', lang)}</h2>
|
||||
<h2 class="form-title">{editingId ? t.edit : t.new_meal}</h2>
|
||||
|
||||
<label class="field-label">
|
||||
{t('meal_name', lang)}
|
||||
{t.meal_name}
|
||||
<input
|
||||
type="text"
|
||||
class="text-input"
|
||||
bind:value={mealName}
|
||||
placeholder={t('meal_name', lang)}
|
||||
placeholder={t.meal_name}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<!-- Ingredients list -->
|
||||
<span class="field-label">{t('ingredients', lang)} ({ingredients.length})</span>
|
||||
<span class="field-label">{t.ingredients} ({ingredients.length})</span>
|
||||
{#if ingredients.length > 0}
|
||||
<div class="ingredients-list">
|
||||
{#each ingredients as ing, i}
|
||||
@@ -256,7 +257,7 @@
|
||||
{/if}
|
||||
<span class="ingredient-cal">
|
||||
{#if sp}<span class="ingredient-grams">{ing.amountGrams}g ·</span>{/if}
|
||||
{fmt((ing.per100g?.calories ?? 0) * ing.amountGrams / 100)} {t('kcal', lang)}
|
||||
{fmt((ing.per100g?.calories ?? 0) * ing.amountGrams / 100)} {t.kcal}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -287,7 +288,7 @@
|
||||
{#if !showSearch}
|
||||
<button class="add-ingredient-btn" onclick={() => { showSearch = true; }}>
|
||||
<Plus size={16} />
|
||||
<span>{t('add_ingredient', lang)}</span>
|
||||
<span>{t.add_ingredient}</span>
|
||||
</button>
|
||||
{:else}
|
||||
<div class="search-section">
|
||||
@@ -295,20 +296,20 @@
|
||||
onselect={addIngredient}
|
||||
oncancel={() => { showSearch = false; }}
|
||||
showDetailLinks={false}
|
||||
confirmLabel={t('add_ingredient', lang)}
|
||||
confirmLabel={t.add_ingredient}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="form-actions">
|
||||
<button class="btn secondary" onclick={cancelEdit}>{t('cancel', lang)}</button>
|
||||
<button class="btn secondary" onclick={cancelEdit}>{t.cancel}</button>
|
||||
<button
|
||||
class="btn primary"
|
||||
onclick={saveMeal}
|
||||
disabled={saving || !mealName.trim() || ingredients.length === 0}
|
||||
>
|
||||
{saving ? t('loading', lang) : t('save_meal', lang)}
|
||||
{saving ? t.loading : t.save_meal}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -316,8 +317,8 @@
|
||||
<!-- Empty state -->
|
||||
<div class="empty-state">
|
||||
<UtensilsCrossed size={48} strokeWidth={1.2} />
|
||||
<p class="empty-title">{t('no_custom_meals', lang)}</p>
|
||||
<p class="empty-hint">{t('create_meal_hint', lang)}</p>
|
||||
<p class="empty-title">{t.no_custom_meals}</p>
|
||||
<p class="empty-hint">{t.create_meal_hint}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Meal cards -->
|
||||
@@ -328,14 +329,14 @@
|
||||
<div class="meal-info">
|
||||
<h3 class="meal-name">{meal.name}</h3>
|
||||
<span class="meal-meta">
|
||||
{meal.ingredients.length} {t('ingredients', lang)} — {Math.round(mealTotalCal(meal))} {t('kcal', lang)}
|
||||
{meal.ingredients.length} {t.ingredients} — {Math.round(mealTotalCal(meal))} {t.kcal}
|
||||
</span>
|
||||
</div>
|
||||
<div class="meal-actions">
|
||||
<button class="icon-btn" onclick={() => startEdit(meal)} aria-label={t('edit', lang)}>
|
||||
<button class="icon-btn" onclick={() => startEdit(meal)} aria-label={t.edit}>
|
||||
<Pencil size={16} />
|
||||
</button>
|
||||
<button class="icon-btn danger" onclick={() => deleteMeal(meal)} aria-label={t('delete_', lang)}>
|
||||
<button class="icon-btn danger" onclick={() => deleteMeal(meal)} aria-label={t.delete_}>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -18,12 +18,13 @@
|
||||
import FitnessStreakAura from '$lib/components/fitness/FitnessStreakAura.svelte';
|
||||
import PeriodTracker from '$lib/components/fitness/PeriodTracker.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import { detectFitnessLang, fitnessSlugs, t } from '$lib/js/fitnessI18n';
|
||||
import { detectFitnessLang, fitnessSlugs, m } from '$lib/js/fitnessI18n';
|
||||
import { toast } from '$lib/js/toast.svelte';
|
||||
import StatsRingGraph from '$lib/components/fitness/StatsRingGraph.svelte';
|
||||
import { BODY_PART_CARDS, bodyPartSlug, bodyPartAccent } from '$lib/js/fitnessBodyParts';
|
||||
|
||||
const lang = $derived(detectFitnessLang(page.url.pathname));
|
||||
const t = $derived(m[lang]);
|
||||
const statsSlug = $derived(lang === 'en' ? 'stats' : 'statistik');
|
||||
const historySlug = $derived(lang === 'en' ? 'history' : 'verlauf');
|
||||
|
||||
@@ -226,7 +227,7 @@
|
||||
|
||||
const bfChartTitle = $derived.by(() => {
|
||||
const baseline = stats.bfChart?.baseline;
|
||||
const label = t('body_fat', lang).replace(' %', '').replace(' (%)', '');
|
||||
const label = t.body_fat.replace(' %', '').replace(' (%)', '');
|
||||
if (baseline == null) return label;
|
||||
const suffix = lang === 'en'
|
||||
? `Δ from ${baseline.toFixed(1)}%`
|
||||
@@ -284,36 +285,36 @@
|
||||
|
||||
</script>
|
||||
|
||||
<svelte:head><title>{t('stats_title', lang)} - Bocken</title></svelte:head>
|
||||
<svelte:head><title>{t.stats_title} - Bocken</title></svelte:head>
|
||||
|
||||
<div class="stats-page">
|
||||
<h1 class="sr-only">{t('stats_title', lang)}</h1>
|
||||
<h1 class="sr-only">{t.stats_title}</h1>
|
||||
|
||||
<div class="lifetime-cards">
|
||||
<div class="lifetime-card workouts">
|
||||
<div class="card-icon"><Dumbbell size={24} /></div>
|
||||
<div class="card-value">{stats.totalWorkouts ?? 0}</div>
|
||||
<div class="card-label">{(stats.totalWorkouts ?? 0) === 1 ? t('workout_singular', lang) : t('workouts_plural', lang)}</div>
|
||||
<div class="card-label">{(stats.totalWorkouts ?? 0) === 1 ? t.workout_singular : t.workouts_plural}</div>
|
||||
</div>
|
||||
<div class="lifetime-card tonnage">
|
||||
<div class="card-icon"><Weight size={24} /></div>
|
||||
<div class="card-value">{stats.totalTonnage ?? 0}<span class="card-unit">t</span></div>
|
||||
<div class="card-label">{t('lifted', lang)}</div>
|
||||
<div class="card-label">{t.lifted}</div>
|
||||
</div>
|
||||
{#if stats.kcalEstimate}
|
||||
<div class="lifetime-card kcal">
|
||||
<div class="card-icon"><Flame size={24} /></div>
|
||||
<div class="card-value">~{stats.kcalEstimate.kcal.toLocaleString()}<span class="card-unit">kcal</span></div>
|
||||
<div class="card-label">{t('burned', lang)}</div>
|
||||
<div class="card-label">{t.burned}</div>
|
||||
{#if !hasDemographics}
|
||||
<div class="card-hint">{t('kcal_set_profile', lang)} <a href={resolve('/fitness/[checkin=fitnessCheckIn]', { checkin: fitnessSlugs(lang).measure })}>{t('measure_title', lang)}</a></div>
|
||||
<div class="card-hint">{t.kcal_set_profile} <a href={resolve('/fitness/[checkin=fitnessCheckIn]', { checkin: fitnessSlugs(lang).measure })}>{t.measure_title}</a></div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="lifetime-card cardio">
|
||||
<div class="card-icon"><Route size={24} /></div>
|
||||
<div class="card-value">{stats.totalCardioKm ?? 0}<span class="card-unit">km</span></div>
|
||||
<div class="card-label">{t('covered', lang)}</div>
|
||||
<div class="card-label">{t.covered}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -322,18 +323,18 @@
|
||||
<div class="goal-editor-overlay" onkeydown={(e) => { if (e.key === 'Escape') goalEditing = false; }} role="dialog" tabindex="-1">
|
||||
<div class="goal-editor-backdrop" onclick={() => goalEditing = false} onkeydown={(e) => { if (e.key === 'Escape') goalEditing = false; }} role="presentation"></div>
|
||||
<div class="goal-editor-panel">
|
||||
<h3>{t('weekly_goal', lang)}</h3>
|
||||
<h3>{t.weekly_goal}</h3>
|
||||
<div class="goal-input-row">
|
||||
<button class="adj-btn" onclick={() => { if (goalInput > 1) goalInput--; }} disabled={goalInput <= 1}>-</button>
|
||||
<span class="goal-value">{goalInput}</span>
|
||||
<button class="adj-btn" onclick={() => { if (goalInput < 14) goalInput++; }} disabled={goalInput >= 14}>+</button>
|
||||
</div>
|
||||
<span class="goal-unit">{t('workouts_per_week_goal', lang)}</span>
|
||||
<span class="goal-unit">{t.workouts_per_week_goal}</span>
|
||||
<div class="goal-actions">
|
||||
<button class="goal-save" onclick={saveGoal} disabled={goalSaving}>
|
||||
{goalSaving ? t('saving', lang) : t('save', lang)}
|
||||
{goalSaving ? t.saving : t.save}
|
||||
</button>
|
||||
<button class="goal-cancel" onclick={() => goalEditing = false}>{t('cancel', lang)}</button>
|
||||
<button class="goal-cancel" onclick={() => goalEditing = false}>{t.cancel}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -345,23 +346,23 @@
|
||||
<FitnessChart
|
||||
type="bar"
|
||||
data={workoutsChartData}
|
||||
title={t('workouts_per_week', lang)}
|
||||
title={t.workouts_per_week}
|
||||
height="220px"
|
||||
goalLine={goalWeekly ?? undefined}
|
||||
/>
|
||||
{:else}
|
||||
<p class="empty-chart">{t('no_workout_data', lang)}</p>
|
||||
<p class="empty-chart">{t.no_workout_data}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<button class="streak-section" onclick={startGoalEdit}>
|
||||
<FitnessStreakAura value={goalStreak} />
|
||||
<div class="streak-meta">
|
||||
<span class="streak-unit">{goalStreak === 1 ? t('streak_week', lang) : t('streak_weeks', lang)}</span>
|
||||
<span class="streak-label">{t('streak', lang)}</span>
|
||||
<span class="streak-unit">{goalStreak === 1 ? t.streak_week : t.streak_weeks}</span>
|
||||
<span class="streak-label">{t.streak}</span>
|
||||
{#if goalWeekly !== null}
|
||||
<span class="streak-goal">{goalWeekly}x / {t('streak_week', lang).toLowerCase()}</span>
|
||||
<span class="streak-goal">{goalWeekly}x / {t.streak_week.toLowerCase()}</span>
|
||||
{:else}
|
||||
<span class="streak-goal">{t('set_goal', lang)}</span>
|
||||
<span class="streak-goal">{t.set_goal}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
@@ -370,7 +371,7 @@
|
||||
{#if (stats.weightChart?.data?.length ?? 0) > 1}
|
||||
<FitnessChart
|
||||
data={weightChartData}
|
||||
title={t('weight', lang)}
|
||||
title={t.weight}
|
||||
yUnit=" kg"
|
||||
height="220px"
|
||||
/>
|
||||
@@ -390,18 +391,18 @@
|
||||
<div class="lifetime-card protein-card">
|
||||
<div class="card-icon"><Beef size={24} /></div>
|
||||
{#if ns.avgProteinPerKg != null}
|
||||
<div class="card-value">{ns.avgProteinPerKg.toFixed(1)}<span class="card-unit">{t('protein_per_kg_unit', lang)}</span></div>
|
||||
<div class="card-value">{ns.avgProteinPerKg.toFixed(1)}<span class="card-unit">{t.protein_per_kg_unit}</span></div>
|
||||
{:else}
|
||||
<div class="card-value card-value-na">—</div>
|
||||
{/if}
|
||||
<div class="card-label">{t('protein', lang)}</div>
|
||||
<div class="card-label">{t.protein}</div>
|
||||
<div class="card-hint">
|
||||
{#if ns.avgProteinPerKg != null}
|
||||
{t('seven_day_avg', lang)}
|
||||
{t.seven_day_avg}
|
||||
{:else if !ns.trendWeight}
|
||||
{t('no_weight_data', lang)}
|
||||
{t.no_weight_data}
|
||||
{:else}
|
||||
{t('no_nutrition_data', lang)}
|
||||
{t.no_nutrition_data}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -410,13 +411,13 @@
|
||||
<div class="card-icon"><Scale size={24} /></div>
|
||||
{#if ns.avgCalorieBalance != null}
|
||||
<div class="card-value" class:positive={ns.avgCalorieBalance > 0} class:negative={ns.avgCalorieBalance < 0}>
|
||||
{ns.avgCalorieBalance > 0 ? '+' : ''}{ns.avgCalorieBalance}<span class="card-unit">{t('calorie_balance_unit', lang)}</span>
|
||||
{ns.avgCalorieBalance > 0 ? '+' : ''}{ns.avgCalorieBalance}<span class="card-unit">{t.calorie_balance_unit}</span>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="card-value card-value-na">—</div>
|
||||
{/if}
|
||||
<div class="card-label card-label-info">
|
||||
{t('calorie_balance', lang)}
|
||||
{t.calorie_balance}
|
||||
<button class="card-info-trigger" onclick={() => showBalanceInfo = !showBalanceInfo} aria-label="Info"><Info size={12} /></button>
|
||||
{#if showBalanceInfo}
|
||||
<div class="card-info-tooltip">
|
||||
@@ -433,11 +434,11 @@
|
||||
</div>
|
||||
<div class="card-hint" class:card-hint-warning={ns.avgCalorieBalance == null && hasDemographics && ns.trendWeight && ns.daysTracked7 === 0}>
|
||||
{#if ns.avgCalorieBalance != null}
|
||||
{t('seven_day_avg', lang)}
|
||||
{t.seven_day_avg}
|
||||
{:else if !hasDemographics || !ns.trendWeight}
|
||||
{lang === 'en' ? 'Set height, birth year & weight' : 'Größe, Geburtsjahr & Gewicht eintragen'}
|
||||
{:else}
|
||||
{t('no_nutrition_data', lang)}
|
||||
{t.no_nutrition_data}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -450,7 +451,7 @@
|
||||
<div class="card-value card-value-na">—</div>
|
||||
{/if}
|
||||
<div class="card-label card-label-info">
|
||||
{t('diet_adherence', lang)}
|
||||
{t.diet_adherence}
|
||||
<button class="card-info-trigger" onclick={() => showAdherenceInfo = !showAdherenceInfo} aria-label="Info"><Info size={12} /></button>
|
||||
{#if showAdherenceInfo}
|
||||
<div class="card-info-tooltip">
|
||||
@@ -462,16 +463,16 @@
|
||||
</div>
|
||||
<div class="card-hint">
|
||||
{#if ns.adherencePercent != null}
|
||||
{t('since_start', lang)} ({ns.adherenceDays} {t('days', lang)})
|
||||
{t.since_start} ({ns.adherenceDays} {t.days})
|
||||
{:else}
|
||||
{t('no_calorie_goal', lang)}
|
||||
{t.no_calorie_goal}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="lifetime-card macro-card" class:macro-card-empty={!ns.macroSplit}>
|
||||
<div class="macro-left">
|
||||
<div class="macro-header">{t('macro_split', lang)} <span class="macro-subtitle">({t('seven_day_avg', lang)})</span></div>
|
||||
<div class="macro-header">{t.macro_split} <span class="macro-subtitle">({t.seven_day_avg})</span></div>
|
||||
<div class="macro-legend">
|
||||
<span class="macro-legend-item">
|
||||
<svg viewBox="0 0 12 12" width="12" height="12"><path d="M3,9.5 A4,4 0 1,1 9,9.5" fill="none" stroke="var(--color-text-secondary)" stroke-width="2" stroke-linecap="round"/></svg>
|
||||
@@ -483,14 +484,14 @@
|
||||
</span>
|
||||
</div>
|
||||
{#if !ns.macroSplit}
|
||||
<div class="macro-empty-hint">{t('no_nutrition_data', lang)}</div>
|
||||
<div class="macro-empty-hint">{t.no_nutrition_data}</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="macro-rings">
|
||||
{#each [
|
||||
{ pct: ns.macroSplit?.protein ?? 0, target: ns.macroTargets?.protein, label: t('protein', lang), color: 'var(--nord14)', fill: '#a3be8c', icon: Beef },
|
||||
{ pct: ns.macroSplit?.fat ?? 0, target: ns.macroTargets?.fat, label: t('fat', lang), color: 'var(--nord12)', fill: '#d08770', icon: Droplet },
|
||||
{ pct: ns.macroSplit?.carbs ?? 0, target: ns.macroTargets?.carbs, label: t('carbs', lang), color: 'var(--nord9)', fill: '#81a1c1', icon: Wheat },
|
||||
{ pct: ns.macroSplit?.protein ?? 0, target: ns.macroTargets?.protein, label: t.protein, color: 'var(--nord14)', fill: '#a3be8c', icon: Beef },
|
||||
{ pct: ns.macroSplit?.fat ?? 0, target: ns.macroTargets?.fat, label: t.fat, color: 'var(--nord12)', fill: '#d08770', icon: Droplet },
|
||||
{ pct: ns.macroSplit?.carbs ?? 0, target: ns.macroTargets?.carbs, label: t.carbs, color: 'var(--nord9)', fill: '#81a1c1', icon: Wheat },
|
||||
] as macro (macro.color)}
|
||||
{@const MacroIcon = macro.icon}
|
||||
<div class="macro-ring">
|
||||
@@ -509,7 +510,7 @@
|
||||
</div>
|
||||
|
||||
<div class="section-block muscle-heatmap-block">
|
||||
<h2 class="section-title">{t('muscle_balance', lang)}</h2>
|
||||
<h2 class="section-title">{t.muscle_balance}</h2>
|
||||
{#await data.muscleHeatmap}
|
||||
<div class="muscle-heatmap-pending" aria-hidden="true"></div>
|
||||
{:then muscleHeatmap}
|
||||
@@ -522,7 +523,7 @@
|
||||
|
||||
{#if cardsWithData.length > 0}
|
||||
<section class="body-parts-section">
|
||||
<h2>{t('body_parts', lang)}</h2>
|
||||
<h2>{t.body_parts}</h2>
|
||||
<div class="bp-grid">
|
||||
{#each cardsWithData as card (card.key)}
|
||||
{@const cv = currentValue(card)}
|
||||
@@ -542,7 +543,7 @@
|
||||
{/if}
|
||||
</div>
|
||||
<div class="bp-meta">
|
||||
<span class="bp-label">{t(card.labelKey, lang)}</span>
|
||||
<span class="bp-label">{t[card.labelKey]}</span>
|
||||
{#if card.paired}
|
||||
{#if cv.left != null && cv.right != null && cv.left === cv.right}
|
||||
<span class="bp-value">{cv.left.toFixed(1)}<span class="bp-unit">cm</span></span>
|
||||
|
||||
+12
-11
@@ -6,13 +6,14 @@
|
||||
import TrendingUp from '@lucide/svelte/icons/trending-up';
|
||||
import TrendingDown from '@lucide/svelte/icons/trending-down';
|
||||
import MinusIcon from '@lucide/svelte/icons/minus';
|
||||
import { detectFitnessLang, t } from '$lib/js/fitnessI18n';
|
||||
import { detectFitnessLang, m } from '$lib/js/fitnessI18n';
|
||||
import FitnessChart from '$lib/components/fitness/FitnessChart.svelte';
|
||||
import { bodyPartAccent } from '$lib/js/fitnessBodyParts';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
const lang = $derived(detectFitnessLang(page.url.pathname));
|
||||
const t = $derived(m[lang]);
|
||||
const statsSlug = $derived(lang === 'en' ? 'stats' : 'statistik');
|
||||
const checkinSlug = $derived(lang === 'en' ? 'check-in' : 'erfassung');
|
||||
const card = $derived(data.card);
|
||||
@@ -80,7 +81,7 @@
|
||||
labels: series.dates,
|
||||
datasets: [
|
||||
{
|
||||
label: t(card.labelKey, lang),
|
||||
label: t[card.labelKey],
|
||||
data: series.values,
|
||||
borderColor: '#88C0D0',
|
||||
pointBackgroundColor: '#88C0D0'
|
||||
@@ -148,16 +149,16 @@
|
||||
const hasData = $derived(series.dates.length > 0);
|
||||
</script>
|
||||
|
||||
<svelte:head><title>{t(card.labelKey, lang)} · {lang === 'en' ? 'History' : 'Verlauf'} - Bocken</title></svelte:head>
|
||||
<svelte:head><title>{t[card.labelKey]} · {lang === 'en' ? 'History' : 'Verlauf'} - Bocken</title></svelte:head>
|
||||
|
||||
<div class="detail-page">
|
||||
<header class="detail-header" style="--accent: {bodyPartAccent(card.key)}">
|
||||
<a class="back-link" href={resolve('/fitness/[stats=fitnessStats]', { stats: statsSlug })} aria-label={t('back', lang)}>
|
||||
<a class="back-link" href={resolve('/fitness/[stats=fitnessStats]', { stats: statsSlug })} aria-label={t.back}>
|
||||
<ArrowLeft size={18} />
|
||||
</a>
|
||||
<div class="head-text">
|
||||
<span class="eyebrow">{t('body_parts', lang)}</span>
|
||||
<h1>{t(card.labelKey, lang)}</h1>
|
||||
<span class="eyebrow">{t.body_parts}</span>
|
||||
<h1>{t[card.labelKey]}</h1>
|
||||
</div>
|
||||
<div class="head-img" aria-hidden="true">
|
||||
{#if card.img}
|
||||
@@ -173,9 +174,9 @@
|
||||
|
||||
{#if !hasData}
|
||||
<div class="empty">
|
||||
<p>{t('no_measurements_yet', lang)}</p>
|
||||
<p>{t.no_measurements_yet}</p>
|
||||
<a class="cta" href={resolve('/fitness/[checkin=fitnessCheckIn]/body-parts', { checkin: checkinSlug })}>
|
||||
<Ruler size={16} /> {t('measure_body_parts', lang)}
|
||||
<Ruler size={16} /> {t.measure_body_parts}
|
||||
</a>
|
||||
</div>
|
||||
{:else}
|
||||
@@ -183,7 +184,7 @@
|
||||
{#if stats.paired}
|
||||
<div class="stat-grid">
|
||||
<div class="stat">
|
||||
<span class="stat-label">L · {t('latest', lang)}</span>
|
||||
<span class="stat-label">L · {t.latest}</span>
|
||||
<span class="stat-value">
|
||||
{stats.latest.left != null ? stats.latest.left.toFixed(1) : '—'}
|
||||
<span class="stat-unit">cm</span>
|
||||
@@ -201,7 +202,7 @@
|
||||
{/if}
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-label">R · {t('latest', lang)}</span>
|
||||
<span class="stat-label">R · {t.latest}</span>
|
||||
<span class="stat-value">
|
||||
{stats.latest.right != null ? stats.latest.right.toFixed(1) : '—'}
|
||||
<span class="stat-unit">cm</span>
|
||||
@@ -222,7 +223,7 @@
|
||||
{:else}
|
||||
<div class="stat-grid">
|
||||
<div class="stat">
|
||||
<span class="stat-label">{t('latest', lang)}</span>
|
||||
<span class="stat-label">{t.latest}</span>
|
||||
<span class="stat-value">
|
||||
{stats.latest != null ? stats.latest.toFixed(1) : '—'}
|
||||
<span class="stat-unit">cm</span>
|
||||
|
||||
@@ -20,10 +20,11 @@
|
||||
import { getWorkout } from '$lib/js/workout.svelte';
|
||||
import { getWorkoutSync } from '$lib/js/workoutSync.svelte';
|
||||
import { flattenIntervals } from '$lib/js/gps.svelte';
|
||||
import { detectFitnessLang, fitnessSlugs, t } from '$lib/js/fitnessI18n';
|
||||
import { detectFitnessLang, fitnessSlugs, m } from '$lib/js/fitnessI18n';
|
||||
import { toast } from '$lib/js/toast.svelte';
|
||||
|
||||
const lang = $derived(detectFitnessLang(page.url.pathname));
|
||||
const t = $derived(m[lang]);
|
||||
const sl = $derived(fitnessSlugs(lang));
|
||||
import { getExerciseById, getExerciseMetrics, METRIC_LABELS } from '$lib/data/exercises';
|
||||
import TemplateCard from '$lib/components/fitness/TemplateCard.svelte';
|
||||
@@ -174,7 +175,7 @@
|
||||
templates = [...templates, template];
|
||||
libTemplate.added = true;
|
||||
libraryTemplates = [...libraryTemplates];
|
||||
toast.success(t('template_added', lang));
|
||||
toast.success(t.template_added);
|
||||
} else if (res.status === 409) {
|
||||
libTemplate.added = true;
|
||||
libraryTemplates = [...libraryTemplates];
|
||||
@@ -407,7 +408,7 @@
|
||||
<section class="next-workout">
|
||||
<div class="next-label">
|
||||
<CalendarClock size={16} />
|
||||
<span>{t('next_in_schedule', lang)}</span>
|
||||
<span>{t.next_in_schedule}</span>
|
||||
</div>
|
||||
<button class="next-workout-btn" onclick={startNextScheduled}>
|
||||
<div class="next-info">
|
||||
@@ -428,7 +429,7 @@
|
||||
{@const firstExData = firstEx ? getExerciseById(firstEx.exerciseId, lang) : null}
|
||||
{@const firstExWeight = firstEx?.sets.find((/** @type {any} */ s) => s.weight != null)?.weight}
|
||||
<span class="next-exercises">
|
||||
{nextTemplate.exercises.length} {nextTemplate.exercises.length !== 1 ? t('exercises_word', lang) : t('exercise', lang)}
|
||||
{nextTemplate.exercises.length} {nextTemplate.exercises.length !== 1 ? t.exercises_word : t.exercise}
|
||||
{#if firstExData}
|
||||
· {lang === 'en' ? 'starts with' : 'beginnt mit'} {firstExData.localName} {#if firstExWeight != null}({firstExWeight} kg){/if}
|
||||
{/if}
|
||||
@@ -454,7 +455,7 @@
|
||||
<div class="quick-start-row">
|
||||
<button class="start-choice-btn" onclick={startEmpty}>
|
||||
{#if isApp}<Dumbbell size={18} />{/if}
|
||||
<span>{t('start_empty_workout', lang)}</span>
|
||||
<span>{t.start_empty_workout}</span>
|
||||
</button>
|
||||
{#if isApp}
|
||||
<button class="start-choice-btn" onclick={startGps}>
|
||||
@@ -467,7 +468,7 @@
|
||||
|
||||
<section class="templates-section">
|
||||
<div class="templates-header">
|
||||
<h2>{t('templates', lang)}</h2>
|
||||
<h2>{t.templates}</h2>
|
||||
<div class="templates-header-actions">
|
||||
<button class="header-icon-btn" onclick={openCreateTemplate} aria-label="Create template">
|
||||
<Plus size={18} />
|
||||
@@ -477,12 +478,12 @@
|
||||
</button>
|
||||
<button class="schedule-btn" onclick={openScheduleEditor} aria-label="Edit workout schedule">
|
||||
<CalendarClock size={16} />
|
||||
{t('schedule', lang)}
|
||||
{t.schedule}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{#if templates.length > 0}
|
||||
<p class="template-count">{t('my_templates', lang)} ({templates.length})</p>
|
||||
<p class="template-count">{t.my_templates} ({templates.length})</p>
|
||||
<div class="template-grid">
|
||||
{#each templates as template (template._id)}
|
||||
<TemplateCard
|
||||
@@ -493,10 +494,10 @@
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="no-templates">{t('no_templates_yet', lang)}</p>
|
||||
<p class="no-templates">{t.no_templates_yet}</p>
|
||||
<button class="browse-library-btn" onclick={openLibrary}>
|
||||
<BookOpen size={16} />
|
||||
{t('browse_library', lang)}
|
||||
{t.browse_library}
|
||||
</button>
|
||||
{/if}
|
||||
</section>
|
||||
@@ -538,7 +539,7 @@
|
||||
<li>
|
||||
<span class="tex-name">{exercise?.localName ?? ex.exerciseId}</span>
|
||||
<span class="tex-meta">
|
||||
{ex.sets.length} {ex.sets.length !== 1 ? t('sets', lang) : t('set', lang)}{#if firstWeight != null} · {firstWeight} kg{/if}
|
||||
{ex.sets.length} {ex.sets.length !== 1 ? t.sets : t.set}{#if firstWeight != null} · {firstWeight} kg{/if}
|
||||
</span>
|
||||
</li>
|
||||
{/each}
|
||||
@@ -553,14 +554,14 @@
|
||||
{#if selectedTemplate.mode === 'gps'}
|
||||
<MapPin size={16} /> Start GPS Workout
|
||||
{:else}
|
||||
<Play size={16} /> {t('start_workout', lang)}
|
||||
<Play size={16} /> {t.start_workout}
|
||||
{/if}
|
||||
</button>
|
||||
<button class="modal-edit" onclick={() => openEditTemplate(selectedTemplate)}>
|
||||
<Pencil size={16} /> {t('edit_template', lang)}
|
||||
<Pencil size={16} /> {t.edit_template}
|
||||
</button>
|
||||
<button class="modal-delete" onclick={() => deleteTemplate(selectedTemplate)}>
|
||||
<Trash2 size={16} /> {t('delete_template', lang)}
|
||||
<Trash2 size={16} /> {t.delete_template}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -575,14 +576,14 @@
|
||||
<div class="modal-backdrop" onclick={closeEditor}></div>
|
||||
<div class="modal-panel editor-panel">
|
||||
<div class="modal-header">
|
||||
<h2>{editingTemplate ? t('edit_template', lang) : t('new_template', lang)}</h2>
|
||||
<h2>{editingTemplate ? t.edit_template : t.new_template}</h2>
|
||||
<button class="close-btn" onclick={closeEditor} aria-label="Close"><X size={20} /></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input
|
||||
class="editor-name"
|
||||
type="text"
|
||||
placeholder={t('template_name_placeholder', lang)}
|
||||
placeholder={t.template_name_placeholder}
|
||||
bind:value={editorName}
|
||||
/>
|
||||
|
||||
@@ -672,19 +673,19 @@
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
<button class="editor-add-set" onclick={() => editorAddSet(exIdx)}>{t('add_set_lower', lang)}</button>
|
||||
<button class="editor-add-set" onclick={() => editorAddSet(exIdx)}>{t.add_set_lower}</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<button class="editor-add-exercise" onclick={() => editorPicker = true}>
|
||||
<Plus size={16} /> {t('add_exercise_btn', lang)}
|
||||
<Plus size={16} /> {t.add_exercise_btn}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="modal-start" onclick={saveTemplate} disabled={editorSaving || !editorName.trim() || (editorMode === 'manual' && editorExercises.length === 0)}>
|
||||
<Save size={16} /> {editorSaving ? t('saving', lang) : t('save_template', lang)}
|
||||
<Save size={16} /> {editorSaving ? t.saving : t.save_template}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -706,11 +707,11 @@
|
||||
<div class="modal-backdrop" onclick={closeScheduleEditor}></div>
|
||||
<div class="modal-panel editor-panel">
|
||||
<div class="modal-header">
|
||||
<h2>{t('workout_schedule', lang)}</h2>
|
||||
<h2>{t.workout_schedule}</h2>
|
||||
<button class="close-btn" onclick={closeScheduleEditor} aria-label="Close"><X size={20} /></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="schedule-hint">{t('schedule_hint', lang)}</p>
|
||||
<p class="schedule-hint">{t.schedule_hint}</p>
|
||||
|
||||
{#if editorScheduleOrder.length > 0}
|
||||
<div class="schedule-order">
|
||||
@@ -735,7 +736,7 @@
|
||||
{/if}
|
||||
|
||||
<div class="schedule-available">
|
||||
<p class="schedule-available-label">{t('available_templates', lang)}</p>
|
||||
<p class="schedule-available-label">{t.available_templates}</p>
|
||||
{#each templates.filter((t) => !editorScheduleOrder.includes(t._id)) as template (template._id)}
|
||||
<button class="schedule-add-item" onclick={() => toggleScheduleTemplate(template._id)}>
|
||||
<Plus size={14} />
|
||||
@@ -743,13 +744,13 @@
|
||||
</button>
|
||||
{/each}
|
||||
{#if templates.filter((t) => !editorScheduleOrder.includes(t._id)).length === 0}
|
||||
<p class="schedule-all-added">{t('all_templates_scheduled', lang)}</p>
|
||||
<p class="schedule-all-added">{t.all_templates_scheduled}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="modal-start" onclick={saveAndCloseSchedule} disabled={scheduleSaving}>
|
||||
<Save size={16} /> {scheduleSaving ? t('saving', lang) : t('save_schedule', lang)}
|
||||
<Save size={16} /> {scheduleSaving ? t.saving : t.save_schedule}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -764,12 +765,12 @@
|
||||
<div class="modal-backdrop" onclick={() => showLibrary = false}></div>
|
||||
<div class="modal-panel library-panel">
|
||||
<div class="modal-header">
|
||||
<h2>{t('template_library', lang)}</h2>
|
||||
<h2>{t.template_library}</h2>
|
||||
<button class="close-btn" onclick={() => showLibrary = false} aria-label="Close"><X size={20} /></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
{#if libraryLoading}
|
||||
<p class="library-loading">{t('loading', lang)}...</p>
|
||||
<p class="library-loading">{t.loading}...</p>
|
||||
{:else}
|
||||
<div class="library-grid">
|
||||
{#each libraryTemplates as libTmpl (libTmpl.id)}
|
||||
@@ -777,7 +778,7 @@
|
||||
<div class="library-card-info">
|
||||
<h3>{libTmpl.name}</h3>
|
||||
<p>{libTmpl.description}</p>
|
||||
<span class="library-card-meta">{libTmpl.exercises.length} {t('exercises_heading', lang)}</span>
|
||||
<span class="library-card-meta">{libTmpl.exercises.length} {t.exercises_heading}</span>
|
||||
</div>
|
||||
<button
|
||||
class="library-add-btn"
|
||||
|
||||
@@ -21,11 +21,12 @@
|
||||
import Plus from '@lucide/svelte/icons/plus';
|
||||
import GripVertical from '@lucide/svelte/icons/grip-vertical';
|
||||
import Repeat from '@lucide/svelte/icons/repeat';
|
||||
import { detectFitnessLang, fitnessSlugs, t } from '$lib/js/fitnessI18n';
|
||||
import { detectFitnessLang, fitnessSlugs, m } from '$lib/js/fitnessI18n';
|
||||
import { confirm } from '$lib/js/confirmDialog.svelte';
|
||||
import { toast } from '$lib/js/toast.svelte';
|
||||
|
||||
const lang = $derived(detectFitnessLang(page.url.pathname));
|
||||
const t = $derived(m[lang]);
|
||||
const isEn = $derived(lang === 'en');
|
||||
const sl = $derived(fitnessSlugs(lang));
|
||||
import { getWorkout } from '$lib/js/workout.svelte';
|
||||
@@ -335,7 +336,7 @@
|
||||
}
|
||||
|
||||
async function deleteInterval(/** @type {string} */ id) {
|
||||
if (!await confirm(t('delete_interval_confirm', lang))) return;
|
||||
if (!await confirm(t.delete_interval_confirm)) return;
|
||||
await fetch(`/api/fitness/intervals/${id}`, { method: 'DELETE' });
|
||||
if (selectedIntervalId === id) selectedIntervalId = null;
|
||||
await fetchIntervalTemplates();
|
||||
@@ -467,7 +468,7 @@
|
||||
if (_cadenceWarned) return;
|
||||
if (!gps.cadenceAvailable()) {
|
||||
_cadenceWarned = true;
|
||||
toast.info(t('cadence_permission_missing', lang));
|
||||
toast.info(t.cadence_permission_missing);
|
||||
}
|
||||
}, 4000);
|
||||
}
|
||||
@@ -982,7 +983,7 @@
|
||||
{#if completionData}
|
||||
<div class="completion">
|
||||
<div class="completion-header">
|
||||
<h1>{t('workout_complete', lang)}</h1>
|
||||
<h1>{t.workout_complete}</h1>
|
||||
{#if completionData.prs.length > 0}
|
||||
<div class="pr-badge">
|
||||
<span class="pr-badge-count">{completionData.prs.length}</span>
|
||||
@@ -991,7 +992,7 @@
|
||||
{/if}
|
||||
<p class="completion-name">{completionData.name}</p>
|
||||
{#if offlineQueued}
|
||||
<p class="offline-banner">{t('workout_saved_offline', lang)}</p>
|
||||
<p class="offline-banner">{t.workout_saved_offline}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -999,7 +1000,7 @@
|
||||
<div class="comp-stat">
|
||||
<Clock size={18} />
|
||||
<span class="comp-stat-value">{formatDuration(completionData.durationMin)}</span>
|
||||
<span class="comp-stat-label">{t('duration', lang)}</span>
|
||||
<span class="comp-stat-label">{t.duration}</span>
|
||||
</div>
|
||||
{#if completionData.totalTonnage > 0}
|
||||
<div class="comp-stat">
|
||||
@@ -1009,28 +1010,28 @@
|
||||
? `${(completionData.totalTonnage / 1000).toFixed(1)}t`
|
||||
: `${Math.round(completionData.totalTonnage)} kg`}
|
||||
</span>
|
||||
<span class="comp-stat-label">{t('tonnage', lang)}</span>
|
||||
<span class="comp-stat-label">{t.tonnage}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if completionData.totalDistance > 0}
|
||||
<div class="comp-stat">
|
||||
<Route size={18} />
|
||||
<span class="comp-stat-value">{completionData.totalDistance.toFixed(1)} km</span>
|
||||
<span class="comp-stat-label">{t('distance', lang)}</span>
|
||||
<span class="comp-stat-label">{t.distance}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if completionData.kcalResult}
|
||||
<div class="comp-stat kcal">
|
||||
<Flame size={18} />
|
||||
<span class="comp-stat-value">{completionData.kcalResult.kcal} ± {completionData.kcalResult.kcal - completionData.kcalResult.lower} kcal</span>
|
||||
<span class="comp-stat-label">{t('est_kcal', lang)}</span>
|
||||
<span class="comp-stat-label">{t.est_kcal}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if completionData.prs.length > 0}
|
||||
<div class="prs-section">
|
||||
<h2><Trophy size={16} /> {t('personal_records', lang)}</h2>
|
||||
<h2><Trophy size={16} /> {t.personal_records}</h2>
|
||||
<div class="pr-list">
|
||||
{#each completionData.prs as pr}
|
||||
<div class="pr-item">
|
||||
@@ -1043,12 +1044,12 @@
|
||||
{/if}
|
||||
|
||||
<div class="exercise-summaries">
|
||||
<h2>{t('exercises_heading', lang)}</h2>
|
||||
<h2>{t.exercises_heading}</h2>
|
||||
{#each completionData.exerciseSummaries as ex}
|
||||
<div class="ex-summary">
|
||||
<div class="ex-summary-header">
|
||||
<span class="ex-summary-name">{getExerciseById(ex.exerciseId, lang)?.localName ?? ex.exerciseId}</span>
|
||||
<span class="ex-summary-sets">{ex.sets} {ex.sets !== 1 ? t('sets', lang) : t('set', lang)}</span>
|
||||
<span class="ex-summary-sets">{ex.sets} {ex.sets !== 1 ? t.sets : t.set}</span>
|
||||
</div>
|
||||
<div class="ex-summary-stats">
|
||||
{#if ex.isDurationOnly}
|
||||
@@ -1061,11 +1062,11 @@
|
||||
<span>{ex.duration} min</span>
|
||||
{/if}
|
||||
{#if ex.pace > 0}
|
||||
<span>{formatPace(ex.pace)} {t('avg', lang)}</span>
|
||||
<span>{formatPace(ex.pace)} {t.avg}</span>
|
||||
{/if}
|
||||
{:else}
|
||||
{#if ex.tonnage > 0}
|
||||
<span>{ex.tonnage >= 1000 ? `${(ex.tonnage / 1000).toFixed(1)}t` : `${Math.round(ex.tonnage)} kg`} {t('volume', lang)}</span>
|
||||
<span>{ex.tonnage >= 1000 ? `${(ex.tonnage / 1000).toFixed(1)}t` : `${Math.round(ex.tonnage)} kg`} {t.volume}</span>
|
||||
{/if}
|
||||
{#if ex.bestWeight > 0}
|
||||
<span>Top: {ex.bestWeight} kg</span>
|
||||
@@ -1084,11 +1085,11 @@
|
||||
{#if templateUpdateStatus === 'done'}
|
||||
<div class="template-updated">
|
||||
<Check size={16} />
|
||||
<span>{t('template_updated', lang)}</span>
|
||||
<span>{t.template_updated}</span>
|
||||
</div>
|
||||
{:else}
|
||||
<h2><RefreshCw size={16} /> {t('update_template', lang)}</h2>
|
||||
<p class="template-update-desc">{t('template_diff_desc', lang)}</p>
|
||||
<h2><RefreshCw size={16} /> {t.update_template}</h2>
|
||||
<p class="template-update-desc">{t.template_diff_desc}</p>
|
||||
<div class="template-diff-list">
|
||||
{#each templateDiffs as diff}
|
||||
<div class="diff-item">
|
||||
@@ -1114,7 +1115,7 @@
|
||||
{/each}
|
||||
{#if diff.newSets.length > diff.oldSets.length}
|
||||
<div class="diff-set-row">
|
||||
<span class="diff-new">+{diff.newSets.length - diff.oldSets.length} {diff.newSets.length - diff.oldSets.length > 1 ? t('new_sets_added', lang) : t('new_set_added', lang)}</span>
|
||||
<span class="diff-new">+{diff.newSets.length - diff.oldSets.length} {diff.newSets.length - diff.oldSets.length > 1 ? t.new_sets_added : t.new_set_added}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1122,14 +1123,14 @@
|
||||
{/each}
|
||||
</div>
|
||||
<button class="update-template-btn" onclick={updateTemplate} disabled={templateUpdateStatus === 'updating'}>
|
||||
{templateUpdateStatus === 'updating' ? t('updating', lang) : t('update_template', lang)}
|
||||
{templateUpdateStatus === 'updating' ? t.updating : t.update_template}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<button class="done-btn" onclick={() => goto(offlineQueued ? `/fitness/${sl.workout}` : `/fitness/${sl.history}/${completionData.sessionId}`)}>
|
||||
{offlineQueued ? t('done', lang) : t('view_workout', lang)}
|
||||
{offlineQueued ? t.done : t.view_workout}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1169,7 +1170,7 @@
|
||||
{#if gps.intervalState.complete}
|
||||
<div class="interval-complete-badge">
|
||||
<Check size={14} />
|
||||
<span>{t('intervals_complete', lang)}</span>
|
||||
<span>{t.intervals_complete}</span>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="interval-current-label">{gps.intervalState.currentLabel}</div>
|
||||
@@ -1208,8 +1209,8 @@
|
||||
</button>
|
||||
<button class="gps-option-tile" onclick={() => { showIntervalPanel = !showIntervalPanel; showActivityPicker = false; showAudioPanel = false; }} type="button">
|
||||
<Timer size={20} />
|
||||
<span class="gps-option-label">{t('intervals', lang)}</span>
|
||||
<span class="gps-option-value">{selectedInterval?.name ?? t('no_intervals', lang)}</span>
|
||||
<span class="gps-option-label">{t.intervals}</span>
|
||||
<span class="gps-option-value">{selectedInterval?.name ?? t.no_intervals}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1306,21 +1307,21 @@
|
||||
<div class="interval-card" class:selected={selectedIntervalId === tmpl._id}>
|
||||
<button class="interval-card-main" type="button" onclick={() => { selectedIntervalId = selectedIntervalId === tmpl._id ? null : tmpl._id; }}>
|
||||
<span class="interval-card-name">{tmpl.name}</span>
|
||||
<span class="interval-card-info">{flattenIntervals(tmpl.steps).length} {t('steps_count', lang)}</span>
|
||||
<span class="interval-card-info">{flattenIntervals(tmpl.steps).length} {t.steps_count}</span>
|
||||
</button>
|
||||
<div class="interval-card-actions">
|
||||
<button class="interval-card-edit" type="button" onclick={() => openEditInterval(tmpl)}>{t('edit', lang)}</button>
|
||||
<button class="interval-card-edit" type="button" onclick={() => openEditInterval(tmpl)}>{t.edit}</button>
|
||||
<button class="interval-card-delete" type="button" onclick={() => deleteInterval(tmpl._id)}><Trash2 size={14} /></button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="interval-empty">{t('no_intervals', lang)}</p>
|
||||
<p class="interval-empty">{t.no_intervals}</p>
|
||||
{/if}
|
||||
<button class="interval-new-btn" type="button" onclick={openNewInterval}>
|
||||
<Plus size={16} />
|
||||
{t('new_interval', lang)}
|
||||
{t.new_interval}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1335,7 +1336,7 @@
|
||||
|
||||
<button class="gps-cancel-link" onclick={async () => { if (gps.isTracking) await gps.stop(); gps.reset(); workout.cancel(); await sync.onWorkoutEnd(); await goto(`/fitness/${sl.workout}`); }} type="button">
|
||||
<X size={14} />
|
||||
{t('cancel_workout', lang)}
|
||||
{t.cancel_workout}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1344,7 +1345,7 @@
|
||||
<div class="interval-editor-overlay">
|
||||
<div class="interval-editor">
|
||||
<div class="interval-editor-header">
|
||||
<h2>{editingIntervalId ? t('edit_interval', lang) : t('new_interval', lang)}</h2>
|
||||
<h2>{editingIntervalId ? t.edit_interval : t.new_interval}</h2>
|
||||
<button class="interval-editor-close" type="button" onclick={() => showIntervalEditor = false}>
|
||||
<X size={20} />
|
||||
</button>
|
||||
@@ -1353,7 +1354,7 @@
|
||||
<input
|
||||
class="interval-editor-name"
|
||||
type="text"
|
||||
placeholder={t('interval_name_placeholder', lang)}
|
||||
placeholder={t.interval_name_placeholder}
|
||||
bind:value={intervalEditorName}
|
||||
/>
|
||||
|
||||
@@ -1393,14 +1394,14 @@
|
||||
class:selected={step.customLabel}
|
||||
type="button"
|
||||
onclick={() => { step.customLabel = true; }}
|
||||
>{t('custom', lang)}</button>
|
||||
>{t.custom}</button>
|
||||
</div>
|
||||
|
||||
{#if step.customLabel}
|
||||
<input
|
||||
class="interval-step-custom-input"
|
||||
type="text"
|
||||
placeholder={t('step_label', lang)}
|
||||
placeholder={t.step_label}
|
||||
bind:value={step.label}
|
||||
/>
|
||||
{/if}
|
||||
@@ -1418,13 +1419,13 @@
|
||||
class:active={step.durationType === 'distance'}
|
||||
type="button"
|
||||
onclick={() => { step.durationType = 'distance'; }}
|
||||
>{t('meters', lang)}</button>
|
||||
>{t.meters}</button>
|
||||
<button
|
||||
class="interval-type-btn"
|
||||
class:active={step.durationType === 'time'}
|
||||
type="button"
|
||||
onclick={() => { step.durationType = 'time'; }}
|
||||
>{t('seconds', lang)}</button>
|
||||
>{t.seconds}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1436,7 +1437,7 @@
|
||||
<div class="interval-group-card">
|
||||
<div class="interval-group-header">
|
||||
<Repeat size={16} />
|
||||
<span class="interval-group-label">{t('group_label', lang)}</span>
|
||||
<span class="interval-group-label">{t.group_label}</span>
|
||||
<input
|
||||
class="interval-group-repeat"
|
||||
type="number"
|
||||
@@ -1444,11 +1445,11 @@
|
||||
max="99"
|
||||
bind:value={entry.repeat}
|
||||
/>
|
||||
<span class="interval-group-times">× {t('repeat_times', lang)}</span>
|
||||
<span class="interval-group-times">× {t.repeat_times}</span>
|
||||
<div class="interval-group-actions">
|
||||
<button type="button" onclick={() => moveIntervalStep(idx, -1)} disabled={idx === 0}><ChevronUp size={14} /></button>
|
||||
<button type="button" onclick={() => moveIntervalStep(idx, 1)} disabled={idx === intervalEditorSteps.length - 1}><ChevronDown size={14} /></button>
|
||||
<button class="interval-group-ungroup" type="button" onclick={() => ungroupAt(idx)}>{t('ungroup', lang)}</button>
|
||||
<button class="interval-group-ungroup" type="button" onclick={() => ungroupAt(idx)}>{t.ungroup}</button>
|
||||
<button class="interval-step-remove" type="button" onclick={() => removeIntervalStep(idx)} disabled={intervalEditorSteps.length <= 1}>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
@@ -1469,7 +1470,7 @@
|
||||
{/each}
|
||||
<button class="interval-add-step-btn interval-add-step-btn--inner" type="button" onclick={() => addStepToGroup(idx)}>
|
||||
<Plus size={14} />
|
||||
{t('add_step', lang)}
|
||||
{t.add_step}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1491,11 +1492,11 @@
|
||||
<div class="interval-add-row">
|
||||
<button class="interval-add-step-btn" type="button" onclick={addIntervalStep}>
|
||||
<Plus size={16} />
|
||||
{t('add_step', lang)}
|
||||
{t.add_step}
|
||||
</button>
|
||||
<button class="interval-add-step-btn" type="button" onclick={addIntervalGroup}>
|
||||
<Repeat size={16} />
|
||||
{t('add_group', lang)}
|
||||
{t.add_group}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1505,7 +1506,7 @@
|
||||
onclick={saveInterval}
|
||||
disabled={intervalSaving || !intervalEditorName.trim() || intervalEditorSteps.length === 0}
|
||||
>
|
||||
{intervalSaving ? t('saving', lang) : t('save_interval', lang)}
|
||||
{intervalSaving ? t.saving : t.save_interval}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1522,7 +1523,7 @@
|
||||
onfocus={() => { nameEditing = true; }}
|
||||
onblur={() => { nameEditing = false; workout.name = nameInput; }}
|
||||
onkeydown={(e) => { if (e.key === 'Enter' && e.target instanceof HTMLElement) e.target.blur(); }}
|
||||
placeholder={t('workout_name_placeholder', lang)}
|
||||
placeholder={t.workout_name_placeholder}
|
||||
/>
|
||||
{/snippet}
|
||||
|
||||
@@ -1535,7 +1536,7 @@
|
||||
</button>
|
||||
{#if gpsToggling}
|
||||
<div class="gps-initializing">
|
||||
<span class="gps-spinner"></span> {t('initializing_gps', lang)}
|
||||
<span class="gps-spinner"></span> {t.initializing_gps}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -1646,7 +1647,7 @@
|
||||
syncStatus={sync.status}
|
||||
setsDone={workoutSetsDone}
|
||||
setsTotal={workoutSetsTotal}
|
||||
addLabel={t('add_exercise', lang)}
|
||||
addLabel={t.add_exercise}
|
||||
pauseLabel={isEn ? 'Pause' : 'Pause'}
|
||||
resumeLabel={isEn ? 'Resume' : 'Fortsetzen'}
|
||||
removeLabel={isEn ? 'Remove exercise' : 'Übung entfernen'}
|
||||
@@ -1718,7 +1719,7 @@
|
||||
/>
|
||||
|
||||
<button class="add-set-btn" onclick={() => workout.addSet(activeIdx)}>
|
||||
{t('add_set', lang)}
|
||||
{t.add_set}
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
@@ -1729,9 +1730,9 @@
|
||||
|
||||
<div class="workout-actions">
|
||||
<button class="cancel-btn" onclick={async () => { if (gps.isTracking) await gps.stop(); gps.reset(); workout.cancel(); await sync.onWorkoutEnd(); await goto(`/fitness/${sl.workout}`); }}>
|
||||
{t('cancel_workout', lang)}
|
||||
{t.cancel_workout}
|
||||
</button>
|
||||
<button class="finish-btn" onclick={finishWorkout}>{t('finish', lang)}</button>
|
||||
<button class="finish-btn" onclick={finishWorkout}>{t.finish}</button>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user