feat: implement touch-friendly KPI details with bottom sheet interaction
All checks were successful
Deploy Development / deploy (push) Successful in 1m1s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 16s

- Added a new button for displaying KPI details on touch devices, replacing hover functionality.
- Introduced a bottom sheet component to present detailed information when the info button is clicked.
- Enhanced the BodyKpiOverview component to detect touch UI and adjust interactions accordingly.
- Updated CSS styles for new touch elements, ensuring a responsive and user-friendly design.
This commit is contained in:
Lars 2026-04-19 16:46:05 +02:00
parent 8fc7d9c1c4
commit 8c60601ed1
2 changed files with 190 additions and 5 deletions

View File

@ -220,6 +220,113 @@ body { font-family: var(--font); background: var(--bg); color: var(--text1); -we
box-shadow: 0 1px 5px rgba(0, 0, 0, 0.07);
}
/* Kennzahlen: Touch — iOS hat kein Hover; öffnet Bottom-Sheet */
.body-kpi-info-btn {
position: absolute;
top: 6px;
right: 6px;
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 36px;
min-height: 36px;
margin: 0;
padding: 0;
border: none;
border-radius: 8px;
background: transparent;
color: var(--text3);
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.body-kpi-info-btn:active {
background: var(--surface);
color: var(--accent);
}
.body-kpi-touch-backdrop {
position: fixed;
inset: 0;
z-index: 10050;
display: flex;
align-items: flex-end;
justify-content: center;
padding: 0 12px;
padding-bottom: max(12px, env(safe-area-inset-bottom));
background: rgba(0, 0, 0, 0.45);
animation: body-kpi-fade-in 0.15s ease;
}
@keyframes body-kpi-fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
.body-kpi-touch-sheet {
width: 100%;
max-width: 520px;
max-height: min(72vh, 560px);
overflow: auto;
margin: 0 auto;
padding: 14px 16px 18px;
border-radius: 16px 16px 0 0;
background: var(--surface);
border: 1px solid var(--border);
border-bottom: none;
box-shadow: 0 -8px 32px rgba(0, 0, 0, 0.18);
}
.body-kpi-touch-sheet__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 10px;
}
.body-kpi-touch-sheet__title {
margin: 0;
font-size: 16px;
font-weight: 700;
color: var(--text1);
line-height: 1.3;
flex: 1;
min-width: 0;
}
.body-kpi-touch-sheet__close {
flex-shrink: 0;
width: 40px;
height: 40px;
margin: -6px -8px 0 0;
padding: 0;
border: none;
border-radius: 10px;
background: transparent;
color: var(--text2);
font-size: 26px;
line-height: 1;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.body-kpi-touch-sheet__close:active {
background: var(--surface2);
}
.body-kpi-touch-sheet__body {
font-size: 13px;
line-height: 1.5;
color: var(--text2);
white-space: pre-wrap;
word-break: break-word;
}
.body-kpi-touch-sheet__body--muted {
color: var(--text3);
font-style: italic;
}
.history-page__title {
margin-bottom: 12px;
}

View File

@ -6,7 +6,7 @@ import {
XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid,
ReferenceLine, PieChart, Pie, Cell, ComposedChart
} from 'recharts'
import { ChevronRight, Brain, ChevronDown, ChevronUp, Trash2 } from 'lucide-react'
import { ChevronRight, Brain, ChevronDown, ChevronUp, Trash2, Info } from 'lucide-react'
import { api } from '../utils/api'
import { photoMonthKey, photoSortKey, formatPhotoCaption } from '../utils/photoDisplay'
import { getBfCategory } from '../utils/calc'
@ -232,12 +232,51 @@ function buildBodyKpiTiles({
return tiles
}
/** KPI-Kacheln: Kurzvergleich sichtbar, ausführlicher Text per nativem Hover (`title`). */
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, body }
}
/** KPI-Kacheln: Desktop — Hover (`title`). Touch — öffnet gleichen Text im Bottom-Sheet (iOS hat kein Hover). */
function BodyKpiOverview({ tiles }) {
const [touchUi, setTouchUi] = useState(false)
const [openKey, setOpenKey] = useState(null)
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
return (
<div style={{ marginBottom: 12 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 8 }}>Kennzahlen</div>
{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="body-kpi-overview">
{tiles.map(t => {
const accent = getStatusColor(t.status)
@ -246,10 +285,21 @@ function BodyKpiOverview({ tiles }) {
<div
key={t.key}
className="body-kpi-card"
style={{ borderLeft: `4px solid ${accent}` }}
title={tip}
style={{ borderLeft: `4px solid ${accent}`, position: 'relative' }}
title={touchUi ? undefined : tip}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 6 }}>
{touchUi && (
<button
type="button"
className="body-kpi-info-btn"
aria-label={`Details: ${t.category}`}
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 }}>
<span style={{ fontSize: 14, lineHeight: 1 }}>{t.icon}</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text3)', textTransform: 'uppercase', letterSpacing: '0.04em' }}>{t.category}</div>
@ -266,6 +316,34 @@ function BodyKpiOverview({ tiles }) {
)
})}
</div>
{openParts && (
<div
className="body-kpi-touch-backdrop"
role="presentation"
onClick={() => setOpenKey(null)}
>
<div
className="body-kpi-touch-sheet"
role="dialog"
aria-modal="true"
aria-labelledby="body-kpi-touch-title"
onClick={e => e.stopPropagation()}
>
<div className="body-kpi-touch-sheet__head">
<h3 id="body-kpi-touch-title" className="body-kpi-touch-sheet__title">{openParts.title}</h3>
<button type="button" className="body-kpi-touch-sheet__close" onClick={() => setOpenKey(null)} aria-label="Schließen">
×
</button>
</div>
{openParts.body ? (
<div className="body-kpi-touch-sheet__body">{openParts.body}</div>
) : (
<div className="body-kpi-touch-sheet__body body-kpi-touch-sheet__body--muted">Keine weiteren Details.</div>
)}
</div>
</div>
)}
</div>
)
}