16 Commits
Author SHA1 Message Date
jhoogie a996c767f0 Merge pull request 'bugfix' (#19) from update-1.0.13 into main
Reviewed-on: #19
2026-10-01 17:02:12 +02:00
Jordy Hoogeveen 4181ac3f0f bugfix 2026-10-01 16:57:16 +02:00
jhoogie 65233fb5b4 Merge pull request 'bugfix' (#18) from update-1.0.12 into main
Reviewed-on: #18
2026-09-18 15:25:08 +02:00
Jordy Hoogeveen 2799bc12ee bugfix 2026-09-18 15:18:47 +02:00
jhoogie ec63906cb2 Merge pull request 'cve fixes' (#17) from update-1.0.11 into main
Reviewed-on: #17
2026-09-18 15:03:54 +02:00
Jordy Hoogeveen e9192a8aba cve fixes 2026-09-18 13:38:22 +02:00
jhoogie 2d7e67e315 Merge pull request 'bugfix dashboard' (#16) from update-1.0.10 into main
Reviewed-on: #16
2026-09-17 17:28:11 +02:00
Jordy Hoogeveen 2420a00777 bugfix dashboard 2026-09-17 17:25:41 +02:00
jhoogie 539b42dde7 Merge pull request 'bugfix dashboard' (#15) from update-1.0.9 into main
Reviewed-on: #15
2026-09-17 17:07:24 +02:00
Jordy Hoogeveen ad45e372e6 bugfix dashboard 2026-09-17 16:23:39 +02:00
jhoogie 73ce30c5b0 Merge pull request 'Bugfixes' (#14) from update-1.0.8 into main
Reviewed-on: http://10.0.3.108:3000/Vibe/VibeFinance/pulls/14
2026-09-15 14:06:45 +02:00
Jordy Hoogeveen 0b67f346e1 Bugfixes 2026-09-15 14:02:17 +02:00
jhoogie 4915a090a3 Merge pull request 'dashboard tooltip update' (#13) from update-1.0.7 into main
Reviewed-on: http://10.0.3.108:3000/Vibe/VibeFinance/pulls/13
2026-09-11 17:13:03 +02:00
Jordy Hoogeveen 750dc69e56 dashboard tooltip update 2026-09-11 17:11:11 +02:00
jhoogie 6d1b114b78 Merge pull request 'dashboard update' (#12) from update-1.0.6 into main
Reviewed-on: http://10.0.3.108:3000/Vibe/VibeFinance/pulls/12
2026-09-11 15:22:34 +02:00
Jordy Hoogeveen b4d1e25e50 dashboard update 2026-09-11 15:20:09 +02:00
27 changed files with 1553 additions and 250 deletions
+90 -4
View File
@@ -32,6 +32,32 @@ Vite proxyt `/api` naar `localhost:3001` binnen dezelfde container.
> ⚠️ Alleen `.\dev.ps1 down` wist de development-database (`docker compose down -v`).
> `.\dev.ps1 up` doet een gewone `down` en laat het volume staan.
### Hot reload pakt wijzigingen van de host niet op
De naam "hot reload" in de tabel hierboven is optimistischer dan de praktijk.
`docker-compose.dev.yml` mount `./frontend` de container in, maar over die
bind-mount komen op Windows geen inotify-events aan. Vite's bestandswatcher ziet
je wijziging dus niet, houdt de oude versie van de module in zijn geheugen, en
blijft die serveren — ook na een harde herlaad in de browser.
Zo zie je het verschil tussen "de browser cachet" en "Vite serveert oud":
```powershell
# Haalt de module rechtstreeks bij Vite op, langs de browser om
(Invoke-WebRequest "http://localhost/src/pages/DashboardTab.jsx" -UseBasicParsing).Content -match "jouw-nieuwe-code"
```
Komt daar `False` uit terwijl het wél in het bestand staat, dan is het de
watcher. Een `touch` binnen de container helpt niet; herstarten wel:
```powershell
docker compose -f docker-compose.dev.yml restart app
```
Wil je het structureel oplossen, dan is `server: { watch: { usePolling: true,
interval: 300 } }` in `frontend/vite.config.js` de knop daarvoor. Polling kost
wel constant CPU, vandaar dat het er niet standaard in staat.
### Welke versie de dev-app toont
De versie in "Over VibeFinance" komt uit `VITE_APP_VERSION`. `dev.ps1` leest die
@@ -245,9 +271,11 @@ tag op een Raspberry Pi en op een x86-server werkt.
| `README.md` | de `versie-x.x.x`-badge bovenaan | vóór de build |
| `package.json` (root) | versie → `x.x.(x+1)-dev` voor de volgende cyclus | **ná** het publiceren |
Een `-DryRun` meldt alle vier zonder te schrijven. Ze blijven na een echte
release als wijziging in je working tree staan; neem ze mee in de volgende
commit.
Een `-DryRun` meldt de vier bestandswijzigingen zonder ze te schrijven (de
Git-tag is de uitzondering in die tabel: die hoort bij stap 4, niet bij de
auto-updates). Na een echte release blijven die vier als wijziging in je working
tree staan; neem ze mee in de volgende commit — de tag staat dan op de commit
ervóór, dus de release bevat ze nog niet.
De laatste staat bewust ná het publiceren: breekt de release eerder af, dan
schuift het versienummer niet vooruit. Bijeffect dat je moet kennen — na een
@@ -307,9 +335,67 @@ dan is het antwoord `no configuration file provided: not found`. Met
Pullen zelf werkt anoniem zolang de organisatie public is — zie
[Pullen op een deploy-machine](#pullen-op-een-deploy-machine).
### De scan meldt 46 CVE's op postgres:18-alpine
Dat klopt, en het blijft zo. Ze zitten allemaal in één bestand:
```bash
# Alle bevindingen komen uit /usr/local/bin/gosu, niets uit de Alpine-pakketten
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
aquasec/trivy image --scanners vuln postgres:18-alpine
```
`gosu` is een klein Go-programma dat het postgres-entrypoint gebruikt om van
root naar de `postgres`-gebruiker te schakelen. Upstream compileert het met Go
1.24.6, en alle meldingen zijn CVE's in die Go-standaardbibliotheek. Je kunt er
niets aan verhelpen met een `pull`: dit ís de nieuwste versie.
Wat wél is gedaan: beide compose-bestanden starten de container met
`user: "postgres"`. Het entrypoint roept gosu alleen aan als het als root start
(`if [ "$(id -u)" = '0' ]`), dus die tak wordt overgeslagen en de binary draait
nooit. De CVE's zijn daarmee onbereikbaar — de scanner kijkt naar wat aanwezig
is, niet naar wat uitgevoerd wordt.
> Dit werkt ook op een volume dat ooit als root is aangemaakt: de
> data-directory is dan al eigendom van postgres. Getest met een bestaande
> database; die start en de data blijft intact.
**Waarom geen eigen image zonder gosu.** Dat kan (`FROM postgres:18-alpine` +
`RUN rm -f /usr/local/bin/gosu`) en zet de scan op nul, maar je ruilt een
onbereikbaar risico voor een echt risico: je moet dat image dan zelf herbouwen
bij elke postgres-patch. Precies die achterstand was het probleem toen het image
vier maanden op 18.4 stond met 143 bevindingen. Met het officiële image haalt
`docker compose pull` de patches vanzelf op, en zodra upstream gosu met een
nieuwere Go herbouwt is het gratis opgelost. `test.ps1` waarschuwt daarom bij
stap 4 als `postgres:18-alpine` in de registry nieuwer is dan wat je lokaal hebt.
### PostgreSQL versie-upgrade
Bij een upgrade van PostgreSQL (bijv. 16 → 18) is de bestaande data niet compatibel. Stappen:
Twee soorten, met een belangrijk verschil. Kijk eerst welke je voor je hebt:
```bash
docker exec vibefinance-postgres postgres --version
```
**Patch binnen dezelfde major** (18.4 → 18.6) — de data-directory blijft
geldig, dus je hoeft niets te dumpen. Image ophalen en de container opnieuw
laten maken; het volume blijft staan:
```bash
docker compose pull postgres
docker compose up -d postgres # let op: géén -v
```
> Gebruik hier nooit `docker compose down -v`. Dat wist het volume en dan is je
> database echt weg, terwijl deze upgrade dat helemaal niet vraagt.
De tag `postgres:18-alpine` beweegt mee met de patches, dus `pull` is genoeg —
je hoeft niets in `docker-compose.yml` aan te passen. Loont de moeite: tussen de
image van 18.4 en die van 18.6 zat een verschil van 143 naar 45 bevindingen in
de kwetsbaarhedenscan, vooral in de Alpine-pakketten eronder.
**Major-upgrade** (bijv. 16 → 18) — dán is de bestaande data niet compatibel en
moet je er wel doorheen met een dump:
```bash
# 1. Backup maken
+269
View File
@@ -2,6 +2,275 @@
## [Niet vrijgegeven]
### Opgelost
- **De kaart "Rendement Eigen Vermogen" op Voortgang stond leeg.** Bedrag en
percentage waren sinds de eerste commit hardcoded als `—` in de JSX; er zat
geen berekening achter en `evRows` werd niet eens uit de context gehaald. Het
oogde als een datafout — lege database, mislukte API-call — maar de kaart was
nooit afgebouwd. Hij rekent nu met dezelfde formule als het blok "Rendement"
op Eigen vermogen: huidige waarde min geïnvesteerde fiat, gedeeld door die
inleg. Groen bij winst, rood bij verlies, en `—` zolang er niets is ingelegd,
zodat er niet door nul wordt gedeeld
- Beide kaarten tonen daarmee hetzelfde totaal. Dat is bewust: twee plekken die
"rendement" noemen horen niet twee verschillende getallen te geven
### Gewijzigd
- De achtergrond achter een popup is lichter: `rgba(0,0,0,0.75)` naar `0.45` in
de gedeelde `Overlay`. Op 0.75 was van het scherm eronder vrijwel niets meer
te zien, terwijl je bij het invullen van een modal juist wilt kunnen nakijken
waar je vandaan kwam. Gedimd genoeg om de modal de aandacht te laten houden
- De popups van NavBar, Instellingen en Gebruikersbeheer gebruiken de gedeelde
`Overlay` niet maar hadden hun eigen backdrop op `rgba(0,0,0,0.55)` met
`backdropFilter: blur(4px)`. Alle drie staan nu op dezelfde 0.45, en de blur
is eruit: die maakte de achtergrond onscherp in plaats van zichtbaar, en
zonder dat weg te halen bleven ze alsnog afwijken van de rest. Vier
popup-backdrops, één waarde
## [1.0.12] - 2026-09-18
### Beveiliging
- **De postgres-container draait niet meer als root.** Beide compose-bestanden
starten hem met `user: "postgres"`. Alle 46 bevindingen op `postgres:18-alpine`
zitten in één bestand — `/usr/local/bin/gosu`, dat upstream met Go 1.24.6
compileert — en het entrypoint roept gosu alleen aan als het als root start.
Non-root starten slaat die tak over, dus de binary draait nooit meer. De
Alpine-pakketten in dat image zijn zelf schoon
- Getest op een bestaande database die ooit als root was aangemaakt: die start
als non-root en de data blijft intact. Ook een verse database initialiseert
gewoon
- Bewust géén eigen postgres-image gebouwd om die binary te verwijderen. Dat zou
de scan op nul zetten maar je verplichten het image zelf te herbouwen bij elke
postgres-patch — precies de achterstand die het image vier maanden op 18.4 met
143 bevindingen hield. De afweging staat in `BEHEER.md`, zodat de volgende
scan niet opnieuw tot dezelfde vraag leidt
- `test.ps1` waarschuwt bij stap 4 wanneer `postgres:18-alpine` in de registry
nieuwer is dan wat er lokaal staat. Die achterstand is het werkelijke risico;
het getal in de scan is dat niet
## [1.0.11] - 2026-09-18
### Beveiliging
- **PostgreSQL 18.4 → 18.6.** Het `postgres:18-alpine` image dat in gebruik was
dateerde van vier maanden geleden. Dat is een patch binnen dezelfde major, dus
de data-directory blijft geldig en er komt geen dump/restore aan te pas — het
volume blijft gewoon staan. De kwetsbaarhedenscan op het image gaat van 143
naar 45 bevindingen, het grootste deel in de Alpine-pakketten eronder. Wat
overblijft (1 kritiek, 21 hoog) zit allemaal in Go's `stdlib` v1.24.6 in een
meegecompileerde binary; dat kan alleen upstream oplossen
- `BEHEER.md` beschreef alleen de major-upgrade, met een `docker compose down
-v` erin. Wie dat bij een patch-upgrade zou volgen, wist zijn database zonder
dat het nodig is. De twee gevallen staan nu apart beschreven
- **De imagescan staat op nul.** De scan van 1.0.10 gaf 39 bevindingen
(1 kritiek, 12 hoog, 15 middel, 11 laag). Geen enkele kwam uit deze
applicatie: twintig zaten in `libssl3` en `libcrypto3` (Alpine in het
node-image liep achter op 3.5.8-r0) en negentien in de dependencies die npm
zelf meelevert — `tar`, `pacote`, `sigstore`, `brace-expansion`, `picomatch`,
`ip-address`. De eigen `ip-address` van de app stond al op 10.7.0 en was dus
nooit het probleem
- Het productie-image doet nu een `apk upgrade` aan het eind van de build. Het
node-image wordt minder vaak herbouwd dan Alpine patches uitbrengt, dus
OpenSSL loopt er structureel op achter
- npm, npx, corepack en yarn gaan uit het productie-image. De container start
met `node src/server.js` en gebruikt nooit een pakketbeheerder; ze zaten er
alleen in omdat het base-image ze meelevert. Weg met de tool is weg met die
hele categorie, ook bij de volgende ronde — npm's dependencies zijn een
terugkerende bron van meldingen
- Nieuwe teststap **[4/5] Imagescan** met Trivy, die het gebouwde image scant en
faalt op CRITICAL of HIGH waarvoor een fix bestaat (`--ignore-unfixed`, want
een CVE zonder patch hoort je release niet te blokkeren). `-Strict` trekt de
lat naar MEDIUM en LOW, `-SkipScan` slaat hem over. Stap 1 kijkt naar de
`package.json`'s en had geen van deze 39 gezien; deze kijkt naar wat er
werkelijk in het image zit. `release.ps1` draait dezelfde scan vóór het
pushen, dus een image met openstaande CVE's komt de registry niet meer in
### Toegevoegd
- **Export naar HTML.** Onder Instellingen staat naast backup en restore nu
"Exporteren naar HTML": één zelfdragend bestand met al je gegevens, bedoeld
als laatste vangnet. Draait de app niet meer, is de database weg en ligt er
geen backup, dan is dit bestand nog steeds te openen — in elke browser, op een
computer zonder internet. Er zit niets van buiten in: opmaak staat inline,
geen lettertypen, geen afbeeldingen, geen scripts
- De pagina toont een samenvatting met de KPI's, en daaronder eigen vermogen en
schulden per categorie met subtotalen en een eindtotaal, de voortgangstabel
met verschil per moment, en de historie van EV en schuld. Bedragen staan met
twee decimalen — anders dan het scherm, want dit is een archiefstuk waar de
exacte waarde in hoort — rechts uitgelijnd met cijfers van gelijke breedte
- **Het bestand is tegelijk een backup.** Onderin staat de complete ruwe data in
een `<script type="application/json">`-blok dat de browser niet uitvoert en
niet toont. "Backup herstellen" accepteert daarom nu naast de ZIP ook zo'n
HTML-bestand. Eén bestand dat je zowel kunt lezen als kunt terugzetten
- Uitvoer wordt zowel als HTML als binnen het JSON-blok ge-escaped. Een post die
`Fonds <A> & co` heet, of een `</script>` in de gegevens, sloopt de pagina
niet en breekt de ingebakken backup niet open
- Nieuwe `utils/htmlExport.js`, met `leesHtmlBackup` voor de weg terug. Die
gebruikt `DOMParser` en geen reguliere expressie: de gegevens kunnen van alles
bevatten, en een parser die HTML echt begrijpt trapt daar niet in. Geen nieuwe
dependency
- De voortgangstabel is Datum plus één kolom per categorie plus vier vaste
kolommen — bij zes categorieën twaalf stuks, en die passen op geen enkel
scherm. De tabellen scrollen daarom horizontaal in plaats van kolommen buiten
beeld te laten vallen. De datumkolom blijft daarbij staan, want zonder die
kolom weet je niet meer naar welk moment je kijkt, en een schaduwrandje
verschijnt aan de kant waar nog inhoud zit. Dat laatste is een CSS-truc met
meeschuivende achtergronden: de pagina mag geen scripts bevatten, maar zonder
hint ziet niemand dat er rechts meer staat. Op print vervalt het allemaal en
wordt de tabel kleiner gezet, zodat alle kolommen op papier alsnog meekomen
- De **initiële schuld** stond wel in de gegevens maar niet op de pagina. Hij
hoort niet bij een schuldblok, dus zonder eigen regel viel hij buiten de
export. Staat er nu onder de schuldentabel, met "afgelost sinds start" erbij
- Stortingen op een **verwijderde categorie** blijven in oude voortgangsrijen
staan, maar de app toont alleen kolommen van bestaande categorieën — die
bedragen waren dus nergens meer te zien. De export geeft ze alsnog een kolom,
gemerkt als `Verwijderd (c9)`. Voor een bestand dat alles moet bewaren wat je
ooit hebt ingevoerd is stil weglaten het verkeerde gedrag
## [1.0.10] - 2026-09-17
### 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
- **Kleurthema's.** Negen paletten in `frontend/src/constants/themas.js`, te kiezen
onder Instellingen naast de dark-mode schakelaar. Thema en licht/donker staan
los van elkaar: het thema levert het accent, de schakelaar de ondergrond. Dat
scheelt een compleet tweede palet per thema en laat de bestaande schakelaar
doen wat hij deed
- `useTheme(themaId, darkMode)` leidt randen, invoervelden en schaduwen af uit
het accent van het thema, dus die kleuren vanzelf mee. Nieuwe sleutels op `T`:
`accent`, `accentLicht`, `accentTekst`, `accentGradient`, `chartVan`,
`chartTot`, `darkMode`, `themaId` en `themaNaam`. De bestaande sleutels houden
hun naam en betekenis, zodat er niets hoeft te wijzigen dat ze al gebruikt
- Het accent dat tekst draagt is per ondergrond een andere stap van dezelfde
kleur — de lichte op donker, de donkere op licht. Eén stap voor beide modi
haalt aan één kant altijd te weinig contrast
### Opgelost
- De weergavevoorkeuren overleven een herlaad. `darkMode` stond op
`useState(true)` en werd nergens bewaard: zette je de app op licht en laadde je
opnieuw, dan stond je weer in het donker. Thema en modus gaan nu naar
localStorage, net als de kolombreedtes. Ze staan los van je financiële data en
gaan dus niet mee in de backup-ZIP
- `VoortgangTab.jsx` bepaalde de modus met `T.bg === "#0f1117"`. Die kleur is de
achtergrond allang niet meer (dat is `#241A39`), dus de grafiek gebruikte
altijd de lichte rasterlijnen — ook in het donker. Leest nu `T.darkMode`, wat
met instelbare thema's sowieso de enige werkbare manier is
- De kolomkoppen van de categorieën hebben een `title`, net als de
Opmerkingen-cellen al hadden: bij de standaardbreedte kapt "Crowdfunding" af
- De dark-mode schakelaar in `InstellingenModal.jsx` was een `<div>` met een
klik-handler en dus niet met het toetsenbord te bedienen. Nu een
`<button role="switch">` met `aria-checked`
### Gewijzigd
- De accenten door de hele app lopen via het thema. Ongeveer 80 verwijzingen in
negen bestanden: knoppen, badges, de actieve tab, de doelbalk, de avatar, het
logo in de navigatiebalk en de gradient van de vermogenslijn in de grafiek.
`PURPLE` en `PURPLE_LIGHT` werden in vijf bestanden uit `constants/index.js`
geïmporteerd; die blijven bestaan voor het loginscherm, maar de app leest nu
`T.accent` en `T.accentTekst`
- De knopstijlen `primaryBtn`, `editBtn` en `addBtn` in `EigenVermogenTab.jsx` en
`SchuldTab.jsx` zijn functies van `T` geworden, zoals `cancelBtn` al was — een
object op moduleniveau kan het thema niet volgen
- `VBadge`, `TabBtn` en `VProgressBar` in `components/ui/index.jsx` lezen hun
standaardkleur uit het thema in plaats van uit een geïmporteerde constante. Wie
een kleur meegeeft houdt die, dus de categorie- en schuldkleuren blijven staan
- Een paar plekken die in lichte modus nauwelijks werkten, lopen nu ook via het
thema: de doelbalk had `rgba(255,255,255,0.07)` als achtergrond (onzichtbaar op
wit) en de tooltips in beide grafieken hadden een vaste donkere vulling
### Bekend
- Het loginscherm blijft bewust op de huisstijl. Daar zit bijna de helft van alle
paarse accenten (32 van de 70) voor een scherm dat je enkele seconden ziet — en
bij opslag per account weet je daar nog niet welk thema je moet tonen
- De kleurenkiezer voor schuldblokken (`SchuldTab.jsx`) en de categoriekleuren
houden hun vaste lijst. Dat is data van de gebruiker, geen thema
## [1.0.7] - 2026-09-11
### Opgelost
- De tooltip van de dashboardgrafiek viel buiten de tekening zodra vermogen en
inleg ver uit elkaar lagen. Hij werd boven het hoogste punt geplaatst en klapte
naar onderen als daar geen ruimte was — maar dat "onderen" rekende vanaf het
laagste punt, en met een kaart van 76px kwam hij dan onder het tekenvlak
terecht: over de datumlabels heen en half afgekapt. Precies in het midden van
de grafiek, waar je hem het meest gebruikt. De kaart staat nu náást de
hoverlijn (links of rechts, afhankelijk van waar de meeste ruimte is) en wordt
verticaal geklemd binnen het tekenvlak, dus hij past altijd
## [1.0.6] - 2026-09-11
### Toegevoegd
- De grafiek "Ontwikkeling van Eigen Vermogen" op het dashboard heeft een tweede
lijn: de **cumulatief geïnvesteerde fiat**. Die is opgebouwd uit dezelfde
gegevens als de KPI's erboven — het oudste voortgangsmoment telt als inleg,
elke latere rij telt zijn stortingen erbij op — zodat het laatste punt van de
lijn exact gelijk is aan "Totaal geïnvesteerde fiat". Het verschil tussen de
twee lijnen is je rendement, en dat staat nu ook in de tooltip: vermogen,
ingelegd en resultaat per datum
- Legenda boven de grafiek, nodig zodra er meer dan één lijn in staat
### Gewijzigd
- De inleglijn wordt met rechte segmenten getekend en niet met de
Catmull-Rom-afronding van de vermogenslijn. Cumulatieve inleg loopt nooit
terug, maar de bezier-afronding trekt tussen twee ongelijke stortingen een
doorbuiging onder het vorige punt — dan zou de grafiek een daling tonen die
in de data niet bestaat
- De y-as schaalt op beide reeksen. Op alleen de vermogenswaarden zou de
inleglijn buiten beeld lopen zodra je meer hebt ingelegd dan het vermogen
waard is
- De schermentabel in `README.md` beschreef het Dashboard als "totaal eigen
vermogen, schulden en netto vermogen". Dat klopte niet: `DashboardTab.jsx`
leest `totSchuld` helemaal niet en toont geen schuld en geen nettowaarde — dat
staat op het Voortgang-tabblad. De rij noemt nu wat er wél staat (KPI's over
inleg, vermogen, winst en ROI, de doelbalk en de grafiek), met een alinea
eronder over de twee lijnen
### Bekend
- De screenshot bovenaan `README.md` is van vóór deze wijziging en toont de
grafiek nog met één lijn
## [1.0.5] - 2026-09-11
### Gewijzigd
- De documentatie is gelijkgetrokken met de code. `README.md`: de bestandsboom
miste `backend/src/logger.js`, `docker/check-updates.mjs`,
+23
View File
@@ -60,6 +60,29 @@ ARG APP_VERSION=dev
ENV NODE_ENV=production
ENV APP_VERSION=$APP_VERSION
# ── Aanvalsoppervlak beperken ─────────────────────────────────────────────────
# Twee stappen die samen elke CVE uit de imagescan wegnemen. Ze staan hier en
# niet in een andere stage: alleen dit image gaat naar de registry.
#
# 1. OS-pakketten bijwerken. Het node-image wordt minder vaak herbouwd dan
# Alpine patches uitbrengt, dus libssl3 en libcrypto3 lopen er structureel
# op achter — goed voor twintig CVE's in de laatste scan. Dit haalt ze op
# het moment van bouwen alsnog bij.
#
# 2. De pakketbeheerders eruit. De container start met `node src/server.js` en
# gebruikt npm, npx, corepack of yarn nooit; ze zitten er alleen in omdat
# het base-image ze meelevert. Alle negentien Node-CVE's uit de scan zaten
# in npm's eigen meegeleverde dependencies (tar, pacote, sigstore,
# brace-expansion, picomatch, ip-address) en hadden niets met deze
# applicatie te maken. Weg met de tool is weg met die hele categorie —
# inclusief de volgende ronde, want ze komen steeds terug.
RUN apk upgrade --no-cache \
&& rm -rf /usr/local/lib/node_modules/npm \
/usr/local/lib/node_modules/corepack \
/usr/local/bin/npm /usr/local/bin/npx /usr/local/bin/corepack \
/usr/local/bin/yarn /usr/local/bin/yarnpkg \
/opt/yarn-v*
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
WORKDIR /app
+88 -10
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)
![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)
![Version](https://img.shields.io/badge/versie-1.0.4-8b5cf6?style=flat)
![Version](https://img.shields.io/badge/versie-1.0.12-8b5cf6?style=flat)
![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">
@@ -117,11 +117,12 @@ uitzonderingen nodig. Handige acties:
### Testen
```powershell
.\test.ps1 # dependency-check + backend-tests + build + smoketest
.\test.ps1 # deps + backend-tests + build + imagescan + smoketest
.\test.ps1 -Unit # dependency-check + backend-tests
.\test.ps1 -NoSmoke # zonder de stack te starten
.\test.ps1 -Strict # faal als een dependency achterloopt
.\test.ps1 -NoSmoke # zonder de stack te starten (imagescan draait wél)
.\test.ps1 -Strict # faal op achterlopende deps; scan ook op MEDIUM/LOW
.\test.ps1 -SkipDeps # dependency-check overslaan (bijv. offline)
.\test.ps1 -SkipScan # imagescan overslaan (bijv. offline)
```
Alles loopt in Docker — Node op de host is niet nodig. De backend-tests draaien
@@ -141,6 +142,26 @@ backend/package.json (15 dependencies)
alles actueel
```
Stap 4 scant het gebouwde image met [Trivy](https://trivy.dev) en blokkeert bij
CRITICAL of HIGH waarvoor een fix bestaat. Dat is iets anders dan stap 1: die
kijkt naar de `package.json`'s, deze naar wat er daadwerkelijk in het image zit
— de Alpine-pakketten en alles wat het base-image meelevert. Dat onderscheid is
niet theoretisch. Bij de scan van 1.0.10 kwamen alle 39 bevindingen daarvandaan
en geen enkele uit de applicatie zelf:
| Bron | Aantal | Waar het vandaan kwam |
|---|---|---|
| `libssl3` + `libcrypto3` | 20 | Alpine in het node-image liep achter op 3.5.8-r0 |
| npm's eigen dependencies | 19 | `tar`, `pacote`, `sigstore`, `brace-expansion`, `picomatch`, `ip-address` |
Een dependency-check had er nul van gezien. Het productie-image doet daarom aan
het eind van de build een `apk upgrade` en gooit npm, npx, corepack en yarn
eruit: de container start met `node src/server.js` en gebruikt nooit een
pakketbeheerder. Dat bracht de scan op nul, en houdt hem daar — npm's
dependencies zijn een terugkerende bron die nu simpelweg niet meer in het image
zit. `release.ps1` draait dezelfde scan vóór het pushen, dus een image met
openstaande CVE's komt de registry niet in.
### Productie release
```powershell
@@ -166,12 +187,46 @@ build — anders zou de release met een placeholder als notes online komen.
| Scherm | Wat het doet |
|---|---|
| 📊 **Dashboard** | Overzicht van totaal eigen vermogen, schulden en netto vermogen |
| 📊 **Dashboard** | KPI's over inleg, vermogen, winst en ROI, de voortgang naar je doel, en de grafiek met vermogen tegen inleg |
| 💰 **Eigen vermogen** | Vermogensposten per categorie beheren + geïnvesteerde fiat historiek |
| 📉 **Schuld** | Schulden bijhouden per blok met afbetalingsoverzicht |
| 📈 **Voortgang Schuld & EV** | Grafieken en trends van EV en schuld over tijd (apart bij te houden) |
| 📈 **Voortgang Schuld & EV** | KPI-kaarten met de verdeling van schuld en EV, het rendement en de netto waarde, plus grafieken en trends van EV en schuld over tijd (apart bij te houden) |
| 👥 **Gebruikers** | Accounts aanmaken, bewerken en beheren (Admin-rol vereist) |
| ⚙️ **Instellingen** | Licht/donker thema, backup downloaden en backup terugzetten |
| ⚙️ **Instellingen** | Kleurthema, licht/donker, backup downloaden, backup terugzetten en export naar HTML |
De grafiek "Ontwikkeling van Eigen Vermogen" op het dashboard tekent twee lijnen
op één as: je netto waarde per voortgangsmoment, en daaronder de cumulatief
geïnvesteerde fiat. Het verschil tussen de twee is je rendement; de tooltip zet
vermogen, inleg en resultaat per datum onder elkaar. Het laatste punt van de
inleglijn is per definitie gelijk aan de KPI "Totaal geïnvesteerde fiat" — beide
rekenen het oudste voortgangsmoment als startinleg en tellen daar de stortingen
per rij bij op.
Rendement betekent overal hetzelfde: huidige waarde min geïnvesteerde fiat,
gedeeld door die inleg. De kaart "Rendement Eigen Vermogen" op Voortgang en het
blok "Rendement" op Eigen vermogen tonen daarom hetzelfde totaal — dat laatste
splitst het alleen nog uit per categorie. Zolang er geen inleg is vastgelegd
staat er `—` in plaats van een percentage, want zonder noemer is er geen
rendement te berekenen.
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
licht/donker staan los van elkaar: het thema levert het accent, de schakelaar de
ondergrond. De paletten staan in
[`frontend/src/constants/themas.js`](frontend/src/constants/themas.js);
`useTheme(themaId, darkMode)` leidt daar randen, invoervelden en schaduwen uit af.
Groen, rood en goud veranderen bewust nooit mee — die betekenen winst, verlies en
inleg, en betekenis hoort niet van een kleurkeuze af te hangen. Thema en modus
worden per browser bewaard.
De kolommen in de tabellen op Eigen vermogen en Voortgang zijn versleepbaar: pak
de rechterrand van een kolomkop. De breedtes worden per tabel in de browser
@@ -182,6 +237,28 @@ downloadt een ZIP (`data.json` + `meta.json`) met je eigen financiële data,
"Backup terugzetten" leest zo'n ZIP weer in en schrijft hem via `/api/data` weg.
Dat is de gebruikersvariant; een volledige database-dump staat in `BEHEER.md`.
Daarnaast staat er **"Exporteren naar HTML"**: één zelfdragend bestand met al je
gegevens, bedoeld als laatste vangnet. Draait de app niet meer, is de database
weg en ligt er geen backup, dan opent dit bestand nog steeds — in elke browser,
op een computer zonder internet. Er zit niets van buiten in: de opmaak staat
inline, er worden geen lettertypen, afbeeldingen of scripts geladen. Je ziet een
samenvatting met de KPI's, eigen vermogen en schulden per categorie met
subtotalen, de initiële schuld met wat er sinds de start is afgelost, de
voortgangstabel en de historie.
Alles wat je ooit hebt ingevoerd komt mee, ook wat de app zelf niet meer toont:
stortingen op een inmiddels verwijderde categorie krijgen een eigen kolom
(`Verwijderd (c9)`). De voortgangstabel is bij zes categorieën twaalf kolommen
breed en scrollt horizontaal in plaats van kolommen af te kappen; de datumkolom
blijft daarbij staan. Op print vervalt de scrollbalk en wordt de tabel kleiner
gezet, zodat alle kolommen op papier alsnog passen.
Het bestand is tegelijk een backup: onderin staat de complete ruwe data in een
`<script type="application/json">`-blok dat de browser niet uitvoert en niet
toont. "Backup herstellen" accepteert daarom naast de ZIP ook zo'n HTML-bestand.
Eén bestand dat je zowel kunt lézen als kunt terugzetten — zie
[`frontend/src/utils/htmlExport.js`](frontend/src/utils/htmlExport.js).
---
## 🏗️ Architectuur
@@ -215,8 +292,9 @@ vibefinance/
│ ├── App.css # Reset + losse klassen (o.a. .kolom-grip)
│ ├── main.jsx
│ ├── version.js # Draaiende versie (__APP_VERSION__ uit de build)
│ ├── constants/ # Kleuren, tabs, begindata
│ ├── utils/ # format.js (bedragen/datums), storage.js
│ ├── constants/ # index.js (kleuren, tabs, begindata), themas.js
│ ├── utils/ # format.js (bedragen/datums), storage.js,
│ │ # grafiek.js (SVG), htmlExport.js (export)
│ ├── hooks/ # useTheme, useUpdateCheck, useKolomBreedtes
│ ├── context/ # AppContext (global state + API sync + backup)
│ ├── components/
@@ -225,7 +303,7 @@ vibefinance/
│ │ ├── LoginBackground.jsx
│ │ ├── NavBar.jsx
│ │ ├── ProfielPopup.jsx
│ │ ├── InstellingenModal.jsx # Thema + backup/restore
│ │ ├── InstellingenModal.jsx # Thema + backup/restore + export
│ │ └── GebruikersBeheer.jsx
│ └── pages/
│ ├── DashboardTab.jsx
+3
View File
@@ -15,6 +15,9 @@ services:
postgres:
image: postgres:18-alpine
container_name: vibefinance-postgres-dev
# Zie docker-compose.yml: non-root starten omzeilt gosu, waar alle CVE's
# van dit image in zitten.
user: "postgres"
environment:
POSTGRES_DB: ${POSTGRES_DB:-vibefinance}
POSTGRES_USER: ${POSTGRES_USER:-vibefinance}
+7
View File
@@ -16,6 +16,13 @@ services:
image: postgres:18-alpine
container_name: vibefinance_postgres
restart: unless-stopped
# Meteen als postgres draaien in plaats van als root. Het entrypoint schakelt
# anders zelf terug met `gosu`, en juist die binary bevat alle 46 CVE's van
# dit image (Go stdlib 1.24.6, meegecompileerd door upstream). Start de
# container al als postgres, dan slaat het entrypoint die tak over en wordt
# gosu nooit uitgevoerd. Werkt ook op een volume dat ooit als root is
# aangemaakt — de data-directory is dan al van postgres.
user: "postgres"
environment:
POSTGRES_DB: ${POSTGRES_DB:-vibefinance}
POSTGRES_USER: ${POSTGRES_USER:-vibefinance}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "vibefinance-frontend",
"version": "1.0.4",
"version": "1.0.13-dev",
"private": true,
"type": "module",
"scripts": {
+6 -6
View File
@@ -8,7 +8,7 @@ import DashboardTab from "./pages/DashboardTab.jsx";
import EigenVermogenTab from "./pages/EigenVermogenTab.jsx";
import SchuldTab from "./pages/SchuldTab.jsx";
import VoortgangTab from "./pages/VoortgangTab.jsx";
import { RED, PURPLE_LIGHT } from "./constants/index.js";
import { RED } from "./constants/index.js";
import { fmt } from "./utils/format.js";
import { version } from "./version.js";
import { useUpdateCheck } from "./hooks/useUpdateCheck.js";
@@ -86,10 +86,10 @@ function AppInner() {
</div>
<button onClick={() => { setSessionVerlopen(false); }} style={{
width: "100%", padding: "14px",
background: "linear-gradient(135deg, #8b5cf6, #a855f7)",
background: T.accentGradient,
border: "none", borderRadius: 12,
color: "#fff", fontWeight: 700, fontSize: 15, cursor: "pointer",
boxShadow: "0 4px 16px rgba(139,92,246,0.4)",
boxShadow: T.cardShadow,
}}>
Opnieuw inloggen
</button>
@@ -110,8 +110,8 @@ function AppInner() {
}}>
<div style={{
width: 40, height: 40, borderRadius: "50%",
border: `3px solid rgba(139,92,246,0.2)`,
borderTop: `3px solid ${PURPLE_LIGHT}`,
border: `3px solid ${T.border}`,
borderTop: `3px solid ${T.accentTekst}`,
animation: "spin 0.8s linear infinite",
}} />
<style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
@@ -242,7 +242,7 @@ function AppInner() {
>
<div style={{
width: 36, height: 36, borderRadius: "50%", overflow: "hidden", flexShrink: 0,
background: avatar ? "transparent" : "linear-gradient(135deg, #7c3aed, #a855f7)",
background: avatar ? "transparent" : T.accentGradient,
display: "flex", alignItems: "center", justifyContent: "center",
fontSize: 12, fontWeight: 800, color: "#fff",
border: `2px solid ${T.border}`,
+21 -25
View File
@@ -1,10 +1,6 @@
import { useState, useEffect, useRef } from "react";
import { RED, GREEN } from "../constants/index.js";
const TEAL = "linear-gradient(135deg, #7c3aed, #a855f7)";
const TEAL_COLOR = "#a855f7";
const TEAL_BG = "rgba(124,58,237,0.15)";
const TEAL_BORDER = "rgba(124,58,237,0.35)";
import { useApp } from "../context/AppContext.jsx";
const getToken = () => localStorage.getItem("vf_token");
@@ -56,11 +52,11 @@ function RolSelect({ value, onChange, opties, T, darkMode }) {
{opties.map((o) => (
<div key={o} onClick={() => { onChange(o); setOpen(false); }} style={{
padding: "9px 12px", fontSize: 13, cursor: "pointer",
color: o === value ? TEAL_COLOR : T.text,
background: o === value ? TEAL_BG : "transparent",
color: o === value ? T.accentTekst : T.text,
background: o === value ? T.inputBg : "transparent",
}}
onMouseEnter={(e) => e.currentTarget.style.background = "rgba(14,221,212,0.10)"}
onMouseLeave={(e) => e.currentTarget.style.background = o === value ? TEAL_BG : "transparent"}
onMouseLeave={(e) => e.currentTarget.style.background = o === value ? T.inputBg : "transparent"}
>
{o}
</div>
@@ -176,7 +172,7 @@ export default function GebruikersBeheer({ onClose }) {
onClick={onClose}
style={{
position: "fixed", inset: 0, zIndex: 400,
background: "rgba(0,0,0,0.55)", backdropFilter: "blur(4px)",
background: "rgba(0,0,0,0.45)",
display: "flex", alignItems: "center", justifyContent: "center",
}}
>
@@ -197,8 +193,8 @@ export default function GebruikersBeheer({ onClose }) {
}}>
<div>
<span style={{ fontWeight: 700, fontSize: 15, color: T.text }}>Gebruikersbeheer</span>
<span style={{ marginLeft: 10, fontSize: 11, fontWeight: 600, color: TEAL_COLOR,
background: TEAL_BG, borderRadius: 99, padding: "2px 8px", border: `1px solid ${TEAL_BORDER}`,
<span style={{ marginLeft: 10, fontSize: 11, fontWeight: 600, color: T.accentTekst,
background: T.inputBg, borderRadius: 99, padding: "2px 8px", border: `1px solid ${T.inputBorder}`,
}}>
{users.length} gebruiker{users.length !== 1 ? "s" : ""}
</span>
@@ -236,18 +232,18 @@ export default function GebruikersBeheer({ onClose }) {
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<div style={{
width: 32, height: 32, borderRadius: "50%",
background: TEAL_BG, display: "flex",
background: T.inputBg, display: "flex",
alignItems: "center", justifyContent: "center",
fontSize: 12, fontWeight: 700, color: TEAL_COLOR, flexShrink: 0,
fontSize: 12, fontWeight: 700, color: T.accentTekst, flexShrink: 0,
}}>
{initials}
</div>
<span style={{ fontWeight: 600 }}>{u.naam}</span>
{isSelf && (
<span style={{
fontSize: 10, fontWeight: 700, color: TEAL_COLOR,
background: TEAL_BG, borderRadius: 99,
padding: "1px 7px", border: `1px solid ${TEAL_BORDER}`,
fontSize: 10, fontWeight: 700, color: T.accentTekst,
background: T.inputBg, borderRadius: 99,
padding: "1px 7px", border: `1px solid ${T.inputBorder}`,
}}>jij</span>
)}
</div>
@@ -263,9 +259,9 @@ export default function GebruikersBeheer({ onClose }) {
<td style={{ ...td, whiteSpace: "nowrap" }}>
<div style={{ display: "flex", gap: 6, alignItems: "center" }}>
<button onClick={() => openEdit(u)} style={{
padding: "3px 8px", background: TEAL_BG,
border: `1px solid ${TEAL_BORDER}`, borderRadius: 6,
color: TEAL_COLOR, cursor: "pointer", fontSize: 12,
padding: "3px 8px", background: T.inputBg,
border: `1px solid ${T.inputBorder}`, borderRadius: 6,
color: T.accentTekst, cursor: "pointer", fontSize: 12,
}}>✏️</button>
{!isSelf && (
<>
@@ -303,10 +299,10 @@ export default function GebruikersBeheer({ onClose }) {
{/* Bewerken modal */}
{editUser && (
<div style={{
background: darkMode ? "rgba(124,58,237,0.06)" : "#f5f0ff",
border: `1px solid ${TEAL_BORDER}`, borderRadius: 10, padding: "16px", marginBottom: 16,
background: darkMode ? "rgba(124,58,237,0.06)" : T.inputBg,
border: `1px solid ${T.inputBorder}`, borderRadius: 10, padding: "16px", marginBottom: 16,
}}>
<div style={{ fontSize: 11, color: TEAL_COLOR, fontWeight: 700, textTransform: "uppercase", letterSpacing: "0.05em", marginBottom: 12 }}>
<div style={{ fontSize: 11, color: T.accentTekst, fontWeight: 700, textTransform: "uppercase", letterSpacing: "0.05em", marginBottom: 12 }}>
{editUser.naam} bewerken
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10, marginBottom: 10 }}>
@@ -322,7 +318,7 @@ export default function GebruikersBeheer({ onClose }) {
}}>Annuleren</button>
<button onClick={saveEdit} disabled={saving} style={{
flex: 2, padding: "8px",
background: TEAL,
background: T.accentGradient,
border: "none", borderRadius: 8, color: "#fff",
fontWeight: 700, fontSize: 13, cursor: saving ? "not-allowed" : "pointer",
}}>{saving ? "Opslaan..." : "Opslaan"}</button>
@@ -333,7 +329,7 @@ export default function GebruikersBeheer({ onClose }) {
{/* Nieuwe gebruiker */}
{showAdd ? (
<div style={{
background: darkMode ? "rgba(124,58,237,0.06)" : "#f5f0ff",
background: darkMode ? "rgba(124,58,237,0.06)" : T.inputBg,
border: `1px solid ${T.border}`, borderRadius: 10, padding: "16px",
}}>
<div style={{ fontSize: 11, color: T.muted, fontWeight: 700, textTransform: "uppercase", letterSpacing: "0.05em", marginBottom: 12 }}>
@@ -353,7 +349,7 @@ export default function GebruikersBeheer({ onClose }) {
}}>Annuleren</button>
<button onClick={addUser} disabled={saving} style={{
flex: 2, padding: "8px",
background: TEAL,
background: T.accentGradient,
border: "none", borderRadius: 8, color: "#fff",
fontWeight: 700, fontSize: 13, cursor: saving ? "not-allowed" : "pointer",
}}>
@@ -364,7 +360,7 @@ export default function GebruikersBeheer({ onClose }) {
) : (
<button onClick={() => setShowAdd(true)} style={{
padding: "8px 18px",
background: TEAL,
background: T.accentGradient,
border: "none", borderRadius: 8, color: "#fff",
fontWeight: 700, fontSize: 13, cursor: "pointer",
}}>+ Nieuwe gebruiker</button>
+64 -8
View File
@@ -1,6 +1,5 @@
import { useApp } from "../context/AppContext.jsx";
const TEAL = "linear-gradient(135deg, #7c3aed, #a855f7)";
import { THEMAS } from "../constants/themas.js";
const BACKUP_ICON = (
<svg width="22" height="22" viewBox="0 0 24 24" fill="none">
@@ -16,6 +15,14 @@ const RESTORE_ICON = (
</svg>
);
const EXPORT_ICON = (
<svg width="22" height="22" viewBox="0 0 24 24" fill="none">
<path d="M14 3H7a1 1 0 00-1 1v16a1 1 0 001 1h10a1 1 0 001-1V7l-4-4z" stroke="currentColor" strokeWidth="1.8" strokeLinejoin="round"/>
<path d="M14 3v4h4" stroke="currentColor" strokeWidth="1.8" strokeLinejoin="round"/>
<path d="M9 12h6M9 16h6" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round"/>
</svg>
);
function ActionRow({ icon, title, description, onClick }) {
return (
<div
@@ -35,14 +42,14 @@ function ActionRow({ icon, title, description, onClick }) {
}
export default function InstellingenModal({ onClose }) {
const { T, darkMode, setDarkMode, doBackup, doRestore } = useApp();
const { T, darkMode, setDarkMode, thema, setThema, doBackup, doRestore, doExport } = useApp();
return (
<div
onClick={onClose}
style={{
position: "fixed", inset: 0, zIndex: 400,
background: "rgba(0,0,0,0.55)", backdropFilter: "blur(4px)",
background: "rgba(0,0,0,0.45)",
display: "flex", alignItems: "center", justifyContent: "center",
}}
>
@@ -70,21 +77,63 @@ export default function InstellingenModal({ onClose }) {
padding: "4px 0 16px",
}}>
<span style={{ fontWeight: 600, fontSize: 14 }}>Dark mode</span>
<div
<button
type="button"
role="switch"
aria-checked={darkMode}
aria-label="Donkere modus"
onClick={() => setDarkMode((d) => !d)}
style={{
width: 44, height: 24, borderRadius: 12, cursor: "pointer",
background: darkMode ? TEAL : "#334155",
background: darkMode ? T.accent : "#334155",
border: "none", padding: 0,
position: "relative", transition: "background 0.2s", flexShrink: 0,
}}
>
<div style={{
<span style={{
position: "absolute", top: 3, borderRadius: "50%",
width: 18, height: 18, background: "#fff",
left: darkMode ? 23 : 3,
transition: "left 0.2s",
boxShadow: "0 1px 4px rgba(0,0,0,0.3)",
}} />
</button>
</div>
{/* Kleurthema */}
<div style={{ padding: "0 0 18px" }}>
<div style={{
display: "flex", alignItems: "baseline", justifyContent: "space-between",
gap: 10, marginBottom: 10,
}}>
<span style={{ fontWeight: 600, fontSize: 14 }}>Kleurthema</span>
<span style={{ fontSize: 12, color: T.muted }}>{T.themaNaam}</span>
</div>
{/* Drie kolommen: negen thema's vullen zo precies drie rijen, zonder
een halve rij onderaan. */}
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 8 }}>
{THEMAS.map((t) => {
const gekozen = t.id === thema;
return (
<button
key={t.id}
type="button"
onClick={() => setThema(t.id)}
title={t.naam}
aria-label={`Thema ${t.naam}`}
aria-pressed={gekozen}
style={{
height: 30, borderRadius: 9, cursor: "pointer", padding: 0,
background: `linear-gradient(135deg, ${t.hoofd}, ${t.licht})`,
// De gekozen kleur krijgt een ring in de kaartkleur plus een
// rand, zodat hij ook opvalt als het thema zelf licht is.
border: `2px solid ${gekozen ? T.text : "transparent"}`,
boxShadow: gekozen ? `0 0 0 2px ${T.accentLicht}` : "none",
transition: "box-shadow 0.15s, border-color 0.15s",
}}
/>
);
})}
</div>
</div>
@@ -102,9 +151,16 @@ export default function InstellingenModal({ onClose }) {
<ActionRow
icon={RESTORE_ICON}
title="Backup herstellen"
description="Herstel data vanuit een backup-bestand"
description="Herstel vanuit een backup-ZIP of HTML"
onClick={() => { doRestore(); onClose(); }}
/>
<div style={{ height: 1, background: T.border }} />
<ActionRow
icon={EXPORT_ICON}
title="Exporteren naar HTML"
description="Leesbare pagina die ook als backup werkt"
onClick={() => { doExport(); onClose(); }}
/>
</div>
</div>
</div>
+15 -18
View File
@@ -1,5 +1,5 @@
import { useState } from "react";
import { TABS } from "../constants/index.js";
import { TABS, RED } from "../constants/index.js";
import { version } from "../version.js";
import { useUpdateCheck } from "../hooks/useUpdateCheck.js";
import { useApp } from "../context/AppContext.jsx";
@@ -82,9 +82,6 @@ function SectionLabel({ children }) {
);
}
const ACTIVE_BG = "#7c3aed";
const ACTIVE_SHADOW = "0 4px 14px rgba(124,58,237,0.4)";
function NavItem({ active, onClick, icon, label, T, danger }) {
const [hovered, setHovered] = useState(false);
const isLit = active || hovered;
@@ -98,12 +95,12 @@ function NavItem({ active, onClick, icon, label, T, danger }) {
display: "flex", alignItems: "center", gap: 10,
padding: "11px 12px", borderRadius: 9, cursor: "pointer", marginBottom: 4,
background: active
? ACTIVE_BG
: hovered ? "rgba(139,92,246,0.08)" : "transparent",
color: active ? "#fff" : hovered ? T.text : (danger ? "#ef4444" : T.muted),
? T.accent
: hovered ? T.inputBg : "transparent",
color: active ? "#fff" : hovered ? T.text : (danger ? RED : T.muted),
fontWeight: active ? 600 : 400,
fontSize: 15,
boxShadow: active ? ACTIVE_SHADOW : "none",
boxShadow: active ? T.cardShadow : "none",
transition: "all 0.15s",
}}
>
@@ -119,7 +116,7 @@ function OverPopup({ onClose, nieuweVersie, T }) {
{/* Overlay */}
<div onClick={onClose} style={{
position: "fixed", inset: 0, zIndex: 490,
background: "rgba(0,0,0,0.55)", backdropFilter: "blur(4px)",
background: "rgba(0,0,0,0.45)",
display: "flex", alignItems: "center", justifyContent: "center",
}}>
{/* Modal */}
@@ -141,12 +138,12 @@ function OverPopup({ onClose, nieuweVersie, T }) {
<div style={{ display: "flex", alignItems: "center", gap: 12, marginBottom: 20 }}>
<div style={{
width: 44, height: 44, borderRadius: 12, flexShrink: 0,
background: "rgba(124,58,237,0.15)", border: "1px solid rgba(124,58,237,0.3)",
background: T.inputBg, border: `1px solid ${T.inputBorder}`,
display: "flex", alignItems: "center", justifyContent: "center",
}}>
<svg width="22" height="22" viewBox="0 0 20 20" fill="none">
<polyline points="2,15 6,9 10,12 14,5 18,8" stroke="#a855f7" strokeWidth="2.2" strokeLinejoin="round" strokeLinecap="round"/>
<line x1="2" y1="17" x2="18" y2="17" stroke="rgba(168,85,247,0.35)" strokeWidth="1.4"/>
<polyline points="2,15 6,9 10,12 14,5 18,8" stroke={T.accentTekst} strokeWidth="2.2" strokeLinejoin="round" strokeLinecap="round"/>
<line x1="2" y1="17" x2="18" y2="17" stroke={T.accentTekst} strokeOpacity="0.35" strokeWidth="1.4"/>
</svg>
</div>
<div>
@@ -158,7 +155,7 @@ function OverPopup({ onClose, nieuweVersie, T }) {
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 10 }}>
<span style={{ fontSize: 13, color: T.muted }}>Versie</span>
<span style={{ fontSize: 12, fontWeight: 700, color: "#a855f7", background: "rgba(124,58,237,0.15)", borderRadius: 6, padding: "3px 10px" }}>
<span style={{ fontSize: 12, fontWeight: 700, color: T.accentTekst, background: T.inputBg, borderRadius: 6, padding: "3px 10px" }}>
v{version}
</span>
</div>
@@ -198,7 +195,7 @@ function DarkModeToggle({ darkMode, setDarkMode, T }) {
{/* Toggle pill */}
<div style={{
width: 36, height: 20, borderRadius: 99, flexShrink: 0,
background: darkMode ? "#7c3aed" : "rgba(255,255,255,0.15)",
background: darkMode ? T.accent : "rgba(255,255,255,0.15)",
position: "relative", transition: "background 0.2s",
}}>
<div style={{
@@ -231,16 +228,16 @@ export default function NavBar() {
<div style={{ padding: "22px 16px 28px", display: "flex", alignItems: "center", gap: 10 }}>
<div style={{
width: 32, height: 32, flexShrink: 0, borderRadius: 9,
background: "rgba(124,58,237,0.15)",
border: "1px solid rgba(124,58,237,0.3)",
background: T.inputBg,
border: `1px solid ${T.inputBorder}`,
display: "flex", alignItems: "center", justifyContent: "center",
}}>
<svg width="18" height="18" viewBox="0 0 20 20" fill="none">
<polyline points="2,15 6,9 10,12 14,5 18,8"
stroke="#a855f7" strokeWidth="2.2"
stroke={T.accentTekst} strokeWidth="2.2"
strokeLinejoin="round" strokeLinecap="round"/>
<line x1="2" y1="17" x2="18" y2="17"
stroke="rgba(168,85,247,0.35)" strokeWidth="1.4"/>
stroke={T.accentTekst} strokeOpacity="0.35" strokeWidth="1.4"/>
</svg>
</div>
<span style={{ fontSize: 17, fontWeight: 700, color: T.text, letterSpacing: "-0.3px" }}>VibeFinance</span>
+6 -8
View File
@@ -2,8 +2,6 @@ import { useState } from "react";
import { useApp } from "../context/AppContext.jsx";
import { RED } from "../constants/index.js";
const TEAL = "linear-gradient(135deg, #7c3aed, #a855f7)";
const TEAL_SHADOW = "0 4px 14px rgba(124,58,237,0.4)";
function initials(naam = "") {
const parts = naam.trim().split(/\s+/);
@@ -149,7 +147,7 @@ export default function ProfielPopup({ onClose }) {
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", padding: "24px 18px 20px", gap: 6 }}>
<div style={{
width: 72, height: 72, borderRadius: "50%", overflow: "hidden",
background: avatar ? "transparent" : TEAL,
background: avatar ? "transparent" : T.accentGradient,
display: "flex", alignItems: "center", justifyContent: "center",
fontSize: 24, fontWeight: 800, color: "#fff",
boxShadow: "0 0 20px rgba(124,58,237,0.4)", marginBottom: 4,
@@ -163,10 +161,10 @@ export default function ProfielPopup({ onClose }) {
<div style={{ fontSize: 13, color: T.muted }}>{currentUser?.email}</div>
<button onClick={() => setView("profiel")} style={{
marginTop: 8, padding: "9px 24px",
background: TEAL,
background: T.accentGradient,
border: "none", borderRadius: 10, color: "#fff",
fontWeight: 700, fontSize: 13, cursor: "pointer",
boxShadow: TEAL_SHADOW,
boxShadow: T.cardShadow,
}}>Profiel bewerken</button>
</div>
@@ -189,7 +187,7 @@ export default function ProfielPopup({ onClose }) {
<label style={{ cursor: "pointer", position: "relative", display: "block" }} title="Klik om foto te uploaden">
<div style={{
width: 64, height: 64, borderRadius: "50%", overflow: "hidden",
background: avatar ? "transparent" : TEAL,
background: avatar ? "transparent" : T.accentGradient,
display: "flex", alignItems: "center", justifyContent: "center",
fontSize: 20, fontWeight: 800, color: "#fff",
}}>
@@ -229,7 +227,7 @@ export default function ProfielPopup({ onClose }) {
{profielErr && <div style={{ fontSize: 12, color: RED }}>{profielErr}</div>}
{profielOk && <div style={{ fontSize: 12, color: "#10b981" }}>Profiel opgeslagen.</div>}
<button onClick={saveProfiel} disabled={profielBusy} style={{
padding: "11px", background: TEAL,
padding: "11px", background: T.accentGradient,
border: "none", borderRadius: 10, color: "#fff", fontWeight: 700,
fontSize: 13, cursor: profielBusy ? "default" : "pointer", opacity: profielBusy ? 0.7 : 1,
}}>{profielBusy ? "Opslaan…" : "Opslaan"}</button>
@@ -252,7 +250,7 @@ export default function ProfielPopup({ onClose }) {
{pwErr && <div style={{ fontSize: 12, color: RED }}>{pwErr}</div>}
{pwOk && <div style={{ fontSize: 12, color: "#10b981" }}>Wachtwoord gewijzigd.</div>}
<button onClick={saveWachtwoord} disabled={pwBusy} style={{
padding: "11px", background: TEAL,
padding: "11px", background: T.accentGradient,
border: "none", borderRadius: 10, color: "#fff", fontWeight: 700,
fontSize: 13, cursor: pwBusy ? "default" : "pointer", opacity: pwBusy ? 0.7 : 1,
}}>{pwBusy ? "Opslaan…" : "Opslaan"}</button>
+17 -12
View File
@@ -1,5 +1,5 @@
import { useRef, useState, useCallback, useContext, createContext } from "react";
import { PURPLE, PURPLE_LIGHT, EMOJI_OPTIONS } from "../../constants/index.js";
import { EMOJI_OPTIONS } from "../../constants/index.js";
import { useApp } from "../../context/AppContext.jsx";
const DragContext = createContext(null);
@@ -18,11 +18,13 @@ export function VCard({ children, style = {} }) {
}
// ── VBadge ────────────────────────────────────────────────────────────────────
export function VBadge({ label, color = PURPLE }) {
export function VBadge({ label, color }) {
const { T } = useApp();
const kleur = color ?? T.accentTekst;
return (
<span style={{
background: `${color}22`, color,
border: `1px solid ${color}44`,
background: `${kleur}22`, color: kleur,
border: `1px solid ${kleur}44`,
borderRadius: 99, padding: "2px 10px",
fontSize: 11, fontWeight: 700,
}}>
@@ -32,16 +34,17 @@ export function VBadge({ label, color = PURPLE }) {
}
// ── TabBtn ────────────────────────────────────────────────────────────────────
export function TabBtn({ active, onClick, children, darkMode = true }) {
export function TabBtn({ active, onClick, children }) {
const { T } = useApp();
return (
<button
onClick={onClick}
style={{
padding: "8px 16px", border: "none", borderRadius: 8, cursor: "pointer",
fontSize: 13, fontWeight: active ? 700 : 500,
background: active ? `${PURPLE}22` : "transparent",
color: active ? PURPLE_LIGHT : (darkMode ? "#94a3b8" : "#64748b"),
borderBottom: active ? `2px solid ${PURPLE_LIGHT}` : "2px solid transparent",
background: active ? `${T.accent}22` : "transparent",
color: active ? T.accentTekst : T.muted,
borderBottom: active ? `2px solid ${T.accentTekst}` : "2px solid transparent",
transition: "all 0.15s",
}}
>
@@ -51,12 +54,14 @@ export function TabBtn({ active, onClick, children, darkMode = true }) {
}
// ── VProgressBar ──────────────────────────────────────────────────────────────
export function VProgressBar({ value, max, color = PURPLE }) {
export function VProgressBar({ value, max, color }) {
const { T } = useApp();
const kleur = color ?? T.accent;
const p = Math.min((value / (max || 1)) * 100, 100);
return (
<div style={{ height: 8, background: "rgba(255,255,255,0.07)", borderRadius: 99, overflow: "hidden" }}>
<div style={{
height: "100%", width: `${p}%`, background: color,
height: "100%", width: `${p}%`, background: kleur,
borderRadius: 99, transition: "width 0.5s ease",
}} />
</div>
@@ -314,7 +319,7 @@ export function Overlay({ children, onClose, zIndex = 400 }) {
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
style={{
position: "fixed", inset: 0,
background: "rgba(0,0,0,0.75)",
background: "rgba(0,0,0,0.45)",
zIndex,
}}
>
@@ -527,7 +532,7 @@ export function EmptyState({ icon, text, hint, onClick, locked, T }) {
<div style={{ fontSize: 32, marginBottom: 10 }}>{icon}</div>
<div style={{ fontSize: 13, marginBottom: 4 }}>{text}</div>
{!locked && hint && (
<div style={{ fontSize: 12, color: PURPLE_LIGHT }}>{hint}</div>
<div style={{ fontSize: 12, color: T.accentTekst }}>{hint}</div>
)}
</div>
);
+92
View File
@@ -0,0 +1,92 @@
/**
* Kleurthema's.
*
* Een thema bepaalt het **accent**, de dark-mode schakelaar bepaalt de
* **ondergrond**. Dat scheelt een compleet tweede palet per thema, en de
* bestaande schakelaar blijft doen wat hij deed.
*
* Per thema:
* hoofd de donkere stap van het accent — draagt tekst op een lichte ondergrond
* licht de lichte stap — draagt tekst op een donkere ondergrond
* chart startkleur van de gradient in de vermogensgrafiek (loopt naar `licht`)
* donker / helder: de ondergronden, licht naar het accent toe getint
*
* Eén stap voor beide modi haalt aan één kant altijd te weinig contrast; daarom
* kiest `useTheme` per modus de passende stap als `accentTekst`.
*
* Wat hier NIET in staat en dat ook niet hoort: GREEN, RED en GOLD uit
* `constants/index.js`. Die betekenen iets — winst, verlies en schuld, en de
* inleglijn in de grafiek. Betekenis mag niet van een themakeuze afhangen.
* Hetzelfde geldt voor de kleuren die de gebruiker zelf aan categorieën en
* schuldblokken geeft.
*/
export const THEMAS = [
{
id: "violet", naam: "Violet",
hoofd: "#7c3aed", licht: "#a855f7", chart: "#0eddd4",
donker: { bg: "#241A39", diep: "#1b1330", card: "#201930", tekst: "#ffffff", sub: "#a0aec0", muted: "#8b8fa8" },
helder: { bg: "#eef1f8", diep: "#e6eaf5", card: "#ffffff", tekst: "#0f172a", sub: "#475569", muted: "#7c8399" },
},
{
id: "oceaan", naam: "Oceaan",
hoofd: "#0369a1", licht: "#38bdf8", chart: "#22d3ee",
donker: { bg: "#11202f", diep: "#0c1826", card: "#16283a", tekst: "#ffffff", sub: "#9fb4c7", muted: "#7e93a6" },
helder: { bg: "#eef3f9", diep: "#e3ebf4", card: "#ffffff", tekst: "#0c1a26", sub: "#41576b", muted: "#748699" },
},
{
id: "indigo", naam: "Indigo",
hoofd: "#4338ca", licht: "#818cf8", chart: "#67e8f9",
donker: { bg: "#1a1c35", diep: "#13152a", card: "#1f2240", tekst: "#ffffff", sub: "#a5abcd", muted: "#868cb0" },
helder: { bg: "#eef0fa", diep: "#e5e8f6", card: "#ffffff", tekst: "#12142b", sub: "#474d75", muted: "#787ea3" },
},
{
id: "azuur", naam: "Azuur",
hoofd: "#1d4ed8", licht: "#60a5fa", chart: "#7dd3fc",
donker: { bg: "#131c33", diep: "#0e1628", card: "#1a2440", tekst: "#ffffff", sub: "#a3b2d4", muted: "#838fb3" },
helder: { bg: "#eef1fb", diep: "#e4e9f7", card: "#ffffff", tekst: "#0d1426", sub: "#42506e", muted: "#737e9c" },
},
{
id: "jade", naam: "Jade",
// Ligt dicht bij de GREEN die winst betekent: in de KPI-rij staan dan twee
// soorten groen naast elkaar.
hoofd: "#0f766e", licht: "#2dd4bf", chart: "#5eead4",
donker: { bg: "#0f2629", diep: "#0a1c1f", card: "#143035", tekst: "#ffffff", sub: "#9dbdbd", muted: "#7d9d9d" },
helder: { bg: "#edf5f4", diep: "#e2eeed", card: "#ffffff", tekst: "#0b1f21", sub: "#3c5c5c", muted: "#6e8b8b" },
},
{
id: "magenta", naam: "Magenta",
hoofd: "#a21caf", licht: "#e879f9", chart: "#f5d0fe",
donker: { bg: "#2a1130", diep: "#200b26", card: "#331639", tekst: "#ffffff", sub: "#c3a3c9", muted: "#a084a6" },
helder: { bg: "#faeefb", diep: "#f3e2f5", card: "#ffffff", tekst: "#25092a", sub: "#6a4370", muted: "#94709a" },
},
// Roos (#be123c) en Koper (#9a3412) stonden hier ook. Verwijderd: ze botsten
// met kleuren die al betekenis dragen — roos met de RED van verlies en schuld,
// koper met de gouden inleglijn en het oranje Belastingdienst-blok.
{
id: "grafiet", naam: "Grafiet",
hoofd: "#475569", licht: "#94a3b8", chart: "#cbd5e1",
donker: { bg: "#17191f", diep: "#111318", card: "#1e2129", tekst: "#ffffff", sub: "#a8b0bd", muted: "#868e9c" },
helder: { bg: "#f1f2f5", diep: "#e7e9ee", card: "#ffffff", tekst: "#14161a", sub: "#414751", muted: "#737a86" },
},
{
id: "papier", naam: "Papier",
// Warm van toon en bijna monochroom: het accent doet een stap terug.
hoofd: "#3f5570", licht: "#8fa5c0", chart: "#b6c9dc",
donker: { bg: "#1c1a17", diep: "#161412", card: "#24211d", tekst: "#f6f2eb", sub: "#b8b0a4", muted: "#948c80" },
helder: { bg: "#f5f1e8", diep: "#ece7db", card: "#fffdf8", tekst: "#1f1c17", sub: "#4d4a43", muted: "#7c766a" },
},
{
id: "puur", naam: "Puur",
// Zwart of wit, niets ertussenin. Op OLED scheelt de zwarte variant stroom.
hoofd: "#6d28d9", licht: "#c4b5fd", chart: "#22d3ee",
donker: { bg: "#000000", diep: "#000000", card: "#0e0e12", tekst: "#ffffff", sub: "#d4d4d8", muted: "#a1a1aa" },
helder: { bg: "#f7f7f8", diep: "#efeff1", card: "#ffffff", tekst: "#000000", sub: "#27272a", muted: "#52525b" },
},
];
export const STANDAARD_THEMA = "violet";
/** Zoekt een thema op id; valt terug op het standaardthema. */
export const zoekThema = (id) =>
THEMAS.find((t) => t.id === id) ?? THEMAS.find((t) => t.id === STANDAARD_THEMA);
+63 -9
View File
@@ -1,7 +1,9 @@
import { createContext, useContext, useState, useMemo, useRef } from "react";
import JSZip from "jszip";
import { INITIAL_DATA } from "../constants/index.js";
import { migrateData } from "../utils/storage.js";
import { STANDAARD_THEMA } from "../constants/themas.js";
import { migrateData, loadFromStorage, saveToStorage } from "../utils/storage.js";
import { bouwHtmlExport, leesHtmlBackup } from "../utils/htmlExport.js";
import { useTheme } from "../hooks/useTheme.js";
const AppContext = createContext(null);
@@ -34,7 +36,22 @@ export function AppProvider({ children }) {
};
// ── UI ────────────────────────────────────────────────────────
const [darkMode, setDarkMode] = useState(true);
// Weergavevoorkeuren overleven een herlaad. Ze stonden eerder alleen in
// state, dus na een refresh stond je weer in het donker.
const [darkMode, setDarkModeRaw] = useState(() => loadFromStorage("darkMode") ?? true);
const [thema, setThemaRaw] = useState(() => loadFromStorage("thema") ?? STANDAARD_THEMA);
const setDarkMode = (v) => {
const n = typeof v === "function" ? v(darkMode) : v;
saveToStorage("darkMode", n);
setDarkModeRaw(n);
};
const setThema = (id) => {
saveToStorage("thema", id);
setThemaRaw(id);
};
const [locked, setLocked] = useState(false);
const [tab, setTab] = useState(0);
const [menuOpen, setMenuOpen] = useState(false);
@@ -87,7 +104,7 @@ export function AppProvider({ children }) {
const setUsers = (u) => { localStorage.setItem("wbjw_users", JSON.stringify(u)); setUsersRaw(u); };
// ── Thema ─────────────────────────────────────────────────────
const T = useTheme(darkMode);
const T = useTheme(thema, darkMode);
// ── Afgeleide waarden ─────────────────────────────────────────
const ev = data.eigenVermogen;
@@ -152,17 +169,43 @@ export function AppProvider({ children }) {
if (saveTimer.current) clearTimeout(saveTimer.current);
};
// ── Backup / restore ──────────────────────────────────────────
// ── Backup / restore / export ─────────────────────────────────
const downloadZip = async (zip, bestandsnaam) => {
const blob = await zip.generateAsync({ type: "blob", compression: "DEFLATE" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = bestandsnaam;
a.click();
URL.revokeObjectURL(url);
};
const doBackup = async () => {
const zip = new JSZip();
const datum = new Date().toISOString().slice(0, 10);
zip.file("data.json", JSON.stringify({ ...data, evVoortgang: evRows, evVoortgangHistory }, null, 2));
zip.file("meta.json", JSON.stringify({ versie: 1, datum, app: "vibefinance", gebruiker: currentUser?.email }, null, 2));
const blob = await zip.generateAsync({ type: "blob", compression: "DEFLATE" });
await downloadZip(zip, `vibefinance_backup_${datum}.zip`);
};
/**
* Export naar één zelfdragende HTML-pagina: leesbaar zonder de app, en met de
* ruwe data ingebakken zodat `doRestore` hem ook weer terug kan zetten.
*/
const doExport = () => {
const datum = new Date().toISOString().slice(0, 10);
const html = bouwHtmlExport({
cats, schuldBlokken, evRows, evVoortgangHistory, schuldHistory,
totEV, totSchuld, doel: ev?.doel || 0,
initieleSchuld: schuld?.initieleSchuld || 0,
gebruiker: currentUser?.email || "",
ruweData: { ...data, evVoortgang: evRows, evVoortgangHistory },
});
const blob = new Blob([html], { type: "text/html;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `vibefinance_backup_${datum}.zip`;
a.download = `vibefinance_${datum}.html`;
a.click();
URL.revokeObjectURL(url);
};
@@ -170,15 +213,25 @@ export function AppProvider({ children }) {
const doRestore = () => {
const input = document.createElement("input");
input.type = "file";
input.accept = ".zip";
input.accept = ".zip,.html";
input.onchange = async (e) => {
const file = e.target.files[0];
if (!file) return;
try {
// Twee soorten bestanden met dezelfde inhoud: de backup-ZIP met
// `data.json`, en de geëxporteerde HTML-pagina met hetzelfde JSON in
// een script-blok. Zonder dit tweede pad is de belofte onderaan die
// pagina — "hiermee kun je herstellen" — niet waar te maken.
let parsed;
if (file.name.toLowerCase().endsWith(".html")) {
parsed = leesHtmlBackup(await file.text());
if (!parsed) throw new Error("Deze HTML-pagina bevat geen VibeFinance-gegevens.");
} else {
const zip = await JSZip.loadAsync(file);
const entry = zip.file("data.json");
if (!entry) throw new Error("Geen data.json gevonden in backup.");
const parsed = JSON.parse(await entry.async("string"));
parsed = JSON.parse(await entry.async("string"));
}
const migrated = migrateData({ eigenVermogen: parsed.eigenVermogen, schuld: parsed.schuld });
const newEvRows = parsed.evVoortgang || [];
const newEvVH = parsed.evVoortgangHistory || [];
@@ -199,6 +252,7 @@ export function AppProvider({ children }) {
loggedIn, currentUser, login, logout, loading, updateProfile, changePassword,
avatar, setAvatar,
darkMode, setDarkMode,
thema, setThema,
locked, setLocked,
tab, setTab,
menuOpen, setMenuOpen,
@@ -212,7 +266,7 @@ export function AppProvider({ children }) {
evRows, setEvRows,
evVoortgangHistory, setEvVoortgangHistory,
users, setUsers,
doBackup, doRestore,
doBackup, doRestore, doExport,
}}>
{children}
</AppContext.Provider>
+60 -19
View File
@@ -1,27 +1,68 @@
import { zoekThema } from "../constants/themas.js";
/** `#rrggbb` (of `#rgb`) → `rgba(r, g, b, alpha)` */
function metAlpha(hex, alpha) {
const h = hex.replace("#", "");
const vol = h.length === 3 ? h.split("").map((c) => c + c).join("") : h;
const n = parseInt(vol, 16);
return `rgba(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}, ${alpha})`;
}
/**
* Geeft een thema-object (T) terug op basis van darkMode.
* Wordt gebruikt door alle componenten om consistent kleuren te krijgen.
* Geeft een thema-object (T) terug op basis van het gekozen kleurthema en
* licht/donker. Wordt gebruikt door alle componenten om consistent kleuren te
* krijgen.
*
* Thema en modus staan los van elkaar: het thema levert het accent, `darkMode`
* de ondergrond. De randen, invoervelden en schaduwen worden uit het accent
* afgeleid, zodat ze vanzelf meekleuren.
*
* @param {string} themaId id uit `constants/themas.js`
* @param {boolean} darkMode
*/
export function useTheme(darkMode) {
const bg = darkMode ? "#241A39" : "#eef1f8";
const card = darkMode ? "rgba(22,23,27,0.30)" : "#ffffff";
export function useTheme(themaId, darkMode) {
const thema = zoekThema(themaId);
const p = darkMode ? thema.donker : thema.helder;
// Het accent dat tekst draagt is per ondergrond een andere stap van dezelfde
// kleur: de lichte op donker, de donkere op licht. Eén stap voor beide haalt
// aan één kant altijd te weinig contrast.
const accentTekst = darkMode ? thema.licht : thema.hoofd;
return {
// Exacte Finex kleuren (dark) / licht alternatief
bg,
card,
bg: p.bg,
card: p.card,
// Ondoorzichtige achtergrond voor popups/modals: kaartkleur over de pagina-bg.
popupBg: `linear-gradient(${card}, ${card}), ${bg}`,
sidebar: darkMode ? "#16171B" : "#f8faff",
border: darkMode ? "rgba(139,92,246,0.15)" : "rgba(0,0,0,0.07)",
muted: darkMode ? "#8b8fa8" : "#94a3b8",
text: darkMode ? "#ffffff" : "#0f172a",
subtext: darkMode ? "#a0aec0" : "#64748b",
inputBg: darkMode ? "rgba(139,92,246,0.08)" : "#f1f5ff",
inputBorder:darkMode ? "rgba(139,92,246,0.2)" : "#e2e8f0",
contentBg: darkMode ? "#241A39" : "#eef1f8",
shadow: darkMode ? "0 4px 24px rgba(124,58,237,0.25)" : "0 4px 20px rgba(0,0,0,0.07)",
cardShadow: darkMode ? "0 4px 20px rgba(124,58,237,0.2)" : "0 2px 8px rgba(0,0,0,0.06)",
popupBg: `linear-gradient(${p.card}, ${p.card}), ${p.bg}`,
sidebar: p.diep,
border: darkMode ? metAlpha(thema.licht, 0.15) : "rgba(0,0,0,0.07)",
muted: p.muted,
text: p.tekst,
subtext: p.sub,
inputBg: darkMode ? metAlpha(thema.licht, 0.08) : metAlpha(thema.hoofd, 0.05),
inputBorder: darkMode ? metAlpha(thema.licht, 0.2) : "#e2e8f0",
contentBg: p.bg,
shadow: darkMode ? `0 4px 24px ${metAlpha(thema.hoofd, 0.25)}` : "0 4px 20px rgba(0,0,0,0.07)",
cardShadow: darkMode ? `0 4px 20px ${metAlpha(thema.hoofd, 0.2)}` : "0 2px 8px rgba(0,0,0,0.06)",
radius: "14px",
font: "'Poppins', system-ui, sans-serif",
// ── Accenten ──────────────────────────────────────────────────────────────
// Componenten die nu nog PURPLE / PURPLE_LIGHT uit constants/index.js
// importeren horen hier naartoe: een const op moduleniveau is niet
// reactief en kan dus niet met een themakeuze meeveranderen.
accent: thema.hoofd,
accentLicht: thema.licht,
accentTekst,
accentGradient: `linear-gradient(135deg, ${thema.hoofd}, ${thema.licht})`,
// Start- en eindkleur van de vermogenslijn in de grafiek.
chartVan: thema.chart,
chartTot: thema.licht,
// Voor componenten die op de modus moeten beslissen. Eerder werd dat gedaan
// door T.bg met een vaste hex te vergelijken; met elf thema's kan dat niet meer.
darkMode,
themaId: thema.id,
themaNaam: thema.naam,
};
}
+131 -50
View File
@@ -1,9 +1,10 @@
import { useState } from "react";
import { useApp } from "../context/AppContext.jsx";
import { fmt, pct, fmtDatum } from "../utils/format.js";
import { GREEN, RED } from "../constants/index.js";
import { fmt, pct, fmtDatum, fmtMaand } from "../utils/format.js";
import { crPath } from "../utils/grafiek.js";
import { GREEN, RED, GOLD } from "../constants/index.js";
const TEAL = "#a855f7";
import { VCard, VBadge } from "../components/ui/index.jsx";
export default function DashboardTab() {
@@ -27,9 +28,9 @@ export default function DashboardTab() {
const progressPct = Math.min(totEV / doel, 1);
const milestones = [0, 0.25, 0.5, 0.75, 1].map((f) => f * doel);
const kpis = [
{ label: "Start eigen vermogen", val: fmt(startEV), accent: TEAL},
{ label: "Extra geïnvesteerde fiat", val: fmt(extraFiat), accent: TEAL},
{ label: "Totaal geïnvesteerde fiat", val: fmt(totaalFiat), accent: TEAL},
{ label: "Start eigen vermogen", val: fmt(startEV), accent: T.accentTekst},
{ label: "Extra geïnvesteerde fiat", val: fmt(extraFiat), accent: T.accentTekst},
{ label: "Totaal geïnvesteerde fiat", val: fmt(totaalFiat), accent: T.accentTekst},
{ label: "Totaal eigen vermogen", val: fmt(totEV), accent: GREEN },
{ label: "Winst op investering", val: fmt(winst), accent: winst >= 0 ? GREEN : RED },
{ label: "ROI %", val: totaalFiat > 0 ? pct(roi, 2) : "—", accent: roi >= 0 ? GREEN : RED },
@@ -38,6 +39,16 @@ export default function DashboardTab() {
// Minigrafiek data
const vData = [...evRows].sort((a, b) => a.datum.localeCompare(b.datum));
// Cumulatief geïnvesteerde fiat, op dezelfde datums als vData. Dezelfde
// opbouw als de KPI's hierboven: het oudste punt is de start-EV (daar is de
// inleg per definitie gelijk aan de waarde), elk volgend punt telt de
// stortingen van die rij erbij op. Het laatste punt is daarmee gelijk aan
// "Totaal geïnvesteerde fiat".
const fiatData = vData.reduce((acc, row, i) => {
acc.push(i === 0 ? (row.nettoWaarde || 0) : acc[i - 1] + catSum(row));
return acc;
}, []);
return (
<div>
<div style={{ marginBottom: 24 }}>
@@ -81,19 +92,19 @@ export default function DashboardTab() {
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<span style={{ fontSize: 16 }}>🎯</span>
<span style={{ fontWeight: 600 }}>Doel &amp; Bijbehorende Progressie</span>
<VBadge label={fmt(doel)} color={TEAL} />
<VBadge label={fmt(doel)} color={T.accentTekst} />
</div>
<span style={{ color: T.muted, fontSize: 13 }}>{pct(progressPct)} behaald</span>
</div>
<div style={{
height: 14, background: "rgba(255,255,255,0.07)",
height: 14, background: T.inputBg,
borderRadius: 99, overflow: "hidden",
position: "relative", marginBottom: 8,
}}>
<div style={{
position: "absolute", inset: 0,
width: `${progressPct * 100}%`,
background: "linear-gradient(90deg, #7c3aed, #a855f7)",
background: `linear-gradient(90deg, ${T.accent}, ${T.accentLicht})`,
borderRadius: 99, transition: "width 0.7s ease",
}} />
{[0.25, 0.5, 0.75].map((f) => (
@@ -113,7 +124,21 @@ export default function DashboardTab() {
{/* EV Grafiek */}
<VCard style={{ background: T.card, border: `1px solid ${T.border}`, marginBottom: 20 }}>
<div style={{ fontWeight: 600, marginBottom: 16 }}>Ontwikkeling van Eigen Vermogen</div>
<div style={{
display: "flex", alignItems: "center", justifyContent: "space-between",
gap: 12, flexWrap: "wrap", marginBottom: 16,
}}>
<div style={{ fontWeight: 600 }}>Ontwikkeling van Eigen Vermogen</div>
{vData.length >= 2 && (
<div style={{ display: "flex", alignItems: "center", gap: 14, fontSize: 11, color: T.muted }}>
<Legenda kleur={`linear-gradient(90deg, ${T.chartVan}, ${T.chartTot})`} label="Eigen vermogen" />
<Legenda
kleur={`repeating-linear-gradient(90deg, ${GOLD} 0 5px, transparent 5px 9px)`}
label="Geïnvesteerde fiat"
/>
</div>
)}
</div>
{vData.length < 2 ? (
<div style={{ textAlign: "center", padding: "32px 0", color: T.muted }}>
<div style={{ fontSize: 28, marginBottom: 8 }}>📈</div>
@@ -122,7 +147,7 @@ export default function DashboardTab() {
</div>
</div>
) : (
<MiniLineChart vData={vData} darkMode={darkMode} />
<MiniLineChart vData={vData} fiatData={fiatData} darkMode={darkMode} T={T} />
)}
</VCard>
@@ -130,39 +155,47 @@ export default function DashboardTab() {
);
}
// ── 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;
// ── Legenda-item bij de grafiek ─────────────────────────────────────────────
function Legenda({ kleur, label }) {
return (
<span style={{ display: "inline-flex", alignItems: "center", gap: 6 }}>
<span style={{ width: 16, height: 3, borderRadius: 2, background: kleur }} />
{label}
</span>
);
}
// ── Mini inline SVG grafiek ────────────────────────────────────────────────────
function MiniLineChart({ vData, darkMode }) {
function MiniLineChart({ vData, fiatData, darkMode, T }) {
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 mn = Math.min(...vals) * 0.95;
const mx = Math.max(...vals) * 1.05;
const fVals = fiatData;
// Beide reeksen staan in euro's op dezelfde as, dus het bereik moet ze allebei
// omvatten — anders loopt de inleglijn buiten beeld zodra die boven de EV uitkomt.
const mn = Math.min(...vals, ...fVals) * 0.95;
const mx = Math.max(...vals, ...fVals) * 1.05;
const rng = mx - mn || 1;
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 ys = vals.map((v) => pt + (1 - (v - mn) / rng) * iH);
const fYs = fVals.map((v) => pt + (1 - (v - mn) / rng) * iH);
const linePath = crPath(xs, ys);
const areaPath = `${linePath} L ${xs[xs.length-1]},${pt+iH} L ${xs[0]},${pt+iH} Z`;
// Rechte segmenten, geen crPath: de inleg loopt nooit terug, en de bezier-
// afronding zou tussen twee stortingen een dip tekenen die er niet is.
const fiatPath = xs.map((x, i) => `${i ? "L" : "M"} ${x},${fYs[i]}`).join(" ");
const gc = darkMode ? "rgba(255,255,255,0.07)" : "rgba(0,0,0,0.06)";
const lc = darkMode ? "#6b7280" : "#94a3b8";
const lc = T.muted;
const handleMouseMove = (e) => {
const rect = e.currentTarget.getBoundingClientRect();
@@ -172,9 +205,19 @@ function MiniLineChart({ vData, darkMode }) {
};
const h = hovered;
const tipW = 114, tipH = 46;
const tipX = h !== null ? Math.min(Math.max(xs[h] - tipW / 2, pl), W - tipW - 4) : 0;
const tipY = h !== null ? (ys[h] - tipH - 14 < pt ? ys[h] + 14 : ys[h] - tipH - 14) : 0;
const tipW = 150, tipH = 76;
// Náást de hoverlijn, niet erboven of eronder. Zodra de twee punten ver uit
// elkaar liggen is er verticaal geen ruimte voor een kaart van 76px: dan
// klapte hij naar onderen tot buiten de tekening, over de datumlabels heen en
// half afgekapt. Zijwaarts past hij altijd; de klemmen houden hem binnen het
// tekenvlak.
const naarRechts = h !== null && xs[h] < pl + iW / 2;
const tipX = h === null ? 0 : naarRechts
? Math.min(xs[h] + 16, W - pr - tipW)
: Math.max(xs[h] - tipW - 16, pl);
const tipY = h === null ? 0
: Math.max(pt, Math.min((ys[h] + fYs[h]) / 2 - tipH / 2, pt + iH - tipH));
const resultaat = h !== null ? vals[h] - fVals[h] : 0;
return (
<svg
@@ -185,8 +228,8 @@ function MiniLineChart({ vData, darkMode }) {
>
<defs>
<linearGradient id="chartLineGrad" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor="#0eddd4" />
<stop offset="100%" stopColor="#8b5cf6" />
<stop offset="0%" stopColor={T.chartVan} />
<stop offset="100%" stopColor={T.chartTot} />
</linearGradient>
<linearGradient id="chartFadeMask" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="white" stopOpacity="1" />
@@ -214,6 +257,11 @@ function MiniLineChart({ vData, darkMode }) {
{/* Vlakkleur met gradient + fade */}
<path d={areaPath} fill="url(#chartLineGrad)" mask="url(#chartAreaMask)" opacity="0.4" />
{/* Geïnvesteerde fiat — gestippeld, zodat het als referentielijn leest
en de EV-lijn de hoofdrol houdt */}
<path d={fiatPath} fill="none" stroke={GOLD} strokeWidth="1.8"
strokeDasharray="5,4" strokeLinecap="round" strokeLinejoin="round" opacity="0.85" />
{/* Lijn */}
<path d={linePath} fill="none" stroke="url(#chartLineGrad)"
strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" />
@@ -221,38 +269,71 @@ function MiniLineChart({ vData, darkMode }) {
{/* Verticale hover-lijn */}
{h !== null && (
<line x1={xs[h]} y1={pt} x2={xs[h]} y2={pt + iH}
stroke="rgba(255,255,255,0.25)" strokeWidth="1" strokeDasharray="4,3" />
stroke={T.muted} strokeOpacity="0.6" strokeWidth="1" strokeDasharray="4,3" />
)}
{/* Hover punt */}
{/* Hover punten */}
{h !== null && (
<>
<circle cx={xs[h]} cy={fYs[h]} r="4.5"
fill={GOLD} stroke={T.card} strokeWidth="2.5" />
<circle cx={xs[h]} cy={ys[h]} r="5.5"
fill="#0eddd4" stroke={darkMode ? "#151c2e" : "#fff"} strokeWidth="2.5" />
fill={T.chartVan} stroke={T.card} strokeWidth="2.5" />
</>
)}
{/* Tooltip */}
{h !== null && (
<g>
<rect x={tipX} y={tipY} width={tipW} height={tipH} rx="7"
fill={darkMode ? "#1a2035" : "#fff"}
stroke="rgba(14,221,212,0.35)" strokeWidth="1" />
fill={T.card}
stroke={T.border} strokeWidth="1" />
<text x={tipX + tipW / 2} y={tipY + 16} fill={lc} fontSize="9" textAnchor="middle">
{fmtDatum(vData[h].datum)}
</text>
<text x={tipX + tipW / 2} y={tipY + 34} fill="#0eddd4" fontSize="11"
fontWeight="700" textAnchor="middle">
<text x={tipX + 10} y={tipY + 33} fill={lc} fontSize="9">Vermogen</text>
<text x={tipX + tipW - 10} y={tipY + 33} fill={T.chartVan} fontSize="10"
fontWeight="700" textAnchor="end">
{fmt(vals[h])}
</text>
<text x={tipX + 10} y={tipY + 48} fill={lc} fontSize="9">Ingelegd</text>
<text x={tipX + tipW - 10} y={tipY + 48} fill={GOLD} fontSize="10"
fontWeight="700" textAnchor="end">
{fmt(fVals[h])}
</text>
<line x1={tipX + 10} y1={tipY + 55} x2={tipX + tipW - 10} y2={tipY + 55}
stroke={gc} strokeWidth="1" />
<text x={tipX + 10} y={tipY + 68} fill={lc} fontSize="9">Resultaat</text>
<text x={tipX + tipW - 10} y={tipY + 68}
fill={resultaat >= 0 ? GREEN : RED} fontSize="10"
fontWeight="700" textAnchor="end">
{`${resultaat >= 0 ? "+" : ""}${fmt(resultaat)}`}
</text>
</g>
)}
{/* X-as labels */}
{xs.map((x, i) => (
<text key={i} x={x} y={H - pb + 16} fill={lc} fontSize="8"
textAnchor="end" transform={`rotate(-45, ${x}, ${H - pb + 16})`}>
{fmtDatum(vData[i].datum)}
{/* X-as: korte maandlabels, rechtop. Elk punt een volledige datum geven
liep bij een lange reeks over in een onleesbare band — tien tekens
gedraaid kost ~45px en er passen er maar een stuk of veertien. De
grafiek rekent nu uit hoeveel er passen en toont elke n-de; het
laatste punt staat er altijd bij, want dat is de datum waar je naar
kijkt. De exacte dag lees je in de tooltip. */}
{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>
);
}
+27 -25
View File
@@ -2,7 +2,7 @@ import { useState } from "react";
import { useApp } from "../context/AppContext.jsx";
import { fmt, fmtDatum } from "../utils/format.js";
import { useKolomBreedtes } from "../hooks/useKolomBreedtes.js";
import { PURPLE, PURPLE_LIGHT, GREEN, RED, CAT_COLORS } from "../constants/index.js";
import { GREEN, RED, CAT_COLORS } from "../constants/index.js";
import {
VCard, DonutChart, Overlay, ModalBox, ModalHeader, ModalFooter,
EmojiPicker, AppIcon, ConfirmDeleteDialog, EmptyState,
@@ -130,14 +130,14 @@ function AanpassenPopup({ cat, onClose }) {
<div style={{
display: "flex", alignItems: "center", gap: 8,
background: darkMode ? "rgba(255,255,255,0.03)" : "#f8fafc",
border: `1px solid ${T.border}`, borderLeft: `3px solid ${PURPLE}`,
border: `1px solid ${T.border}`, borderLeft: `3px solid ${T.accent}`,
borderRadius: 10, padding: "10px 12px",
}}>
<input value={nieuwNaam}
onChange={(e) => { setNieuwNaam(e.target.value); setErr(""); }}
placeholder="Naam" style={{ ...is, flex: 1 }} />
<EuroInput value={nieuwVal} onChange={setNieuwVal} is={is} T={T} />
<button onClick={addNieuw} style={addBtn}>+ Voeg toe</button>
<button onClick={addNieuw} style={addBtn(T)}>+ Voeg toe</button>
</div>
{err && <ErrMsg msg={err} />}
</div>
@@ -275,12 +275,12 @@ function EVVoortgangTabel() {
<Overlay onClose={closeModal}>
<ModalBox maxWidth={560}>
<ModalHeader
icon="📊" iconBg={PURPLE}
icon="📊" iconBg={T.accent}
title={editDatum ? "Rij bewerken" : "Nieuw geïnvesteerde fiat"}
subtitle="Vul de waarden in voor dit moment"
subtitleColor={T.muted}
onClose={closeModal}
headerBg={darkMode ? `${PURPLE}08` : "#f8f5ff"}
headerBg={darkMode ? `${T.accent}08` : T.inputBg}
T={T}
/>
<div style={{ padding: "20px 22px", overflowY: "auto", maxHeight: "70vh" }}>
@@ -325,7 +325,7 @@ function EVVoortgangTabel() {
</div>
<ModalFooter T={T}>
<button onClick={closeModal} style={cancelBtn(T)}>Annuleren</button>
<button onClick={addRow} style={primaryBtn}>{editDatum ? "Opslaan" : "Toevoegen"}</button>
<button onClick={addRow} style={primaryBtn(T)}>{editDatum ? "Opslaan" : "Toevoegen"}</button>
</ModalFooter>
</ModalBox>
</Overlay>
@@ -334,7 +334,7 @@ function EVVoortgangTabel() {
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 16 }}>
<div style={{ fontWeight: 700, fontSize: 14, color: T.text }}>Geïnvesteerde Fiat</div>
{!locked && (
<button onClick={openModal} style={primaryBtn}>+ Toevoegen</button>
<button onClick={openModal} style={primaryBtn(T)}>+ Toevoegen</button>
)}
</div>
@@ -349,7 +349,9 @@ function EVVoortgangTabel() {
<tr>
<th style={th}>Datum<span {...grip("datum")} /></th>
{cats.map((c) => (
<th key={c.id} style={{ ...th, color: c.color }}>
// title: een lange categorienaam ("Crowdfunding") wordt bij
// de standaardbreedte afgekapt, dus hover geeft de volle naam.
<th key={c.id} style={{ ...th, color: c.color }} title={c.naam}>
{c.naam}<span {...grip(c.id)} />
</th>
))}
@@ -369,14 +371,14 @@ function EVVoortgangTabel() {
<tr key={i}>
<td style={td}>{fmtDatum(row.datum)}</td>
{cats.map((c) => <td key={c.id} style={{ ...td, color: c.color }}>{fmt(row[c.id] || 0)}</td>)}
<td style={{ ...td, fontWeight: 700, color: PURPLE_LIGHT }}>{fmt(row.nettoWaarde || 0)}</td>
<td style={{ ...td, fontWeight: 700, color: T.accentTekst }}>{fmt(row.nettoWaarde || 0)}</td>
<td style={{ ...td, fontWeight: 700, color: vColor }}>{vLabel}</td>
{/* title: bij een smalle kolom is de tekst afgekapt */}
<td style={{ ...td, color: T.muted }} title={row.opmerkingen || ""}>{row.opmerkingen || "—"}</td>
{!locked && (
<td style={td}>
<div style={{ display: "flex", gap: 6 }}>
<button onClick={() => openEdit(row)} style={editBtn}>✏️</button>
<button onClick={() => openEdit(row)} style={editBtn(T)}>✏️</button>
<button onClick={() => removeRow(row.datum)} style={deleteSmBtn}>×</button>
</div>
</td>
@@ -422,7 +424,7 @@ function DoelProgressie({ ev, data, setData, totVerdeling, locked, T }) {
<span style={{ fontWeight: 600, fontSize: 14 }}>Gewenst doel EV</span>
</div>
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
<span style={{ fontWeight: 700, fontSize: 15, color: PURPLE_LIGHT }}>{fmt(totVerdeling)}</span>
<span style={{ fontWeight: 700, fontSize: 15, color: T.accentTekst }}>{fmt(totVerdeling)}</span>
{editing ? (
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
<span style={{ color: T.muted, fontSize: 13 }}>/</span>
@@ -432,10 +434,10 @@ function DoelProgressie({ ev, data, setData, totVerdeling, locked, T }) {
onKeyDown={(e) => { if (e.key === "Enter") save(); if (e.key === "Escape") setEditing(false); }}
style={{
width: 130, padding: "4px 8px", borderRadius: 8, fontSize: 13,
background: T.inputBg, border: `1px solid ${PURPLE}`, color: T.text, outline: "none",
background: T.inputBg, border: `1px solid ${T.accent}`, color: T.text, outline: "none",
}}
/>
<button onClick={save} style={{ padding: "4px 10px", background: PURPLE, border: "none", borderRadius: 7, color: "#fff", cursor: "pointer", fontSize: 12, fontWeight: 700 }}>OK</button>
<button onClick={save} style={{ padding: "4px 10px", background: T.accent, border: "none", borderRadius: 7, color: "#fff", cursor: "pointer", fontSize: 12, fontWeight: 700 }}>OK</button>
</div>
) : (
<>
@@ -445,13 +447,13 @@ function DoelProgressie({ ev, data, setData, totVerdeling, locked, T }) {
)}
</>
)}
<span style={{ color: PURPLE_LIGHT, fontWeight: 700, fontSize: 13 }}>{Math.round(progressPct * 100)}%</span>
<span style={{ color: T.accentTekst, fontWeight: 700, fontSize: 13 }}>{Math.round(progressPct * 100)}%</span>
</div>
</div>
<div style={{ height: 12, background: "rgba(255,255,255,0.07)", borderRadius: 99, overflow: "hidden" }}>
<div style={{
height: "100%", width: `${progressPct * 100}%`,
background: `linear-gradient(90deg,${PURPLE},#a855f7)`,
background: `linear-gradient(90deg, ${T.accent}, ${T.accentLicht})`,
borderRadius: 99, transition: "width 0.7s ease",
}} />
</div>
@@ -494,9 +496,9 @@ export default function EigenVermogenTab() {
total: fmt(totVerdeling), totalColor: GREEN,
},
{
title: "Geïnvesteerde Fiat", accent: PURPLE_LIGHT,
title: "Geïnvesteerde Fiat", accent: T.accentTekst,
rows: cats.map((c) => ({ label: c.naam, value: evRows.length > 0 ? fmt(sumCat(c.id)) : "—", color: c.color })),
total: evRows.length > 0 ? fmt(totGeinv) : "—", totalColor: PURPLE_LIGHT,
total: evRows.length > 0 ? fmt(totGeinv) : "—", totalColor: T.accentTekst,
},
{
title: "Rendement", accent: "#f59e0b",
@@ -579,16 +581,16 @@ export default function EigenVermogenTab() {
{!locked && (
<div style={{ marginBottom: 16 }}>
<button onClick={() => setShowAddCat(true)} style={{
padding: "10px 20px", background: `${PURPLE}22`,
border: `1px solid ${PURPLE}44`, borderRadius: 10,
color: PURPLE_LIGHT, cursor: "pointer", fontWeight: 700, fontSize: 13,
padding: "10px 20px", background: `${T.accent}22`,
border: `1px solid ${T.accent}44`, borderRadius: 10,
color: T.accentTekst, cursor: "pointer", fontWeight: 700, fontSize: 13,
}}>+ Nieuwe categorie</button>
</div>
)}
{showAddCat && (
<Overlay onClose={() => setShowAddCat(false)}>
<ModalBox maxWidth={400}>
<ModalHeader icon="➕" iconBg={PURPLE} title="Nieuwe categorie" subtitle="Voeg een EV-categorie toe" subtitleColor={PURPLE_LIGHT} onClose={() => setShowAddCat(false)} headerBg={darkMode ? `${PURPLE}08` : "#f8f5ff"} T={T} />
<ModalHeader icon="➕" iconBg={T.accent} title="Nieuwe categorie" subtitle="Voeg een EV-categorie toe" subtitleColor={T.accentTekst} onClose={() => setShowAddCat(false)} headerBg={darkMode ? `${T.accent}08` : T.inputBg} T={T} />
<div style={{ padding: "20px 22px" }}>
<div style={{ fontSize: 11, color: T.muted, fontWeight: 700, textTransform: "uppercase", marginBottom: 6 }}>Naam *</div>
<input autoFocus placeholder="Bijv. Aandelen" value={newNaam}
@@ -608,7 +610,7 @@ export default function EigenVermogenTab() {
</div>
<ModalFooter T={T}>
<button onClick={() => setShowAddCat(false)} style={{ flex: 1, padding: "10px", background: "transparent", border: `1px solid ${T.border}`, borderRadius: 10, color: T.muted, cursor: "pointer", fontWeight: 600, fontSize: 13 }}>Annuleren</button>
<button onClick={addCat} style={{ flex: 2, padding: "10px", background: `linear-gradient(135deg, ${PURPLE}, #a855f7)`, border: "none", borderRadius: 10, color: "#fff", fontWeight: 700, cursor: "pointer", fontSize: 13 }}>Aanmaken</button>
<button onClick={addCat} style={{ flex: 2, padding: "10px", background: T.accentGradient, border: "none", borderRadius: 10, color: "#fff", fontWeight: 700, cursor: "pointer", fontSize: 13 }}>Aanmaken</button>
</ModalFooter>
</ModalBox>
</Overlay>
@@ -668,9 +670,9 @@ function ErrMsg({ msg }) {
}}>{msg}</div>
);
}
const primaryBtn = { padding: "10px 20px", background: PURPLE, border: "none", borderRadius: 8, color: "#fff", fontWeight: 700, cursor: "pointer", fontSize: 13 };
const editBtn = { padding: "2px 10px", background: `${PURPLE}22`, border: `1px solid ${PURPLE}44`, borderRadius: 6, color: PURPLE_LIGHT, cursor: "pointer", fontSize: 12, fontWeight: 600 };
const primaryBtn = (T) => ({ padding: "10px 20px", background: T.accent, border: "none", borderRadius: 8, color: "#fff", fontWeight: 700, cursor: "pointer", fontSize: 13 });
const editBtn = (T) => ({ padding: "2px 10px", background: `${T.accent}22`, border: `1px solid ${T.accent}44`, borderRadius: 6, color: T.accentTekst, cursor: "pointer", fontSize: 12, fontWeight: 600 });
const deleteSmBtn = { padding: "2px 8px", background: "rgba(239,68,68,0.1)", border: "1px solid rgba(239,68,68,0.25)", borderRadius: 6, color: RED, cursor: "pointer", fontSize: 12 };
const trashBtn = { padding: "10px 14px", background: "rgba(239,68,68,0.08)", border: "1px solid rgba(239,68,68,0.25)", borderRadius: 10, color: RED, cursor: "pointer", fontWeight: 600, fontSize: 13 };
const addBtn = { padding: "6px 14px", background: `${PURPLE}22`, border: `1px solid ${PURPLE}44`, borderRadius: 8, color: PURPLE_LIGHT, cursor: "pointer", fontSize: 12, fontWeight: 700, flexShrink: 0 };
const addBtn = (T) => ({ padding: "6px 14px", background: `${T.accent}22`, border: `1px solid ${T.accent}44`, borderRadius: 8, color: T.accentTekst, cursor: "pointer", fontSize: 12, fontWeight: 700, flexShrink: 0 });
const cancelBtn = (T) => ({ padding: "10px", background: "transparent", border: `1px solid ${T.border}`, borderRadius: 10, color: T.muted, cursor: "pointer", fontWeight: 600, fontSize: 13 });
+4 -4
View File
@@ -1,7 +1,7 @@
import { useState } from "react";
import { useApp } from "../context/AppContext.jsx";
import { fmt, pct } from "../utils/format.js";
import { PURPLE, PURPLE_LIGHT, RED, GREEN } from "../constants/index.js";
import { RED, GREEN } from "../constants/index.js";
import { VCard, DonutChart, Overlay, ModalBox, ModalHeader, EmojiPicker, AppIcon, ConfirmDeleteDialog } from "../components/ui/index.jsx";
// ── SchuldAanpassenPopup ──────────────────────────────────────────────────────
@@ -108,14 +108,14 @@ function SchuldAanpassenPopup({ blok, onClose }) {
<div style={{
display: "flex", alignItems: "center", gap: 8,
background: darkMode ? "rgba(255,255,255,0.03)" : "#f8fafc",
border: `1px solid ${T.border}`, borderLeft: `3px solid ${PURPLE}`,
border: `1px solid ${T.border}`, borderLeft: `3px solid ${T.accent}`,
borderRadius: 10, padding: "10px 12px",
}}>
<input value={nieuwNaam}
onChange={(e) => { setNieuwNaam(e.target.value); setNieuwErr(""); }}
placeholder="Naam" style={{ ...is, flex: 1 }} />
<EuroInput value={nieuwVal} onChange={setNieuwVal} is={is} T={T} />
<button onClick={addNieuw} style={addBtn}>+ Voeg toe</button>
<button onClick={addNieuw} style={addBtn(T)}>+ Voeg toe</button>
</div>
{nieuwErr && <ErrMsg msg={nieuwErr} />}
</div>
@@ -448,5 +448,5 @@ function ErrMsg({ msg }) {
}
const deleteSmBtn = { padding: "4px 8px", background: "rgba(239,68,68,0.1)", border: "1px solid rgba(239,68,68,0.25)", borderRadius: 6, color: RED, cursor: "pointer", fontSize: 13, flexShrink: 0 };
const trashBtn = { padding: "10px 14px", background: "rgba(239,68,68,0.08)", border: "1px solid rgba(239,68,68,0.25)", borderRadius: 10, color: RED, cursor: "pointer", fontWeight: 600, fontSize: 13 };
const addBtn = { padding: "6px 14px", background: `${PURPLE}22`, border: `1px solid ${PURPLE}44`, borderRadius: 8, color: PURPLE_LIGHT, cursor: "pointer", fontSize: 12, fontWeight: 700, flexShrink: 0 };
const addBtn = (T) => ({ padding: "6px 14px", background: `${T.accent}22`, border: `1px solid ${T.accent}44`, borderRadius: 8, color: T.accentTekst, cursor: "pointer", fontSize: 12, fontWeight: 700, flexShrink: 0 });
const cancelBtn = (T) => ({ padding: "10px", background: "transparent", border: `1px solid ${T.border}`, borderRadius: 10, color: T.muted, cursor: "pointer", fontWeight: 600, fontSize: 13 });
+71 -32
View File
@@ -1,8 +1,9 @@
import { useState } from "react";
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 { PURPLE, PURPLE_LIGHT, 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";
// ── Inline SVG line chart ─────────────────────────────────────────────────────
@@ -16,8 +17,13 @@ function SvgLineChart({ pts, lines, T, H = 200 }) {
</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;
// 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 dataMin = Math.min(...allVals);
const dataMax = Math.max(...allVals);
@@ -27,8 +33,11 @@ function SvgLineChart({ pts, lines, T, H = 200 }) {
const rng = mx - mn || 1;
const xp = (i) => pl + (i / (pts.length - 1)) * iW;
const yp = (v) => pt2 + (1 - ((v ?? 0) - mn) / rng) * iH;
const gc = T.bg === "#0f1117" ? "rgba(255,255,255,0.05)" : "rgba(0,0,0,0.05)";
const darkMode = T.bg === "#0f1117";
// Was een vergelijking met een vaste hex (`T.bg === "#0f1117"`). Die kleur is
// de achtergrond allang niet meer, dus beide stonden altijd op licht — en met
// instelbare thema's valt er sowieso niets meer aan de bg af te lezen.
const darkMode = T.darkMode;
const gc = darkMode ? "rgba(255,255,255,0.05)" : "rgba(0,0,0,0.05)";
return (
<div style={{ overflowX: "auto" }}>
@@ -57,17 +66,21 @@ function SvgLineChart({ pts, lines, T, H = 200 }) {
if (validPts.length < 2) return null;
const xs = validPts.map((p) => xp(p._i));
const ys = validPts.map((p) => yp(p[l.key]));
const ls = xs.map((x, i) => `${x},${ys[i]}`).join(" ");
const ar = `${xs[0]},${pt2 + iH} ${ls} ${xs[xs.length - 1]},${pt2 + iH}`;
// Dezelfde Catmull-Rom afronding als de dashboardgrafiek, in plaats
// van rechte segmenten tussen de punten.
const lijn = crPath(xs, ys);
const vlak = vlakPad(lijn, xs[0], xs[xs.length - 1], pt2 + iH);
return (
<g key={l.key}>
<polygon points={ar} fill={`url(#g_${l.key})`} />
<polyline points={ls} fill="none" stroke={l.color} strokeWidth="2.5"
<path d={vlak} fill={`url(#g_${l.key})`} />
<path d={lijn} fill="none" stroke={l.color} strokeWidth="2.5"
strokeLinejoin="round" strokeLinecap="round" />
{validPts.map((p, i) => {
const x = xs[i], y = ys[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 tipY = y - tipH - 8 < pt2 ? y + 10 : y - tipH - 8;
return (
@@ -81,10 +94,14 @@ function SvgLineChart({ pts, lines, T, H = 200 }) {
fill={l.color} stroke={T.card} strokeWidth="2" />
{isHov && (
<g>
<rect x={tipX} y={tipY} width={tipW} height={22}
rx="5" fill={darkMode ? "#1e2130" : "#fff"}
<rect x={tipX} y={tipY} width={tipW} height={tipH}
rx="5" fill={T.card}
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">
{fmt(p[l.key])}
</text>
@@ -96,15 +113,24 @@ function SvgLineChart({ pts, lines, T, H = 200 }) {
</g>
);
})}
{pts.map((p, i) => (
<text key={i}
x={xp(i)} y={H - pb + 16}
fill={T.muted} fontSize="8"
textAnchor="end"
transform={`rotate(-45, ${xp(i)}, ${H - pb + 16})`}>
{fmtDatum(p.datum)}
{/* X-as: korte maandlabels, rechtop en uitgedund op wat er past. Elk
punt een volledige datum geven liep bij een lange reeks over in een
onleesbare band. De exacte dag staat in de tooltip op elk punt. */}
{pts.map((p, i) => {
const laatste = i === pts.length - 1;
// Terugtellen vanaf het eind, zodat het laatste punt op een stap valt.
if ((pts.length - 1 - i) % labelStap !== 0) return null;
return (
<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>
<div style={{ display: "flex", gap: 20, justifyContent: "center", marginTop: 10 }}>
{lines.map((l) => (
@@ -145,12 +171,12 @@ function VoortgangModal({ onSave, onClose, T, darkMode, initialData }) {
<Overlay onClose={onClose}>
<ModalBox maxWidth={480}>
<ModalHeader
icon="📊" iconBg={PURPLE}
icon="📊" iconBg={T.accent}
title={isEdit ? "Voortgang bewerken" : "Voortgang toevoegen"}
subtitle="Vul de waarden in voor dit moment"
subtitleColor={T.muted}
onClose={onClose}
headerBg={darkMode ? `${PURPLE}08` : "#f8f5ff"}
headerBg={darkMode ? `${T.accent}08` : T.inputBg}
T={T}
/>
<div style={{ padding: "20px 22px", overflowY: "auto", maxHeight: "70vh" }}>
@@ -189,7 +215,7 @@ function VoortgangModal({ onSave, onClose, T, darkMode, initialData }) {
<button onClick={onClose} style={{ padding: "10px", background: "transparent", border: `1px solid ${T.border}`, borderRadius: 10, color: T.muted, cursor: "pointer", fontWeight: 600, fontSize: 13 }}>
Annuleren
</button>
<button onClick={save} style={{ flex: 2, padding: "10px", background: `linear-gradient(135deg, ${PURPLE}, ${PURPLE}cc)`, border: "none", borderRadius: 10, color: "#fff", fontWeight: 700, cursor: "pointer", fontSize: 13 }}>
<button onClick={save} style={{ flex: 2, padding: "10px", background: `linear-gradient(135deg, ${T.accent}, ${T.accent}cc)`, border: "none", borderRadius: 10, color: "#fff", fontWeight: 700, cursor: "pointer", fontSize: 13 }}>
{isEdit ? "Opslaan" : "Toevoegen"}
</button>
</ModalFooter>
@@ -211,13 +237,22 @@ export default function VoortgangTab() {
const [showAdd, setShowAdd] = useState(false);
const [editRow, setEditRow] = useState(null);
const {
totEV, totSchuld, catTotals, schuldBlokken, schuldHistory,
totEV, totSchuld, cats, catTotals, evRows, schuldBlokken, schuldHistory,
evVoortgangHistory, setEvVoortgangHistory,
data, setData, schuld, locked, darkMode, T,
} = useApp();
const nettoWaarde = totEV - totSchuld;
// Rendement t.o.v. de geinvesteerde fiat, zelfde formule als op de EV-tab.
const totGeinvesteerd = cats.reduce(
(a, c) => a + evRows.reduce((b, r) => b + (r[c.id] || 0), 0),
0
);
const rendementBedrag = totGeinvesteerd > 0 ? totEV - totGeinvesteerd : null;
const rendementPct = totGeinvesteerd > 0 ? (rendementBedrag / totGeinvesteerd) * 100 : null;
const rendementKleur = rendementBedrag === null ? T.muted : rendementBedrag >= 0 ? GREEN : RED;
const schuldCatsDash = schuldBlokken.map((b) => ({
naam: b.naam, color: b.color,
subtot: b.items.reduce((a, i) => a + (i.waarde || 0), 0),
@@ -286,8 +321,8 @@ export default function VoortgangTab() {
{/* Hero netto */}
<VCard style={{
background: `linear-gradient(135deg, ${PURPLE}22, rgba(16,185,129,0.08))`,
border: `1px solid ${T.border}`, borderLeft: `4px solid ${PURPLE}`,
background: `linear-gradient(135deg, ${T.accent}22, rgba(16,185,129,0.08))`,
border: `1px solid ${T.border}`, borderLeft: `4px solid ${T.accent}`,
marginBottom: 16,
}}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", flexWrap: "wrap", gap: 12 }}>
@@ -351,11 +386,15 @@ export default function VoortgangTab() {
<div style={{ fontWeight: 700, fontSize: 12, letterSpacing: "0.08em", textTransform: "uppercase", color: "#f59e0b", marginBottom: 12 }}>Rendement Eigen Vermogen</div>
<div style={{ padding: "10px 0", borderBottom: `1px solid ${T.border}` }}>
<div style={{ fontSize: 11, color: T.muted, marginBottom: 4 }}>Bedrag</div>
<div style={{ fontSize: 15, fontWeight: 700, color: T.muted }}>—</div>
<div style={{ fontSize: 15, fontWeight: 700, color: rendementKleur }}>
{rendementBedrag === null ? "—" : fmt(rendementBedrag)}
</div>
</div>
<div style={{ padding: "10px 0" }}>
<div style={{ fontSize: 11, color: T.muted, marginBottom: 4 }}>Percentage</div>
<div style={{ fontSize: 15, fontWeight: 700, color: T.muted }}>—</div>
<div style={{ fontSize: 15, fontWeight: 700, color: rendementKleur }}>
{rendementPct === null ? "—" : `${rendementPct >= 0 ? "+" : ""}${rendementPct.toFixed(1)}%`}
</div>
</div>
</VCard>
@@ -397,7 +436,7 @@ export default function VoortgangTab() {
<div style={{ fontWeight: 700, fontSize: 14 }}>Voortgang overzicht</div>
{!locked && (
<button onClick={() => setShowAdd(true)} style={{
padding: "6px 14px", background: PURPLE, border: `1px solid ${PURPLE}`,
padding: "6px 14px", background: T.accent, border: `1px solid ${T.accent}`,
borderRadius: 8, color: "#fff", fontWeight: 700, fontSize: 12, cursor: "pointer",
}}>+ Toevoegen</button>
)}
@@ -419,7 +458,7 @@ export default function VoortgangTab() {
<th style={th}>Datum<span {...grip("datum")} /></th>
<th style={{ ...th, color: GREEN }}>Eigen Vermogen<span {...grip("ev")} /></th>
<th style={{ ...th, color: RED }}>Schuld<span {...grip("schuld")} /></th>
<th style={{ ...th, color: PURPLE_LIGHT }}>Nettowaarde<span {...grip("netto")} /></th>
<th style={{ ...th, color: T.accentTekst }}>Nettowaarde<span {...grip("netto")} /></th>
<th style={th}>Opmerkingen<span {...grip("opmerkingen")} /></th>
{!locked && <th style={th}><span {...grip("acties")} /></th>}
</tr>
@@ -435,7 +474,7 @@ export default function VoortgangTab() {
<td style={{ ...td, color: T.muted }} title={row.opmerkingen || ""}>{row.opmerkingen || "—"}</td>
{!locked && (
<td style={{ ...td, whiteSpace: "nowrap" }}>
<button onClick={() => setEditRow(row)} style={{ padding: "2px 8px", background: `${PURPLE}22`, border: `1px solid ${PURPLE}44`, borderRadius: 6, color: PURPLE_LIGHT, cursor: "pointer", fontSize: 12, marginRight: 4 }}>✏️</button>
<button onClick={() => setEditRow(row)} style={{ padding: "2px 8px", background: `${T.accent}22`, border: `1px solid ${T.accent}44`, borderRadius: 6, color: T.accentTekst, cursor: "pointer", fontSize: 12, marginRight: 4 }}>✏️</button>
<button onClick={() => removeVoortgang(row.datum)} style={{ padding: "2px 8px", background: "rgba(239,68,68,0.1)", border: "1px solid rgba(239,68,68,0.25)", borderRadius: 6, color: RED, cursor: "pointer", fontSize: 12 }}>×</button>
</td>
)}
+15
View File
@@ -14,6 +14,21 @@ export const fmtDatum = (s) => {
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) */
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`;
+354
View File
@@ -0,0 +1,354 @@
/**
* Export naar één zelfdragende HTML-pagina.
*
* Bedoeld als laatste vangnet: als de app niet meer draait, de database weg is
* en er geen backup ligt, moet dit ene bestand nog steeds al je gegevens tonen.
* Daarom zit alles erin — opmaak inline, geen scripts, geen lettertypen of
* afbeeldingen van buiten. Het bestand is op een computer zonder internet, over
* tien jaar, in elke browser nog te openen.
*
* Het is bovendien tegelijk een backup. Onderaan de pagina staat de complete
* ruwe data in een `<script type="application/json">`-blok dat de browser niet
* uitvoert en niet toont. "Backup herstellen" leest dat blok, dus je kunt dit
* bestand net zo goed terugzetten als een backup-ZIP. Eén bestand dat zowel
* leesbaar is voor jou als bruikbaar voor de app.
*/
// ── Formatters ────────────────────────────────────────────────────────────────
// Bewust met twee decimalen, anders dan de `fmt` op het scherm. Dit is een
// archiefstuk: hier hoort de exacte waarde in te staan, niet de afgeronde.
const euro = (n) =>
new Intl.NumberFormat("nl-NL", { style: "currency", currency: "EUR", minimumFractionDigits: 2 })
.format(Number(n) || 0);
const procent = (n) => `${((Number(n) || 0) * 100).toFixed(2).replace(".", ",")}%`;
const datumNl = (s) => {
if (!s) return "";
const [y, m, d] = String(s).split("-");
return d ? `${d}-${m}-${y}` : String(s);
};
/**
* Tekst veilig in HTML zetten. Een post die "Fonds <A> & co" heet mag de pagina
* niet slopen, en opmerkingen zijn vrije tekst.
*/
const esc = (v) =>
String(v ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
const cel = (v, klasse = "") => `<td${klasse ? ` class="${klasse}"` : ""}>${esc(v)}</td>`;
const kop = (v, klasse = "") => `<th${klasse ? ` class="${klasse}"` : ""}>${esc(v)}</th>`;
/**
* Tabel met kopregel. `num` bevat de kolomindexen met bedragen; die worden
* rechts uitgelijnd. Een rij mag `{ klasse, cellen }` zijn om zichzelf als
* subtotaal of eindtotaal te markeren.
*/
const maakTabel = (kolommen, rijen, num = []) => `
<div class="scroll">
<table>
<thead><tr>${kolommen.map((k, i) => kop(k, num.includes(i) ? "num" : "")).join("")}</tr></thead>
<tbody>${rijen.map((r) => `<tr${r.klasse ? ` class="${r.klasse}"` : ""}>${
(r.cellen || r).map((c, i) => cel(c, num.includes(i) ? "num" : "")).join("")
}</tr>`).join("")}</tbody>
</table>
</div>`;
// ── Opmaak ────────────────────────────────────────────────────────────────────
// Licht en rustig: dit is een document om te lezen en eventueel te printen, niet
// het dashboard. Systeemlettertypen, zodat er niets van buiten geladen hoeft.
const STIJL = `
:root { color-scheme: light; }
* { box-sizing: border-box; }
body {
margin: 0; padding: 32px 20px 64px;
font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
color: #1e2330; background: #f6f7fb;
}
.blad { max-width: 1180px; margin: 0 auto; }
/* De voortgangstabel is Datum + één kolom per categorie + vier vaste kolommen.
Bij zes categorieën zijn dat elf kolommen, en dat past op geen enkel scherm
dat je toevallig bij de hand hebt. Liever horizontaal scrollen dan kolommen
die buiten beeld vallen: zichtbaar is belangrijker dan in één oogopslag. */
.scroll {
overflow-x: auto;
/* Schaduwtruc met vier achtergronden: de twee op 'local' schuiven mee en
dekken de schaduw af zolang je aan die kant al bent, de twee op 'scroll'
blijven staan. Resultaat is een randje dat alleen verschijnt aan de kant
waar nog inhoud zit. Puur CSS — de pagina mag geen scripts bevatten, en
zonder hint ziet niemand dat er rechts nog kolommen staan. */
background:
linear-gradient(to right, #fff 30%, rgba(255,255,255,0)) left center,
linear-gradient(to left, #fff 30%, rgba(255,255,255,0)) right center,
radial-gradient(farthest-side at 0 50%, rgba(30,35,48,0.16), rgba(30,35,48,0)) left center,
radial-gradient(farthest-side at 100% 50%, rgba(30,35,48,0.16), rgba(30,35,48,0)) right center;
background-repeat: no-repeat;
background-size: 40px 100%, 40px 100%, 14px 100%, 14px 100%;
background-attachment: local, local, scroll, scroll;
}
.scroll table { min-width: 100%; width: max-content; }
/* Datum blijft staan tijdens het scrollen; zonder die kolom weet je bij elf
kolommen niet meer naar welk moment je kijkt. */
.scroll th:first-child, .scroll td:first-child { position: sticky; left: 0; background: #fff; }
tr.subtotaal td:first-child { background: #fafbff; }
tr.totaal td:first-child { background: #f4f2ff; }
header { border-bottom: 3px solid #8b5cf6; padding-bottom: 16px; margin-bottom: 28px; }
h1 { margin: 0 0 4px; font-size: 26px; letter-spacing: -0.4px; }
.meta { margin: 0; color: #6b7280; font-size: 13px; }
h2 {
font-size: 15px; text-transform: uppercase; letter-spacing: 0.6px;
color: #6b7280; margin: 34px 0 10px; font-weight: 700;
}
section { background: #fff; border: 1px solid #e3e6ef; border-radius: 10px; padding: 4px 18px 14px; }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
th, td { padding: 8px 10px; text-align: left; border-bottom: 1px solid #eceef5; }
th { font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; color: #6b7280; }
/* Bedragen rechts en met cijfers van gelijke breedte, zodat kolommen uitlijnen */
.num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
tr.subtotaal td { font-weight: 700; background: #fafbff; }
tr.totaal td { font-weight: 800; border-top: 2px solid #d9dced; background: #f4f2ff; }
.kpi { display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 12px; padding: 14px 0; }
.kpi div { border: 1px solid #e3e6ef; border-radius: 8px; padding: 12px 14px; background: #fcfcff; }
.kpi span { display: block; font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; color: #6b7280; }
.kpi strong { font-size: 19px; font-variant-numeric: tabular-nums; }
.leeg { color: #9aa1b0; font-style: italic; padding: 10px 0; }
footer { margin-top: 34px; color: #6b7280; font-size: 12px; line-height: 1.6; }
footer code { background: #eceef5; padding: 1px 5px; border-radius: 4px; }
@media print {
body { background: #fff; padding: 0; }
section { border: none; border-radius: 0; padding: 0; }
h2 { margin-top: 22px; }
tr { break-inside: avoid; }
/* Papier scrollt niet, dus op print moet alles passen. Liggend formaat: een
tabel van twaalf kolommen krijg je staand met geen enkele lettergrootte
fatsoenlijk op een A4. Verder vervallen de scrollbalk en de schaduwhint,
en gaat de nowrap van de bedragen af — die hield de kolommen op hun volle
breedte en was precies waaraan het krimpen vastliep. */
@page { size: A4 landscape; margin: 12mm; }
.scroll { overflow: visible; background: none; }
.scroll table { width: 100%; min-width: 0; table-layout: fixed; }
.scroll th:first-child, .scroll td:first-child { position: static; }
table { font-size: 9px; }
th, td { padding: 3px 4px; }
.num { white-space: normal; }
.blad { max-width: none; }
}
`;
/**
* Bouwt de complete HTML-pagina.
*
* @returns {string} het volledige bestand, klaar om weg te schrijven
*/
export function bouwHtmlExport({
cats = [],
schuldBlokken = [],
evRows = [],
evVoortgangHistory = [],
schuldHistory = [],
totEV = 0,
totSchuld = 0,
doel = 0,
initieleSchuld = 0,
gebruiker = "",
ruweData = null,
}) {
const nu = new Date();
const exportDatum = `${String(nu.getDate()).padStart(2, "0")}-${String(nu.getMonth() + 1).padStart(2, "0")}-${nu.getFullYear()}`;
// ── Samenvatting ────────────────────────────────────────────
// Zelfde opbouw als de dashboard-KPI's: het oudste voortgangsmoment is de
// startinleg, elke latere rij telt zijn stortingen erbij op.
const startEV = evRows.length > 0 ? (evRows[evRows.length - 1]?.nettoWaarde || 0) : 0;
const extraFiat = evRows.length > 1
? evRows.slice(0, -1).reduce((a, r) => a + cats.reduce((b, c) => b + (r[c.id] || 0), 0), 0)
: 0;
const totaalFiat = startEV + extraFiat;
const winst = totEV - totaalFiat;
const kpis = [
["Totaal eigen vermogen", euro(totEV)],
["Totale schuld", euro(totSchuld)],
["Netto waarde", euro(totEV - totSchuld)],
["Totaal geïnvesteerde fiat", euro(totaalFiat)],
["Winst op investering", euro(winst)],
["ROI", totaalFiat > 0 ? procent(winst / totaalFiat) : "—"],
["Doel eigen vermogen", euro(doel)],
["Voortgang naar doel", doel > 0 ? procent(Math.min(totEV / doel, 1)) : "—"],
];
// ── Eigen vermogen ──────────────────────────────────────────
// Per categorie de posten met een subtotaalregel eronder, en helemaal
// onderaan het eindtotaal. Zo is de pagina te lezen zonder te rekenen.
const evRijen = [];
for (const c of cats) {
const items = c.items || [];
for (const i of items) evRijen.push([c.naam, i.naam, euro(i.waarde)]);
// Eerste kolom leeg: het label zegt al om welke categorie het gaat, en de
// naam er nog eens naast maakt de regel alleen maar drukker.
evRijen.push({
klasse: "subtotaal",
cellen: ["", `Subtotaal ${c.naam}`, euro(items.reduce((a, i) => a + (i.waarde || 0), 0))],
});
}
if (evRijen.length) evRijen.push({ klasse: "totaal", cellen: ["", "Totaal eigen vermogen", euro(totEV)] });
// ── Schulden ────────────────────────────────────────────────
const schuldRijen = [];
for (const b of schuldBlokken) {
const items = b.items || [];
for (const i of items) schuldRijen.push([b.naam, i.naam, euro(i.waarde)]);
schuldRijen.push({
klasse: "subtotaal",
cellen: ["", `Subtotaal ${b.naam}`, euro(items.reduce((a, i) => a + (i.waarde || 0), 0))],
});
}
if (schuldRijen.length) schuldRijen.push({ klasse: "totaal", cellen: ["", "Totale schuld", euro(totSchuld)] });
// De initiële schuld is het beginpunt waartegen het afbetalingsoverzicht in de
// app rekent. Hij staat los van de blokken, dus zonder eigen regel valt hij
// buiten de export — en dan mist de pagina een ingevoerd gegeven.
if (initieleSchuld) {
schuldRijen.push({ klasse: "subtotaal", cellen: ["", "Initiële schuld (startpunt)", euro(initieleSchuld)] });
schuldRijen.push({ klasse: "subtotaal", cellen: ["", "Afgelost sinds start", euro(initieleSchuld - totSchuld)] });
}
// ── Voortgang eigen vermogen ────────────────────────────────
// Oplopend op datum: een archief lees je van oud naar nieuw.
const oplopend = [...evRows].sort((a, b) => String(a.datum).localeCompare(String(b.datum)));
// Verwijder je een categorie, dan blijven de stortingen ervan in de oude
// voortgangsrijen staan — de app toont alleen de kolommen van bestaande
// categorieën, dus die bedragen zie je nergens meer. Voor een export die
// "alles wat is ingevoerd" moet tonen is dat precies het verkeerde gedrag, dus
// die wezen krijgen hier alsnog een kolom, gemerkt met hun ruwe sleutel.
const bekend = new Set(cats.map((c) => c.id));
const vaste = new Set(["datum", "nettoWaarde", "opmerkingen"]);
const wezen = [...new Set(oplopend.flatMap((r) => Object.keys(r)))]
.filter((k) => !bekend.has(k) && !vaste.has(k))
.filter((k) => oplopend.some((r) => typeof r[k] === "number"));
const kolomIds = [...cats.map((c) => c.id), ...wezen];
const voortgangRijen = oplopend.map((r, i) => {
const storting = kolomIds.reduce((a, id) => a + (r[id] || 0), 0);
const verschil = i > 0 ? (r.nettoWaarde || 0) - (oplopend[i - 1].nettoWaarde || 0) : null;
return [
datumNl(r.datum),
...kolomIds.map((id) => euro(r[id])),
euro(storting),
euro(r.nettoWaarde),
verschil === null ? "—" : euro(verschil),
r.opmerkingen || "",
];
});
// ── Historie EV / schuld ────────────────────────────────────
// Twee reeksen die los worden bijgehouden en alleen op datum samenkomen; een
// moment kan er dus maar één van hebben.
const datums = [...new Set([
...evVoortgangHistory.map((r) => r.datum),
...schuldHistory.map((s) => s.datum),
])].sort((a, b) => String(a).localeCompare(String(b)));
const historieRijen = datums.map((d) => {
const e = evVoortgangHistory.find((r) => r.datum === d);
const s = schuldHistory.find((x) => x.datum === d);
const ev = e ? (e.ev ?? null) : null;
const sc = s ? (s.schuld ?? null) : null;
return [
datumNl(d),
ev === null ? "—" : euro(ev),
sc === null ? "—" : euro(sc),
ev !== null && sc !== null ? euro(ev - sc) : "—",
e?.opmerkingen || "",
];
});
const sectie = (titel, inhoud, leegTekst) =>
`<h2>${esc(titel)}</h2><section>${inhoud || `<p class="leeg">${esc(leegTekst)}</p>`}</section>`;
// De ruwe data gaat mee zodat dit bestand ook als backup dient. `<` wordt
// ge-escaped, anders sluit een `</script>` in de gegevens het blok voortijdig
// af en is zowel de pagina als de backup stuk.
const jsonBlok = ruweData
? `\n<script type="application/json" id="vibefinance-backup">${
JSON.stringify(ruweData).replace(/</g, "\\u003c")
}</script>\n`
: "";
return `<!doctype html>
<html lang="nl">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>VibeFinance — overzicht ${esc(exportDatum)}</title>
<style>${STIJL}</style>
</head>
<body>
<div class="blad">
<header>
<h1>VibeFinance</h1>
<p class="meta">Volledig overzicht · geëxporteerd op ${esc(exportDatum)}${gebruiker ? ` · ${esc(gebruiker)}` : ""}</p>
</header>
<h2>Samenvatting</h2>
<section>
<div class="kpi">${kpis.map(([l, v]) => `<div><span>${esc(l)}</span><strong>${esc(v)}</strong></div>`).join("")}</div>
</section>
${sectie("Eigen vermogen", evRijen.length ? maakTabel(["Categorie", "Post", "Waarde"], evRijen, [2]) : "", "Geen vermogensposten ingevoerd.")}
${sectie("Schulden", schuldRijen.length ? maakTabel(["Blok", "Onderdeel", "Waarde"], schuldRijen, [2]) : "", "Geen schulden ingevoerd.")}
${sectie("Voortgang eigen vermogen", voortgangRijen.length
? maakTabel(
[
"Datum",
...cats.map((c) => c.naam),
// Een verwijderde categorie heeft geen naam meer; de sleutel is dan
// het enige wat er nog van over is.
...wezen.map((k) => `Verwijderd (${k})`),
"Totaal storting", "Netto waarde", "Verschil", "Opmerkingen",
],
voortgangRijen,
// Alle bedragkolommen: vanaf kolom 1 tot en met "Verschil".
Array.from({ length: kolomIds.length + 3 }, (_, i) => i + 1),
)
: "", "Geen voortgangsmomenten vastgelegd.")}
${sectie("Historie eigen vermogen en schuld", historieRijen.length
? maakTabel(["Datum", "Eigen vermogen", "Schuld", "Netto waarde", "Opmerkingen"], historieRijen, [1, 2, 3])
: "", "Geen historie vastgelegd.")}
<footer>
<p><strong>Dit bestand is ook een backup.</strong> Onderin staat je complete
data in machineleesbare vorm, onzichtbaar op het scherm. Ga in VibeFinance
naar <code>Instellingen → Backup herstellen</code> en kies dit
<code>.html</code>-bestand om alles terug te zetten.</p>
<p>De pagina staat volledig op zichzelf: geen internet, geen lettertypen of
scripts van buitenaf. Bewaar hem naast je backup-ZIP, dan heb je één bestand
dat je zowel kunt lézen als kunt terugzetten.</p>
</footer>
</div>${jsonBlok}</body>
</html>`;
}
/**
* Haalt de ingebakken backup uit een geëxporteerde pagina terug.
*
* Bewust met DOMParser en niet met een reguliere expressie: de gegevens kunnen
* zelf van alles bevatten, en een parser die HTML écht begrijpt trapt daar niet
* in. De pagina wordt niet uitgevoerd — DOMParser draait geen scripts, en het
* blok is `type="application/json"`, dus ook in een browser geen code.
*
* @returns {object|null} de data, of null als het geen export-pagina is
*/
export function leesHtmlBackup(htmlTekst) {
const doc = new DOMParser().parseFromString(htmlTekst, "text/html");
const blok = doc.getElementById("vibefinance-backup");
if (!blok) return null;
return JSON.parse(blok.textContent);
}
+2
View File
@@ -1,2 +1,4 @@
export * from "./format.js";
export * from "./storage.js";
export * from "./grafiek.js";
export * from "./htmlExport.js";
+4
View File
@@ -3,6 +3,10 @@ const KEYS = {
evRows: "ev_voortgang_rows",
users: "wbjw_users",
exitplan: "exitplan_data",
// Weergavevoorkeuren: per browser, net als de kolombreedtes. Ze staan bewust
// los van de financiële data, dus ze gaan niet mee in de backup-ZIP.
thema: "vibefinance_thema",
darkMode: "vibefinance_darkmode",
};
export function loadFromStorage(key) {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "vibefinance",
"version": "1.0.5",
"version": "1.0.13",
"description": "VibeFinance — persoonlijk vermogensbeheer",
"private": true,
"repository": "https://vibehoogie.duckdns.org/vibe/VibeFinance",
+67 -9
View File
@@ -5,7 +5,8 @@
# 1. Dependency-check (staan package.json's op de laatste versies?)
# 2. Backend-tests (Dockerfile target 'test' → node --test, zonder database)
# 3. Productiebuild (Vite-build + app-image; faalt de frontend, faalt dit)
# 4. Smoketest (productie-image + postgres, /api/health en / opvragen)
# 4. Imagescan (Trivy op het gebouwde image; OS- én npm-pakketten)
# 5. Smoketest (productie-image + postgres, /api/health en / opvragen)
#
# De smoketest publiceert geen hostpoorten en gebruikt een eigen netwerk, dus
# hij botst niet met een draaiende dev-stack.
@@ -14,14 +15,17 @@
# .\test.ps1 # alle stappen
# .\test.ps1 -Unit # dependency-check + backend-tests
# .\test.ps1 -NoSmoke # zonder de stack te starten
# .\test.ps1 -Strict # faal als een dependency achterloopt
# .\test.ps1 -Strict # faal als een dependency achterloopt; scan ook op
# # MEDIUM en LOW in plaats van alleen CRITICAL/HIGH
# .\test.ps1 -SkipDeps # dependency-check overslaan (bijv. offline)
# .\test.ps1 -SkipScan # imagescan overslaan (bijv. offline)
param(
[switch]$Unit,
[switch]$NoSmoke,
[switch]$Strict,
[switch]$SkipDeps
[switch]$SkipDeps,
[switch]$SkipScan
)
$ErrorActionPreference = "Stop"
@@ -43,7 +47,7 @@ function Write-Step {
# Vergelijkt de versies in frontend/ en backend/package.json met het
# npm-register. Bewuste uitzonderingen staan in docker/update-exceptions.json.
if (-not $SkipDeps) {
Write-Step "[1/4] Dependency-check"
Write-Step "[1/5] Dependency-check"
$checkArgs = @("node", "docker/check-updates.mjs")
if ($Strict) { $checkArgs += "--strict" }
@@ -59,11 +63,11 @@ if (-not $SkipDeps) {
Write-Host "Let op: er zijn nieuwere versies beschikbaar (zie hierboven)." -ForegroundColor Yellow
}
} else {
Write-Step "[1/4] Dependency-check overgeslagen (-SkipDeps)"
Write-Step "[1/5] Dependency-check overgeslagen (-SkipDeps)"
}
# ── 2. Backend-tests ──────────────────────────────────────────────────────
Write-Step "[2/4] Backend-tests"
Write-Step "[2/5] Backend-tests"
docker build --target test -t $testImage $PSScriptRoot
if ($LASTEXITCODE -ne 0) { Write-Error "Testimage bouwen mislukt"; exit 1 }
@@ -78,7 +82,7 @@ if ($Unit) {
}
# ── 3. Productiebuild (inclusief Vite-build) ──────────────────────────────
Write-Step "[3/4] Productie-image bouwen"
Write-Step "[3/5] Productie-image bouwen"
docker build --target production `
--build-arg APP_VERSION=$appVersion `
--build-arg VITE_APP_VERSION=$appVersion `
@@ -87,13 +91,67 @@ docker build --target production `
if ($LASTEXITCODE -ne 0) { Write-Error "Productiebuild mislukt"; exit 1 }
Write-Host "Productie-image gebouwd: $prodImage" -ForegroundColor Green
# ── 4. Imagescan ──────────────────────────────────────────────────────────
# Stap 1 kijkt naar de package.json's; deze kijkt naar wat er daadwerkelijk in
# het image zit — de Alpine-pakketten en alles wat het base-image meelevert.
# Dat onderscheid is niet theoretisch: bij de scan van 1.0.10 kwamen alle 39
# bevindingen daarvandaan (OpenSSL in Alpine, en npm's eigen dependencies) en
# geen enkele uit deze applicatie. Een dependency-check had er nul van gezien.
#
# --ignore-unfixed: alleen falen op iets waar een fix voor bestaat. Anders
# blokkeert een CVE zonder patch je release zonder dat je er iets aan kunt doen.
if (-not $SkipScan) {
Write-Step "[4/5] Imagescan (Trivy)"
$trivyCache = Join-Path $env:USERPROFILE ".cache\trivy"
New-Item -ItemType Directory -Force -Path $trivyCache | Out-Null
# Normaal blokkeren alleen CRITICAL en HIGH; -Strict trekt de lat naar alles.
$ernst = if ($Strict) { "CRITICAL,HIGH,MEDIUM,LOW" } else { "CRITICAL,HIGH" }
# Uitvoer opvangen en alleen tonen als er iets is: Trivy print bij succes
# een tabelregel voor élk schoon pakket, en dat is honderden regels ruis
# waarin een echte melding juist wegvalt.
$scanUit = docker run --rm `
-v /var/run/docker.sock:/var/run/docker.sock `
-v "${trivyCache}:/root/.cache" `
aquasec/trivy:latest image `
--scanners vuln --severity $ernst --ignore-unfixed --exit-code 1 --quiet `
$prodImage 2>&1
if ($LASTEXITCODE -ne 0) {
$scanUit | ForEach-Object { Write-Host $_ }
Write-Error "Imagescan vond op te lossen kwetsbaarheden ($ernst)"
exit 1
}
Write-Host "Imagescan schoon ($ernst)" -ForegroundColor Green
# Het postgres-image scannen we niet als gate: alle bevindingen daarin zitten
# in /usr/local/bin/gosu, en die binary wordt nooit uitgevoerd omdat de
# compose-bestanden de container al als `postgres` starten (zie BEHEER.md).
# Wat wél misgaat is achterstand: het image stond vier maanden op 18.4 met
# 143 bevindingen omdat niemand pullde. Daar waarschuwen we voor — falen zou
# onterecht zijn, want een release van de app hangt er niet van af.
$pgTag = "postgres:18-alpine"
$lokaal = (docker image inspect $pgTag --format '{{index .RepoDigests 0}}' 2>$null)
if ($lokaal) {
$remote = (docker buildx imagetools inspect $pgTag --format '{{.Manifest.Digest}}' 2>$null)
if ($remote -and $lokaal -notlike "*$remote*") {
Write-Host "Let op: $pgTag is nieuwer in de registry — draai 'docker compose pull postgres'." -ForegroundColor Yellow
} else {
Write-Host "$pgTag is actueel" -ForegroundColor Green
}
}
} else {
Write-Step "[4/5] Imagescan overgeslagen (-SkipScan)"
}
if ($NoSmoke) {
Write-Host ""
Write-Host "Smoketest overgeslagen (-NoSmoke) — klaar." -ForegroundColor Green
exit 0
}
# ── 4. Smoketest op het productie-image + postgres ────────────────────────
Write-Step "[4/4] Smoketest (app + postgres)"
# ── 5. Smoketest op het productie-image + postgres ────────────────────────
Write-Step "[5/5] Smoketest (app + postgres)"
function Remove-SmokeStack {
docker rm -f $smokeApp $smokeDb 2>$null | Out-Null