fix: systemd ohne ProtectHome (venv unter /root sichtbar) + Phase-3-Backend
Deployment-Fixes (Dienst startete nicht -> 203/EXEC "uvicorn not found"): - Unit ohne ProtectHome/ProtectSystem: Repo/venv dürfen unter /root oder /home liegen (ProtectHome=true blendete diese Pfade für den Dienst aus). - install.sh: Python-3.11+-Prüfung mit klarer Meldung; prüft nach der Installation, dass uvicorn wirklich in der venv liegt. - EZB-Kursabruf läuft beim Start jetzt im Hintergrund (blockiert den Start nie). Phase-3-Backend (Dashboard): - Modell + Migration 0004: dashboard_layouts (JSON-Layout je Nutzer). - routers/dashboard: Layout speichern/laden/zurücksetzen + Kartendaten (Budget-Donut, Budget-Fortschritt, Einnahmen/Ausgaben, Kontostände, Vermögensverlauf, letzte Buchungen). - Frontend-Infrastruktur: Chart-Komponenten (Donut/Line), palette, API-Methoden, react-grid-layout. (Dashboard-Seite folgt im nächsten Commit.) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,109 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { tokenFarbe } from "../palette";
|
||||
import useGroesse from "./useGroesse";
|
||||
|
||||
// Liniendiagramm über die Zeit.
|
||||
// labels: ["01.07.", …] serien: [{ name, color, werte: number[] }]
|
||||
// fmt: Werteformatierer.
|
||||
export default function Line({ labels = [], serien = [], fmt = (v) => String(v) }) {
|
||||
const wirt = useRef(null);
|
||||
const plot = useRef(null);
|
||||
const { breite, hoehe } = useGroesse(plot);
|
||||
const [index, setIndex] = useState(null);
|
||||
|
||||
const legende = serien.length > 1;
|
||||
const hatWerte = serien.some((s) => s.werte?.length);
|
||||
|
||||
if (labels.length <= 1 || !hatWerte) {
|
||||
return <div className="chart-line" ref={wirt}><div className="empty">Keine Daten vorhanden.</div></div>;
|
||||
}
|
||||
|
||||
const rand = { oben: 12, rechts: 12, unten: 24, links: 52 };
|
||||
const w = Math.max(breite, 240);
|
||||
const h = Math.max(hoehe, 120);
|
||||
const flaecheB = Math.max(w - rand.links - rand.rechts, 10);
|
||||
const flaecheH = Math.max(h - rand.oben - rand.unten, 10);
|
||||
|
||||
const alleWerte = serien.flatMap((s) => s.werte);
|
||||
const maxWert = Math.max(...alleWerte, 1);
|
||||
const minWert = Math.min(...alleWerte, 0);
|
||||
const spanne = Math.max(maxWert - minWert, 1);
|
||||
const skalaMax = maxWert + spanne * 0.1;
|
||||
const skalaMin = minWert - spanne * 0.05;
|
||||
const skalaSpanne = Math.max(skalaMax - skalaMin, 1);
|
||||
|
||||
const x = (i) => rand.links + (i / (labels.length - 1)) * flaecheB;
|
||||
const y = (wert) => rand.oben + flaecheH - ((wert - skalaMin) / skalaSpanne) * flaecheH;
|
||||
|
||||
const gitter = [0, 0.5, 1].map((teil) => {
|
||||
const wert = skalaMin + skalaSpanne * teil;
|
||||
return { wert, y: y(wert) };
|
||||
});
|
||||
|
||||
const schritt = Math.max(1, Math.round(labels.length / 5));
|
||||
const achse = labels.map((l, i) => ({ l, i })).filter(({ i }) => i % schritt === 0);
|
||||
|
||||
const gitterFarbe = tokenFarbe("--border", "#ddd");
|
||||
const textFarbe = tokenFarbe("--muted", "#888");
|
||||
|
||||
function bewegen(event) {
|
||||
const box = event.currentTarget.getBoundingClientRect();
|
||||
const rel = event.clientX - box.left - rand.links;
|
||||
const i = Math.round((rel / flaecheB) * (labels.length - 1));
|
||||
setIndex(Math.min(Math.max(i, 0), labels.length - 1));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="chart-line" ref={wirt}>
|
||||
{legende && (
|
||||
<ul className="chart-legend inline">
|
||||
{serien.map((s) => (
|
||||
<li key={s.name}>
|
||||
<span className="swatch" style={{ background: s.color }} />
|
||||
<span className="legend-name">{s.name}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<div className="line-plot" ref={plot}>
|
||||
<svg width={w} height={h} role="img" aria-label={`Liniendiagramm über ${labels.length} Tage`}
|
||||
onMouseMove={bewegen} onMouseLeave={() => setIndex(null)}>
|
||||
{gitter.map((g) => (
|
||||
<g key={g.wert}>
|
||||
<line x1={rand.links} x2={w - rand.rechts} y1={g.y} y2={g.y} stroke={gitterFarbe} strokeWidth="1" />
|
||||
<text x={rand.links - 8} y={g.y + 4} textAnchor="end" fontSize="11" fill={textFarbe}>{fmt(g.wert)}</text>
|
||||
</g>
|
||||
))}
|
||||
{achse.map(({ l, i }) => (
|
||||
<text key={l + i} x={x(i)} y={h - 6} textAnchor="middle" fontSize="11" fill={textFarbe}>{l}</text>
|
||||
))}
|
||||
{serien.map((s) => (
|
||||
<path key={s.name} fill="none" stroke={s.color} strokeWidth="2" strokeLinejoin="round" strokeLinecap="round"
|
||||
d={s.werte.map((wert, i) => `${i === 0 ? "M" : "L"} ${x(i)} ${y(wert)}`).join(" ")} />
|
||||
))}
|
||||
{index != null && (
|
||||
<>
|
||||
<line x1={x(index)} x2={x(index)} y1={rand.oben} y2={rand.oben + flaecheH} stroke={gitterFarbe} strokeWidth="1" />
|
||||
{serien.map((s) => (
|
||||
<circle key={s.name} cx={x(index)} cy={y(s.werte[index] ?? 0)} r="4.5"
|
||||
fill={s.color} stroke={tokenFarbe("--surface", "#fff")} strokeWidth="2" />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</svg>
|
||||
{index != null && (
|
||||
<div className="line-tooltip" style={{ left: Math.min(Math.max(x(index), 60), w - 60) }}>
|
||||
<div className="tip-date">{labels[index]}</div>
|
||||
{serien.map((s) => (
|
||||
<div key={s.name} className="tip-row">
|
||||
<span className="swatch" style={{ background: s.color }} />
|
||||
{serien.length > 1 && <span className="tip-name">{s.name}</span>}
|
||||
<strong>{fmt(s.werte[index] ?? 0)}</strong>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user