1 Commits
Author SHA1 Message Date
Jordy Hoogeveen ad45e372e6 bugfix dashboard 2026-09-17 16:23:39 +02:00
9 changed files with 158 additions and 48 deletions
+30
View File
@@ -2,6 +2,36 @@
## [Niet vrijgegeven] ## [Niet vrijgegeven]
### Gewijzigd
- De x-as van beide grafieken toont korte maandlabels (`sep '26`) in plaats van
een volledige datum bij elk punt, en ze staan rechtop in plaats van 45 graden
gedraaid. Tien tekens gedraaid kosten zo'n 45px en er passen er maar een stuk
of veertien op de tekenbreedte; bij een langere reeks liepen ze door elkaar
heen tot één grijze band. De grafiek rekent nu uit hoeveel labels er passen en
toont elke n-de, met het laatste punt er altijd bij — groeit je reeks, dan dunt
hij vanzelf verder uit
- Doordat de labels niet meer gedraaid zijn is de ruimte eronder kleiner: `pb`
van 52 naar 30 op het dashboard en van 62 naar 34 op Voortgang. Die hoogte gaat
naar de lijn zelf
- Nieuwe formatter `fmtMaand` in `utils/format.js`, naast `fmtDatum`
- De lijnen in de Voortgang-grafieken zijn vloeiend, net als op het dashboard.
Ze werden met `<polyline>` als rechte segmenten getekend; nu met dezelfde
Catmull-Rom afronding. De gradient eronder is daarmee ook een `<path>` in
plaats van een `<polygon>`
- `crPath` stond als lokale functie in `DashboardTab.jsx` en is verhuisd naar
`utils/grafiek.js`, samen met `vlakPad` voor de vulling onder een lijn. Twee
grafieken die dezelfde curve horen te tekenen moeten niet elk hun eigen kopie
hebben. In dat bestand staat ook waaróm de inleglijn bewust géén afronding
krijgt: een spline schiet tussen twee punten iets buiten hun bereik, en bij
een reeks die nooit terugloopt tekent dat een daling die er niet is
### Opgelost
- De tooltip van de Voortgang-grafiek toonde alleen een bedrag. Nu de x-as geen
dagen meer noemt was dat de enige plek waar de exacte datum nog te lezen was,
en die stond er niet in. De tooltip heeft er een datumregel bij
## [1.0.8] - 2026-09-15
### Toegevoegd ### Toegevoegd
- **Kleurthema's.** Negen paletten in `frontend/src/constants/themas.js`, te kiezen - **Kleurthema's.** Negen paletten in `frontend/src/constants/themas.js`, te kiezen
onder Instellingen naast de dark-mode schakelaar. Thema en licht/donker staan onder Instellingen naast de dark-mode schakelaar. Thema en licht/donker staan
+1 -1
View File
@@ -10,7 +10,7 @@ Eigen vermogen · Schulden · Voortgang · Gebruikersbeheer
![Node.js](https://img.shields.io/badge/Node.js_22-339933?style=flat&logo=node.js&logoColor=white) ![Node.js](https://img.shields.io/badge/Node.js_22-339933?style=flat&logo=node.js&logoColor=white)
![PostgreSQL](https://img.shields.io/badge/PostgreSQL_18-4169E1?style=flat&logo=postgresql&logoColor=white) ![PostgreSQL](https://img.shields.io/badge/PostgreSQL_18-4169E1?style=flat&logo=postgresql&logoColor=white)
![Docker](https://img.shields.io/badge/Docker_Compose-2496ED?style=flat&logo=docker&logoColor=white) ![Docker](https://img.shields.io/badge/Docker_Compose-2496ED?style=flat&logo=docker&logoColor=white)
![Version](https://img.shields.io/badge/versie-1.0.7-8b5cf6?style=flat) ![Version](https://img.shields.io/badge/versie-1.0.8-8b5cf6?style=flat)
![Built with Claude](https://img.shields.io/badge/Gebouwd_met-Claude.ai-8b5cf6?style=flat&logo=anthropic&logoColor=white) ![Built with Claude](https://img.shields.io/badge/Gebouwd_met-Claude.ai-8b5cf6?style=flat&logo=anthropic&logoColor=white)
<img src="docs/screenshot-dashboard.png" alt="VibeFinance dashboard" width="820"> <img src="docs/screenshot-dashboard.png" alt="VibeFinance dashboard" width="820">
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "vibefinance-frontend", "name": "vibefinance-frontend",
"version": "1.0.8-dev", "version": "1.0.8",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+28 -27
View File
@@ -1,6 +1,7 @@
import { useState } from "react"; import { useState } from "react";
import { useApp } from "../context/AppContext.jsx"; import { useApp } from "../context/AppContext.jsx";
import { fmt, pct, fmtDatum } from "../utils/format.js"; import { fmt, pct, fmtDatum, fmtMaand } from "../utils/format.js";
import { crPath } from "../utils/grafiek.js";
import { GREEN, RED, GOLD } from "../constants/index.js"; import { GREEN, RED, GOLD } from "../constants/index.js";
@@ -164,28 +165,12 @@ function Legenda({ kleur, label }) {
); );
} }
// ── Smooth Catmull-Rom bezier path ──────────────────────────────────────────
function crPath(xs, ys, t = 0.4) {
if (xs.length < 2) return "";
let d = `M ${xs[0]},${ys[0]}`;
for (let i = 0; i < xs.length - 1; i++) {
const p0 = i > 0 ? [xs[i-1], ys[i-1]] : [xs[i], ys[i]];
const p1 = [xs[i], ys[i]];
const p2 = [xs[i+1], ys[i+1]];
const p3 = i < xs.length - 2 ? [xs[i+2], ys[i+2]] : [xs[i+1], ys[i+1]];
const cp1x = (p1[0] + (p2[0] - p0[0]) * t).toFixed(2);
const cp1y = (p1[1] + (p2[1] - p0[1]) * t).toFixed(2);
const cp2x = (p2[0] - (p3[0] - p1[0]) * t).toFixed(2);
const cp2y = (p2[1] - (p3[1] - p1[1]) * t).toFixed(2);
d += ` C ${cp1x},${cp1y} ${cp2x},${cp2y} ${p2[0]},${p2[1]}`;
}
return d;
}
// ── Mini inline SVG grafiek ──────────────────────────────────────────────────── // ── Mini inline SVG grafiek ────────────────────────────────────────────────────
function MiniLineChart({ vData, fiatData, darkMode, T }) { function MiniLineChart({ vData, fiatData, darkMode, T }) {
const [hovered, setHovered] = useState(null); const [hovered, setHovered] = useState(null);
const W = 700, H = 230, pl = 62, pr = 16, pt = 16, pb = 52; // pb was 52 voor gedraaide datumlabels. Die staan nu rechtop en zijn korter,
// dus er is minder hoogte nodig en houdt de lijn zelf meer ruimte over.
const W = 700, H = 230, pl = 62, pr = 16, pt = 16, pb = 30;
const vals = vData.map((d) => d.nettoWaarde || 0); const vals = vData.map((d) => d.nettoWaarde || 0);
const fVals = fiatData; const fVals = fiatData;
// Beide reeksen staan in euro's op dezelfde as, dus het bereik moet ze allebei // Beide reeksen staan in euro's op dezelfde as, dus het bereik moet ze allebei
@@ -194,6 +179,9 @@ function MiniLineChart({ vData, fiatData, darkMode, T }) {
const mx = Math.max(...vals, ...fVals) * 1.05; const mx = Math.max(...vals, ...fVals) * 1.05;
const rng = mx - mn || 1; const rng = mx - mn || 1;
const iW = W - pl - pr, iH = H - pt - pb; const iW = W - pl - pr, iH = H - pt - pb;
// Eén label "sep '26" is bij deze lettergrootte ongeveer 46px breed. Zoveel
// passen er dus naast elkaar; de rest wordt overgeslagen.
const labelStap = Math.max(1, Math.ceil(vData.length / Math.max(1, Math.floor(iW / 46))));
const xs = vData.map((_, i) => pl + (i / Math.max(vData.length - 1, 1)) * iW); const xs = vData.map((_, i) => pl + (i / Math.max(vData.length - 1, 1)) * iW);
const ys = vals.map((v) => pt + (1 - (v - mn) / rng) * iH); const ys = vals.map((v) => pt + (1 - (v - mn) / rng) * iH);
const fYs = fVals.map((v) => pt + (1 - (v - mn) / rng) * iH); const fYs = fVals.map((v) => pt + (1 - (v - mn) / rng) * iH);
@@ -320,13 +308,26 @@ function MiniLineChart({ vData, fiatData, darkMode, T }) {
</g> </g>
)} )}
{/* X-as labels */} {/* X-as: korte maandlabels, rechtop. Elk punt een volledige datum geven
{xs.map((x, i) => ( liep bij een lange reeks over in een onleesbare band — tien tekens
<text key={i} x={x} y={H - pb + 16} fill={lc} fontSize="8" gedraaid kost ~45px en er passen er maar een stuk of veertien. De
textAnchor="end" transform={`rotate(-45, ${x}, ${H - pb + 16})`}> grafiek rekent nu uit hoeveel er passen en toont elke n-de; het
{fmtDatum(vData[i].datum)} laatste punt staat er altijd bij, want dat is de datum waar je naar
</text> kijkt. De exacte dag lees je in de tooltip. */}
))} {xs.map((x, i) => {
const laatste = i === xs.length - 1;
if (i % labelStap !== 0 && !laatste) return null;
return (
<g key={i}>
<line x1={x} y1={pt + iH} x2={x} y2={pt + iH + 4}
stroke={lc} strokeOpacity="0.5" strokeWidth="1" />
<text x={x} y={pt + iH + 17} fill={lc} fontSize="9"
textAnchor={i === 0 ? "start" : laatste ? "end" : "middle"}>
{fmtMaand(vData[i].datum)}
</text>
</g>
);
})}
</svg> </svg>
); );
} }
+38 -18
View File
@@ -1,6 +1,7 @@
import { useState } from "react"; import { useState } from "react";
import { useApp } from "../context/AppContext.jsx"; import { useApp } from "../context/AppContext.jsx";
import { fmt, fmtDatum } from "../utils/format.js"; import { fmt, fmtDatum, fmtMaand } from "../utils/format.js";
import { crPath, vlakPad } from "../utils/grafiek.js";
import { useKolomBreedtes } from "../hooks/useKolomBreedtes.js"; import { useKolomBreedtes } from "../hooks/useKolomBreedtes.js";
import { RED, GREEN } from "../constants/index.js"; import { RED, GREEN } from "../constants/index.js";
import { VCard, DonutChart, Overlay, ModalBox, ModalHeader, ModalFooter } from "../components/ui/index.jsx"; import { VCard, DonutChart, Overlay, ModalBox, ModalHeader, ModalFooter } from "../components/ui/index.jsx";
@@ -16,8 +17,11 @@ function SvgLineChart({ pts, lines, T, H = 200 }) {
</div> </div>
); );
} }
const W = 700, pl = 64, pr = 16, pt2 = 16, pb = 62; // pb was 62 voor gedraaide datumlabels. Die staan nu rechtop en zijn korter.
const W = 700, pl = 64, pr = 16, pt2 = 16, pb = 34;
const iW = W - pl - pr, iH = H - pt2 - pb; const iW = W - pl - pr, iH = H - pt2 - pb;
// Eén label "sep '26" is ongeveer 46px breed; zoveel passen er naast elkaar.
const labelStap = Math.max(1, Math.ceil(pts.length / Math.max(1, Math.floor(iW / 46))));
const allVals = lines.flatMap((l) => pts.map((p) => p[l.key] ?? 0)); const allVals = lines.flatMap((l) => pts.map((p) => p[l.key] ?? 0));
const dataMin = Math.min(...allVals); const dataMin = Math.min(...allVals);
const dataMax = Math.max(...allVals); const dataMax = Math.max(...allVals);
@@ -60,17 +64,21 @@ function SvgLineChart({ pts, lines, T, H = 200 }) {
if (validPts.length < 2) return null; if (validPts.length < 2) return null;
const xs = validPts.map((p) => xp(p._i)); const xs = validPts.map((p) => xp(p._i));
const ys = validPts.map((p) => yp(p[l.key])); const ys = validPts.map((p) => yp(p[l.key]));
const ls = xs.map((x, i) => `${x},${ys[i]}`).join(" "); // Dezelfde Catmull-Rom afronding als de dashboardgrafiek, in plaats
const ar = `${xs[0]},${pt2 + iH} ${ls} ${xs[xs.length - 1]},${pt2 + iH}`; // van rechte segmenten tussen de punten.
const lijn = crPath(xs, ys);
const vlak = vlakPad(lijn, xs[0], xs[xs.length - 1], pt2 + iH);
return ( return (
<g key={l.key}> <g key={l.key}>
<polygon points={ar} fill={`url(#g_${l.key})`} /> <path d={vlak} fill={`url(#g_${l.key})`} />
<polyline points={ls} fill="none" stroke={l.color} strokeWidth="2.5" <path d={lijn} fill="none" stroke={l.color} strokeWidth="2.5"
strokeLinejoin="round" strokeLinecap="round" /> strokeLinejoin="round" strokeLinecap="round" />
{validPts.map((p, i) => { {validPts.map((p, i) => {
const x = xs[i], y = ys[i]; const x = xs[i], y = ys[i];
const isHov = hovered?.lineKey === l.key && hovered?.ptIdx === i; const isHov = hovered?.lineKey === l.key && hovered?.ptIdx === i;
const tipW = 90, tipH = 22; // Twee regels: sinds de x-as alleen nog maanden noemt, is dit
// de enige plek in de grafiek waar de exacte dag te lezen is.
const tipW = 104, tipH = 34;
const tipX = Math.min(Math.max(x - tipW / 2, pl), W - tipW - 4); const tipX = Math.min(Math.max(x - tipW / 2, pl), W - tipW - 4);
const tipY = y - tipH - 8 < pt2 ? y + 10 : y - tipH - 8; const tipY = y - tipH - 8 < pt2 ? y + 10 : y - tipH - 8;
return ( return (
@@ -84,10 +92,14 @@ function SvgLineChart({ pts, lines, T, H = 200 }) {
fill={l.color} stroke={T.card} strokeWidth="2" /> fill={l.color} stroke={T.card} strokeWidth="2" />
{isHov && ( {isHov && (
<g> <g>
<rect x={tipX} y={tipY} width={tipW} height={22} <rect x={tipX} y={tipY} width={tipW} height={tipH}
rx="5" fill={T.card} rx="5" fill={T.card}
stroke={l.color} strokeWidth="1" /> stroke={l.color} strokeWidth="1" />
<text x={tipX + tipW / 2} y={tipY + 15} fill={l.color} fontSize="8" <text x={tipX + tipW / 2} y={tipY + 13} fill={T.muted} fontSize="8"
textAnchor="middle">
{fmtDatum(p.datum)}
</text>
<text x={tipX + tipW / 2} y={tipY + 26} fill={l.color} fontSize="9"
fontWeight="700" textAnchor="middle"> fontWeight="700" textAnchor="middle">
{fmt(p[l.key])} {fmt(p[l.key])}
</text> </text>
@@ -99,15 +111,23 @@ function SvgLineChart({ pts, lines, T, H = 200 }) {
</g> </g>
); );
})} })}
{pts.map((p, i) => ( {/* X-as: korte maandlabels, rechtop en uitgedund op wat er past. Elk
<text key={i} punt een volledige datum geven liep bij een lange reeks over in een
x={xp(i)} y={H - pb + 16} onleesbare band. De exacte dag staat in de tooltip op elk punt. */}
fill={T.muted} fontSize="8" {pts.map((p, i) => {
textAnchor="end" const laatste = i === pts.length - 1;
transform={`rotate(-45, ${xp(i)}, ${H - pb + 16})`}> if (i % labelStap !== 0 && !laatste) return null;
{fmtDatum(p.datum)} return (
</text> <g key={i}>
))} <line x1={xp(i)} y1={pt2 + iH} x2={xp(i)} y2={pt2 + iH + 4}
stroke={T.muted} strokeOpacity="0.5" strokeWidth="1" />
<text x={xp(i)} y={pt2 + iH + 17} fill={T.muted} fontSize="9"
textAnchor={i === 0 ? "start" : laatste ? "end" : "middle"}>
{fmtMaand(p.datum)}
</text>
</g>
);
})}
</svg> </svg>
<div style={{ display: "flex", gap: 20, justifyContent: "center", marginTop: 10 }}> <div style={{ display: "flex", gap: 20, justifyContent: "center", marginTop: 10 }}>
{lines.map((l) => ( {lines.map((l) => (
+15
View File
@@ -14,6 +14,21 @@ export const fmtDatum = (s) => {
return `${d}-${m}-${y}`; return `${d}-${m}-${y}`;
}; };
const MAANDEN = ["jan", "feb", "mrt", "apr", "mei", "jun", "jul", "aug", "sep", "okt", "nov", "dec"];
/**
* ISO datum → korte maandnotatie (`2026-09-16` → `sep '26`).
*
* Voor de x-as van de grafieken: een volledige datum is tien tekens en past
* daar alleen gedraaid, wat bij een lange reeks onleesbaar wordt. De exacte
* dag staat in de tooltip.
*/
export const fmtMaand = (s) => {
if (!s) return s;
const [y, m] = s.split("-");
return `${MAANDEN[parseInt(m, 10) - 1] ?? m} '${y.slice(2)}`;
};
/** Crypto-bedrag formatter (4 decimalen standaard) */ /** Crypto-bedrag formatter (4 decimalen standaard) */
export const fmtCrypto = (n, d = 4) => (parseFloat(n) || 0).toFixed(d); export const fmtCrypto = (n, d = 4) => (parseFloat(n) || 0).toFixed(d);
+43
View File
@@ -0,0 +1,43 @@
/**
* Tekenhulp voor de SVG-grafieken.
*
* Stond eerder als lokale functie in `DashboardTab.jsx`. Nu de Voortgang-grafiek
* dezelfde afronding gebruikt, hoort hij op één plek te staan — anders lopen de
* twee vroeg of laat uit elkaar.
*/
/**
* Catmull-Rom spline als bezier-pad: een vloeiende lijn door alle punten heen.
*
* @param {number[]} xs x-posities in SVG-eenheden
* @param {number[]} ys y-posities in SVG-eenheden
* @param {number} t spanning; hoger is boller, 0.4 is de huisstijl
*
* Let op: een spline kan tussen twee punten iets buiten hun bereik uitschieten.
* Voor een reeks die per definitie nooit terugloopt — zoals de cumulatief
* geïnvesteerde fiat — tekent dat een daling die niet in de data zit. Gebruik
* daar rechte segmenten.
*/
export function crPath(xs, ys, t = 0.4) {
if (xs.length < 2) return "";
let d = `M ${xs[0]},${ys[0]}`;
for (let i = 0; i < xs.length - 1; i++) {
const p0 = i > 0 ? [xs[i-1], ys[i-1]] : [xs[i], ys[i]];
const p1 = [xs[i], ys[i]];
const p2 = [xs[i+1], ys[i+1]];
const p3 = i < xs.length - 2 ? [xs[i+2], ys[i+2]] : [xs[i+1], ys[i+1]];
const cp1x = (p1[0] + (p2[0] - p0[0]) * t).toFixed(2);
const cp1y = (p1[1] + (p2[1] - p0[1]) * t).toFixed(2);
const cp2x = (p2[0] - (p3[0] - p1[0]) * t).toFixed(2);
const cp2y = (p2[1] - (p3[1] - p1[1]) * t).toFixed(2);
d += ` C ${cp1x},${cp1y} ${cp2x},${cp2y} ${p2[0]},${p2[1]}`;
}
return d;
}
/**
* Sluit een lijnpad af tot een vlak: door naar de basislijn en terug naar de
* start. Voor de gradient-vulling onder een lijn.
*/
export const vlakPad = (lijn, xEerste, xLaatste, yBasis) =>
`${lijn} L ${xLaatste},${yBasis} L ${xEerste},${yBasis} Z`;
+1
View File
@@ -1,2 +1,3 @@
export * from "./format.js"; export * from "./format.js";
export * from "./storage.js"; export * from "./storage.js";
export * from "./grafiek.js";
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "vibefinance", "name": "vibefinance",
"version": "1.0.8", "version": "1.0.9-dev",
"description": "VibeFinance — persoonlijk vermogensbeheer", "description": "VibeFinance — persoonlijk vermogensbeheer",
"private": true, "private": true,
"repository": "https://vibehoogie.duckdns.org/vibe/VibeFinance", "repository": "https://vibehoogie.duckdns.org/vibe/VibeFinance",