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:
Guido
2026-08-19 19:31:55 +02:00
co-authored by Claude Opus 4.8
parent f53c79a20c
commit 2dd8f9cd35
14 changed files with 685 additions and 8 deletions
+94
View File
@@ -11,6 +11,7 @@
"i18next": "^24.2.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-grid-layout": "^1.5.3",
"react-i18next": "^15.4.0",
"react-router-dom": "^6.28.0"
},
@@ -1368,6 +1369,15 @@
],
"license": "CC-BY-4.0"
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
@@ -1452,6 +1462,12 @@
"node": ">=6"
}
},
"node_modules/fast-equals": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-4.0.3.tgz",
"integrity": "sha512-G3BSX9cfKttjr+2o1O22tYMLq0DPluZnYtq1rXumE1SpL/F/SLIfHx08WYQoWSIpeMYf8sRbJ8++71+v6Pnxfg==",
"license": "MIT"
},
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -1625,6 +1641,15 @@
"node": ">=18"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -1674,6 +1699,17 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.4.0",
"object-assign": "^4.1.1",
"react-is": "^16.13.1"
}
},
"node_modules/react": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
@@ -1699,6 +1735,38 @@
"react": "^18.3.1"
}
},
"node_modules/react-draggable": {
"version": "4.7.1",
"resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-4.7.1.tgz",
"integrity": "sha512-wa3tzfFnYt3yaZLuyU58fl1TNunfWfBekDgWhZA1+gb2jnp42wZ0ymuopR6M5kqDYmm4hKmzGlcKWjZf3Zb6RQ==",
"license": "MIT",
"dependencies": {
"clsx": "^2.1.1",
"prop-types": "^15.8.1"
},
"peerDependencies": {
"react": ">= 16.3.0",
"react-dom": ">= 16.3.0"
}
},
"node_modules/react-grid-layout": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/react-grid-layout/-/react-grid-layout-1.5.4.tgz",
"integrity": "sha512-AyScMfqhgRfie9ECXddQeaYmTuNlomDj9SIryF/+vtiwXS6IsxX3cI6CCWbSRXqbAePHW397nPxvnekO5gViPg==",
"license": "MIT",
"dependencies": {
"clsx": "^2.1.1",
"fast-equals": "^4.0.3",
"prop-types": "^15.8.1",
"react-draggable": "^4.4.6",
"react-resizable": "^3.0.5",
"resize-observer-polyfill": "^1.5.1"
},
"peerDependencies": {
"react": ">= 16.3.0",
"react-dom": ">= 16.3.0"
}
},
"node_modules/react-i18next": {
"version": "15.7.4",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-15.7.4.tgz",
@@ -1725,6 +1793,12 @@
}
}
},
"node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT"
},
"node_modules/react-refresh": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
@@ -1735,6 +1809,20 @@
"node": ">=0.10.0"
}
},
"node_modules/react-resizable": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/react-resizable/-/react-resizable-3.2.0.tgz",
"integrity": "sha512-3NKQ0SLZV7rs3LQHeXlOzDSRQfFrkX6TVet77/Qk03zqiZyee37b7N8/gwDJAA8UUjRz7PdWCCy49hcso45SMQ==",
"license": "MIT",
"dependencies": {
"prop-types": "15.x",
"react-draggable": "^4.5.0"
},
"peerDependencies": {
"react": ">= 16.3",
"react-dom": ">= 16.3"
}
},
"node_modules/react-router": {
"version": "6.30.6",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.6.tgz",
@@ -1767,6 +1855,12 @@
"react-dom": ">=16.8"
}
},
"node_modules/resize-observer-polyfill": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz",
"integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==",
"license": "MIT"
},
"node_modules/rollup": {
"version": "4.62.4",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz",
+1
View File
@@ -12,6 +12,7 @@
"i18next": "^24.2.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-grid-layout": "^1.5.3",
"react-i18next": "^15.4.0",
"react-router-dom": "^6.28.0"
},
+11
View File
@@ -175,4 +175,15 @@ export const api = {
createIncome: (body) => request("/budget/income", { method: "POST", body }),
updateIncome: (id, body) => request(`/budget/income/${id}`, { method: "PATCH", body }),
deleteIncome: (id) => request(`/budget/income/${id}`, { method: "DELETE" }),
// Dashboard
dashboardLayout: () => request("/dashboard/layout"),
saveDashboardLayout: (layout) => request("/dashboard/layout", { method: "PUT", body: layout }),
resetDashboardLayout: () => request("/dashboard/layout", { method: "DELETE" }),
cardBudgetDonut: () => request("/dashboard/budget-donut"),
cardBudgetProgress: () => request("/dashboard/budget-progress"),
cardIncomeExpense: () => request("/dashboard/income-expense"),
cardBalances: () => request("/dashboard/balances"),
cardNetWorth: (days) => request(`/dashboard/net-worth${days ? `?days=${days}` : ""}`),
cardRecent: (limit) => request(`/dashboard/recent${limit ? `?limit=${limit}` : ""}`),
};
+64
View File
@@ -0,0 +1,64 @@
import { useState } from "react";
// Ringdiagramm für Anteile. segmente: [{ name, value, color }]
// fmt: Formatierer für die Werte (z. B. Geldbetrag).
export default function Donut({ segmente = [], gesamtLabel = "gesamt", fmt = (v) => String(v) }) {
const [aktiv, setAktiv] = useState(null);
const werte = segmente.filter((s) => s.value > 0);
const summe = werte.reduce((s, e) => s + e.value, 0);
if (summe <= 0) return <div className="empty">Keine Daten vorhanden.</div>;
const groesse = 180;
const mitte = groesse / 2;
const radius = 74;
const dicke = 26;
const umfang = 2 * Math.PI * radius;
const luecke = 2;
let versatz = 0;
const boegen = werte.map((s) => {
const anteil = s.value / summe;
const laenge = Math.max(anteil * umfang - luecke, 0.5);
const bogen = { ...s, laenge, versatz, anteil };
versatz += anteil * umfang;
return bogen;
});
const gezeigt = aktiv ?? null;
return (
<div className="chart-donut">
<svg viewBox={`0 0 ${groesse} ${groesse}`} className="donut-svg" role="img"
aria-label={`Ringdiagramm, ${werte.length} Anteile`}>
<g transform={`rotate(-90 ${mitte} ${mitte})`}>
{boegen.map((b) => (
<circle key={b.name}
cx={mitte} cy={mitte} r={radius} fill="none" stroke={b.color}
strokeWidth={gezeigt?.name === b.name ? dicke + 4 : dicke}
strokeDasharray={`${b.laenge} ${umfang - b.laenge}`}
strokeDashoffset={-b.versatz}
onMouseEnter={() => setAktiv(b)} onMouseLeave={() => setAktiv(null)} />
))}
</g>
<text x={mitte} y={mitte - 4} textAnchor="middle" className="donut-value">
{fmt(gezeigt ? gezeigt.value : summe)}
</text>
<text x={mitte} y={mitte + 14} textAnchor="middle" className="donut-label">
{gezeigt ? gezeigt.name : gesamtLabel}
</text>
</svg>
<div className="chart-legend-scroll">
<ul className="chart-legend">
{boegen.map((b) => (
<li key={b.name} onMouseEnter={() => setAktiv(b)} onMouseLeave={() => setAktiv(null)}>
<span className="swatch" style={{ background: b.color }} />
<span className="legend-name">{b.name}</span>
<span className="legend-value">{fmt(b.value)} · {Math.round(b.anteil * 100)} %</span>
</li>
))}
</ul>
</div>
</div>
);
}
+109
View File
@@ -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>
);
}
+16
View File
@@ -0,0 +1,16 @@
import { useEffect, useState } from "react";
// Misst die tatsächliche Größe eines Elements, damit ein SVG in echten Pixeln
// zeichnen kann (feste viewBox mit Skalierung würde Linien/Schrift verzerren).
export default function useGroesse(ref) {
const [groesse, setGroesse] = useState({ breite: 0, hoehe: 0 });
useEffect(() => {
if (!ref.current) return undefined;
const beobachter = new ResizeObserver(([eintrag]) => {
setGroesse({ breite: eintrag.contentRect.width, hoehe: eintrag.contentRect.height });
});
beobachter.observe(ref.current);
return () => beobachter.disconnect();
}, [ref]);
return groesse;
}
+26
View File
@@ -0,0 +1,26 @@
// Farben für Diagramme. Kategorie-Farben in fester, farbfehlsicht-geprüfter
// Reihenfolge; Zustandsfarben (grün/rot) kommen aus den CSS-Tokens.
export const CATEGORY_COLORS_LIGHT = ["#2a78d6", "#eb6834", "#1baf7a", "#eda100", "#e87ba4", "#008300"];
export const CATEGORY_COLORS_DARK = ["#3987e5", "#d95926", "#199e70", "#c98500", "#d55181", "#008300"];
export const OTHER_COLOR_LIGHT = "#8a8f98";
export const OTHER_COLOR_DARK = "#6f7783";
export function istDunkel() {
if (typeof document === "undefined") return false;
const gesetzt = document.documentElement.getAttribute("data-theme");
if (gesetzt === "dark") return true;
if (gesetzt === "light") return false;
return window.matchMedia?.("(prefers-color-scheme: dark)").matches ?? false;
}
export function kategorieFarben(dunkel) {
return dunkel ? CATEGORY_COLORS_DARK : CATEGORY_COLORS_LIGHT;
}
/** Liest einen CSS-Tokenwert aus (die Diagramme zeichnen in echtem SVG). */
export function tokenFarbe(name, fallback = "#888") {
if (typeof document === "undefined") return fallback;
const wert = getComputedStyle(document.documentElement).getPropertyValue(name);
return wert?.trim() || fallback;
}