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:
parent
dee8d10496
commit
75529cbdab
2 changed files with 107 additions and 44 deletions
|
|
@ -268,69 +268,128 @@ window.Page_health = (() => {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
// GEWICHT — mit SVG-Diagramm
|
// GEWICHT — Großanzeige + SVG-Diagramm
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
function _renderGewicht(entries) {
|
function _renderGewicht(entries) {
|
||||||
const addBtn = `<button class="btn btn-primary btn-sm" data-action="add-entry">+ Gewicht eintragen</button>`;
|
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({
|
if (!entries.length) return UI.emptyState({
|
||||||
icon: '⚖️', title: 'Noch keine Gewichtseinträge', action: addBtn
|
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 => `
|
const items = sorted.slice().reverse().map(e => `
|
||||||
<div class="health-card" data-id="${e.id}" data-action="open-entry">
|
<div class="health-card" data-id="${e.id}" data-action="open-entry"
|
||||||
<div class="health-card-body">
|
style="padding:var(--space-3) var(--space-4)">
|
||||||
<div class="health-card-title" style="font-size:var(--text-xl);font-weight:700">
|
<div style="display:flex;justify-content:space-between;align-items:center;width:100%">
|
||||||
${e.wert} ${e.einheit || 'kg'}
|
<span style="font-size:var(--text-sm);color:var(--c-text-secondary)">
|
||||||
</div>
|
${UI.time.format(e.datum + 'T00:00:00')}
|
||||||
<div class="health-card-meta">${UI.time.format(e.datum + 'T00:00:00')}</div>
|
</span>
|
||||||
${e.notiz ? `<div class="health-card-note">${_esc(e.notiz)}</div>` : ''}
|
<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>
|
</div>
|
||||||
|
${e.notiz ? `<div class="health-card-note" style="padding-top:var(--space-1)">${_esc(e.notiz)}</div>` : ''}
|
||||||
</div>
|
</div>
|
||||||
`).join('');
|
`).join('');
|
||||||
|
|
||||||
return `
|
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>` : ''}
|
${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>
|
<div style="text-align:center;padding:var(--space-4)">${addBtn}</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function _weightChart(entries) {
|
function _weightChart(entries) {
|
||||||
const W = 320, H = 120, PAD = 24;
|
const W = 340, H = 160, PX = 36, PY = 16, PB = 28;
|
||||||
const vals = entries.map(e => parseFloat(e.wert));
|
const vals = entries.map(e => parseFloat(e.wert));
|
||||||
const min = Math.min(...vals);
|
const minV = Math.min(...vals);
|
||||||
const max = Math.max(...vals);
|
const maxV = Math.max(...vals);
|
||||||
const range = max - min || 1;
|
const range = maxV - minV || 1;
|
||||||
|
const innerW = W - PX * 2;
|
||||||
|
const innerH = H - PY - PB;
|
||||||
|
|
||||||
const pts = entries.map((e, i) => {
|
const toX = i => PX + (i / (entries.length - 1)) * innerW;
|
||||||
const x = PAD + (i / (entries.length - 1)) * (W - PAD * 2);
|
const toY = v => PY + (1 - (v - minV) / range) * innerH;
|
||||||
const y = PAD + (1 - (parseFloat(e.wert) - min) / range) * (H - PAD * 2);
|
const pts = entries.map((e, i) => [toX(i), toY(parseFloat(e.wert))]);
|
||||||
return `${x},${y}`;
|
|
||||||
});
|
|
||||||
|
|
||||||
const dots = entries.map((e, i) => {
|
// Smooth bezier path
|
||||||
const [x, y] = pts[i].split(',');
|
const linePath = pts.reduce((acc, [x, y], i) => {
|
||||||
return `<circle cx="${x}" cy="${y}" r="4" fill="var(--c-primary)"/>`;
|
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('');
|
}).join('');
|
||||||
|
|
||||||
const labels = [
|
// X-axis labels
|
||||||
`<text x="${PAD}" y="${H - 4}" font-size="10" fill="var(--c-text-secondary)">${entries[0].datum.slice(5)}</text>`,
|
const xIdxs = entries.length <= 3
|
||||||
`<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>`,
|
? entries.map((_, i) => i)
|
||||||
`<text x="${PAD - 2}" y="${PAD + 4}" font-size="10" fill="var(--c-text-secondary)" text-anchor="end">${max.toFixed(1)}</text>`,
|
: [0, Math.round((entries.length - 1) / 2), entries.length - 1];
|
||||||
`<text x="${PAD - 2}" y="${H - PAD + 4}" font-size="10" fill="var(--c-text-secondary)" text-anchor="end">${min.toFixed(1)}</text>`,
|
const xLabels = [...new Set(xIdxs)].map(i => {
|
||||||
].join('');
|
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 `
|
return `
|
||||||
<svg viewBox="0 0 ${W} ${H}" style="width:100%;max-width:${W}px;display:block;margin:0 auto">
|
<svg viewBox="0 0 ${W} ${H}" style="width:100%;display:block">
|
||||||
<polyline points="${pts.join(' ')}"
|
<defs>
|
||||||
fill="none" stroke="var(--c-primary)" stroke-width="2" stroke-linejoin="round"/>
|
<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}
|
${dots}
|
||||||
${labels}
|
${xLabels}
|
||||||
</svg>
|
</svg>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
@ -481,7 +540,10 @@ window.Page_health = (() => {
|
||||||
</div>
|
</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', () => {
|
document.getElementById('health-detail-edit')?.addEventListener('click', () => {
|
||||||
UI.modal.close();
|
UI.modal.close();
|
||||||
|
|
@ -538,12 +600,13 @@ window.Page_health = (() => {
|
||||||
const t = typ || _activeTab;
|
const t = typ || _activeTab;
|
||||||
|
|
||||||
const commonFields = `
|
const commonFields = `
|
||||||
<div class="form-group">
|
${t !== 'gewicht' ? `
|
||||||
<label class="form-label">Bezeichnung *</label>
|
<div class="form-group">
|
||||||
<input class="form-control" type="text" name="bezeichnung"
|
<label class="form-label">Bezeichnung *</label>
|
||||||
value="${_esc(entry?.bezeichnung || '')}" required
|
<input class="form-control" type="text" name="bezeichnung"
|
||||||
placeholder="${_formPlaceholder(t)}">
|
value="${_esc(entry?.bezeichnung || '')}" required
|
||||||
</div>
|
placeholder="${_formPlaceholder(t)}">
|
||||||
|
</div>` : ''}
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label">Datum *</label>
|
<label class="form-label">Datum *</label>
|
||||||
<input class="form-control" type="date" name="datum"
|
<input class="form-control" type="date" name="datum"
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
Offline-Cache + Push Notifications
|
Offline-Cache + Push Notifications
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
|
||||||
const CACHE_VERSION = 'by-v8';
|
const CACHE_VERSION = 'by-v9';
|
||||||
const CACHE_STATIC = `${CACHE_VERSION}-static`;
|
const CACHE_STATIC = `${CACHE_VERSION}-static`;
|
||||||
|
|
||||||
// Diese Dateien werden beim Install gecacht (App Shell)
|
// Diese Dateien werden beim Install gecacht (App Shell)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue