- Added `get_energy_availability_warning_payload` function to assess energy availability and provide contextual warnings based on multiple health indicators. - Integrated energy availability KPI tile into the nutrition history visualization, enhancing user insights on energy balance. - Updated frontend components to conditionally display the energy availability warning, improving user experience and data interpretation. - Refactored existing logic in `charts.py` to utilize the new energy availability functionality, streamlining data handling.
179 lines
6.9 KiB
JavaScript
179 lines
6.9 KiB
JavaScript
import { useState, useEffect, useId } from 'react'
|
||
import { Info } from 'lucide-react'
|
||
import { getStatusColor } from '../utils/interpret'
|
||
|
||
/**
|
||
* Zerlegt eine KPI-Kachel für Bottom-Sheet / Tooltip.
|
||
* @param {{ hoverTop?: string, category?: string, hoverBody?: string, keys?: string[] }} t
|
||
*/
|
||
export function kpiTileDetailParts(t) {
|
||
const registryLine = t.keys?.length ? `Registry: ${t.keys.join(', ')}` : ''
|
||
const body = [t.hoverBody, registryLine].filter(Boolean).join('\n\n')
|
||
return { title: t.hoverTop || t.category || 'Kennzahl', body }
|
||
}
|
||
|
||
/** Ein Zeilentext wie natives `title` (Desktop-Hover). */
|
||
export function buildKpiTileTitleString(t) {
|
||
return [t.hoverTop, t.hoverBody, t.keys?.length ? `Registry: ${t.keys.join(', ')}` : '']
|
||
.filter(Boolean)
|
||
.join('\n\n')
|
||
}
|
||
|
||
/**
|
||
* Standard-KPI-Kacheln: Desktop `title`-Tooltip, Touch ℹ → Bottom-Sheet (gleicher Inhalt).
|
||
*
|
||
* Erwartete Kachel-Felder:
|
||
* - `key` (string, eindeutig)
|
||
* - `category` (string) — Zeilenkopf
|
||
* - `value` (ReactNode) — Hauptwert
|
||
* - `status` — für Farbstreifen: `good` | `warn` | `bad`
|
||
* - optional: `icon`, `sublabel`, `verdict`, `valueColor`, `hoverTop`, `hoverBody`, `keys`
|
||
* - optional: `hint` — Kurz-Hinweis/Warnung direkt auf der Kachel (z. B. Ernährung bei warn/bad)
|
||
*/
|
||
export default function KpiTilesOverview({
|
||
tiles,
|
||
heading = 'Kennzahlen',
|
||
showTouchHint = true,
|
||
gridClassName = 'kpi-tiles-grid',
|
||
marginBottom = 12,
|
||
}) {
|
||
const [touchUi, setTouchUi] = useState(false)
|
||
const [openKey, setOpenKey] = useState(null)
|
||
const sheetTitleId = useId()
|
||
|
||
useEffect(() => {
|
||
const mq = window.matchMedia('(hover: none)')
|
||
const apply = () => setTouchUi(mq.matches)
|
||
apply()
|
||
mq.addEventListener('change', apply)
|
||
return () => mq.removeEventListener('change', apply)
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
if (!openKey) return
|
||
const onKey = e => { if (e.key === 'Escape') setOpenKey(null) }
|
||
const prev = document.body.style.overflow
|
||
document.body.style.overflow = 'hidden'
|
||
window.addEventListener('keydown', onKey)
|
||
return () => {
|
||
document.body.style.overflow = prev
|
||
window.removeEventListener('keydown', onKey)
|
||
}
|
||
}, [openKey])
|
||
|
||
if (!tiles?.length) return null
|
||
|
||
const openTile = openKey ? tiles.find(x => x.key === openKey) : null
|
||
const openParts = openTile ? kpiTileDetailParts(openTile) : null
|
||
|
||
const showVerdict = (v) => v != null && String(v).trim() !== '' && String(v).trim() !== '—'
|
||
|
||
return (
|
||
<div style={{ marginBottom }}>
|
||
{heading ? (
|
||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 8 }}>{heading}</div>
|
||
) : null}
|
||
{showTouchHint && touchUi && (
|
||
<div style={{ fontSize: 10, color: 'var(--text3)', marginBottom: 8, lineHeight: 1.4 }}>
|
||
<Info size={12} style={{ display: 'inline', verticalAlign: 'middle', marginRight: 4 }} aria-hidden />
|
||
Auf dem Smartphone: <strong>ℹ</strong> für Erklärung und Details.
|
||
</div>
|
||
)}
|
||
<div className={gridClassName}>
|
||
{tiles.map(t => {
|
||
const accent = getStatusColor(t.status)
|
||
const tip = buildKpiTileTitleString(t)
|
||
const cardHint = t.hint ? String(t.hint) : null
|
||
return (
|
||
<div
|
||
key={t.key}
|
||
className="kpi-tiles-card"
|
||
style={{ borderLeft: `4px solid ${accent}`, position: 'relative' }}
|
||
title={touchUi ? undefined : tip}
|
||
>
|
||
{touchUi && (
|
||
<button
|
||
type="button"
|
||
className="kpi-tiles-info-btn"
|
||
aria-label={`Details: ${t.category || t.hoverTop || 'Kennzahl'}`}
|
||
aria-expanded={openKey === t.key}
|
||
onClick={() => setOpenKey(k => (k === t.key ? null : t.key))}
|
||
>
|
||
<Info size={16} strokeWidth={2.25} aria-hidden />
|
||
</button>
|
||
)}
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 6, paddingRight: touchUi ? 28 : 0 }}>
|
||
{t.icon != null && t.icon !== false ? (
|
||
<span style={{ fontSize: 14, lineHeight: 1 }}>{t.icon}</span>
|
||
) : (
|
||
<span style={{ width: 0, flexShrink: 0 }} aria-hidden />
|
||
)}
|
||
<div style={{ flex: 1, minWidth: 0 }}>
|
||
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text3)', textTransform: 'uppercase', letterSpacing: '0.04em' }}>{t.category}</div>
|
||
<div style={{ fontSize: 18, fontWeight: 700, color: t.valueColor || 'var(--text1)', marginTop: 2, lineHeight: 1.2 }}>{t.value}</div>
|
||
{t.sublabel ? (
|
||
<div style={{ fontSize: 10, color: 'var(--text3)', marginTop: 2, lineHeight: 1.25 }}>{t.sublabel}</div>
|
||
) : null}
|
||
</div>
|
||
{showVerdict(t.verdict) ? (
|
||
<div style={{ textAlign: 'right', flexShrink: 0 }}>
|
||
<div style={{ fontSize: 10, fontWeight: 700, color: accent, lineHeight: 1.2 }}>{t.verdict}</div>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
{cardHint ? (
|
||
<div
|
||
className="kpi-tiles-card__hint"
|
||
style={{
|
||
marginTop: 6,
|
||
paddingLeft: 8,
|
||
borderLeft: `2px solid ${accent}`,
|
||
fontSize: 9,
|
||
lineHeight: 1.35,
|
||
color: 'var(--text2)',
|
||
display: '-webkit-box',
|
||
WebkitLineClamp: 2,
|
||
WebkitBoxOrient: 'vertical',
|
||
overflow: 'hidden',
|
||
wordBreak: 'break-word',
|
||
}}
|
||
>
|
||
{cardHint}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
|
||
{openParts && (
|
||
<div
|
||
className="kpi-tiles-touch-backdrop"
|
||
role="presentation"
|
||
onClick={() => setOpenKey(null)}
|
||
>
|
||
<div
|
||
className="kpi-tiles-touch-sheet"
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-labelledby={sheetTitleId}
|
||
onClick={e => e.stopPropagation()}
|
||
>
|
||
<div className="kpi-tiles-touch-sheet__head">
|
||
<h3 id={sheetTitleId} className="kpi-tiles-touch-sheet__title">{openParts.title}</h3>
|
||
<button type="button" className="kpi-tiles-touch-sheet__close" onClick={() => setOpenKey(null)} aria-label="Schließen">
|
||
×
|
||
</button>
|
||
</div>
|
||
{openParts.body ? (
|
||
<div className="kpi-tiles-touch-sheet__body">{openParts.body}</div>
|
||
) : (
|
||
<div className="kpi-tiles-touch-sheet__body kpi-tiles-touch-sheet__body--muted">Keine weiteren Details.</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|