UX: Gewicht-Tab mit großem Chart und ohne Bezeichnungsfeld

- Aktuelles Gewicht groß prominent oben angezeigt
- Delta zur letzten Messung mit ▲/▼ und Farbe (grün/orange)
- SVG-Diagramm: glatte Bezier-Kurve, Gradient-Füllung, Rastlinien
- Formular: kein "Bezeichnung"-Feld mehr bei Gewicht-Einträgen
- Detail-Modal-Titel zeigt Gewichtswert statt leerer Bezeichnung
- SW-Cache → by-v9
This commit is contained in:
rene 2026-04-13 20:22:23 +02:00
parent dee8d10496
commit 75529cbdab
2 changed files with 107 additions and 44 deletions

View file

@ -268,69 +268,128 @@ window.Page_health = (() => {
}
// ----------------------------------------------------------
// GEWICHT — mit SVG-Diagramm
// GEWICHT — Großanzeige + SVG-Diagramm
// ----------------------------------------------------------
function _renderGewicht(entries) {
const addBtn = `<button class="btn btn-primary btn-sm" data-action="add-entry">+ Gewicht eintragen</button>`;
const sorted = [...entries].sort((a, b) => a.datum.localeCompare(b.datum));
const chart = sorted.length >= 2 ? _weightChart(sorted) : '';
if (!entries.length) return UI.emptyState({
icon: '⚖️', title: 'Noch keine Gewichtseinträge', action: addBtn
});
const sorted = [...entries].sort((a, b) => a.datum.localeCompare(b.datum));
const latest = sorted[sorted.length - 1];
const prev = sorted.length >= 2 ? sorted[sorted.length - 2] : null;
const delta = prev ? (parseFloat(latest.wert) - parseFloat(prev.wert)) : null;
const deltaHtml = delta !== null ? (() => {
const sign = delta > 0 ? '+' : '';
const color = delta > 0 ? 'var(--c-warning)' : delta < 0 ? 'var(--c-success)' : 'var(--c-text-muted)';
const arrow = delta > 0 ? '▲' : delta < 0 ? '▼' : '→';
return `<div style="font-size:var(--text-sm);color:${color};margin-top:var(--space-1)">
${arrow} ${sign}${delta.toFixed(1)} kg seit letzter Messung
</div>`;
})() : '';
const chart = sorted.length >= 2 ? _weightChart(sorted) : '';
const items = sorted.slice().reverse().map(e => `
<div class="health-card" data-id="${e.id}" data-action="open-entry">
<div class="health-card-body">
<div class="health-card-title" style="font-size:var(--text-xl);font-weight:700">
${e.wert} ${e.einheit || 'kg'}
</div>
<div class="health-card-meta">${UI.time.format(e.datum + 'T00:00:00')}</div>
${e.notiz ? `<div class="health-card-note">${_esc(e.notiz)}</div>` : ''}
<div class="health-card" data-id="${e.id}" data-action="open-entry"
style="padding:var(--space-3) var(--space-4)">
<div style="display:flex;justify-content:space-between;align-items:center;width:100%">
<span style="font-size:var(--text-sm);color:var(--c-text-secondary)">
${UI.time.format(e.datum + 'T00:00:00')}
</span>
<span style="font-weight:var(--weight-bold);font-size:var(--text-lg)">
${e.wert} <span style="font-size:var(--text-sm);color:var(--c-text-secondary)">${e.einheit || 'kg'}</span>
</span>
</div>
${e.notiz ? `<div class="health-card-note" style="padding-top:var(--space-1)">${_esc(e.notiz)}</div>` : ''}
</div>
`).join('');
return `
<div style="text-align:center;padding:var(--space-5) var(--space-4) var(--space-2)">
<div style="font-size:3rem;font-weight:var(--weight-bold);color:var(--c-text);line-height:1">
${latest.wert}
<span style="font-size:1.1rem;font-weight:var(--weight-normal);color:var(--c-text-secondary)">kg</span>
</div>
${deltaHtml}
</div>
${chart ? `<div class="health-chart-wrap">${chart}</div>` : ''}
<div class="health-list">${items}</div>
<div class="health-list" style="margin-top:var(--space-2)">${items}</div>
<div style="text-align:center;padding:var(--space-4)">${addBtn}</div>
`;
}
function _weightChart(entries) {
const W = 320, H = 120, PAD = 24;
const vals = entries.map(e => parseFloat(e.wert));
const min = Math.min(...vals);
const max = Math.max(...vals);
const range = max - min || 1;
const W = 340, H = 160, PX = 36, PY = 16, PB = 28;
const vals = entries.map(e => parseFloat(e.wert));
const minV = Math.min(...vals);
const maxV = Math.max(...vals);
const range = maxV - minV || 1;
const innerW = W - PX * 2;
const innerH = H - PY - PB;
const pts = entries.map((e, i) => {
const x = PAD + (i / (entries.length - 1)) * (W - PAD * 2);
const y = PAD + (1 - (parseFloat(e.wert) - min) / range) * (H - PAD * 2);
return `${x},${y}`;
});
const toX = i => PX + (i / (entries.length - 1)) * innerW;
const toY = v => PY + (1 - (v - minV) / range) * innerH;
const pts = entries.map((e, i) => [toX(i), toY(parseFloat(e.wert))]);
const dots = entries.map((e, i) => {
const [x, y] = pts[i].split(',');
return `<circle cx="${x}" cy="${y}" r="4" fill="var(--c-primary)"/>`;
// Smooth bezier path
const linePath = pts.reduce((acc, [x, y], i) => {
if (i === 0) return `M ${x},${y}`;
const [px, py] = pts[i - 1];
const cpx = (px + x) / 2;
return `${acc} C ${cpx},${py} ${cpx},${y} ${x},${y}`;
}, '');
const fillPath = `${linePath} L ${pts[pts.length - 1][0]},${H - PB} L ${pts[0][0]},${H - PB} Z`;
// Grid lines
const gridLines = [0, 0.5, 1].map(frac => {
const v = maxV - frac * range;
const y = toY(v);
return `
<line x1="${PX}" y1="${y}" x2="${W - PX}" y2="${y}"
stroke="var(--c-border-light)" stroke-width="1" stroke-dasharray="4,4"/>
<text x="${PX - 5}" y="${y + 4}" font-size="9" fill="var(--c-text-muted)" text-anchor="end">
${v.toFixed(1)}
</text>`;
}).join('');
const labels = [
`<text x="${PAD}" y="${H - 4}" font-size="10" fill="var(--c-text-secondary)">${entries[0].datum.slice(5)}</text>`,
`<text x="${W - PAD}" y="${H - 4}" font-size="10" fill="var(--c-text-secondary)" text-anchor="end">${entries[entries.length - 1].datum.slice(5)}</text>`,
`<text x="${PAD - 2}" y="${PAD + 4}" font-size="10" fill="var(--c-text-secondary)" text-anchor="end">${max.toFixed(1)}</text>`,
`<text x="${PAD - 2}" y="${H - PAD + 4}" font-size="10" fill="var(--c-text-secondary)" text-anchor="end">${min.toFixed(1)}</text>`,
].join('');
// X-axis labels
const xIdxs = entries.length <= 3
? entries.map((_, i) => i)
: [0, Math.round((entries.length - 1) / 2), entries.length - 1];
const xLabels = [...new Set(xIdxs)].map(i => {
const [m, d] = entries[i].datum.slice(5).split('-');
return `<text x="${toX(i)}" y="${H - 6}" font-size="9" fill="var(--c-text-muted)"
text-anchor="middle">${d}.${m}.</text>`;
}).join('');
// Dots
const dots = pts.map(([x, y], i) => {
const isLast = i === pts.length - 1;
return `<circle cx="${x}" cy="${y}" r="${isLast ? 5 : 3.5}"
fill="${isLast ? 'var(--c-primary)' : 'var(--c-surface)'}"
stroke="var(--c-primary)" stroke-width="2"/>`;
}).join('');
const gId = `wg${Math.random().toString(36).slice(2, 7)}`;
return `
<svg viewBox="0 0 ${W} ${H}" style="width:100%;max-width:${W}px;display:block;margin:0 auto">
<polyline points="${pts.join(' ')}"
fill="none" stroke="var(--c-primary)" stroke-width="2" stroke-linejoin="round"/>
<svg viewBox="0 0 ${W} ${H}" style="width:100%;display:block">
<defs>
<linearGradient id="${gId}" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="var(--c-primary)" stop-opacity="0.22"/>
<stop offset="100%" stop-color="var(--c-primary)" stop-opacity="0.02"/>
</linearGradient>
</defs>
${gridLines}
<path d="${fillPath}" fill="url(#${gId})"/>
<path d="${linePath}" fill="none" stroke="var(--c-primary)"
stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
${dots}
${labels}
${xLabels}
</svg>
`;
}
@ -481,7 +540,10 @@ window.Page_health = (() => {
</div>
`;
UI.modal.open({ title: `${tabInfo.icon} ${_esc(entry.bezeichnung)}`, body });
const modalTitle = entry.typ === 'gewicht'
? `${tabInfo.icon} ${entry.wert} ${entry.einheit || 'kg'}`
: `${tabInfo.icon} ${_esc(entry.bezeichnung)}`;
UI.modal.open({ title: modalTitle, body });
document.getElementById('health-detail-edit')?.addEventListener('click', () => {
UI.modal.close();
@ -538,12 +600,13 @@ window.Page_health = (() => {
const t = typ || _activeTab;
const commonFields = `
<div class="form-group">
<label class="form-label">Bezeichnung *</label>
<input class="form-control" type="text" name="bezeichnung"
value="${_esc(entry?.bezeichnung || '')}" required
placeholder="${_formPlaceholder(t)}">
</div>
${t !== 'gewicht' ? `
<div class="form-group">
<label class="form-label">Bezeichnung *</label>
<input class="form-control" type="text" name="bezeichnung"
value="${_esc(entry?.bezeichnung || '')}" required
placeholder="${_formPlaceholder(t)}">
</div>` : ''}
<div class="form-group">
<label class="form-label">Datum *</label>
<input class="form-control" type="date" name="datum"

View file

@ -3,7 +3,7 @@
Offline-Cache + Push Notifications
============================================================ */
const CACHE_VERSION = 'by-v8';
const CACHE_VERSION = 'by-v9';
const CACHE_STATIC = `${CACHE_VERSION}-static`;
// Diese Dateien werden beim Install gecacht (App Shell)