8 Commits
9 changed files with 49 additions and 193 deletions
-44
View File
@@ -2,50 +2,6 @@
## [Niet vrijgegeven] ## [Niet vrijgegeven]
### Opgelost
- Het laatste label op de x-as viel over zijn buurman heen (`mei '26` en `26`
over elkaar). De stap werd vanaf het begin van de reeks geteld en het laatste
punt werd daar los bovenop geforceerd, zodat het op willekeurige afstand van
zijn voorganger landde — bij een reeks die niet toevallig op een veelvoud
uitkwam, pal ernaast. De stap telt nu terug vanaf het laatste punt: dat valt
daarmee per definitie op een stap en houdt volle tussenruimte, zonder dat er
nog een uitzondering voor nodig is
- De breedte waarop de grafiek rekent ging van 46 naar 54px per label, zodat er
lucht tussen staat in plaats van kop-aan-kop. Over reekslengtes van 13 tot 120
punten blijft de tussenruimte nu minimaal 57px
## [1.0.9] - 2026-09-17
### 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
+2 -13
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.9-8b5cf6?style=flat) ![Version](https://img.shields.io/badge/versie-1.0.7-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">
@@ -181,16 +181,6 @@ inleglijn is per definitie gelijk aan de KPI "Totaal geïnvesteerde fiat" — be
rekenen het oudste voortgangsmoment als startinleg en tellen daar de stortingen rekenen het oudste voortgangsmoment als startinleg en tellen daar de stortingen
per rij bij op. per rij bij op.
De x-as noemt maanden (`sep '26`), niet losse datums: de grafiek rekent uit
hoeveel labels er naast elkaar passen en toont er zoveel, terugtellend vanaf het
laatste punt zodat dat er altijd bij staat. Bij een langere reeks dunt hij
vanzelf verder uit. De exacte dag lees je in de tooltip. De vermogenslijn is een
Catmull-Rom spline; de inleglijn krijgt bewust rechte segmenten, omdat een
spline tussen twee punten iets buiten hun bereik uitschiet en dat bij een reeks
die nooit terugloopt een daling zou tekenen die er niet is. Beide lijnen delen
de tekenhulp in
[`frontend/src/utils/grafiek.js`](frontend/src/utils/grafiek.js).
Onder **Instellingen** kies je een van de negen kleurthema's. Thema en Onder **Instellingen** kies je een van de negen kleurthema's. Thema en
licht/donker staan los van elkaar: het thema levert het accent, de schakelaar de licht/donker staan los van elkaar: het thema levert het accent, de schakelaar de
ondergrond. De paletten staan in ondergrond. De paletten staan in
@@ -243,8 +233,7 @@ vibefinance/
│ ├── main.jsx │ ├── main.jsx
│ ├── version.js # Draaiende versie (__APP_VERSION__ uit de build) │ ├── version.js # Draaiende versie (__APP_VERSION__ uit de build)
│ ├── constants/ # index.js (kleuren, tabs, begindata), themas.js │ ├── constants/ # index.js (kleuren, tabs, begindata), themas.js
│ ├── utils/ # format.js (bedragen/datums), storage.js, │ ├── utils/ # format.js (bedragen/datums), storage.js
│ │ # grafiek.js (SVG-padberekening)
│ ├── hooks/ # useTheme, useUpdateCheck, useKolomBreedtes │ ├── hooks/ # useTheme, useUpdateCheck, useKolomBreedtes
│ ├── context/ # AppContext (global state + API sync + backup) │ ├── context/ # AppContext (global state + API sync + backup)
│ ├── components/ │ ├── components/
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "vibefinance-frontend", "name": "vibefinance-frontend",
"version": "1.0.10-dev", "version": "1.0.8-dev",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+27 -34
View File
@@ -1,7 +1,6 @@
import { useState } from "react"; import { useState } from "react";
import { useApp } from "../context/AppContext.jsx"; import { useApp } from "../context/AppContext.jsx";
import { fmt, pct, fmtDatum, fmtMaand } from "../utils/format.js"; import { fmt, pct, fmtDatum } 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";
@@ -165,12 +164,28 @@ 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);
// pb was 52 voor gedraaide datumlabels. Die staan nu rechtop en zijn korter, const W = 700, H = 230, pl = 62, pr = 16, pt = 16, pb = 52;
// 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
@@ -179,13 +194,6 @@ 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 54px breed inclusief
// wat lucht ernaast. Zoveel passen er naast elkaar; de rest wordt
// overgeslagen. De stap telt vanaf het láátste punt terug, niet vanaf het
// eerste: dat punt is de datum waar je naar kijkt en moet er altijd bij staan.
// Forceerden we hem er los bovenop, dan botste hij op willekeurige afstand
// tegen zijn voorganger — precies wat er eerst misging.
const labelStap = Math.max(1, Math.ceil(vData.length / Math.max(1, Math.floor(iW / 54))));
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);
@@ -312,28 +320,13 @@ function MiniLineChart({ vData, fiatData, darkMode, T }) {
</g> </g>
)} )}
{/* X-as: korte maandlabels, rechtop. Elk punt een volledige datum geven {/* X-as labels */}
liep bij een lange reeks over in een onleesbare band — tien tekens {xs.map((x, i) => (
gedraaid kost ~45px en er passen er maar een stuk of veertien. De <text key={i} x={x} y={H - pb + 16} fill={lc} fontSize="8"
grafiek rekent nu uit hoeveel er passen en toont elke n-de; het textAnchor="end" transform={`rotate(-45, ${x}, ${H - pb + 16})`}>
laatste punt staat er altijd bij, want dat is de datum waar je naar {fmtDatum(vData[i].datum)}
kijkt. De exacte dag lees je in de tooltip. */} </text>
{xs.map((x, i) => { ))}
const laatste = i === xs.length - 1;
// Terugtellen vanaf het eind: het laatste punt valt zo vanzelf op een
// stap en houdt volle afstand tot zijn voorganger.
if ((xs.length - 1 - i) % labelStap !== 0) 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>
); );
} }
+18 -41
View File
@@ -1,7 +1,6 @@
import { useState } from "react"; import { useState } from "react";
import { useApp } from "../context/AppContext.jsx"; import { useApp } from "../context/AppContext.jsx";
import { fmt, fmtDatum, fmtMaand } from "../utils/format.js"; import { fmt, fmtDatum } 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";
@@ -17,13 +16,8 @@ function SvgLineChart({ pts, lines, T, H = 200 }) {
</div> </div>
); );
} }
// pb was 62 voor gedraaide datumlabels. Die staan nu rechtop en zijn korter. const W = 700, pl = 64, pr = 16, pt2 = 16, pb = 62;
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 54px breed inclusief lucht ernaast. De stap
// telt vanaf het laatste punt terug, zodat dat er altijd bij staat zonder op
// zijn voorganger te botsen. Zie DashboardTab voor de uitleg.
const labelStap = Math.max(1, Math.ceil(pts.length / Math.max(1, Math.floor(iW / 54))));
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);
@@ -66,21 +60,17 @@ 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]));
// Dezelfde Catmull-Rom afronding als de dashboardgrafiek, in plaats const ls = xs.map((x, i) => `${x},${ys[i]}`).join(" ");
// van rechte segmenten tussen de punten. const ar = `${xs[0]},${pt2 + iH} ${ls} ${xs[xs.length - 1]},${pt2 + iH}`;
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}>
<path d={vlak} fill={`url(#g_${l.key})`} /> <polygon points={ar} fill={`url(#g_${l.key})`} />
<path d={lijn} fill="none" stroke={l.color} strokeWidth="2.5" <polyline points={ls} 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;
// Twee regels: sinds de x-as alleen nog maanden noemt, is dit const tipW = 90, tipH = 22;
// 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 (
@@ -94,14 +84,10 @@ 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={tipH} <rect x={tipX} y={tipY} width={tipW} height={22}
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 + 13} fill={T.muted} fontSize="8" <text x={tipX + tipW / 2} y={tipY + 15} fill={l.color} 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>
@@ -113,24 +99,15 @@ function SvgLineChart({ pts, lines, T, H = 200 }) {
</g> </g>
); );
})} })}
{/* X-as: korte maandlabels, rechtop en uitgedund op wat er past. Elk {pts.map((p, i) => (
punt een volledige datum geven liep bij een lange reeks over in een <text key={i}
onleesbare band. De exacte dag staat in de tooltip op elk punt. */} x={xp(i)} y={H - pb + 16}
{pts.map((p, i) => { fill={T.muted} fontSize="8"
const laatste = i === pts.length - 1; textAnchor="end"
// Terugtellen vanaf het eind, zodat het laatste punt op een stap valt. transform={`rotate(-45, ${xp(i)}, ${H - pb + 16})`}>
if ((pts.length - 1 - i) % labelStap !== 0) 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,21 +14,6 @@ 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
@@ -1,43 +0,0 @@
/**
* 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,3 +1,2 @@
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.10", "version": "1.0.8",
"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",