mitai-jinkendo/frontend/src/components/KpiTilesOverview.jsx
Lars d7304c1a44
All checks were successful
Deploy Development / deploy (push) Successful in 55s
Build Test / pytest-backend (push) Successful in 9s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 16s
feat: implement energy availability warning and enhance nutrition visualization
- 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.
2026-04-19 17:43:29 +02:00

179 lines
6.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
)
}