27 Commits
Author SHA1 Message Date
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
jhoogie a9b6203dec Merge pull request 'bugfix' (#11) from update-1.0.5 into main
Reviewed-on: http://10.0.3.108:3000/Vibe/VibeFinance/pulls/11
2026-09-11 14:10:57 +02:00
Jordy Hoogeveen e315039c35 bugfix 2026-09-11 14:05:55 +02:00
jhoogie 36f49b216f Merge pull request 'bugfix' (#10) from update-1.0.5 into main
Reviewed-on: http://10.0.3.108:3000/Vibe/VibeFinance/pulls/10
2026-09-10 21:38:12 +02:00
Jordy Hoogeveen 0d41c0af0b bugfix 2026-09-10 21:33:44 +02:00
jhoogie 796245fefe Merge pull request 'bugfix' (#9) from update-1.0.4 into main
Reviewed-on: http://10.0.3.108:3000/Vibe/VibeFinance/pulls/9
2026-09-10 21:28:05 +02:00
Jordy Hoogeveen 04e70208bd bugfix 2026-09-10 16:51:06 +02:00
jhoogie 0bbb166b32 Merge pull request 'bugfix' (#8) from update-1.0.3 into main
Reviewed-on: http://10.0.3.108:3000/Vibe/VibeFinance/pulls/8
2026-09-10 16:42:38 +02:00
Jordy Hoogeveen ceff734793 bugfix 2026-09-10 16:24:30 +02:00
jhoogie fe6b94aa7f Merge pull request 'bugfix' (#7) from update-1.0.2 into main
Reviewed-on: http://10.0.3.108:3000/Vibe/VibeFinance/pulls/7
2026-09-10 15:43:57 +02:00
Jordy Hoogeveen 624f51679a bugfix 2026-09-10 15:40:27 +02:00
jhoogie 5f90331694 Merge pull request 'Versie 1.0.1 - versleepbare kolommen en een enkel Gitea-token' (#6) from update-1.0.1 into main 2026-09-10 15:01:55 +02:00
Jordy HoogeveenandClaude Opus 5 4e923fa998 Versienummer naar 1.0.2-dev voor de volgende cyclus
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 14:44:24 +02:00
Jordy HoogeveenandClaude Opus 5 857322a490 Tokendocumentatie terug naar één werkstation-token
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 14:30:18 +02:00
Jordy HoogeveenandClaude Opus 5 6fd4f4766b Versie 1.0.1 in README-badge en frontend, changelog vrijgegeven
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 14:16:09 +02:00
Jordy Hoogeveen 1b14553417 bugfixes 2026-09-10 14:03:34 +02:00
jhoogie e5f78f5368 Merge pull request 'Versie 1.0.0 in package.json en README-badge, no-store op de API' (#5) from update-1.0.0 into main
Reviewed-on: http://10.0.3.108:3000/Vibe/VibeFinance/pulls/5
2026-09-09 22:05:57 +02:00
Jordy Hoogeveen 09cc6ae488 Versie 1.0.0 in package.json en README-badge, no-store op de API 2026-09-09 21:59:18 +02:00
32 changed files with 1632 additions and 400 deletions
+5 -2
View File
@@ -17,6 +17,9 @@ CORS_ORIGIN=http://localhost:3400
# Update-notificaties (leeg = melding uit) # Update-notificaties (leeg = melding uit)
UPDATE_CHECK_URL=https://vibehoogie.duckdns.org/api/v1/repos/vibe/VibeFinance/releases/latest UPDATE_CHECK_URL=https://vibehoogie.duckdns.org/api/v1/repos/vibe/VibeFinance/releases/latest
# Nodig zolang de Gitea-repo niet publiek leesbaar is; scope read:repository. # De Gitea-organisatie is publiek, dus de check werkt anoniem — laat dit staan.
# Zonder token geeft releases/latest 404 en blijft de update-melding leeg. # Alleen nodig als de organisatie weer privé wordt; scope read:repository.
# Dan geeft releases/latest zonder token 404 en blijft de melding leeg.
# Let op: een ongeldig token geeft 401, ook op een publieke repo. Bij twijfel
# de regel uitgecommentarieerd laten in plaats van een token te gokken.
# UPDATE_CHECK_TOKEN= # UPDATE_CHECK_TOKEN=
+270 -83
View File
@@ -32,6 +32,51 @@ Vite proxyt `/api` naar `localhost:3001` binnen dezelfde container.
> ⚠️ Alleen `.\dev.ps1 down` wist de development-database (`docker compose down -v`). > ⚠️ Alleen `.\dev.ps1 down` wist de development-database (`docker compose down -v`).
> `.\dev.ps1 up` doet een gewone `down` en laat het volume staan. > `.\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
uit de **root** `package.json`, zet hem in de omgeving en synct hem naar
`frontend/package.json`. In de dev-container is het een *runtime*-variabele
(`docker-compose.dev.yml`, sectie `environment`) en geen build-arg: Vite bakt
`__APP_VERSION__` in bij het **starten** van de dev-server, niet bij het bouwen
van het image. Is hij leeg, dan valt `vite.config.js` terug op
`frontend/package.json`.
Ziet de app een oude versie, dan draait de dev-server nog van vóór de bump —
`.\dev.ps1 up` hercreëert de container en start Vite opnieuw. Start je de stack
handmatig met `docker compose -f docker-compose.dev.yml up`, dan staat
`VITE_APP_VERSION` niet in je shell en zie je de versie uit
`frontend/package.json`.
> Een versie met een suffix (`1.0.1-dev`) onderdrukt de update-melding
> permanent — zie [Update-notificaties](#update-notificaties).
--- ---
## Testen ## Testen
@@ -77,66 +122,121 @@ docker compose -f docker-compose.dev.yml exec -w /app/frontend app npm run lint
## Tokens ## Tokens
Twee tokens, gesplitst op machine en op read/write. De grens die iets betekent **Eén token, op het werkstation.** Dat is genoeg omdat de `Vibe`-organisatie op
ligt tussen werkstation en server, niet tussen push en release: op één machine public staat: alles wat alleen *leest* heeft geen token meer nodig — `git clone`,
kan wie het ene token kan lezen, het andere ook. `docker compose pull` en de update-check van de app werken anoniem. Wat overblijft
is schrijven, en dat gebeurt allemaal vanaf deze ene machine.
| Token | Machine | Scopes | Gebruikt voor | | Token | Machine | Scopes | Gebruikt voor |
|---|---|---|---| |---|---|---|---|
| `vibefinance-workstation` | dit werkstation | `write:package`, `write:repository` | `docker push` (`release.ps1`, `dev.ps1 push`) en het aanmaken van de Gitea-release | | `vibefinance-workstation` | dit werkstation | `write:package`, `write:repository` | `docker push` (`release.ps1`, `dev.ps1 push`) én het aanmaken van de Gitea-release |
| `vibefinance-host` | de Docker-host | `read:package`, `read:repository` | `docker compose pull` en de update-check van de app |
Aanmaken: Gitea → Settings → Applications → Generate New Token. Doe dat onder een Er is geen apart host-token en geen apart registry-token:
account dat lid is van de `Vibe`-organisatie — die is privé, dus een token van
een buitenstaander ziet de repo niet.
**Waar ze terechtkomen** - **`git push`** gaat over SSH (`gitea@…:Vibe/VibeFinance.git`), dus die gebruikt
je SSH-sleutel en niet dit token. Werkt SSH even niet — bijvoorbeeld omdat de
ssh-agent niet meedoet in een niet-interactieve shell, wat `Permission denied
(publickey,password)` geeft — dan kun je *lezen* nu anoniem over https, omdat
de organisatie public is:
| | Werkstation | Docker-host | ```powershell
|---|---|---| git fetch https://vibehoogie.duckdns.org/Vibe/VibeFinance.git --prune --tags "+refs/heads/*:refs/remotes/origin/*"
| Registry | `docker login` (credential store), of `REGISTRY_USER` + `REGISTRY_TOKEN` in `.env.release` | `docker login` als de gebruiker die deployt | ```
| Gitea-API | `GITEA_TOKEN` in `.env.release` | `UPDATE_CHECK_TOKEN` in `.env` |
**Waarom het host-token read-only blijft.** Dat staat in platte tekst in `.env` op Die refspec werkt ook `origin/*` bij, dus daarna doen `git merge --ff-only
een server die de app op poort 3400 publiceert. Met schrijfrechten kan wie bij dat origin/main` en `git log origin/main` het weer. Pushen blijft SSH; `release.ps1`
bestand komt — via de container, een shell op die host, of een backup — een eigen leest daarvoor `core.sshCommand` uit, omdat op Windows alleen de OpenSSH van het
image als `latest` naar de registry pushen, en die host pullt `latest` zelf. systeem met de ssh-agent praat en Git's eigen `ssh` niet.
Read-only houdt je eigen deploy-pad uit de aanvalsketen. - **De registry** heeft geen eigen variabele nodig: `release.ps1` en `dev.ps1`
vallen bij een ontbrekende `REGISTRY_TOKEN` terug op `GITEA_TOKEN`. Zet alleen
`REGISTRY_USER` erbij, zodat het script zelf kan inloggen als de credential
store leeg is.
- **De Docker-host** hoeft niets meer te hebben: pull en update-check zijn anoniem.
Aanmaken: Gitea → Settings → Applications → Generate New Token, met **beide**
scopes aangevinkt. Doe dat onder een account dat push-rechten op de repo heeft —
lid zijn van de organisatie is daarvoor niet automatisch genoeg. Scopes van een
bestaand token zijn later niet aan te passen, dus vink ze in één keer goed aan.
**Waar hij terechtkomt** — `.env.release` naast de scripts, niet in git
(`.gitignore` pakt `.env.*`):
```ini
GITEA_TOKEN=<token met write:package + write:repository>
REGISTRY_USER=<je Gitea-account>
```
Een variabele die al in je shell staat wint, zodat je per run kunt afwijken. Een
lege waarde in het bestand wordt overgeslagen.
**Als de organisatie weer privé gaat** heb je er wél een tweede bij nodig: een
read-only token op de Docker-host, voor `docker compose pull` en
`UPDATE_CHECK_TOKEN`. Houd dat token dan read-only. Het staat in platte tekst in
`.env` op een server die de app op poort 3400 publiceert; met schrijfrechten kan
wie bij dat bestand komt — via de container, een shell op die host, of een
backup — een eigen image als `latest` naar de registry pushen, en die host pullt
`latest` zelf. Read-only houdt je eigen deploy-pad uit de aanvalsketen.
### Wat een foutcode betekent ### Wat een foutcode betekent
| Status | Betekenis | | Status | Betekenis |
|---|---| |---|---|
| 401 | Token ongeldig of verlopen. Op de registry: helemaal niet ingelogd. | | 401 | Token ongeldig, verlopen of ingetrokken — Gitea herkent hem niet. Op de registry: helemaal niet ingelogd. |
| 403 | Het token mag de repo wél zien, maar mist de **scope** voor dit endpoint. Gitea noemt de vereiste scope in de response-body. | | 403 | Het token **bestaat en werkt**, maar mist de **scope** voor dit endpoint. Gitea noemt de vereiste scope in de response-body. |
| 404 | Het token mag de repo niet zien. Gitea geeft geen 403, om niet te verklappen dat iets bestaat: verkeerd account, of geen toegang tot de organisatie. | | 404 | Het token mag de repo niet zien. Gitea geeft geen 403, om niet te verklappen dat iets bestaat: verkeerd account, of geen toegang tot de organisatie. Nu de organisatie public is, wijst een 404 op een leesactie eerder op een verkeerde URL dan op rechten. |
Scopes van een bestaand token zijn in Gitea niet aan te passen. Bij een 403 maak Scopes van een bestaand token zijn in Gitea niet aan te passen. Bij een 403 maak
je dus een nieuw token aan; bijwerken kan niet. je dus een nieuw token aan; bijwerken kan niet.
Nakijken wie een token is en wat hij ziet: **Leeft mijn token nog?** Juist dat onderscheid tussen 401 en 403 is de test.
Vraag *niet* `/api/v1/user` op om te kijken "wie het token is": het
werkstation-token heeft geen `read:user`, dus dat endpoint geeft altijd 403 en
zegt niets over geldigheid. Kijk naar de statuscode, niet naar de inhoud:
```powershell ```powershell
$h = @{ Authorization = "token $env:GITEA_TOKEN" } $u = "https://vibehoogie.duckdns.org/api/v1/user/repos"
(Invoke-RestMethod https://vibehoogie.duckdns.org/api/v1/user -Headers $h).login try { (Invoke-WebRequest $u -Headers @{ Authorization = "token $env:GITEA_TOKEN" } -UseBasicParsing).StatusCode }
(Invoke-RestMethod https://vibehoogie.duckdns.org/api/v1/user/repos -Headers $h).full_name catch { $_.Exception.Response.StatusCode.value__ }
``` ```
`403` = het token leeft (alleen deze scope ontbreekt). `401` = ingetrokken of
fout gekopieerd. Nagemeten op 2026-09-10: geen header `401`, een nep-token van 40
nullen `401`, het werkstation-token `403`.
Vanuit de app-container op de host valt de body van een fout anders weg — Vanuit de app-container op de host valt de body van een fout anders weg —
busybox-`wget` gooit die bij een foutstatus weg, en Node zit in het image: busybox-`wget` gooit die bij een foutstatus weg, en Node zit in het image:
```bash ```bash
docker exec vibefinance_app node -e 'fetch(process.env.UPDATE_CHECK_URL,{headers:{Accept:"application/json",Authorization:`token ${process.env.UPDATE_CHECK_TOKEN}`}}).then(async r=>console.log(r.status, (await r.text()).slice(0,300)))' # Anoniem, zoals de app het doet zonder UPDATE_CHECK_TOKEN
docker exec vibefinance_app node -e 'fetch(process.env.UPDATE_CHECK_URL,{headers:{Accept:"application/json"}}).then(async r=>console.log(r.status, (await r.text()).slice(0,300)))'
# Mét token, precies zoals routes/update.js de header opbouwt
docker exec vibefinance_app node -e 'const t=process.env.UPDATE_CHECK_TOKEN; fetch(process.env.UPDATE_CHECK_URL,{headers:{Accept:"application/json",...(t&&{Authorization:`token ${t}`})}}).then(async r=>console.log(r.status, (await r.text()).slice(0,300)))'
``` ```
### Tokens opruimen > Let op: een **ongeldig** token geeft `401`, ook op deze publieke repo — Gitea
> valt bij een verkeerd token niet terug op anonieme toegang. Krijg je 401
> terwijl anoniem `200` geeft, haal `UPDATE_CHECK_TOKEN` dan uit `.env` in plaats
> van hem te vervangen. Een *leeg* token is onschuldig: `Authorization: token `
> negeert Gitea (`200`), en `config/index.js` zet met `|| null` bij een lege
> waarde helemaal geen header. Nagemeten op 2026-09-10:
> geen header `200`, `token ongeldig123` `401`, leeg `200`.
De lijst onder Settings → Applications heeft een kolom **Last used**. Zet eerst ### Oude tokens opruimen
beide machines om, draai daarna een release en een pull, en kijk een dag later
welke tokens niet meer meebewegen — die kun je intrekken. Betrouwbaarder dan uit Nu er één token overblijft, kunnen de rest weg — maar niet op de gok. De lijst
de naam afleiden wat waar in gebruik is. Trek niets in vóór die controle: het onder Settings → Applications heeft een kolom **Last used**. Werkwijze:
token achter je `docker login` op dit werkstation laat een release op de push
falen. 1. Zet het nieuwe token in `.env.release` en draai een release die helemaal
doorloopt (inclusief `docker push` en stap 5).
2. Kijk in de lijst welk token net **Last used** heeft gekregen.
3. Wacht een dag en trek de tokens in die niet meer meebewegen.
Betrouwbaarder dan uit de naam afleiden wat waar in gebruik is. Trek vooral niets
in vóór die controle: het token achter je `docker login` zit in de credential
store en niet in een bestand, dus je ziet aan niets welke het is — trek je die
in, dan valt de push in stap 3 om. Loopt dat toch mis, dan is
`docker login vibehoogie.duckdns.org` met het nieuwe token genoeg.
--- ---
@@ -144,7 +244,8 @@ falen.
```powershell ```powershell
# 1. Versienummer in package.json van "x.x.x-dev" naar "x.x.x" # 1. Versienummer in package.json van "x.x.x-dev" naar "x.x.x"
# 2. Changelog.md: kop van "Niet vrijgegeven" naar "[x.x.x] - datum" # 2. Wijzigingen in Changelog.md onder de kop "## [Niet vrijgegeven]"
# (de kop zelf omzetten hoeft niet — dat doet het script)
# 3. Token staat in .env.release (zie Tokens) — of eenmalig in de shell: # 3. Token staat in .env.release (zie Tokens) — of eenmalig in de shell:
$env:GITEA_TOKEN = "<vibefinance-workstation>" $env:GITEA_TOKEN = "<vibefinance-workstation>"
@@ -152,8 +253,9 @@ $env:GITEA_TOKEN = "<vibefinance-workstation>"
.\release.ps1 -DryRun # droogloop — toont alle stappen zonder uit te voeren .\release.ps1 -DryRun # droogloop — toont alle stappen zonder uit te voeren
.\release.ps1 -NoBuild # sla docker build over, alleen tag + release .\release.ps1 -NoBuild # sla docker build over, alleen tag + release
.\release.ps1 -SkipTests # sla de testsuite over (niet aanbevolen) .\release.ps1 -SkipTests # sla de testsuite over (niet aanbevolen)
.\release.ps1 -NoChangelog # publiceer bewust zonder release-notes
# 4. Versienummer in package.json naar "x.x.(x+1)-dev" voor volgende cyclus # Klaar. Het versienummer voor de volgende cyclus zet het script zelf.
``` ```
Het script bouwt één image voor `linux/amd64` en `linux/arm64`, zodat dezelfde Het script bouwt één image voor `linux/amd64` en `linux/arm64`, zodat dezelfde
@@ -161,38 +263,53 @@ tag op een Raspberry Pi en op een x86-server werkt.
**Wat het script zelf bijwerkt** — met de hand aanpassen is dus dubbel werk: **Wat het script zelf bijwerkt** — met de hand aanpassen is dus dubbel werk:
| Bestand | Wat | | Bestand | Wat | Wanneer |
|---|---| |---|---|---|
| `frontend/package.json` | versie gelijkgetrokken met de root `package.json` | | `Changelog.md` | kop `## [Niet vrijgegeven]` → `## [x.x.x] - datum` | vóór de build |
| `README.md` | de `versie-x.x.x`-badge bovenaan | | Git-tag | `vx.x.x` op `HEAD`, gepusht naar origin | stap 4 |
| `frontend/package.json` | versie gelijkgetrokken met de root `package.json` | vóór de build |
| `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 |
Beide gebeuren vóór de build, en een `-DryRun` meldt ze zonder te schrijven. Ze Een `-DryRun` meldt de vier bestandswijzigingen zonder ze te schrijven (de
blijven na een echte release als wijziging in je working tree staan; neem ze mee Git-tag is de uitzondering in die tabel: die hoort bij stap 4, niet bij de
in de volgende commit. 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 changelog-kop moet exact `## [x.x.x]` zijn.** `release.ps1` zoekt die kop De laatste staat bewust ná het publiceren: breekt de release eerder af, dan
> om de release-notes te vullen. Staat je werk nog onder `## [Niet vrijgegeven]`, schuift het versienummer niet vooruit. Bijeffect dat je moet kennen — na een
> dan vindt het script niets en valt het terug op de tekst `"Release x.x.x"` — geslaagde release staat er een `-dev` nummer in `package.json`, en de controle
> dan staat er geen inhoudelijke release-notes in Gitea en ziet een gebruiker in bovenin het script weigert een `-dev` versie. Dat sluit een dubbele publicatie
> het belletje een placeholder. Dat overkwam 0.1.9. Achteraf repareren kan met uit, maar wil je dezelfde versie bewust opnieuw releasen (bijvoorbeeld omdat een
> een `PATCH` op `/repos/<org>/<repo>/releases/<id>`, maar vóór de release tag is verdwenen), zet het nummer dan eerst zelf terug naar `x.x.x`.
> omzetten is een stuk minder werk.
> **Zonder changelog-sectie breekt het script af.** De release-notes komen uit de
> sectie `## [x.x.x]` in `Changelog.md`. Vond het script daar niets, dan
> publiceerde het stil de tekst `"Release x.x.x"` — dat gebeurde bij 0.1.9 en
> 1.0.2. Nu zet het de kop `## [Niet vrijgegeven]` zelf om, en als er ook dán
> niets over deze versie in staat, stopt het vóór de build met de melding wat je
> moet toevoegen. Wil je bewust zonder notes publiceren, gebruik `-NoChangelog`.
>
> De controle staat vóór de drie auto-updates hierboven, zodat een afgebroken
> release geen gewijzigde bestanden achterlaat. Is een release toch al gepubliceerd
> met de placeholder, dan repareer je dat met een `PATCH` op
> `/repos/<org>/<repo>/releases/<id>` met de juiste `body`.
### `.env.release` — token niet elke sessie opnieuw zetten ### `.env.release` — token niet elke sessie opnieuw zetten
Staat er een `.env.release` naast `release.ps1`, dan leest het script die in Staat er een `.env.release` naast `release.ps1`, dan leest het script die in
voordat er iets gebeurt, en hoeft stap 3 niet meer met de hand: voordat er iets gebeurt, en hoeft stap 3 niet meer met de hand:
``` ```ini
# .env.release — staat in .gitignore, hoort alleen op de release-machine # .env.release — staat in .gitignore, hoort alleen op de release-machine
GITEA_TOKEN=<vibefinance-workstation> GITEA_TOKEN=<vibefinance-workstation>
REGISTRY_USER=<gitea-gebruiker> REGISTRY_USER=<gitea-gebruiker>
REGISTRY_TOKEN=<vibefinance-workstation>
``` ```
Beide tokenvariabelen krijgen hetzelfde token; zie [Tokens](#tokens) voor waarom `REGISTRY_TOKEN` hoeft er niet in: beide scripts vallen bij een ontbrekende
dat op deze machine één token mag zijn, en voor wat een 401, 403 of 404 van Gitea `REGISTRY_TOKEN` terug op `GITEA_TOKEN`. Zie [Tokens](#tokens) voor de scopes en
betekent. voor wat een 401, 403 of 404 van Gitea betekent.
Een variabele die al in je shell staat wint, zodat je per run kunt afwijken zonder Een variabele die al in je shell staat wint, zodat je per run kunt afwijken zonder
het bestand aan te passen. Het script logt alleen welke namen het heeft geladen, het bestand aan te passen. Het script logt alleen welke namen het heeft geladen,
@@ -210,7 +327,12 @@ docker compose pull && docker compose up -d
docker compose down -v && docker compose up -d docker compose down -v && docker compose up -d
``` ```
Pullen vereist een registry-login op die host — zie Draai dit in de map met `docker-compose.yml` en `.env`. `docker compose` zoekt
dat bestand namelijk in de huidige map en kijkt niet omhoog; staat het er niet,
dan is het antwoord `no configuration file provided: not found`. Met
`docker compose -f /pad/naar/docker-compose.yml` kun je het expliciet meegeven.
Pullen zelf werkt anoniem zolang de organisatie public is — zie
[Pullen op een deploy-machine](#pullen-op-een-deploy-machine). [Pullen op een deploy-machine](#pullen-op-een-deploy-machine).
### PostgreSQL versie-upgrade ### PostgreSQL versie-upgrade
@@ -239,9 +361,11 @@ De Express-app serveert zowel de API als de gebouwde frontend:
| Verzoek | Wat de app doet | | Verzoek | Wat de app doet |
|---|---| |---|---|
| `/api/...` | REST API (rate limited) | | `/api/...` | REST API (rate limited, `Cache-Control: no-store`) |
| `/assets/...` | statische build uit `STATIC_DIR`, 1 jaar `immutable` gecached | | `/assets/...` | gehashte build-bestanden uit `STATIC_DIR`, 1 jaar `immutable` |
| al het overige | `index.html` — fallback voor React-routing | | `/favicon.svg`, `/icons/...` | vaste namen, dus `max-age=0, must-revalidate` |
| pad mét extensie zonder bestand | echte 404, niet `index.html` |
| al het overige | `index.html` — fallback voor React-routing, `no-store` |
`STATIC_DIR` staat standaard op `/app/public`; daar zet de `Dockerfile` de `STATIC_DIR` staat standaard op `/app/public`; daar zet de `Dockerfile` de
Vite-build neer. Bestaat die map niet (bijv. in dev), dan serveert de app Vite-build neer. Bestaat die map niet (bijv. in dev), dan serveert de app
@@ -287,6 +411,12 @@ De backend slaat alle data op in **PostgreSQL**.
> PostgreSQL 18 gebruikt `PGDATA=/var/lib/postgresql/18/docker`. Het volume is gemount op `/var/lib/postgresql` zodat data bewaard blijft na een container restart. > PostgreSQL 18 gebruikt `PGDATA=/var/lib/postgresql/18/docker`. Het volume is gemount op `/var/lib/postgresql` zodat data bewaard blijft na een container restart.
Daarnaast heeft de app zelf een backup onder **Instellingen**: die schrijft een
ZIP met de data van de *ingelogde* gebruiker (`data.json` + `meta.json`) en leest
zo'n ZIP ook weer in. Handig per gebruiker, maar het is geen servercopie — accounts,
wachtwoordhashes en de data van andere gebruikers zitten er niet in. Voor een
volledige backup gebruik je `pg_dump` hieronder.
**Backup maken:** **Backup maken:**
```bash ```bash
@@ -313,12 +443,25 @@ docker exec -i vibefinance_postgres psql -U vibefinance -d vibefinance < backup-
De eerste geregistreerde gebruiker krijgt automatisch de **Admin**-rol. Verdere accounts kunnen via de app aangemaakt worden (Gebruikersbeheer, Admin vereist) of via de API. De eerste geregistreerde gebruiker krijgt automatisch de **Admin**-rol. Verdere accounts kunnen via de app aangemaakt worden (Gebruikersbeheer, Admin vereist) of via de API.
Rollen: ### Rollen
| Rol | Rechten |
De rolkeuze in Gebruikersbeheer biedt drie waarden, maar de backend dwingt er op
dit moment één af:
| Rol | Wat de code ermee doet |
|---|---| |---|---|
| `Admin` | Alles, inclusief gebruikersbeheer | | `Admin` | Enige rol die wordt gecontroleerd: `requireAdmin` in `middleware/auth.js` bewaakt alle routes onder `/api/users` |
| `Bewerker` | Data lezen en bewerken | | `Bewerker` | Geen eigen rechten — gelijk aan `Viewer` |
| `Viewer` | Alleen lezen | | `Viewer` | Standaardrol voor een nieuw account; mag de eigen data lezen én schrijven |
Dat `Viewer` toch mag schrijven is geen gat in de afscherming: `/api/data` werkt
per gebruiker en gaat altijd over de rij van het eigen token (`req.user.sub`).
Niemand ziet dus data van een ander. Wat ontbreekt is een read-only account —
wil je dat, dan is er een controle op `rol` nodig bij `PUT`/`DELETE /api/data`.
> De "Gebruikers"-knop in de navigatiebalk staat bij iedereen in beeld; bij een
> niet-Admin antwoordt `/api/users` met `403` en blijft de lijst leeg. De
> afscherming zit dus in de API, niet in de navigatie.
--- ---
@@ -380,6 +523,10 @@ Tags:
x.x.x-dev development — specifieke dev versie x.x.x-dev development — specifieke dev versie
``` ```
> Image-tags hebben géén `v`-prefix (`1.0.4`), Git-tags wél (`v1.0.4`). Dat is
> geen inconsistentie maar de conventie aan beide kanten: container-registries
> taggen op het kale nummer, Git-releases met `v`.
Inloggen op de registry: Inloggen op de registry:
```bash ```bash
@@ -391,8 +538,9 @@ machine — `vibefinance-workstation`, zie [Tokens](#tokens). Zonder login faalt
een push met `401 Unauthorized`; `release.ps1` en `dev.ps1 push` controleren dit een push met `401 Unauthorized`; `release.ps1` en `dev.ps1 push` controleren dit
vóór de build en stoppen dan meteen. vóór de build en stoppen dan meteen.
Liever niet handmatig inloggen? Zet `REGISTRY_USER` en `REGISTRY_TOKEN` in Liever niet handmatig inloggen? Zet `REGISTRY_USER` in `.env.release`, dan logt
`.env.release`, dan logt het script zelf in. het script zelf in met `GITEA_TOKEN` — een aparte `REGISTRY_TOKEN` is alleen
nodig als je daar bewust een ánder token voor wilt gebruiken.
Twee valkuilen: log in op `vibehoogie.duckdns.org` **zonder** `:3000` (dat is een Twee valkuilen: log in op `vibehoogie.duckdns.org` **zonder** `:3000` (dat is een
aparte sleutel in de credential store), en doe het als dezelfde Windows-gebruiker aparte sleutel in de credential store), en doe het als dezelfde Windows-gebruiker
@@ -406,20 +554,8 @@ docker-credential-desktop list
### Pullen op een deploy-machine ### Pullen op een deploy-machine
Het package is privé, dus ook een **pull** vereist een login — anoniem geeft De organisatie is public, dus een **pull** werkt anoniem — `docker compose pull &&
`401 Unauthorized` (`docker compose pull` meldt dan "no basic auth credentials" docker compose up -d` en klaar, zonder `docker login`.
of "pull access denied"). Eenmalig op de Docker-host:
```bash
docker login vibehoogie.duckdns.org
# gebruikersnaam: je Gitea-account
# wachtwoord: het token van deze host (vibefinance-host, read:package)
```
Daarna werkt `docker compose pull && docker compose up -d`. De login komt in
`~/.docker/config.json` van de gebruiker die het commando uitvoert — doe het dus
als dezelfde gebruiker (bij `sudo docker` is dat root). Log in op
`vibehoogie.duckdns.org` **zonder** `:3000`.
Controleren zonder te pullen: Controleren zonder te pullen:
@@ -427,8 +563,23 @@ Controleren zonder te pullen:
docker manifest inspect vibehoogie.duckdns.org/vibe/vibefinance-app:latest docker manifest inspect vibehoogie.duckdns.org/vibe/vibefinance-app:latest
``` ```
Ditzelfde token gaat als `UPDATE_CHECK_TOKEN` in de `.env` op deze host, voor de Krijg je `401 Unauthorized`, "no basic auth credentials" of "pull access denied",
update-check van de app — één token voor beide, zie [Tokens](#tokens). dan is het package niet publiek leesbaar (organisatie weer op privé, of de
package-eigenaar wijkt af van de organisatie). Dan alsnog eenmalig op de host:
```bash
docker login vibehoogie.duckdns.org
# gebruikersnaam: je Gitea-account
# wachtwoord: een token met read:package
```
De login komt in `~/.docker/config.json` van de gebruiker die het commando
uitvoert — doe het dus als dezelfde gebruiker (bij `sudo docker` is dat root).
Log in op `vibehoogie.duckdns.org` **zonder** `:3000`.
Moet je terug naar een token, dan kan datzelfde token ook als
`UPDATE_CHECK_TOKEN` in de `.env` op deze host — één token voor beide, zie
[Tokens](#tokens).
--- ---
@@ -447,7 +598,43 @@ dat op het belletje in de topbalk en onder "Over VibeFinance".
Builds met een `-dev` suffix melden nooit (die lopen voor op de laatste release). Builds met een `-dev` suffix melden nooit (die lopen voor op de laatste release).
Wil je de melding tijdens ontwikkeling toch testen: open de app met `?updatecheck` Wil je de melding tijdens ontwikkeling toch testen: open de app met `?updatecheck`
in de URL. in de URL. Let op dat `isNieuwer` alleen bij een écht hoger nummer aanslaat — bij
`1.0.2-dev` naast release `1.0.1` blijft de kaart dus ook met `?updatecheck` leeg.
Zet dan tijdelijk een lagere versie in de root `package.json`.
**Wat de melding toont**
Alleen de kop, de versieregel en een link:
```
🔔 Nieuwe versie beschikbaar
v1.0.1 is uitgebracht. Je gebruikt momenteel v1.0.0.
Release bekijken →
```
De release-notes staan er bewust **niet** in. `/api/update-check` stuurt ze wel
mee als `notes`, maar dat is de ruwe Markdown uit de changelog; in een kaart van
280px leverde dat zichtbare `### `-koppen en afgekapte regels op. Wie de details
wil, volgt de link. Het veld blijft in de API zitten voor het geval je ze ooit
ergens met meer ruimte wilt tonen — dan is Markdown renderen de volgende stap.
**De release-link hangt aan Gitea's `ROOT_URL`**
De `url` in het antwoord is de `html_url` van de release, en die stelt Gitea bij
elk verzoek samen uit `ROOT_URL` in `app.ini` — hij wordt *niet* per release
opgeslagen. Staat `ROOT_URL` op een intern adres, dan wijst "Release bekijken"
naar iets dat buiten je netwerk niet bestaat, terwijl de melding zelf gewoon
werkt (die leest alleen het versienummer).
Nakijken welke waarde Gitea daadwerkelijk gebruikt — de `issuer` is `ROOT_URL`:
```bash
curl -s https://vibehoogie.duckdns.org/.well-known/openid-configuration | grep issuer
```
`app.ini` wordt alleen bij het opstarten gelezen, dus na een wijziging moet Gitea
herstarten. Omdat de URL wordt samengesteld en niet bewaard, zijn daarna álle
releases in één keer goed — ook de oude. Een `PATCH` per release is niet nodig.
**Configuratie** **Configuratie**
@@ -464,7 +651,7 @@ uit `.env` (of de default in `docker-compose.yml`). Beschikbare backend-variabel
| Variabele | Betekenis | | Variabele | Betekenis |
|---|---| |---|---|
| `UPDATE_CHECK_URL` | Release-API die gepolld wordt. Leeg = melding uit. | | `UPDATE_CHECK_URL` | Release-API die gepolld wordt. Leeg = melding uit. |
| `UPDATE_CHECK_TOKEN` | Nodig zolang de `Vibe`-organisatie in Gitea privé is: het token van de host (`read:repository`, zie [Tokens](#tokens)). Zonder token geeft `releases/latest` 404, met een verkeerde scope 403 — in beide gevallen blijft de melding leeg, met alleen een `WARN` in het containerlog. | | `UPDATE_CHECK_TOKEN` | Optioneel. De `Vibe`-organisatie is public, dus `releases/latest` antwoordt anoniem met `200` en de check heeft geen token nodig. Gaat de organisatie weer privé, dan is het host-token nodig (`read:repository`, zie [Tokens](#tokens)): zonder geeft `releases/latest` dan 404, met een verkeerde scope 403 — in beide gevallen blijft de melding leeg, met alleen een `WARN` in het containerlog. |
**Een release publiceren** **Een release publiceren**
+291
View File
@@ -2,6 +2,297 @@
## [Niet vrijgegeven] ## [Niet vrijgegeven]
### Opgelost
- Het laatste label op de x-as viel over zijn buurman heen (`mei '26` en `26`
over elkaar). De stap werd vanaf het begin van de reeks geteld en het laatste
punt werd daar los bovenop geforceerd, zodat het op willekeurige afstand van
zijn voorganger landde — bij een reeks die niet toevallig op een veelvoud
uitkwam, pal ernaast. De stap telt nu terug vanaf het laatste punt: dat valt
daarmee per definitie op een stap en houdt volle tussenruimte, zonder dat er
nog een uitzondering voor nodig is
- De breedte waarop de grafiek rekent ging van 46 naar 54px per label, zodat er
lucht tussen staat in plaats van kop-aan-kop. Over reekslengtes van 13 tot 120
punten blijft de tussenruimte nu minimaal 57px
## [1.0.9] - 2026-09-17
### Gewijzigd
- De x-as van beide grafieken toont korte maandlabels (`sep '26`) in plaats van
een volledige datum bij elk punt, en ze staan rechtop in plaats van 45 graden
gedraaid. Tien tekens gedraaid kosten zo'n 45px en er passen er maar een stuk
of veertien op de tekenbreedte; bij een langere reeks liepen ze door elkaar
heen tot één grijze band. De grafiek rekent nu uit hoeveel labels er passen en
toont elke n-de, met het laatste punt er altijd bij — groeit je reeks, dan dunt
hij vanzelf verder uit
- Doordat de labels niet meer gedraaid zijn is de ruimte eronder kleiner: `pb`
van 52 naar 30 op het dashboard en van 62 naar 34 op Voortgang. Die hoogte gaat
naar de lijn zelf
- Nieuwe formatter `fmtMaand` in `utils/format.js`, naast `fmtDatum`
- De lijnen in de Voortgang-grafieken zijn vloeiend, net als op het dashboard.
Ze werden met `<polyline>` als rechte segmenten getekend; nu met dezelfde
Catmull-Rom afronding. De gradient eronder is daarmee ook een `<path>` in
plaats van een `<polygon>`
- `crPath` stond als lokale functie in `DashboardTab.jsx` en is verhuisd naar
`utils/grafiek.js`, samen met `vlakPad` voor de vulling onder een lijn. Twee
grafieken die dezelfde curve horen te tekenen moeten niet elk hun eigen kopie
hebben. In dat bestand staat ook waaróm de inleglijn bewust géén afronding
krijgt: een spline schiet tussen twee punten iets buiten hun bereik, en bij
een reeks die nooit terugloopt tekent dat een daling die er niet is
### Opgelost
- De tooltip van de Voortgang-grafiek toonde alleen een bedrag. Nu de x-as geen
dagen meer noemt was dat de enige plek waar de exacte datum nog te lezen was,
en die stond er niet in. De tooltip heeft er een datumregel bij
## [1.0.8] - 2026-09-15
### Toegevoegd
- **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`,
`frontend/public/`, `InstellingenModal.jsx`, `LoginBackground.jsx` en de
losse bestanden in `utils/`, en noemde de update-route `update-check` terwijl
het bestand `routes/update.js` heet. De schermentabel gebruikt nu de labels
zoals ze in `constants/index.js` staan ("Schuld", "Voortgang Schuld & EV") en
vermeldt **Instellingen** — thema plus backup/restore via ZIP, dat nergens in
de README stond
- De tabel "wat de app met een verzoek doet" (in beide bestanden) noemde alleen
`/assets/...` en de SPA-fallback. `server.js` kent vier gevallen: `no-store`
op de API en op HTML, een jaar `immutable` op de gehashte bundels,
`must-revalidate` op bestanden met een vaste naam (`favicon.svg`, `icons/*`)
en een echte 404 op een pad mét extensie waar geen bestand achter zit
- `BEHEER.md` beschreef `Bewerker` als "data lezen en bewerken" en `Viewer` als
"alleen lezen". Zo werkt het niet: `requireAdmin` is de enige rolcontrole in
de code en bewaakt alleen `/api/users`. Een `Viewer` mag daardoor gewoon zijn
eigen data schrijven — geen gat in de afscherming, want `/api/data` gaat altijd
over de rij van het eigen token, maar wél iets anders dan er stond. De sectie
zegt nu wat de code doet en wat er nodig is voor een echt read-only account
- `README.md` en `BEHEER.md` wijzen bij backup naar het juiste niveau: de ZIP
onder Instellingen bevat alleen de data van de ingelogde gebruiker, `pg_dump`
is de volledige servercopie
- `DATABASE_URL` staat in de variabelentabel van `README.md`. Hij wordt door
`docker-compose.yml` samengesteld uit de `POSTGRES_*` waarden, dus zelf zetten
is alleen nodig buiten Compose
## [1.0.4] - 2026-09-10
### Gewijzigd
- Git-tags krijgen weer een `v`-prefix (`v1.0.4`), net als t/m `v1.0.0`. Sinds
1.0.1 was dat `1.0.1`, `1.0.2`, `1.0.3` — en omdat Gitea de tag op de
release-pagina toont, stond daar het kale nummer terwijl de release-naam wél
`v1.0.3` was. De image-tags in de registry houden bewust géén `v`; dat is de
conventie voor container-tags. De update-check verdraagt beide: backend en
frontend strippen een leidende `v` voordat ze versies vergelijken
### Opgelost
- Het omzetten van de changelog-kop slokte de lege regel eronder op, waardoor
`## [x.x.x] - datum` direct tegen `### Toegevoegd` aan kwam te staan. Oorzaak
was `\s*$` in het zoekpatroon: `\s` matcht ook newlines. Het patroon eindigt nu
op `[ \t]*(?=\r?$)` — een lookahead, want dit bestand heeft CRLF en met alleen
`[ \t]*$` matchte de kop helemaal niet meer (de `\r` zit ertussen). Zo blijft
de match binnen één regel én blijft het regeleinde ongemoeid
## [1.0.3] - 2026-09-10
### Toegevoegd
- `release.ps1` zet de changelog-kop `## [Niet vrijgegeven]` nu zelf om naar
`## [x.x.x] - datum`, net zoals het al deed met `frontend/package.json` en de
README-badge. Handmatig omzetten hoeft dus niet meer
- Staat er ook na die omzetting niets over deze versie in `Changelog.md`, dan
breekt het script af **vóór** de build met de melding wat je moet toevoegen.
Eerder viel het stil terug op de tekst `"Release x.x.x"` en meldde het
"Gepubliceerd" — dat overkwam 0.1.9 en 1.0.2, en is achteraf alleen met een
`PATCH` op de release te herstellen. Nieuwe switch `-NoChangelog` om bewust
zonder release-notes te publiceren
- `release.ps1` zet na een geslaagde release de versie in de root
`package.json` op `x.x.(x+1)-dev`. Dat was voorheen de laatste handmatige stap
van de release-workflow. Het staat bewust ná het publiceren, zodat een
afgebroken release het nummer niet vooruit schuift
### Opgelost
- **`-DryRun` in combinatie met `-Force` verwijderde écht de Git-tag.** De twee
commando's in stap 4 (`git tag -d` en `git push origin :refs/tags/x.x.x`)
liepen niet via `Invoke-Cmd` en hadden dus geen droogloop-controle. Gitea
ruimt de release op zodra de tag verdwijnt, dus een droogloop kon een
gepubliceerde release wissen — zo is de release van 1.0.2 verloren gegaan en
opnieuw aangemaakt. Onder `-DryRun` worden beide nu alleen gemeld
### Gewijzigd
- De changelog-controle staat vóór de drie auto-updates, zodat een afgebroken
release geen gewijzigde bestanden achterlaat terwijl hij "er is niets
gepubliceerd" meldt
- Stap 5 leest de release-notes uit het geheugen in plaats van opnieuw van
schijf. Een droogloop schrijft de kop niet, en toonde daardoor de placeholder
in de payload — precies wat je met een droogloop wilt uitsluiten
- De Quick Start in `README.md` begint nu met het ophalen van
`docker-compose.yml` (clone, of alleen die twee bestanden via `raw`). Zonder
dat bestand antwoordt `docker compose pull` met `no configuration file
provided: not found`, en die stap ontbrak. Beide foutmeldingen staan er nu bij
met hun oorzaak
## [1.0.2] - 2026-09-10
### Gewijzigd
- De update-melding op het belletje toont alleen nog de kop, de versieregel en de
link naar de release. De release-notes stonden er als ruwe Markdown in — in een
kaart van 280px leverde dat zichtbare `### `-koppen en afgekapte regels op.
`/api/update-check` stuurt `notes` nog wel mee, zodat ze later ergens met meer
ruimte gebruikt kunnen worden
- `BEHEER.md` legt vast dat de release-link uit Gitea's `ROOT_URL` wordt
samengesteld en niet per release wordt opgeslagen: na een `ROOT_URL`-wijziging
plus herstart zijn álle releases in één keer goed, ook de oude. Verder staat er
nu hoe je aan het verschil tussen `401` en `403` ziet of een token nog leeft, en
hoe je anoniem over https fetcht als SSH niet meedoet
## [1.0.1] - 2026-09-10
### Toegevoegd
- Kolommen in de tabellen op Eigen Vermogen ("Geïnvesteerde Fiat") en Voortgang
zijn versleepbaar: pak de rechterrand van een kolomkop. De breedtes gaan per
tabel naar localStorage, dus ze overleven een tabwissel en een herlaad;
dubbelklik op de greep zet één kolom terug op zijn standaardbreedte. De logica
staat in de nieuwe hook `frontend/src/hooks/useKolomBreedtes.js`, de greep zelf
is `.kolom-grip` in `App.css`
### Gewijzigd
- De `Vibe`-organisatie in Gitea staat op **public**, dus lezen vereist geen
token meer: `git clone`, `docker compose pull` en de update-check werken
anoniem. `UPDATE_CHECK_TOKEN` in `.env` is daarmee optioneel — de code
ondersteunt hem nog als vangnet voor het geval de organisatie weer privé
wordt. Schrijven verandert niet: `docker push` en het aanmaken van een release
hebben nog steeds het werkstation-token nodig
- Het tweetoken-model in `BEHEER.md` is teruggebracht tot **één** Gitea-token op
het werkstation, met `write:package` en `write:repository`. Het host-token is
niet meer nodig (pull en update-check zijn anoniem), `git push` gaat over SSH,
en `REGISTRY_TOKEN` hoeft niet apart: `release.ps1` en `dev.ps1` vielen daar al
terug op `GITEA_TOKEN`. Geen code-wijziging, alleen minder te beheren
- De tabellen op Eigen Vermogen en Voortgang staan op `table-layout: fixed`,
nodig om ingestelde kolombreedtes te respecteren. Gevolg: lange tekst rekt een
kolom niet meer op maar wordt afgekapt met een ellips. De Opmerkingen-cellen
hebben een `title`, dus hover geeft de volledige tekst
- Het label bij een beschikbare update in "Over VibeFinance" is "Nieuwe versie"
(was "Nieuwste versie" in 1.0.0)
### Opgelost
- De sticky kolomkop van die tabellen was in dark mode maar 30% dekkend: hij
gebruikte `T.card` (`rgba(22,23,27,0.30)`), waardoor de rijen tijdens het
scrollen zichtbaar door de koptekst heen liepen. Nu `T.popupBg`, de
ondoorzichtige variant die al in het thema stond
- In dev bakte Vite de versie uit `frontend/package.json` in in plaats van de
versie uit de root `package.json`. `VITE_APP_VERSION` kwam alleen als
build-arg mee, maar de `development`-stage in de `Dockerfile` declareert die
`ARG` niet — en Vite bakt de constante in bij het *starten* van de dev-server,
niet bij het bouwen van het image. De variabele staat nu in de
runtime-omgeving van de dev-container; `vite.config.js` valt met `||` (was
`??`) terug op `package.json` als hij leeg is
- API-antwoorden sturen nu `Cache-Control: no-store`. Ze hadden alleen een
`ETag` en geen cache-beleid, waardoor de browser zelf mocht bepalen hoe lang
hij ze hergebruikte — per browser en profiel verschillend. Gevolg: de
update-melding bleef in Brave op een oud antwoord hangen (nog van vóór het
werkende token) terwijl een verse Edge het goede kreeg
## [1.0.0] - 2026-09-09
### Gewijzigd ### Gewijzigd
- `BEHEER.md` heeft één centrale `## Tokens`-sectie met het tweetoken-model - `BEHEER.md` heeft één centrale `## Tokens`-sectie met het tweetoken-model
(werkstation `write:*`, Docker-host `read:*`) plus de betekenis van een 401, (werkstation `write:*`, Docker-host `read:*`) plus de betekenis van een 401,
+112 -21
View File
@@ -10,7 +10,7 @@ Eigen vermogen · Schulden · Voortgang · Gebruikersbeheer
![Node.js](https://img.shields.io/badge/Node.js_22-339933?style=flat&logo=node.js&logoColor=white) ![Node.js](https://img.shields.io/badge/Node.js_22-339933?style=flat&logo=node.js&logoColor=white)
![PostgreSQL](https://img.shields.io/badge/PostgreSQL_18-4169E1?style=flat&logo=postgresql&logoColor=white) ![PostgreSQL](https://img.shields.io/badge/PostgreSQL_18-4169E1?style=flat&logo=postgresql&logoColor=white)
![Docker](https://img.shields.io/badge/Docker_Compose-2496ED?style=flat&logo=docker&logoColor=white) ![Docker](https://img.shields.io/badge/Docker_Compose-2496ED?style=flat&logo=docker&logoColor=white)
![Version](https://img.shields.io/badge/versie-0.1.9-8b5cf6?style=flat) ![Version](https://img.shields.io/badge/versie-1.0.9-8b5cf6?style=flat)
![Built with Claude](https://img.shields.io/badge/Gebouwd_met-Claude.ai-8b5cf6?style=flat&logo=anthropic&logoColor=white) ![Built with Claude](https://img.shields.io/badge/Gebouwd_met-Claude.ai-8b5cf6?style=flat&logo=anthropic&logoColor=white)
<img src="docs/screenshot-dashboard.png" alt="VibeFinance dashboard" width="820"> <img src="docs/screenshot-dashboard.png" alt="VibeFinance dashboard" width="820">
@@ -32,7 +32,25 @@ Deze applicatie is volledig ontworpen en ontwikkeld met behulp van [Claude.ai](h
De app draait in **2 containers**: `app` (Express API + gebouwde frontend) en De app draait in **2 containers**: `app` (Express API + gebouwde frontend) en
`postgres` (database). `postgres` (database).
**`.env`** — kopieer `.env.example` en vul in: **1. De bestanden op de server zetten.** `docker compose` leest
`docker-compose.yml` uit de map waarin je het commando draait, dus dat bestand
moet er eerst staan. De organisatie in Gitea is publiek, dus dit kan anoniem:
```bash
git clone https://vibehoogie.duckdns.org/Vibe/VibeFinance.git
cd VibeFinance
```
Wil je de repo niet op je server, dan zijn deze twee bestanden genoeg:
```bash
mkdir -p vibefinance && cd vibefinance
base=https://vibehoogie.duckdns.org/Vibe/VibeFinance/raw/branch/main
curl -fsSLO $base/docker-compose.yml
curl -fsSL $base/.env.example -o .env.example
```
**2. `.env` aanmaken** — kopieer `.env.example` en vul in:
```env ```env
POSTGRES_USER=vibefinance POSTGRES_USER=vibefinance
@@ -46,15 +64,26 @@ APP_PORT=3400
openssl rand -hex 64 openssl rand -hex 64
``` ```
**3. Starten:**
```bash ```bash
cp .env.example .env # en vul de waarden in cp .env.example .env # en vul de waarden in
# Het app-image staat in een privé-registry, dus eerst inloggen:
docker login vibehoogie.duckdns.org # wachtwoord = Gitea-token met read:package
docker compose pull && docker compose up -d docker compose pull && docker compose up -d
``` ```
Twee foutmeldingen en wat ze betekenen:
- **`no configuration file provided: not found`** — je staat in een map zonder
`docker-compose.yml`. Controleer met `ls docker-compose.yml`, of geef het
bestand expliciet mee: `docker compose -f /pad/naar/docker-compose.yml up -d`.
- **`401 Unauthorized`** of **"pull access denied"** — de organisatie staat niet
meer op publiek. Dan alsnog inloggen:
```bash
docker login vibehoogie.duckdns.org # wachtwoord = Gitea-token met read:package
```
Navigeer naar `http://<server-ip>:3400` en registreer je account. De **eerste** geregistreerde gebruiker krijgt automatisch de Admin-rol. Navigeer naar `http://<server-ip>:3400` en registreer je account. De **eerste** geregistreerde gebruiker krijgt automatisch de Admin-rol.
De container luistert intern op poort 3001 en serveert daar zowel de frontend De container luistert intern op poort 3001 en serveert daar zowel de frontend
@@ -116,24 +145,69 @@ backend/package.json (15 dependencies)
```powershell ```powershell
# token: staat in .env.release, of eenmalig in de shell # token: staat in .env.release, of eenmalig in de shell
$env:GITEA_TOKEN = "<token met write:repository>" # één Gitea-token met write:package (push) én write:repository (release)
$env:GITEA_TOKEN = "<token met write:package + write:repository>"
.\release.ps1 .\release.ps1
``` ```
Draait de tests, bouwt het app-image voor `amd64` + `arm64` (Raspberry Pi), Draait de tests, bouwt het app-image voor `amd64` + `arm64` (Raspberry Pi),
pusht naar de registry, zet de Git-tag en publiceert de Gitea-release. pusht naar de registry, zet de Git-tag en publiceert de Gitea-release.
Het script regelt ook het bijwerken eromheen: de changelog-kop
`## [Niet vrijgegeven]` wordt `## [x.x.x] - datum`, de versie in
`frontend/package.json` en de badge hierboven gaan mee, en ná het publiceren
zet het de root `package.json` op `x.x.(x+1)-dev` voor de volgende cyclus.
Beschrijft de changelog niets over deze versie, dan stopt het script vóór de
build — anders zou de release met een placeholder als notes online komen.
--- ---
## 🗂️ Functionaliteit ## 🗂️ Functionaliteit
| Scherm | Wat het doet | | 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 | | 💰 **Eigen vermogen** | Vermogensposten per categorie beheren + geïnvesteerde fiat historiek |
| 📉 **Schulden** | Schulden bijhouden per blok met afbetalingsoverzicht | | 📉 **Schuld** | Schulden bijhouden per blok met afbetalingsoverzicht |
| 📈 **Voortgang** | Grafieken en trends van EV en schuld over tijd (apart bij te houden) | | 📈 **Voortgang Schuld & EV** | Grafieken en trends van EV en schuld over tijd (apart bij te houden) |
| 👥 **Gebruikers** | Accounts aanmaken, bewerken en beheren (Admin-rol vereist) | | 👥 **Gebruikers** | Accounts aanmaken, bewerken en beheren (Admin-rol vereist) |
| ⚙️ **Instellingen** | Kleurthema, licht/donker, backup downloaden en backup terugzetten |
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.
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
bewaard; dubbelklik op de greep voor de standaardbreedte.
Backup en restore lopen via **Instellingen** in de navigatiebalk: "Backup maken"
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`.
--- ---
@@ -152,26 +226,34 @@ vibefinance/
├── release.ps1 / release.sh # Release (test, push, tag, Gitea-release) ├── release.ps1 / release.sh # Release (test, push, tag, Gitea-release)
│ │
├── docker/ ├── docker/
│ └── dev-entrypoint.sh # Start API + Vite samen in de dev-container │ ├── dev-entrypoint.sh # Start API + Vite samen in de dev-container
│ ├── check-updates.mjs # Dependency-check tegen het npm-register
│ └── update-exceptions.json # Pakketten die bewust achterblijven, met reden
│ │
├── docs/ ├── docs/
│ └── screenshot-dashboard.png # Screenshot bovenaan deze README │ └── screenshot-dashboard.png # Screenshot bovenaan deze README
│ │
├── frontend/ # React / Vite SPA (build → dist) ├── frontend/ # React / Vite SPA (build → dist)
│ ├── vite.config.js # /api proxy naar de API in dezelfde container │ ├── vite.config.js # /api proxy naar de API in dezelfde container
│ ├── index.html # Vite-entry, verwijst naar /favicon.svg
│ ├── public/ # Ongebundeld: favicon.svg + icons/
│ └── src/ │ └── src/
│ ├── App.jsx # Root – wiring + idle-logout │ ├── App.jsx # Root – wiring + idle-logout
│ ├── App.css # Reset + losse klassen (o.a. .kolom-grip)
│ ├── main.jsx │ ├── main.jsx
│ ├── version.js # Draaiende versie (__APP_VERSION__ uit de build) │ ├── version.js # Draaiende versie (__APP_VERSION__ uit de build)
│ ├── constants/ # Kleuren, tabs, begindata │ ├── constants/ # index.js (kleuren, tabs, begindata), themas.js
│ ├── utils/ # Formatters, helpers │ ├── utils/ # format.js (bedragen/datums), storage.js,
│ ├── hooks/ # useTheme, useUpdateCheck │ │ # grafiek.js (SVG-padberekening)
│ ├── context/ # AppContext (global state + API sync) │ ├── hooks/ # useTheme, useUpdateCheck, useKolomBreedtes
│ ├── context/ # AppContext (global state + API sync + backup)
│ ├── components/ │ ├── components/
│ │ ├── ui/ # Primitieve UI (VCard, DonutChart…) │ │ ├── ui/ # Primitieve UI (VCard, DonutChart…)
│ │ ├── LoginPage.jsx │ │ ├── LoginPage.jsx
│ │ ├── LoginBackground.jsx
│ │ ├── NavBar.jsx │ │ ├── NavBar.jsx
│ │ ├── ProfielPopup.jsx │ │ ├── ProfielPopup.jsx
│ │ ├── InstellingenModal.jsx # Thema + backup/restore
│ │ └── GebruikersBeheer.jsx │ │ └── GebruikersBeheer.jsx
│ └── pages/ │ └── pages/
│ ├── DashboardTab.jsx │ ├── DashboardTab.jsx
@@ -184,10 +266,11 @@ vibefinance/
└── src/ └── src/
├── server.js # Entry point: API op /api, SPA op / ├── server.js # Entry point: API op /api, SPA op /
├── db.js # PostgreSQL pool + initDb() schema-migraties ├── db.js # PostgreSQL pool + initDb() schema-migraties
├── logger.js # pino (pino-pretty buiten productie)
├── config/ # Omgevingsvariabelen (incl. STATIC_DIR) ├── config/ # Omgevingsvariabelen (incl. STATIC_DIR)
├── constants/ # Begindata (initialData.js) ├── constants/ # Begindata (initialData.js)
├── middleware/ # auth.js, errorHandler.js ├── middleware/ # auth.js, errorHandler.js
├── routes/ # auth, data, users, health, update-check ├── routes/ # auth, data, users, health, update
└── stores/ # userStore, dataStore └── stores/ # userStore, dataStore
``` ```
@@ -203,9 +286,11 @@ frontend en API.
| Verzoek | Wat de app doet | | Verzoek | Wat de app doet |
|---|---| |---|---|
| `/api/...` | REST API (rate limited) | | `/api/...` | REST API (rate limited, `Cache-Control: no-store`) |
| `/assets/...` | Statische build, 1 jaar `immutable` gecached | | `/assets/...` | Gehashte build-bestanden, 1 jaar `immutable` gecached |
| al het overige | `index.html` — fallback voor React-routing | | `/favicon.svg`, `/icons/...` | Vaste namen, dus `max-age=0, must-revalidate` |
| pad mét extensie, geen bestand | echte 404 (geen HTML waar een icoon wordt verwacht) |
| al het overige | `index.html` — fallback voor React-routing, niet gecached |
--- ---
@@ -253,13 +338,14 @@ Het schema wordt bij het starten van de app aangemaakt/bijgewerkt via
| `JWT_SECRET` | — | **Verplicht** — minimaal 64 tekens | | `JWT_SECRET` | — | **Verplicht** — minimaal 64 tekens |
| `POSTGRES_USER` | `vibefinance` | Database gebruikersnaam | | `POSTGRES_USER` | `vibefinance` | Database gebruikersnaam |
| `POSTGRES_DB` | `vibefinance` | Database naam | | `POSTGRES_DB` | `vibefinance` | Database naam |
| `DATABASE_URL` | — | Connectiestring; `docker-compose.yml` stelt hem samen uit de drie `POSTGRES_*` waarden, dus zelf zetten hoeft alleen buiten Compose |
| `JWT_EXPIRES` | `7d` | Geldigheidsduur token | | `JWT_EXPIRES` | `7d` | Geldigheidsduur token |
| `CORS_ORIGIN` | `http://localhost` | Externe domeinnaam (één origin, dus zelden nodig) | | `CORS_ORIGIN` | `http://localhost` | Externe domeinnaam (één origin, dus zelden nodig) |
| `APP_PORT` | `3400` | Poort op de host (container luistert op 3001) | | `APP_PORT` | `3400` | Poort op de host (container luistert op 3001) |
| `PORT` | `3001` | Poort binnen de container | | `PORT` | `3001` | Poort binnen de container |
| `STATIC_DIR` | `/app/public` | Map met de frontend-build | | `STATIC_DIR` | `/app/public` | Map met de frontend-build |
| `UPDATE_CHECK_URL` | Gitea releases-API | Bron update-melding (leeg = uit) | | `UPDATE_CHECK_URL` | Gitea releases-API | Bron update-melding (leeg = uit) |
| `UPDATE_CHECK_TOKEN` | — | Nodig zolang de Gitea-repo privé is; scope `read:repository` | | `UPDATE_CHECK_TOKEN` | — | Optioneel: de Gitea-repo is publiek, dus de check werkt anoniem. Alleen nodig als de organisatie weer privé wordt; scope `read:repository` |
--- ---
@@ -267,6 +353,10 @@ Het schema wordt bij het starten van de app aangemaakt/bijgewerkt via
- Wachtwoorden gehasht met bcrypt (cost factor 12) - Wachtwoorden gehasht met bcrypt (cost factor 12)
- JWT authenticatie met instelbare vervaltijd - JWT authenticatie met instelbare vervaltijd
- Data is per gebruiker: `/api/data` leest en schrijft alleen de rij van de
ingelogde gebruiker (`sub` uit het token)
- Van de rollen wordt alleen **Admin** afgedwongen, op `/api/users` — zie de
sectie Rollen in `BEHEER.md`
- Frontend en API op één origin — geen interne proxy, geen CORS-uitzonderingen - Frontend en API op één origin — geen interne proxy, geen CORS-uitzonderingen
- Content-Security-Policy via Helmet, toegespitst op wat de app laadt - Content-Security-Policy via Helmet, toegespitst op wat de app laadt
- Database bereikbaar alleen binnen het interne Docker netwerk - Database bereikbaar alleen binnen het interne Docker netwerk
@@ -289,8 +379,9 @@ Het schema wordt bij het starten van de app aangemaakt/bijgewerkt via
## 🛠️ Beheer ## 🛠️ Beheer
Zie `BEHEER.md` voor commando's voor lokale ontwikkeling, releases, logs en Zie `BEHEER.md` voor commando's voor lokale ontwikkeling, releases, logs en
database-toegang — en voor de sectie **Tokens**: welk Gitea-token op welke database-toegang — en voor de sectie **Tokens**: waarom één Gitea-token op het
machine hoort, met welke scopes, en wat een 401, 403 of 404 van Gitea betekent. werkstation genoeg is, welke scopes het nodig heeft, en wat een 401, 403 of 404
van Gitea betekent.
--- ---
+11
View File
@@ -56,6 +56,17 @@ if (config.NODE_ENV !== "test") {
app.use(pinoHttp({ logger })); app.use(pinoHttp({ logger }));
} }
// ── Geen browsercache op de API ───────────────────────────────────────────────
// Zonder Cache-Control mag de browser zelf bepalen hoe lang hij een antwoord
// hergebruikt (heuristische caching). Dat verschilt per browser en profiel: de
// update-melding bleef in de ene browser op een oud antwoord hangen terwijl een
// verse browser het goede kreeg. API-antwoorden zijn per gebruiker en per moment,
// dus die horen nooit uit de cache te komen.
app.use("/api", (_req, res, next) => {
res.setHeader("Cache-Control", "no-store");
next();
});
// ── API-routes ──────────────────────────────────────────────────────────────── // ── API-routes ────────────────────────────────────────────────────────────────
app.use("/api/health", healthRouter); app.use("/api/health", healthRouter);
app.use("/api/update-check", updateRouter); app.use("/api/update-check", updateRouter);
+9
View File
@@ -68,6 +68,15 @@ describe("API", () => {
assert.equal(body.latest, null); assert.equal(body.latest, null);
assert.equal(body.disabled, true); assert.equal(body.disabled, true);
}); });
// Zonder expliciete header bepaalt de browser zelf hoe lang hij een
// API-antwoord hergebruikt, en dat verschilt per browser.
it("API-antwoorden komen nooit uit de browsercache", async () => {
for (const pad of ["/api/health", "/api/update-check", "/api/bestaat-niet"]) {
const res = await fetch(`${basis}${pad}`);
assert.match(res.headers.get("cache-control") ?? "", /no-store/, pad);
}
});
}); });
describe("Frontend serveren", () => { describe("Frontend serveren", () => {
+5
View File
@@ -49,6 +49,11 @@ services:
environment: environment:
NODE_ENV: development NODE_ENV: development
PORT: 3001 PORT: 3001
# Vite bakt de versie in bij het STARTEN van de dev-server, dus die moet
# in de runtime-omgeving staan; de build-arg hierboven bereikt alleen de
# frontend-build-stage en doet niets voor target: development.
# Leeg = vite.config.js valt terug op frontend/package.json.
VITE_APP_VERSION: ${VITE_APP_VERSION:-}
JWT_SECRET: dev-secret-change-in-production JWT_SECRET: dev-secret-change-in-production
JWT_EXPIRES: 7d JWT_EXPIRES: 7d
CORS_ORIGIN: http://localhost CORS_ORIGIN: http://localhost
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "vibefinance-frontend", "name": "vibefinance-frontend",
"version": "1.0.0", "version": "1.0.10-dev",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

+34
View File
@@ -26,3 +26,37 @@ input[type="number"]::-webkit-inner-spin-button {
@keyframes spin { @keyframes spin {
to { transform: rotate(360deg); } to { transform: rotate(360deg); }
} }
/* Sleepvlak op de rechterrand van een tabelkop — zie hooks/useKolomBreedtes.js.
Blijft binnen de cel (geen negatieve offset), anders duwt de laatste kolom
een horizontale scrollbar tevoorschijn. */
.kolom-grip {
position: absolute;
top: 0;
right: 0;
width: 6px;
height: 100%;
cursor: col-resize;
user-select: none;
touch-action: none;
}
/* Het streepje erft de kleur van de kop, dus per categorie de accentkleur. */
.kolom-grip::after {
content: "";
position: absolute;
top: 25%;
right: 2px;
width: 1px;
height: 50%;
background: currentColor;
opacity: 0.3;
}
.kolom-grip:hover::after,
.kolom-grip:active::after {
top: 0;
height: 100%;
width: 2px;
opacity: 1;
}
+10 -14
View File
@@ -8,7 +8,7 @@ import DashboardTab from "./pages/DashboardTab.jsx";
import EigenVermogenTab from "./pages/EigenVermogenTab.jsx"; import EigenVermogenTab from "./pages/EigenVermogenTab.jsx";
import SchuldTab from "./pages/SchuldTab.jsx"; import SchuldTab from "./pages/SchuldTab.jsx";
import VoortgangTab from "./pages/VoortgangTab.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 { fmt } from "./utils/format.js";
import { version } from "./version.js"; import { version } from "./version.js";
import { useUpdateCheck } from "./hooks/useUpdateCheck.js"; import { useUpdateCheck } from "./hooks/useUpdateCheck.js";
@@ -86,10 +86,10 @@ function AppInner() {
</div> </div>
<button onClick={() => { setSessionVerlopen(false); }} style={{ <button onClick={() => { setSessionVerlopen(false); }} style={{
width: "100%", padding: "14px", width: "100%", padding: "14px",
background: "linear-gradient(135deg, #8b5cf6, #a855f7)", background: T.accentGradient,
border: "none", borderRadius: 12, border: "none", borderRadius: 12,
color: "#fff", fontWeight: 700, fontSize: 15, cursor: "pointer", color: "#fff", fontWeight: 700, fontSize: 15, cursor: "pointer",
boxShadow: "0 4px 16px rgba(139,92,246,0.4)", boxShadow: T.cardShadow,
}}> }}>
Opnieuw inloggen Opnieuw inloggen
</button> </button>
@@ -110,8 +110,8 @@ function AppInner() {
}}> }}>
<div style={{ <div style={{
width: 40, height: 40, borderRadius: "50%", width: 40, height: 40, borderRadius: "50%",
border: `3px solid rgba(139,92,246,0.2)`, border: `3px solid ${T.border}`,
borderTop: `3px solid ${PURPLE_LIGHT}`, borderTop: `3px solid ${T.accentTekst}`,
animation: "spin 0.8s linear infinite", animation: "spin 0.8s linear infinite",
}} /> }} />
<style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style> <style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
@@ -209,14 +209,10 @@ function AppInner() {
<div style={{ fontSize: 12, color: T.muted, lineHeight: 1.5 }}> <div style={{ fontSize: 12, color: T.muted, lineHeight: 1.5 }}>
v{nieuweVersie.latest} is uitgebracht. Je gebruikt momenteel v{version}. v{nieuweVersie.latest} is uitgebracht. Je gebruikt momenteel v{version}.
</div> </div>
{nieuweVersie.notes && ( {/* De release-notes zelf staan hier niet: dat is Markdown
<div style={{ uit de changelog, en die kwam als ruwe `### `-koppen in
fontSize: 11, color: T.subtext, marginTop: 6, lineHeight: 1.5, een smalle kolom te staan. Wie de details wil, volgt de
maxHeight: 130, overflowY: "auto", whiteSpace: "pre-wrap", link naar de release. */}
}}>
{nieuweVersie.notes}
</div>
)}
{nieuweVersie.url && ( {nieuweVersie.url && (
<a href={nieuweVersie.url} target="_blank" rel="noreferrer" style={{ <a href={nieuweVersie.url} target="_blank" rel="noreferrer" style={{
display: "inline-block", marginTop: 8, display: "inline-block", marginTop: 8,
@@ -246,7 +242,7 @@ function AppInner() {
> >
<div style={{ <div style={{
width: 36, height: 36, borderRadius: "50%", overflow: "hidden", flexShrink: 0, 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", display: "flex", alignItems: "center", justifyContent: "center",
fontSize: 12, fontWeight: 800, color: "#fff", fontSize: 12, fontWeight: 800, color: "#fff",
border: `2px solid ${T.border}`, border: `2px solid ${T.border}`,
+20 -24
View File
@@ -1,10 +1,6 @@
import { useState, useEffect, useRef } from "react"; import { useState, useEffect, useRef } from "react";
import { RED, GREEN } from "../constants/index.js"; 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"; import { useApp } from "../context/AppContext.jsx";
const getToken = () => localStorage.getItem("vf_token"); const getToken = () => localStorage.getItem("vf_token");
@@ -56,11 +52,11 @@ function RolSelect({ value, onChange, opties, T, darkMode }) {
{opties.map((o) => ( {opties.map((o) => (
<div key={o} onClick={() => { onChange(o); setOpen(false); }} style={{ <div key={o} onClick={() => { onChange(o); setOpen(false); }} style={{
padding: "9px 12px", fontSize: 13, cursor: "pointer", padding: "9px 12px", fontSize: 13, cursor: "pointer",
color: o === value ? TEAL_COLOR : T.text, color: o === value ? T.accentTekst : T.text,
background: o === value ? TEAL_BG : "transparent", background: o === value ? T.inputBg : "transparent",
}} }}
onMouseEnter={(e) => e.currentTarget.style.background = "rgba(14,221,212,0.10)"} 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} {o}
</div> </div>
@@ -197,8 +193,8 @@ export default function GebruikersBeheer({ onClose }) {
}}> }}>
<div> <div>
<span style={{ fontWeight: 700, fontSize: 15, color: T.text }}>Gebruikersbeheer</span> <span style={{ fontWeight: 700, fontSize: 15, color: T.text }}>Gebruikersbeheer</span>
<span style={{ marginLeft: 10, fontSize: 11, fontWeight: 600, color: TEAL_COLOR, <span style={{ marginLeft: 10, fontSize: 11, fontWeight: 600, color: T.accentTekst,
background: TEAL_BG, borderRadius: 99, padding: "2px 8px", border: `1px solid ${TEAL_BORDER}`, background: T.inputBg, borderRadius: 99, padding: "2px 8px", border: `1px solid ${T.inputBorder}`,
}}> }}>
{users.length} gebruiker{users.length !== 1 ? "s" : ""} {users.length} gebruiker{users.length !== 1 ? "s" : ""}
</span> </span>
@@ -236,18 +232,18 @@ export default function GebruikersBeheer({ onClose }) {
<div style={{ display: "flex", alignItems: "center", gap: 10 }}> <div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<div style={{ <div style={{
width: 32, height: 32, borderRadius: "50%", width: 32, height: 32, borderRadius: "50%",
background: TEAL_BG, display: "flex", background: T.inputBg, display: "flex",
alignItems: "center", justifyContent: "center", alignItems: "center", justifyContent: "center",
fontSize: 12, fontWeight: 700, color: TEAL_COLOR, flexShrink: 0, fontSize: 12, fontWeight: 700, color: T.accentTekst, flexShrink: 0,
}}> }}>
{initials} {initials}
</div> </div>
<span style={{ fontWeight: 600 }}>{u.naam}</span> <span style={{ fontWeight: 600 }}>{u.naam}</span>
{isSelf && ( {isSelf && (
<span style={{ <span style={{
fontSize: 10, fontWeight: 700, color: TEAL_COLOR, fontSize: 10, fontWeight: 700, color: T.accentTekst,
background: TEAL_BG, borderRadius: 99, background: T.inputBg, borderRadius: 99,
padding: "1px 7px", border: `1px solid ${TEAL_BORDER}`, padding: "1px 7px", border: `1px solid ${T.inputBorder}`,
}}>jij</span> }}>jij</span>
)} )}
</div> </div>
@@ -263,9 +259,9 @@ export default function GebruikersBeheer({ onClose }) {
<td style={{ ...td, whiteSpace: "nowrap" }}> <td style={{ ...td, whiteSpace: "nowrap" }}>
<div style={{ display: "flex", gap: 6, alignItems: "center" }}> <div style={{ display: "flex", gap: 6, alignItems: "center" }}>
<button onClick={() => openEdit(u)} style={{ <button onClick={() => openEdit(u)} style={{
padding: "3px 8px", background: TEAL_BG, padding: "3px 8px", background: T.inputBg,
border: `1px solid ${TEAL_BORDER}`, borderRadius: 6, border: `1px solid ${T.inputBorder}`, borderRadius: 6,
color: TEAL_COLOR, cursor: "pointer", fontSize: 12, color: T.accentTekst, cursor: "pointer", fontSize: 12,
}}>✏️</button> }}>✏️</button>
{!isSelf && ( {!isSelf && (
<> <>
@@ -303,10 +299,10 @@ export default function GebruikersBeheer({ onClose }) {
{/* Bewerken modal */} {/* Bewerken modal */}
{editUser && ( {editUser && (
<div style={{ <div style={{
background: darkMode ? "rgba(124,58,237,0.06)" : "#f5f0ff", background: darkMode ? "rgba(124,58,237,0.06)" : T.inputBg,
border: `1px solid ${TEAL_BORDER}`, borderRadius: 10, padding: "16px", marginBottom: 16, 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 {editUser.naam} bewerken
</div> </div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10, marginBottom: 10 }}> <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10, marginBottom: 10 }}>
@@ -322,7 +318,7 @@ export default function GebruikersBeheer({ onClose }) {
}}>Annuleren</button> }}>Annuleren</button>
<button onClick={saveEdit} disabled={saving} style={{ <button onClick={saveEdit} disabled={saving} style={{
flex: 2, padding: "8px", flex: 2, padding: "8px",
background: TEAL, background: T.accentGradient,
border: "none", borderRadius: 8, color: "#fff", border: "none", borderRadius: 8, color: "#fff",
fontWeight: 700, fontSize: 13, cursor: saving ? "not-allowed" : "pointer", fontWeight: 700, fontSize: 13, cursor: saving ? "not-allowed" : "pointer",
}}>{saving ? "Opslaan..." : "Opslaan"}</button> }}>{saving ? "Opslaan..." : "Opslaan"}</button>
@@ -333,7 +329,7 @@ export default function GebruikersBeheer({ onClose }) {
{/* Nieuwe gebruiker */} {/* Nieuwe gebruiker */}
{showAdd ? ( {showAdd ? (
<div style={{ <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", 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 }}> <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> }}>Annuleren</button>
<button onClick={addUser} disabled={saving} style={{ <button onClick={addUser} disabled={saving} style={{
flex: 2, padding: "8px", flex: 2, padding: "8px",
background: TEAL, background: T.accentGradient,
border: "none", borderRadius: 8, color: "#fff", border: "none", borderRadius: 8, color: "#fff",
fontWeight: 700, fontSize: 13, cursor: saving ? "not-allowed" : "pointer", fontWeight: 700, fontSize: 13, cursor: saving ? "not-allowed" : "pointer",
}}> }}>
@@ -364,7 +360,7 @@ export default function GebruikersBeheer({ onClose }) {
) : ( ) : (
<button onClick={() => setShowAdd(true)} style={{ <button onClick={() => setShowAdd(true)} style={{
padding: "8px 18px", padding: "8px 18px",
background: TEAL, background: T.accentGradient,
border: "none", borderRadius: 8, color: "#fff", border: "none", borderRadius: 8, color: "#fff",
fontWeight: 700, fontSize: 13, cursor: "pointer", fontWeight: 700, fontSize: 13, cursor: "pointer",
}}>+ Nieuwe gebruiker</button> }}>+ Nieuwe gebruiker</button>
+47 -6
View File
@@ -1,6 +1,5 @@
import { useApp } from "../context/AppContext.jsx"; import { useApp } from "../context/AppContext.jsx";
import { THEMAS } from "../constants/themas.js";
const TEAL = "linear-gradient(135deg, #7c3aed, #a855f7)";
const BACKUP_ICON = ( const BACKUP_ICON = (
<svg width="22" height="22" viewBox="0 0 24 24" fill="none"> <svg width="22" height="22" viewBox="0 0 24 24" fill="none">
@@ -35,7 +34,7 @@ function ActionRow({ icon, title, description, onClick }) {
} }
export default function InstellingenModal({ onClose }) { export default function InstellingenModal({ onClose }) {
const { T, darkMode, setDarkMode, doBackup, doRestore } = useApp(); const { T, darkMode, setDarkMode, thema, setThema, doBackup, doRestore } = useApp();
return ( return (
<div <div
@@ -70,21 +69,63 @@ export default function InstellingenModal({ onClose }) {
padding: "4px 0 16px", padding: "4px 0 16px",
}}> }}>
<span style={{ fontWeight: 600, fontSize: 14 }}>Dark mode</span> <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)} onClick={() => setDarkMode((d) => !d)}
style={{ style={{
width: 44, height: 24, borderRadius: 12, cursor: "pointer", 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, position: "relative", transition: "background 0.2s", flexShrink: 0,
}} }}
> >
<div style={{ <span style={{
position: "absolute", top: 3, borderRadius: "50%", position: "absolute", top: 3, borderRadius: "50%",
width: 18, height: 18, background: "#fff", width: 18, height: 18, background: "#fff",
left: darkMode ? 23 : 3, left: darkMode ? 23 : 3,
transition: "left 0.2s", transition: "left 0.2s",
boxShadow: "0 1px 4px rgba(0,0,0,0.3)", 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>
</div> </div>
+15 -18
View File
@@ -1,5 +1,5 @@
import { useState } from "react"; import { useState } from "react";
import { TABS } from "../constants/index.js"; import { TABS, RED } from "../constants/index.js";
import { version } from "../version.js"; import { version } from "../version.js";
import { useUpdateCheck } from "../hooks/useUpdateCheck.js"; import { useUpdateCheck } from "../hooks/useUpdateCheck.js";
import { useApp } from "../context/AppContext.jsx"; 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 }) { function NavItem({ active, onClick, icon, label, T, danger }) {
const [hovered, setHovered] = useState(false); const [hovered, setHovered] = useState(false);
const isLit = active || hovered; const isLit = active || hovered;
@@ -98,12 +95,12 @@ function NavItem({ active, onClick, icon, label, T, danger }) {
display: "flex", alignItems: "center", gap: 10, display: "flex", alignItems: "center", gap: 10,
padding: "11px 12px", borderRadius: 9, cursor: "pointer", marginBottom: 4, padding: "11px 12px", borderRadius: 9, cursor: "pointer", marginBottom: 4,
background: active background: active
? ACTIVE_BG ? T.accent
: hovered ? "rgba(139,92,246,0.08)" : "transparent", : hovered ? T.inputBg : "transparent",
color: active ? "#fff" : hovered ? T.text : (danger ? "#ef4444" : T.muted), color: active ? "#fff" : hovered ? T.text : (danger ? RED : T.muted),
fontWeight: active ? 600 : 400, fontWeight: active ? 600 : 400,
fontSize: 15, fontSize: 15,
boxShadow: active ? ACTIVE_SHADOW : "none", boxShadow: active ? T.cardShadow : "none",
transition: "all 0.15s", transition: "all 0.15s",
}} }}
> >
@@ -141,12 +138,12 @@ function OverPopup({ onClose, nieuweVersie, T }) {
<div style={{ display: "flex", alignItems: "center", gap: 12, marginBottom: 20 }}> <div style={{ display: "flex", alignItems: "center", gap: 12, marginBottom: 20 }}>
<div style={{ <div style={{
width: 44, height: 44, borderRadius: 12, flexShrink: 0, 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", display: "flex", alignItems: "center", justifyContent: "center",
}}> }}>
<svg width="22" height="22" viewBox="0 0 20 20" fill="none"> <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"/> <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="rgba(168,85,247,0.35)" strokeWidth="1.4"/> <line x1="2" y1="17" x2="18" y2="17" stroke={T.accentTekst} strokeOpacity="0.35" strokeWidth="1.4"/>
</svg> </svg>
</div> </div>
<div> <div>
@@ -158,14 +155,14 @@ function OverPopup({ onClose, nieuweVersie, T }) {
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 10 }}> <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 10 }}>
<span style={{ fontSize: 13, color: T.muted }}>Versie</span> <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} v{version}
</span> </span>
</div> </div>
{nieuweVersie && ( {nieuweVersie && (
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 10 }}> <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 10 }}>
<span style={{ fontSize: 13, color: T.muted }}>Nieuwste versie</span> <span style={{ fontSize: 13, color: T.muted }}>Nieuwe versie</span>
<span style={{ fontSize: 12, fontWeight: 700, color: "#f97316", background: "rgba(249,115,22,0.12)", borderRadius: 6, padding: "3px 10px" }}> <span style={{ fontSize: 12, fontWeight: 700, color: "#f97316", background: "rgba(249,115,22,0.12)", borderRadius: 6, padding: "3px 10px" }}>
v{nieuweVersie.latest} v{nieuweVersie.latest}
</span> </span>
@@ -198,7 +195,7 @@ function DarkModeToggle({ darkMode, setDarkMode, T }) {
{/* Toggle pill */} {/* Toggle pill */}
<div style={{ <div style={{
width: 36, height: 20, borderRadius: 99, flexShrink: 0, 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", position: "relative", transition: "background 0.2s",
}}> }}>
<div style={{ <div style={{
@@ -231,16 +228,16 @@ export default function NavBar() {
<div style={{ padding: "22px 16px 28px", display: "flex", alignItems: "center", gap: 10 }}> <div style={{ padding: "22px 16px 28px", display: "flex", alignItems: "center", gap: 10 }}>
<div style={{ <div style={{
width: 32, height: 32, flexShrink: 0, borderRadius: 9, width: 32, height: 32, flexShrink: 0, borderRadius: 9,
background: "rgba(124,58,237,0.15)", background: T.inputBg,
border: "1px solid rgba(124,58,237,0.3)", border: `1px solid ${T.inputBorder}`,
display: "flex", alignItems: "center", justifyContent: "center", display: "flex", alignItems: "center", justifyContent: "center",
}}> }}>
<svg width="18" height="18" viewBox="0 0 20 20" fill="none"> <svg width="18" height="18" viewBox="0 0 20 20" fill="none">
<polyline points="2,15 6,9 10,12 14,5 18,8" <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"/> strokeLinejoin="round" strokeLinecap="round"/>
<line x1="2" y1="17" x2="18" y2="17" <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> </svg>
</div> </div>
<span style={{ fontSize: 17, fontWeight: 700, color: T.text, letterSpacing: "-0.3px" }}>VibeFinance</span> <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 { useApp } from "../context/AppContext.jsx";
import { RED } from "../constants/index.js"; 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 = "") { function initials(naam = "") {
const parts = naam.trim().split(/\s+/); 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={{ display: "flex", flexDirection: "column", alignItems: "center", padding: "24px 18px 20px", gap: 6 }}>
<div style={{ <div style={{
width: 72, height: 72, borderRadius: "50%", overflow: "hidden", width: 72, height: 72, borderRadius: "50%", overflow: "hidden",
background: avatar ? "transparent" : TEAL, background: avatar ? "transparent" : T.accentGradient,
display: "flex", alignItems: "center", justifyContent: "center", display: "flex", alignItems: "center", justifyContent: "center",
fontSize: 24, fontWeight: 800, color: "#fff", fontSize: 24, fontWeight: 800, color: "#fff",
boxShadow: "0 0 20px rgba(124,58,237,0.4)", marginBottom: 4, 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> <div style={{ fontSize: 13, color: T.muted }}>{currentUser?.email}</div>
<button onClick={() => setView("profiel")} style={{ <button onClick={() => setView("profiel")} style={{
marginTop: 8, padding: "9px 24px", marginTop: 8, padding: "9px 24px",
background: TEAL, background: T.accentGradient,
border: "none", borderRadius: 10, color: "#fff", border: "none", borderRadius: 10, color: "#fff",
fontWeight: 700, fontSize: 13, cursor: "pointer", fontWeight: 700, fontSize: 13, cursor: "pointer",
boxShadow: TEAL_SHADOW, boxShadow: T.cardShadow,
}}>Profiel bewerken</button> }}>Profiel bewerken</button>
</div> </div>
@@ -189,7 +187,7 @@ export default function ProfielPopup({ onClose }) {
<label style={{ cursor: "pointer", position: "relative", display: "block" }} title="Klik om foto te uploaden"> <label style={{ cursor: "pointer", position: "relative", display: "block" }} title="Klik om foto te uploaden">
<div style={{ <div style={{
width: 64, height: 64, borderRadius: "50%", overflow: "hidden", width: 64, height: 64, borderRadius: "50%", overflow: "hidden",
background: avatar ? "transparent" : TEAL, background: avatar ? "transparent" : T.accentGradient,
display: "flex", alignItems: "center", justifyContent: "center", display: "flex", alignItems: "center", justifyContent: "center",
fontSize: 20, fontWeight: 800, color: "#fff", fontSize: 20, fontWeight: 800, color: "#fff",
}}> }}>
@@ -229,7 +227,7 @@ export default function ProfielPopup({ onClose }) {
{profielErr && <div style={{ fontSize: 12, color: RED }}>{profielErr}</div>} {profielErr && <div style={{ fontSize: 12, color: RED }}>{profielErr}</div>}
{profielOk && <div style={{ fontSize: 12, color: "#10b981" }}>Profiel opgeslagen.</div>} {profielOk && <div style={{ fontSize: 12, color: "#10b981" }}>Profiel opgeslagen.</div>}
<button onClick={saveProfiel} disabled={profielBusy} style={{ <button onClick={saveProfiel} disabled={profielBusy} style={{
padding: "11px", background: TEAL, padding: "11px", background: T.accentGradient,
border: "none", borderRadius: 10, color: "#fff", fontWeight: 700, border: "none", borderRadius: 10, color: "#fff", fontWeight: 700,
fontSize: 13, cursor: profielBusy ? "default" : "pointer", opacity: profielBusy ? 0.7 : 1, fontSize: 13, cursor: profielBusy ? "default" : "pointer", opacity: profielBusy ? 0.7 : 1,
}}>{profielBusy ? "Opslaan…" : "Opslaan"}</button> }}>{profielBusy ? "Opslaan…" : "Opslaan"}</button>
@@ -252,7 +250,7 @@ export default function ProfielPopup({ onClose }) {
{pwErr && <div style={{ fontSize: 12, color: RED }}>{pwErr}</div>} {pwErr && <div style={{ fontSize: 12, color: RED }}>{pwErr}</div>}
{pwOk && <div style={{ fontSize: 12, color: "#10b981" }}>Wachtwoord gewijzigd.</div>} {pwOk && <div style={{ fontSize: 12, color: "#10b981" }}>Wachtwoord gewijzigd.</div>}
<button onClick={saveWachtwoord} disabled={pwBusy} style={{ <button onClick={saveWachtwoord} disabled={pwBusy} style={{
padding: "11px", background: TEAL, padding: "11px", background: T.accentGradient,
border: "none", borderRadius: 10, color: "#fff", fontWeight: 700, border: "none", borderRadius: 10, color: "#fff", fontWeight: 700,
fontSize: 13, cursor: pwBusy ? "default" : "pointer", opacity: pwBusy ? 0.7 : 1, fontSize: 13, cursor: pwBusy ? "default" : "pointer", opacity: pwBusy ? 0.7 : 1,
}}>{pwBusy ? "Opslaan…" : "Opslaan"}</button> }}>{pwBusy ? "Opslaan…" : "Opslaan"}</button>
+16 -11
View File
@@ -1,5 +1,5 @@
import { useRef, useState, useCallback, useContext, createContext } from "react"; 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"; import { useApp } from "../../context/AppContext.jsx";
const DragContext = createContext(null); const DragContext = createContext(null);
@@ -18,11 +18,13 @@ export function VCard({ children, style = {} }) {
} }
// ── VBadge ──────────────────────────────────────────────────────────────────── // ── VBadge ────────────────────────────────────────────────────────────────────
export function VBadge({ label, color = PURPLE }) { export function VBadge({ label, color }) {
const { T } = useApp();
const kleur = color ?? T.accentTekst;
return ( return (
<span style={{ <span style={{
background: `${color}22`, color, background: `${kleur}22`, color: kleur,
border: `1px solid ${color}44`, border: `1px solid ${kleur}44`,
borderRadius: 99, padding: "2px 10px", borderRadius: 99, padding: "2px 10px",
fontSize: 11, fontWeight: 700, fontSize: 11, fontWeight: 700,
}}> }}>
@@ -32,16 +34,17 @@ export function VBadge({ label, color = PURPLE }) {
} }
// ── TabBtn ──────────────────────────────────────────────────────────────────── // ── TabBtn ────────────────────────────────────────────────────────────────────
export function TabBtn({ active, onClick, children, darkMode = true }) { export function TabBtn({ active, onClick, children }) {
const { T } = useApp();
return ( return (
<button <button
onClick={onClick} onClick={onClick}
style={{ style={{
padding: "8px 16px", border: "none", borderRadius: 8, cursor: "pointer", padding: "8px 16px", border: "none", borderRadius: 8, cursor: "pointer",
fontSize: 13, fontWeight: active ? 700 : 500, fontSize: 13, fontWeight: active ? 700 : 500,
background: active ? `${PURPLE}22` : "transparent", background: active ? `${T.accent}22` : "transparent",
color: active ? PURPLE_LIGHT : (darkMode ? "#94a3b8" : "#64748b"), color: active ? T.accentTekst : T.muted,
borderBottom: active ? `2px solid ${PURPLE_LIGHT}` : "2px solid transparent", borderBottom: active ? `2px solid ${T.accentTekst}` : "2px solid transparent",
transition: "all 0.15s", transition: "all 0.15s",
}} }}
> >
@@ -51,12 +54,14 @@ export function TabBtn({ active, onClick, children, darkMode = true }) {
} }
// ── VProgressBar ────────────────────────────────────────────────────────────── // ── 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); const p = Math.min((value / (max || 1)) * 100, 100);
return ( return (
<div style={{ height: 8, background: "rgba(255,255,255,0.07)", borderRadius: 99, overflow: "hidden" }}> <div style={{ height: 8, background: "rgba(255,255,255,0.07)", borderRadius: 99, overflow: "hidden" }}>
<div style={{ <div style={{
height: "100%", width: `${p}%`, background: color, height: "100%", width: `${p}%`, background: kleur,
borderRadius: 99, transition: "width 0.5s ease", borderRadius: 99, transition: "width 0.5s ease",
}} /> }} />
</div> </div>
@@ -527,7 +532,7 @@ export function EmptyState({ icon, text, hint, onClick, locked, T }) {
<div style={{ fontSize: 32, marginBottom: 10 }}>{icon}</div> <div style={{ fontSize: 32, marginBottom: 10 }}>{icon}</div>
<div style={{ fontSize: 13, marginBottom: 4 }}>{text}</div> <div style={{ fontSize: 13, marginBottom: 4 }}>{text}</div>
{!locked && hint && ( {!locked && hint && (
<div style={{ fontSize: 12, color: PURPLE_LIGHT }}>{hint}</div> <div style={{ fontSize: 12, color: T.accentTekst }}>{hint}</div>
)} )}
</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);
+20 -3
View File
@@ -1,7 +1,8 @@
import { createContext, useContext, useState, useMemo, useRef } from "react"; import { createContext, useContext, useState, useMemo, useRef } from "react";
import JSZip from "jszip"; import JSZip from "jszip";
import { INITIAL_DATA } from "../constants/index.js"; 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 { useTheme } from "../hooks/useTheme.js"; import { useTheme } from "../hooks/useTheme.js";
const AppContext = createContext(null); const AppContext = createContext(null);
@@ -34,7 +35,22 @@ export function AppProvider({ children }) {
}; };
// ── UI ──────────────────────────────────────────────────────── // ── 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 [locked, setLocked] = useState(false);
const [tab, setTab] = useState(0); const [tab, setTab] = useState(0);
const [menuOpen, setMenuOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false);
@@ -87,7 +103,7 @@ export function AppProvider({ children }) {
const setUsers = (u) => { localStorage.setItem("wbjw_users", JSON.stringify(u)); setUsersRaw(u); }; const setUsers = (u) => { localStorage.setItem("wbjw_users", JSON.stringify(u)); setUsersRaw(u); };
// ── Thema ───────────────────────────────────────────────────── // ── Thema ─────────────────────────────────────────────────────
const T = useTheme(darkMode); const T = useTheme(thema, darkMode);
// ── Afgeleide waarden ───────────────────────────────────────── // ── Afgeleide waarden ─────────────────────────────────────────
const ev = data.eigenVermogen; const ev = data.eigenVermogen;
@@ -199,6 +215,7 @@ export function AppProvider({ children }) {
loggedIn, currentUser, login, logout, loading, updateProfile, changePassword, loggedIn, currentUser, login, logout, loading, updateProfile, changePassword,
avatar, setAvatar, avatar, setAvatar,
darkMode, setDarkMode, darkMode, setDarkMode,
thema, setThema,
locked, setLocked, locked, setLocked,
tab, setTab, tab, setTab,
menuOpen, setMenuOpen, menuOpen, setMenuOpen,
+78
View File
@@ -0,0 +1,78 @@
import { useRef, useState } from "react";
import { loadFromStorage, saveToStorage } from "../utils/storage.js";
// Smaller dan dit wordt een kolom onleesbaar; de greep blijft wel pakbaar.
const MIN_BREEDTE = 60;
/**
* Versleepbare kolombreedtes voor een tabel.
*
* `kolommen` is een array van { id, breedte } in weergave-volgorde, waarbij
* `breedte` de standaard in pixels is. De aanpassingen van de gebruiker gaan
* per tabel naar localStorage, zodat ze een tabwissel of herlaad overleven.
* Kolommen die later verschijnen (een categorie die de gebruiker toevoegt)
* krijgen hun standaardbreedte; opgeslagen breedtes van verdwenen kolommen
* blijven ongebruikt staan en storen niets.
*
* De tabel moet `tableLayout: "fixed"` krijgen met een <colgroup>, en als
* `width` de teruggegeven `totaal`. Met `width: "100%"` verdeelt de browser de
* ruimte namelijk proportioneel en levert slepen niet exact de gesleepte
* pixels op.
*
* Geeft terug:
* breedtes px per kolom-id, standaard plus aanpassing
* totaal som van de breedtes, voor de `width` van de tabel
* grip props voor het sleepvlak in de <th> van die kolom
* herstel alle kolommen terug naar hun standaardbreedte
*/
export function useKolomBreedtes(opslagKey, kolommen) {
const [aangepast, setAangepast] = useState(() => loadFromStorage(opslagKey) ?? {});
// Spiegelt `aangepast` zodat het slepen en het opslaan bij het loslaten niet
// van de state-updater afhangen (die mag geen bijwerkingen hebben).
const laatste = useRef(aangepast);
const sleep = useRef(null);
const breedtes = {};
for (const k of kolommen) breedtes[k.id] = aangepast[k.id] ?? k.breedte;
const totaal = kolommen.reduce((som, k) => som + breedtes[k.id], 0);
const zet = (volgende) => { laatste.current = volgende; setAangepast(volgende); };
const grip = (id) => ({
className: "kolom-grip",
role: "separator",
title: "Sleep om de kolom breder of smaller te maken — dubbelklik voor de standaardbreedte",
onPointerDown: (e) => {
// Pointer capture: het slepen loopt door buiten de greep, ook boven de
// rijen of naast de tabel, zonder losse listeners op window.
e.preventDefault();
e.currentTarget.setPointerCapture(e.pointerId);
sleep.current = { id, startX: e.clientX, startBreedte: breedtes[id] };
},
onPointerMove: (e) => {
if (!sleep.current) return;
const { id: kolomId, startX, startBreedte } = sleep.current;
const nieuw = Math.max(MIN_BREEDTE, Math.round(startBreedte + e.clientX - startX));
if (laatste.current[kolomId] === nieuw) return;
zet({ ...laatste.current, [kolomId]: nieuw });
},
onPointerUp: () => {
if (!sleep.current) return;
sleep.current = null;
// Pas bij het loslaten opslaan; tijdens het slepen zou elke pixel een
// localStorage-write kosten.
saveToStorage(opslagKey, laatste.current);
},
onDoubleClick: () => {
// Zonder destructuring-omit: die laat een ongebruikte variabele achter.
const rest = { ...laatste.current };
delete rest[id];
zet(rest);
saveToStorage(opslagKey, rest);
},
});
const herstel = () => { zet({}); saveToStorage(opslagKey, {}); };
return { breedtes, totaal, grip, herstel };
}
+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. * Geeft een thema-object (T) terug op basis van het gekozen kleurthema en
* Wordt gebruikt door alle componenten om consistent kleuren te krijgen. * 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) { export function useTheme(themaId, darkMode) {
const bg = darkMode ? "#241A39" : "#eef1f8"; const thema = zoekThema(themaId);
const card = darkMode ? "rgba(22,23,27,0.30)" : "#ffffff"; 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 { return {
// Exacte Finex kleuren (dark) / licht alternatief bg: p.bg,
bg, card: p.card,
card,
// Ondoorzichtige achtergrond voor popups/modals: kaartkleur over de pagina-bg. // Ondoorzichtige achtergrond voor popups/modals: kaartkleur over de pagina-bg.
popupBg: `linear-gradient(${card}, ${card}), ${bg}`, popupBg: `linear-gradient(${p.card}, ${p.card}), ${p.bg}`,
sidebar: darkMode ? "#16171B" : "#f8faff", sidebar: p.diep,
border: darkMode ? "rgba(139,92,246,0.15)" : "rgba(0,0,0,0.07)", border: darkMode ? metAlpha(thema.licht, 0.15) : "rgba(0,0,0,0.07)",
muted: darkMode ? "#8b8fa8" : "#94a3b8", muted: p.muted,
text: darkMode ? "#ffffff" : "#0f172a", text: p.tekst,
subtext: darkMode ? "#a0aec0" : "#64748b", subtext: p.sub,
inputBg: darkMode ? "rgba(139,92,246,0.08)" : "#f1f5ff", inputBg: darkMode ? metAlpha(thema.licht, 0.08) : metAlpha(thema.hoofd, 0.05),
inputBorder:darkMode ? "rgba(139,92,246,0.2)" : "#e2e8f0", inputBorder: darkMode ? metAlpha(thema.licht, 0.2) : "#e2e8f0",
contentBg: darkMode ? "#241A39" : "#eef1f8", contentBg: p.bg,
shadow: darkMode ? "0 4px 24px rgba(124,58,237,0.25)" : "0 4px 20px rgba(0,0,0,0.07)", shadow: darkMode ? `0 4px 24px ${metAlpha(thema.hoofd, 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)", cardShadow: darkMode ? `0 4px 20px ${metAlpha(thema.hoofd, 0.2)}` : "0 2px 8px rgba(0,0,0,0.06)",
radius: "14px", radius: "14px",
font: "'Poppins', system-ui, sans-serif", 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 { useState } from "react";
import { useApp } from "../context/AppContext.jsx"; import { useApp } from "../context/AppContext.jsx";
import { fmt, pct, fmtDatum } from "../utils/format.js"; import { fmt, pct, fmtDatum, fmtMaand } from "../utils/format.js";
import { GREEN, RED } from "../constants/index.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"; import { VCard, VBadge } from "../components/ui/index.jsx";
export default function DashboardTab() { export default function DashboardTab() {
@@ -27,9 +28,9 @@ export default function DashboardTab() {
const progressPct = Math.min(totEV / doel, 1); const progressPct = Math.min(totEV / doel, 1);
const milestones = [0, 0.25, 0.5, 0.75, 1].map((f) => f * doel); const milestones = [0, 0.25, 0.5, 0.75, 1].map((f) => f * doel);
const kpis = [ const kpis = [
{ label: "Start eigen vermogen", val: fmt(startEV), accent: TEAL}, { label: "Start eigen vermogen", val: fmt(startEV), accent: T.accentTekst},
{ label: "Extra geïnvesteerde fiat", val: fmt(extraFiat), accent: TEAL}, { label: "Extra geïnvesteerde fiat", val: fmt(extraFiat), accent: T.accentTekst},
{ label: "Totaal geïnvesteerde fiat", val: fmt(totaalFiat), accent: TEAL}, { label: "Totaal geïnvesteerde fiat", val: fmt(totaalFiat), accent: T.accentTekst},
{ label: "Totaal eigen vermogen", val: fmt(totEV), accent: GREEN }, { label: "Totaal eigen vermogen", val: fmt(totEV), accent: GREEN },
{ label: "Winst op investering", val: fmt(winst), accent: winst >= 0 ? GREEN : RED }, { 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 }, { label: "ROI %", val: totaalFiat > 0 ? pct(roi, 2) : "—", accent: roi >= 0 ? GREEN : RED },
@@ -38,6 +39,16 @@ export default function DashboardTab() {
// Minigrafiek data // Minigrafiek data
const vData = [...evRows].sort((a, b) => a.datum.localeCompare(b.datum)); 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 ( return (
<div> <div>
<div style={{ marginBottom: 24 }}> <div style={{ marginBottom: 24 }}>
@@ -81,19 +92,19 @@ export default function DashboardTab() {
<div style={{ display: "flex", alignItems: "center", gap: 10 }}> <div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<span style={{ fontSize: 16 }}>🎯</span> <span style={{ fontSize: 16 }}>🎯</span>
<span style={{ fontWeight: 600 }}>Doel &amp; Bijbehorende Progressie</span> <span style={{ fontWeight: 600 }}>Doel &amp; Bijbehorende Progressie</span>
<VBadge label={fmt(doel)} color={TEAL} /> <VBadge label={fmt(doel)} color={T.accentTekst} />
</div> </div>
<span style={{ color: T.muted, fontSize: 13 }}>{pct(progressPct)} behaald</span> <span style={{ color: T.muted, fontSize: 13 }}>{pct(progressPct)} behaald</span>
</div> </div>
<div style={{ <div style={{
height: 14, background: "rgba(255,255,255,0.07)", height: 14, background: T.inputBg,
borderRadius: 99, overflow: "hidden", borderRadius: 99, overflow: "hidden",
position: "relative", marginBottom: 8, position: "relative", marginBottom: 8,
}}> }}>
<div style={{ <div style={{
position: "absolute", inset: 0, position: "absolute", inset: 0,
width: `${progressPct * 100}%`, width: `${progressPct * 100}%`,
background: "linear-gradient(90deg, #7c3aed, #a855f7)", background: `linear-gradient(90deg, ${T.accent}, ${T.accentLicht})`,
borderRadius: 99, transition: "width 0.7s ease", borderRadius: 99, transition: "width 0.7s ease",
}} /> }} />
{[0.25, 0.5, 0.75].map((f) => ( {[0.25, 0.5, 0.75].map((f) => (
@@ -113,7 +124,21 @@ export default function DashboardTab() {
{/* EV Grafiek */} {/* EV Grafiek */}
<VCard style={{ background: T.card, border: `1px solid ${T.border}`, marginBottom: 20 }}> <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 ? ( {vData.length < 2 ? (
<div style={{ textAlign: "center", padding: "32px 0", color: T.muted }}> <div style={{ textAlign: "center", padding: "32px 0", color: T.muted }}>
<div style={{ fontSize: 28, marginBottom: 8 }}>📈</div> <div style={{ fontSize: 28, marginBottom: 8 }}>📈</div>
@@ -122,7 +147,7 @@ export default function DashboardTab() {
</div> </div>
</div> </div>
) : ( ) : (
<MiniLineChart vData={vData} darkMode={darkMode} /> <MiniLineChart vData={vData} fiatData={fiatData} darkMode={darkMode} T={T} />
)} )}
</VCard> </VCard>
@@ -130,39 +155,47 @@ export default function DashboardTab() {
); );
} }
// ── Smooth Catmull-Rom bezier path ────────────────────────────────────────── // ── Legenda-item bij de grafiek ─────────────────────────────────────────────
function crPath(xs, ys, t = 0.4) { function Legenda({ kleur, label }) {
if (xs.length < 2) return ""; return (
let d = `M ${xs[0]},${ys[0]}`; <span style={{ display: "inline-flex", alignItems: "center", gap: 6 }}>
for (let i = 0; i < xs.length - 1; i++) { <span style={{ width: 16, height: 3, borderRadius: 2, background: kleur }} />
const p0 = i > 0 ? [xs[i-1], ys[i-1]] : [xs[i], ys[i]]; {label}
const p1 = [xs[i], ys[i]]; </span>
const p2 = [xs[i+1], ys[i+1]]; );
const p3 = i < xs.length - 2 ? [xs[i+2], ys[i+2]] : [xs[i+1], ys[i+1]];
const cp1x = (p1[0] + (p2[0] - p0[0]) * t).toFixed(2);
const cp1y = (p1[1] + (p2[1] - p0[1]) * t).toFixed(2);
const cp2x = (p2[0] - (p3[0] - p1[0]) * t).toFixed(2);
const cp2y = (p2[1] - (p3[1] - p1[1]) * t).toFixed(2);
d += ` C ${cp1x},${cp1y} ${cp2x},${cp2y} ${p2[0]},${p2[1]}`;
}
return d;
} }
// ── Mini inline SVG grafiek ──────────────────────────────────────────────────── // ── Mini inline SVG grafiek ────────────────────────────────────────────────────
function MiniLineChart({ vData, darkMode }) { function MiniLineChart({ vData, fiatData, darkMode, T }) {
const [hovered, setHovered] = useState(null); const [hovered, setHovered] = useState(null);
const W = 700, H = 230, pl = 62, pr = 16, pt = 16, pb = 52; // pb was 52 voor gedraaide datumlabels. Die staan nu rechtop en zijn korter,
// dus er is minder hoogte nodig en houdt de lijn zelf meer ruimte over.
const W = 700, H = 230, pl = 62, pr = 16, pt = 16, pb = 30;
const vals = vData.map((d) => d.nettoWaarde || 0); const vals = vData.map((d) => d.nettoWaarde || 0);
const mn = Math.min(...vals) * 0.95; const fVals = fiatData;
const mx = Math.max(...vals) * 1.05; // 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 rng = mx - mn || 1;
const iW = W - pl - pr, iH = H - pt - pb; const iW = W - pl - pr, iH = H - pt - pb;
// Eén label "sep '26" is bij deze lettergrootte ongeveer 54px breed inclusief
// wat lucht ernaast. Zoveel passen er naast elkaar; de rest wordt
// overgeslagen. De stap telt vanaf het láátste punt terug, niet vanaf het
// eerste: dat punt is de datum waar je naar kijkt en moet er altijd bij staan.
// Forceerden we hem er los bovenop, dan botste hij op willekeurige afstand
// tegen zijn voorganger — precies wat er eerst misging.
const labelStap = Math.max(1, Math.ceil(vData.length / Math.max(1, Math.floor(iW / 54))));
const xs = vData.map((_, i) => pl + (i / Math.max(vData.length - 1, 1)) * iW); const xs = vData.map((_, i) => pl + (i / Math.max(vData.length - 1, 1)) * iW);
const ys = vals.map((v) => pt + (1 - (v - mn) / rng) * iH); const ys = vals.map((v) => pt + (1 - (v - mn) / rng) * iH);
const fYs = fVals.map((v) => pt + (1 - (v - mn) / rng) * iH);
const linePath = crPath(xs, ys); const linePath = crPath(xs, ys);
const areaPath = `${linePath} L ${xs[xs.length-1]},${pt+iH} L ${xs[0]},${pt+iH} Z`; 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 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 handleMouseMove = (e) => {
const rect = e.currentTarget.getBoundingClientRect(); const rect = e.currentTarget.getBoundingClientRect();
@@ -172,9 +205,19 @@ function MiniLineChart({ vData, darkMode }) {
}; };
const h = hovered; const h = hovered;
const tipW = 114, tipH = 46; const tipW = 150, tipH = 76;
const tipX = h !== null ? Math.min(Math.max(xs[h] - tipW / 2, pl), W - tipW - 4) : 0; // Náást de hoverlijn, niet erboven of eronder. Zodra de twee punten ver uit
const tipY = h !== null ? (ys[h] - tipH - 14 < pt ? ys[h] + 14 : ys[h] - tipH - 14) : 0; // 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 ( return (
<svg <svg
@@ -185,8 +228,8 @@ function MiniLineChart({ vData, darkMode }) {
> >
<defs> <defs>
<linearGradient id="chartLineGrad" x1="0%" y1="0%" x2="100%" y2="0%"> <linearGradient id="chartLineGrad" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor="#0eddd4" /> <stop offset="0%" stopColor={T.chartVan} />
<stop offset="100%" stopColor="#8b5cf6" /> <stop offset="100%" stopColor={T.chartTot} />
</linearGradient> </linearGradient>
<linearGradient id="chartFadeMask" x1="0" y1="0" x2="0" y2="1"> <linearGradient id="chartFadeMask" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="white" stopOpacity="1" /> <stop offset="0%" stopColor="white" stopOpacity="1" />
@@ -214,6 +257,11 @@ function MiniLineChart({ vData, darkMode }) {
{/* Vlakkleur met gradient + fade */} {/* Vlakkleur met gradient + fade */}
<path d={areaPath} fill="url(#chartLineGrad)" mask="url(#chartAreaMask)" opacity="0.4" /> <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 */} {/* Lijn */}
<path d={linePath} fill="none" stroke="url(#chartLineGrad)" <path d={linePath} fill="none" stroke="url(#chartLineGrad)"
strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" /> strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" />
@@ -221,38 +269,71 @@ function MiniLineChart({ vData, darkMode }) {
{/* Verticale hover-lijn */} {/* Verticale hover-lijn */}
{h !== null && ( {h !== null && (
<line x1={xs[h]} y1={pt} x2={xs[h]} y2={pt + iH} <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 && ( {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" <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 */} {/* Tooltip */}
{h !== null && ( {h !== null && (
<g> <g>
<rect x={tipX} y={tipY} width={tipW} height={tipH} rx="7" <rect x={tipX} y={tipY} width={tipW} height={tipH} rx="7"
fill={darkMode ? "#1a2035" : "#fff"} fill={T.card}
stroke="rgba(14,221,212,0.35)" strokeWidth="1" /> stroke={T.border} strokeWidth="1" />
<text x={tipX + tipW / 2} y={tipY + 16} fill={lc} fontSize="9" textAnchor="middle"> <text x={tipX + tipW / 2} y={tipY + 16} fill={lc} fontSize="9" textAnchor="middle">
{fmtDatum(vData[h].datum)} {fmtDatum(vData[h].datum)}
</text> </text>
<text x={tipX + tipW / 2} y={tipY + 34} fill="#0eddd4" fontSize="11" <text x={tipX + 10} y={tipY + 33} fill={lc} fontSize="9">Vermogen</text>
fontWeight="700" textAnchor="middle"> <text x={tipX + tipW - 10} y={tipY + 33} fill={T.chartVan} fontSize="10"
fontWeight="700" textAnchor="end">
{fmt(vals[h])} {fmt(vals[h])}
</text> </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> </g>
)} )}
{/* X-as labels */} {/* X-as: korte maandlabels, rechtop. Elk punt een volledige datum geven
{xs.map((x, i) => ( liep bij een lange reeks over in een onleesbare band — tien tekens
<text key={i} x={x} y={H - pb + 16} fill={lc} fontSize="8" gedraaid kost ~45px en er passen er maar een stuk of veertien. De
textAnchor="end" transform={`rotate(-45, ${x}, ${H - pb + 16})`}> grafiek rekent nu uit hoeveel er passen en toont elke n-de; het
{fmtDatum(vData[i].datum)} laatste punt staat er altijd bij, want dat is de datum waar je naar
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> </text>
))} </g>
);
})}
</svg> </svg>
); );
} }
+59 -34
View File
@@ -1,7 +1,8 @@
import { useState } from "react"; import { useState } from "react";
import { useApp } from "../context/AppContext.jsx"; import { useApp } from "../context/AppContext.jsx";
import { fmt, fmtDatum } from "../utils/format.js"; import { fmt, fmtDatum } from "../utils/format.js";
import { PURPLE, PURPLE_LIGHT, GREEN, RED, CAT_COLORS } from "../constants/index.js"; import { useKolomBreedtes } from "../hooks/useKolomBreedtes.js";
import { GREEN, RED, CAT_COLORS } from "../constants/index.js";
import { import {
VCard, DonutChart, Overlay, ModalBox, ModalHeader, ModalFooter, VCard, DonutChart, Overlay, ModalBox, ModalHeader, ModalFooter,
EmojiPicker, AppIcon, ConfirmDeleteDialog, EmptyState, EmojiPicker, AppIcon, ConfirmDeleteDialog, EmptyState,
@@ -129,14 +130,14 @@ function AanpassenPopup({ cat, onClose }) {
<div style={{ <div style={{
display: "flex", alignItems: "center", gap: 8, display: "flex", alignItems: "center", gap: 8,
background: darkMode ? "rgba(255,255,255,0.03)" : "#f8fafc", 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", borderRadius: 10, padding: "10px 12px",
}}> }}>
<input value={nieuwNaam} <input value={nieuwNaam}
onChange={(e) => { setNieuwNaam(e.target.value); setErr(""); }} onChange={(e) => { setNieuwNaam(e.target.value); setErr(""); }}
placeholder="Naam" style={{ ...is, flex: 1 }} /> placeholder="Naam" style={{ ...is, flex: 1 }} />
<EuroInput value={nieuwVal} onChange={setNieuwVal} is={is} T={T} /> <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> </div>
{err && <ErrMsg msg={err} />} {err && <ErrMsg msg={err} />}
</div> </div>
@@ -250,8 +251,22 @@ function EVVoortgangTabel() {
const removeRow = (datum) => setEvRows(evRows.filter((r) => r.datum !== datum)); const removeRow = (datum) => setEvRows(evRows.filter((r) => r.datum !== datum));
const th = { padding: "8px 10px", fontSize: 11, color: T.muted, fontWeight: 600, textAlign: "left", whiteSpace: "nowrap", background: T.card, position: "sticky", top: 0, zIndex: 1, borderBottom: `1px solid ${T.border}` }; // Kolommen in weergave-volgorde, met hun standaardbreedte. De categorieën
const td = { padding: "7px 10px", fontSize: 12, color: T.text, borderBottom: `1px solid ${T.border}` }; // zijn dynamisch, dus de lijst wordt per render opgebouwd.
const kolommen = [
{ id: "datum", breedte: 100 },
...cats.map((c) => ({ id: c.id, breedte: 100 })),
{ id: "nettoWaarde", breedte: 115 },
{ id: "verschil", breedte: 105 },
{ id: "opmerkingen", breedte: 150 },
...(!locked ? [{ id: "acties", breedte: 75 }] : []),
];
const { breedtes, totaal, grip } = useKolomBreedtes("ev_kolom_breedtes", kolommen);
// overflow/ellipsis horen bij tableLayout "fixed": de inhoud rekt de kolom
// niet meer op, dus zonder afkappen loopt lange tekst de buurcel binnen.
const th = { padding: "8px 10px", fontSize: 11, color: T.muted, fontWeight: 600, textAlign: "left", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis", background: T.popupBg, position: "sticky", top: 0, zIndex: 1, borderBottom: `1px solid ${T.border}` };
const td = { padding: "7px 10px", fontSize: 12, color: T.text, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis", borderBottom: `1px solid ${T.border}` };
return ( return (
<div> <div>
@@ -260,12 +275,12 @@ function EVVoortgangTabel() {
<Overlay onClose={closeModal}> <Overlay onClose={closeModal}>
<ModalBox maxWidth={560}> <ModalBox maxWidth={560}>
<ModalHeader <ModalHeader
icon="📊" iconBg={PURPLE} icon="📊" iconBg={T.accent}
title={editDatum ? "Rij bewerken" : "Nieuw geïnvesteerde fiat"} title={editDatum ? "Rij bewerken" : "Nieuw geïnvesteerde fiat"}
subtitle="Vul de waarden in voor dit moment" subtitle="Vul de waarden in voor dit moment"
subtitleColor={T.muted} subtitleColor={T.muted}
onClose={closeModal} onClose={closeModal}
headerBg={darkMode ? `${PURPLE}08` : "#f8f5ff"} headerBg={darkMode ? `${T.accent}08` : T.inputBg}
T={T} T={T}
/> />
<div style={{ padding: "20px 22px", overflowY: "auto", maxHeight: "70vh" }}> <div style={{ padding: "20px 22px", overflowY: "auto", maxHeight: "70vh" }}>
@@ -310,7 +325,7 @@ function EVVoortgangTabel() {
</div> </div>
<ModalFooter T={T}> <ModalFooter T={T}>
<button onClick={closeModal} style={cancelBtn(T)}>Annuleren</button> <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> </ModalFooter>
</ModalBox> </ModalBox>
</Overlay> </Overlay>
@@ -319,22 +334,31 @@ function EVVoortgangTabel() {
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 16 }}> <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 16 }}>
<div style={{ fontWeight: 700, fontSize: 14, color: T.text }}>Geïnvesteerde Fiat</div> <div style={{ fontWeight: 700, fontSize: 14, color: T.text }}>Geïnvesteerde Fiat</div>
{!locked && ( {!locked && (
<button onClick={openModal} style={primaryBtn}>+ Toevoegen</button> <button onClick={openModal} style={primaryBtn(T)}>+ Toevoegen</button>
)} )}
</div> </div>
{evRows.length > 0 ? ( {evRows.length > 0 ? (
<div style={{ overflowX: "auto" }}> <div style={{ overflowX: "auto" }}>
<div style={{ maxHeight: 340, overflowY: "auto" }}> <div style={{ maxHeight: 340, overflowY: "auto" }}>
<table style={{ width: "100%", borderCollapse: "collapse", minWidth: 600 }}> <table style={{ width: totaal, borderCollapse: "collapse", tableLayout: "fixed" }}>
<colgroup>
{kolommen.map((k) => <col key={k.id} style={{ width: breedtes[k.id] }} />)}
</colgroup>
<thead> <thead>
<tr> <tr>
<th style={th}>Datum</th> <th style={th}>Datum<span {...grip("datum")} /></th>
{cats.map((c) => <th key={c.id} style={{ ...th, color: c.color }}>{c.naam}</th>)} {cats.map((c) => (
<th style={th}>Netto Waarde</th> // title: een lange categorienaam ("Crowdfunding") wordt bij
<th style={th}>Verschil</th> // de standaardbreedte afgekapt, dus hover geeft de volle naam.
<th style={th}>Opmerkingen</th> <th key={c.id} style={{ ...th, color: c.color }} title={c.naam}>
{!locked && <th style={th}></th>} {c.naam}<span {...grip(c.id)} />
</th>
))}
<th style={th}>Netto Waarde<span {...grip("nettoWaarde")} /></th>
<th style={th}>Verschil<span {...grip("verschil")} /></th>
<th style={th}>Opmerkingen<span {...grip("opmerkingen")} /></th>
{!locked && <th style={th}><span {...grip("acties")} /></th>}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -347,13 +371,14 @@ function EVVoortgangTabel() {
<tr key={i}> <tr key={i}>
<td style={td}>{fmtDatum(row.datum)}</td> <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>)} {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> <td style={{ ...td, fontWeight: 700, color: vColor }}>{vLabel}</td>
<td style={{ ...td, color: T.muted }}>{row.opmerkingen || "—"}</td> {/* title: bij een smalle kolom is de tekst afgekapt */}
<td style={{ ...td, color: T.muted }} title={row.opmerkingen || ""}>{row.opmerkingen || "—"}</td>
{!locked && ( {!locked && (
<td style={td}> <td style={td}>
<div style={{ display: "flex", gap: 6 }}> <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> <button onClick={() => removeRow(row.datum)} style={deleteSmBtn}>×</button>
</div> </div>
</td> </td>
@@ -399,7 +424,7 @@ function DoelProgressie({ ev, data, setData, totVerdeling, locked, T }) {
<span style={{ fontWeight: 600, fontSize: 14 }}>Gewenst doel EV</span> <span style={{ fontWeight: 600, fontSize: 14 }}>Gewenst doel EV</span>
</div> </div>
<div style={{ display: "flex", alignItems: "center", gap: 12 }}> <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 ? ( {editing ? (
<div style={{ display: "flex", alignItems: "center", gap: 6 }}> <div style={{ display: "flex", alignItems: "center", gap: 6 }}>
<span style={{ color: T.muted, fontSize: 13 }}>/</span> <span style={{ color: T.muted, fontSize: 13 }}>/</span>
@@ -409,10 +434,10 @@ function DoelProgressie({ ev, data, setData, totVerdeling, locked, T }) {
onKeyDown={(e) => { if (e.key === "Enter") save(); if (e.key === "Escape") setEditing(false); }} onKeyDown={(e) => { if (e.key === "Enter") save(); if (e.key === "Escape") setEditing(false); }}
style={{ style={{
width: 130, padding: "4px 8px", borderRadius: 8, fontSize: 13, 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> </div>
) : ( ) : (
<> <>
@@ -422,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> </div>
<div style={{ height: 12, background: "rgba(255,255,255,0.07)", borderRadius: 99, overflow: "hidden" }}> <div style={{ height: 12, background: "rgba(255,255,255,0.07)", borderRadius: 99, overflow: "hidden" }}>
<div style={{ <div style={{
height: "100%", width: `${progressPct * 100}%`, 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", borderRadius: 99, transition: "width 0.7s ease",
}} /> }} />
</div> </div>
@@ -471,9 +496,9 @@ export default function EigenVermogenTab() {
total: fmt(totVerdeling), totalColor: GREEN, 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 })), 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", title: "Rendement", accent: "#f59e0b",
@@ -556,16 +581,16 @@ export default function EigenVermogenTab() {
{!locked && ( {!locked && (
<div style={{ marginBottom: 16 }}> <div style={{ marginBottom: 16 }}>
<button onClick={() => setShowAddCat(true)} style={{ <button onClick={() => setShowAddCat(true)} style={{
padding: "10px 20px", background: `${PURPLE}22`, padding: "10px 20px", background: `${T.accent}22`,
border: `1px solid ${PURPLE}44`, borderRadius: 10, border: `1px solid ${T.accent}44`, borderRadius: 10,
color: PURPLE_LIGHT, cursor: "pointer", fontWeight: 700, fontSize: 13, color: T.accentTekst, cursor: "pointer", fontWeight: 700, fontSize: 13,
}}>+ Nieuwe categorie</button> }}>+ Nieuwe categorie</button>
</div> </div>
)} )}
{showAddCat && ( {showAddCat && (
<Overlay onClose={() => setShowAddCat(false)}> <Overlay onClose={() => setShowAddCat(false)}>
<ModalBox maxWidth={400}> <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={{ padding: "20px 22px" }}>
<div style={{ fontSize: 11, color: T.muted, fontWeight: 700, textTransform: "uppercase", marginBottom: 6 }}>Naam *</div> <div style={{ fontSize: 11, color: T.muted, fontWeight: 700, textTransform: "uppercase", marginBottom: 6 }}>Naam *</div>
<input autoFocus placeholder="Bijv. Aandelen" value={newNaam} <input autoFocus placeholder="Bijv. Aandelen" value={newNaam}
@@ -585,7 +610,7 @@ export default function EigenVermogenTab() {
</div> </div>
<ModalFooter T={T}> <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={() => 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> </ModalFooter>
</ModalBox> </ModalBox>
</Overlay> </Overlay>
@@ -645,9 +670,9 @@ function ErrMsg({ msg }) {
}}>{msg}</div> }}>{msg}</div>
); );
} }
const primaryBtn = { padding: "10px 20px", background: PURPLE, border: "none", borderRadius: 8, color: "#fff", fontWeight: 700, cursor: "pointer", fontSize: 13 }; const primaryBtn = (T) => ({ padding: "10px 20px", background: T.accent, 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 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 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 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 }); 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 { useState } from "react";
import { useApp } from "../context/AppContext.jsx"; import { useApp } from "../context/AppContext.jsx";
import { fmt, pct } from "../utils/format.js"; 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"; import { VCard, DonutChart, Overlay, ModalBox, ModalHeader, EmojiPicker, AppIcon, ConfirmDeleteDialog } from "../components/ui/index.jsx";
// ── SchuldAanpassenPopup ────────────────────────────────────────────────────── // ── SchuldAanpassenPopup ──────────────────────────────────────────────────────
@@ -108,14 +108,14 @@ function SchuldAanpassenPopup({ blok, onClose }) {
<div style={{ <div style={{
display: "flex", alignItems: "center", gap: 8, display: "flex", alignItems: "center", gap: 8,
background: darkMode ? "rgba(255,255,255,0.03)" : "#f8fafc", 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", borderRadius: 10, padding: "10px 12px",
}}> }}>
<input value={nieuwNaam} <input value={nieuwNaam}
onChange={(e) => { setNieuwNaam(e.target.value); setNieuwErr(""); }} onChange={(e) => { setNieuwNaam(e.target.value); setNieuwErr(""); }}
placeholder="Naam" style={{ ...is, flex: 1 }} /> placeholder="Naam" style={{ ...is, flex: 1 }} />
<EuroInput value={nieuwVal} onChange={setNieuwVal} is={is} T={T} /> <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> </div>
{nieuwErr && <ErrMsg msg={nieuwErr} />} {nieuwErr && <ErrMsg msg={nieuwErr} />}
</div> </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 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 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 }); const cancelBtn = (T) => ({ padding: "10px", background: "transparent", border: `1px solid ${T.border}`, borderRadius: 10, color: T.muted, cursor: "pointer", fontWeight: 600, fontSize: 13 });
+82 -38
View File
@@ -1,7 +1,9 @@
import { useState } from "react"; import { useState } from "react";
import { useApp } from "../context/AppContext.jsx"; import { useApp } from "../context/AppContext.jsx";
import { fmt, fmtDatum } from "../utils/format.js"; import { fmt, fmtDatum, fmtMaand } from "../utils/format.js";
import { PURPLE, PURPLE_LIGHT, RED, GREEN } from "../constants/index.js"; import { crPath, vlakPad } from "../utils/grafiek.js";
import { useKolomBreedtes } from "../hooks/useKolomBreedtes.js";
import { RED, GREEN } from "../constants/index.js";
import { VCard, DonutChart, Overlay, ModalBox, ModalHeader, ModalFooter } from "../components/ui/index.jsx"; import { VCard, DonutChart, Overlay, ModalBox, ModalHeader, ModalFooter } from "../components/ui/index.jsx";
// ── Inline SVG line chart ───────────────────────────────────────────────────── // ── Inline SVG line chart ─────────────────────────────────────────────────────
@@ -15,8 +17,13 @@ function SvgLineChart({ pts, lines, T, H = 200 }) {
</div> </div>
); );
} }
const W = 700, pl = 64, pr = 16, pt2 = 16, pb = 62; // pb was 62 voor gedraaide datumlabels. Die staan nu rechtop en zijn korter.
const W = 700, pl = 64, pr = 16, pt2 = 16, pb = 34;
const iW = W - pl - pr, iH = H - pt2 - pb; const iW = W - pl - pr, iH = H - pt2 - pb;
// Eén label "sep '26" is ongeveer 54px breed inclusief lucht ernaast. De stap
// telt vanaf het laatste punt terug, zodat dat er altijd bij staat zonder op
// zijn voorganger te botsen. Zie DashboardTab voor de uitleg.
const labelStap = Math.max(1, Math.ceil(pts.length / Math.max(1, Math.floor(iW / 54))));
const allVals = lines.flatMap((l) => pts.map((p) => p[l.key] ?? 0)); const allVals = lines.flatMap((l) => pts.map((p) => p[l.key] ?? 0));
const dataMin = Math.min(...allVals); const dataMin = Math.min(...allVals);
const dataMax = Math.max(...allVals); const dataMax = Math.max(...allVals);
@@ -26,8 +33,11 @@ function SvgLineChart({ pts, lines, T, H = 200 }) {
const rng = mx - mn || 1; const rng = mx - mn || 1;
const xp = (i) => pl + (i / (pts.length - 1)) * iW; const xp = (i) => pl + (i / (pts.length - 1)) * iW;
const yp = (v) => pt2 + (1 - ((v ?? 0) - mn) / rng) * iH; 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)"; // Was een vergelijking met een vaste hex (`T.bg === "#0f1117"`). Die kleur is
const darkMode = T.bg === "#0f1117"; // 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 ( return (
<div style={{ overflowX: "auto" }}> <div style={{ overflowX: "auto" }}>
@@ -56,17 +66,21 @@ function SvgLineChart({ pts, lines, T, H = 200 }) {
if (validPts.length < 2) return null; if (validPts.length < 2) return null;
const xs = validPts.map((p) => xp(p._i)); const xs = validPts.map((p) => xp(p._i));
const ys = validPts.map((p) => yp(p[l.key])); const ys = validPts.map((p) => yp(p[l.key]));
const ls = xs.map((x, i) => `${x},${ys[i]}`).join(" "); // Dezelfde Catmull-Rom afronding als de dashboardgrafiek, in plaats
const ar = `${xs[0]},${pt2 + iH} ${ls} ${xs[xs.length - 1]},${pt2 + iH}`; // van rechte segmenten tussen de punten.
const lijn = crPath(xs, ys);
const vlak = vlakPad(lijn, xs[0], xs[xs.length - 1], pt2 + iH);
return ( return (
<g key={l.key}> <g key={l.key}>
<polygon points={ar} fill={`url(#g_${l.key})`} /> <path d={vlak} fill={`url(#g_${l.key})`} />
<polyline points={ls} fill="none" stroke={l.color} strokeWidth="2.5" <path d={lijn} fill="none" stroke={l.color} strokeWidth="2.5"
strokeLinejoin="round" strokeLinecap="round" /> strokeLinejoin="round" strokeLinecap="round" />
{validPts.map((p, i) => { {validPts.map((p, i) => {
const x = xs[i], y = ys[i]; const x = xs[i], y = ys[i];
const isHov = hovered?.lineKey === l.key && hovered?.ptIdx === i; const isHov = hovered?.lineKey === l.key && hovered?.ptIdx === i;
const tipW = 90, tipH = 22; // Twee regels: sinds de x-as alleen nog maanden noemt, is dit
// de enige plek in de grafiek waar de exacte dag te lezen is.
const tipW = 104, tipH = 34;
const tipX = Math.min(Math.max(x - tipW / 2, pl), W - tipW - 4); const tipX = Math.min(Math.max(x - tipW / 2, pl), W - tipW - 4);
const tipY = y - tipH - 8 < pt2 ? y + 10 : y - tipH - 8; const tipY = y - tipH - 8 < pt2 ? y + 10 : y - tipH - 8;
return ( return (
@@ -80,10 +94,14 @@ function SvgLineChart({ pts, lines, T, H = 200 }) {
fill={l.color} stroke={T.card} strokeWidth="2" /> fill={l.color} stroke={T.card} strokeWidth="2" />
{isHov && ( {isHov && (
<g> <g>
<rect x={tipX} y={tipY} width={tipW} height={22} <rect x={tipX} y={tipY} width={tipW} height={tipH}
rx="5" fill={darkMode ? "#1e2130" : "#fff"} rx="5" fill={T.card}
stroke={l.color} strokeWidth="1" /> stroke={l.color} strokeWidth="1" />
<text x={tipX + tipW / 2} y={tipY + 15} fill={l.color} fontSize="8" <text x={tipX + tipW / 2} y={tipY + 13} fill={T.muted} fontSize="8"
textAnchor="middle">
{fmtDatum(p.datum)}
</text>
<text x={tipX + tipW / 2} y={tipY + 26} fill={l.color} fontSize="9"
fontWeight="700" textAnchor="middle"> fontWeight="700" textAnchor="middle">
{fmt(p[l.key])} {fmt(p[l.key])}
</text> </text>
@@ -95,15 +113,24 @@ function SvgLineChart({ pts, lines, T, H = 200 }) {
</g> </g>
); );
})} })}
{pts.map((p, i) => ( {/* X-as: korte maandlabels, rechtop en uitgedund op wat er past. Elk
<text key={i} punt een volledige datum geven liep bij een lange reeks over in een
x={xp(i)} y={H - pb + 16} onleesbare band. De exacte dag staat in de tooltip op elk punt. */}
fill={T.muted} fontSize="8" {pts.map((p, i) => {
textAnchor="end" const laatste = i === pts.length - 1;
transform={`rotate(-45, ${xp(i)}, ${H - pb + 16})`}> // Terugtellen vanaf het eind, zodat het laatste punt op een stap valt.
{fmtDatum(p.datum)} 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> </text>
))} </g>
);
})}
</svg> </svg>
<div style={{ display: "flex", gap: 20, justifyContent: "center", marginTop: 10 }}> <div style={{ display: "flex", gap: 20, justifyContent: "center", marginTop: 10 }}>
{lines.map((l) => ( {lines.map((l) => (
@@ -144,12 +171,12 @@ function VoortgangModal({ onSave, onClose, T, darkMode, initialData }) {
<Overlay onClose={onClose}> <Overlay onClose={onClose}>
<ModalBox maxWidth={480}> <ModalBox maxWidth={480}>
<ModalHeader <ModalHeader
icon="📊" iconBg={PURPLE} icon="📊" iconBg={T.accent}
title={isEdit ? "Voortgang bewerken" : "Voortgang toevoegen"} title={isEdit ? "Voortgang bewerken" : "Voortgang toevoegen"}
subtitle="Vul de waarden in voor dit moment" subtitle="Vul de waarden in voor dit moment"
subtitleColor={T.muted} subtitleColor={T.muted}
onClose={onClose} onClose={onClose}
headerBg={darkMode ? `${PURPLE}08` : "#f8f5ff"} headerBg={darkMode ? `${T.accent}08` : T.inputBg}
T={T} T={T}
/> />
<div style={{ padding: "20px 22px", overflowY: "auto", maxHeight: "70vh" }}> <div style={{ padding: "20px 22px", overflowY: "auto", maxHeight: "70vh" }}>
@@ -188,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 }}> <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 Annuleren
</button> </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"} {isEdit ? "Opslaan" : "Toevoegen"}
</button> </button>
</ModalFooter> </ModalFooter>
@@ -261,8 +288,21 @@ export default function VoortgangTab() {
setData({ ...data, schuld: { ...schuld, schuldHistory: schuldHistory.filter((s) => s.datum !== datum) } }); setData({ ...data, schuld: { ...schuld, schuldHistory: schuldHistory.filter((s) => s.datum !== datum) } });
}; };
const th = { padding: "8px 10px", fontSize: 11, color: T.muted, fontWeight: 600, textAlign: "left", whiteSpace: "nowrap", background: T.card, position: "sticky", top: 0, zIndex: 1, borderBottom: `1px solid ${T.border}` }; // Kolommen in weergave-volgorde, met hun standaardbreedte.
const td = { padding: "7px 10px", fontSize: 12, color: T.text, borderBottom: `1px solid ${T.border}` }; const kolommen = [
{ id: "datum", breedte: 120 },
{ id: "ev", breedte: 175 },
{ id: "schuld", breedte: 160 },
{ id: "netto", breedte: 175 },
{ id: "opmerkingen", breedte: 340 },
...(!locked ? [{ id: "acties", breedte: 90 }] : []),
];
const { breedtes, totaal, grip } = useKolomBreedtes("voortgang_kolom_breedtes", kolommen);
// overflow/ellipsis horen bij tableLayout "fixed": de inhoud rekt de kolom
// niet meer op, dus zonder afkappen loopt lange tekst de buurcel binnen.
const th = { padding: "8px 10px", fontSize: 11, color: T.muted, fontWeight: 600, textAlign: "left", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis", background: T.popupBg, position: "sticky", top: 0, zIndex: 1, borderBottom: `1px solid ${T.border}` };
const td = { padding: "7px 10px", fontSize: 12, color: T.text, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis", borderBottom: `1px solid ${T.border}` };
return ( return (
<div> <div>
@@ -272,8 +312,8 @@ export default function VoortgangTab() {
{/* Hero netto */} {/* Hero netto */}
<VCard style={{ <VCard style={{
background: `linear-gradient(135deg, ${PURPLE}22, rgba(16,185,129,0.08))`, background: `linear-gradient(135deg, ${T.accent}22, rgba(16,185,129,0.08))`,
border: `1px solid ${T.border}`, borderLeft: `4px solid ${PURPLE}`, border: `1px solid ${T.border}`, borderLeft: `4px solid ${T.accent}`,
marginBottom: 16, marginBottom: 16,
}}> }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", flexWrap: "wrap", gap: 12 }}> <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", flexWrap: "wrap", gap: 12 }}>
@@ -383,7 +423,7 @@ export default function VoortgangTab() {
<div style={{ fontWeight: 700, fontSize: 14 }}>Voortgang overzicht</div> <div style={{ fontWeight: 700, fontSize: 14 }}>Voortgang overzicht</div>
{!locked && ( {!locked && (
<button onClick={() => setShowAdd(true)} style={{ <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", borderRadius: 8, color: "#fff", fontWeight: 700, fontSize: 12, cursor: "pointer",
}}>+ Toevoegen</button> }}>+ Toevoegen</button>
)} )}
@@ -396,15 +436,18 @@ export default function VoortgangTab() {
)} )}
{voortgangRows.length > 0 ? ( {voortgangRows.length > 0 ? (
<div style={{ overflowX: "auto" }}> <div style={{ overflowX: "auto" }}>
<table style={{ width: "100%", borderCollapse: "collapse", minWidth: 560 }}> <table style={{ width: totaal, borderCollapse: "collapse", tableLayout: "fixed" }}>
<colgroup>
{kolommen.map((k) => <col key={k.id} style={{ width: breedtes[k.id] }} />)}
</colgroup>
<thead> <thead>
<tr> <tr>
<th style={th}>Datum</th> <th style={th}>Datum<span {...grip("datum")} /></th>
<th style={{ ...th, color: GREEN }}>Eigen Vermogen</th> <th style={{ ...th, color: GREEN }}>Eigen Vermogen<span {...grip("ev")} /></th>
<th style={{ ...th, color: RED }}>Schuld</th> <th style={{ ...th, color: RED }}>Schuld<span {...grip("schuld")} /></th>
<th style={{ ...th, color: PURPLE_LIGHT }}>Nettowaarde</th> <th style={{ ...th, color: T.accentTekst }}>Nettowaarde<span {...grip("netto")} /></th>
<th style={th}>Opmerkingen</th> <th style={th}>Opmerkingen<span {...grip("opmerkingen")} /></th>
{!locked && <th style={th}></th>} {!locked && <th style={th}><span {...grip("acties")} /></th>}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -414,10 +457,11 @@ export default function VoortgangTab() {
<td style={{ ...td, color: GREEN, fontWeight: 600 }}>{row.ev !== null ? fmt(row.ev) : "—"}</td> <td style={{ ...td, color: GREEN, fontWeight: 600 }}>{row.ev !== null ? fmt(row.ev) : "—"}</td>
<td style={{ ...td, color: RED, fontWeight: 600 }}>{row.schuld !== null ? fmt(row.schuld) : "—"}</td> <td style={{ ...td, color: RED, fontWeight: 600 }}>{row.schuld !== null ? fmt(row.schuld) : "—"}</td>
<td style={{ ...td, fontWeight: 700, color: row.netto !== null ? (row.netto >= 0 ? GREEN : RED) : T.muted }}>{row.netto !== null ? fmt(row.netto) : "—"}</td> <td style={{ ...td, fontWeight: 700, color: row.netto !== null ? (row.netto >= 0 ? GREEN : RED) : T.muted }}>{row.netto !== null ? fmt(row.netto) : "—"}</td>
<td style={{ ...td, color: T.muted }}>{row.opmerkingen || "—"}</td> {/* title: bij een smalle kolom is de tekst afgekapt */}
<td style={{ ...td, color: T.muted }} title={row.opmerkingen || ""}>{row.opmerkingen || "—"}</td>
{!locked && ( {!locked && (
<td style={{ ...td, whiteSpace: "nowrap" }}> <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> <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> </td>
)} )}
+15
View File
@@ -14,6 +14,21 @@ export const fmtDatum = (s) => {
return `${d}-${m}-${y}`; return `${d}-${m}-${y}`;
}; };
const MAANDEN = ["jan", "feb", "mrt", "apr", "mei", "jun", "jul", "aug", "sep", "okt", "nov", "dec"];
/**
* ISO datum → korte maandnotatie (`2026-09-16` → `sep '26`).
*
* Voor de x-as van de grafieken: een volledige datum is tien tekens en past
* daar alleen gedraaid, wat bij een lange reeks onleesbaar wordt. De exacte
* dag staat in de tooltip.
*/
export const fmtMaand = (s) => {
if (!s) return s;
const [y, m] = s.split("-");
return `${MAANDEN[parseInt(m, 10) - 1] ?? m} '${y.slice(2)}`;
};
/** Crypto-bedrag formatter (4 decimalen standaard) */ /** Crypto-bedrag formatter (4 decimalen standaard) */
export const fmtCrypto = (n, d = 4) => (parseFloat(n) || 0).toFixed(d); export const fmtCrypto = (n, d = 4) => (parseFloat(n) || 0).toFixed(d);
+43
View File
@@ -0,0 +1,43 @@
/**
* Tekenhulp voor de SVG-grafieken.
*
* Stond eerder als lokale functie in `DashboardTab.jsx`. Nu de Voortgang-grafiek
* dezelfde afronding gebruikt, hoort hij op één plek te staan — anders lopen de
* twee vroeg of laat uit elkaar.
*/
/**
* Catmull-Rom spline als bezier-pad: een vloeiende lijn door alle punten heen.
*
* @param {number[]} xs x-posities in SVG-eenheden
* @param {number[]} ys y-posities in SVG-eenheden
* @param {number} t spanning; hoger is boller, 0.4 is de huisstijl
*
* Let op: een spline kan tussen twee punten iets buiten hun bereik uitschieten.
* Voor een reeks die per definitie nooit terugloopt — zoals de cumulatief
* geïnvesteerde fiat — tekent dat een daling die niet in de data zit. Gebruik
* daar rechte segmenten.
*/
export function crPath(xs, ys, t = 0.4) {
if (xs.length < 2) return "";
let d = `M ${xs[0]},${ys[0]}`;
for (let i = 0; i < xs.length - 1; i++) {
const p0 = i > 0 ? [xs[i-1], ys[i-1]] : [xs[i], ys[i]];
const p1 = [xs[i], ys[i]];
const p2 = [xs[i+1], ys[i+1]];
const p3 = i < xs.length - 2 ? [xs[i+2], ys[i+2]] : [xs[i+1], ys[i+1]];
const cp1x = (p1[0] + (p2[0] - p0[0]) * t).toFixed(2);
const cp1y = (p1[1] + (p2[1] - p0[1]) * t).toFixed(2);
const cp2x = (p2[0] - (p3[0] - p1[0]) * t).toFixed(2);
const cp2y = (p2[1] - (p3[1] - p1[1]) * t).toFixed(2);
d += ` C ${cp1x},${cp1y} ${cp2x},${cp2y} ${p2[0]},${p2[1]}`;
}
return d;
}
/**
* Sluit een lijnpad af tot een vlak: door naar de basislijn en terug naar de
* start. Voor de gradient-vulling onder een lijn.
*/
export const vlakPad = (lijn, xEerste, xLaatste, yBasis) =>
`${lijn} L ${xLaatste},${yBasis} L ${xEerste},${yBasis} Z`;
+1
View File
@@ -1,2 +1,3 @@
export * from "./format.js"; export * from "./format.js";
export * from "./storage.js"; export * from "./storage.js";
export * from "./grafiek.js";
+4
View File
@@ -3,6 +3,10 @@ const KEYS = {
evRows: "ev_voortgang_rows", evRows: "ev_voortgang_rows",
users: "wbjw_users", users: "wbjw_users",
exitplan: "exitplan_data", 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) { export function loadFromStorage(key) {
+1 -1
View File
@@ -41,6 +41,6 @@ export default defineConfig({
define: { define: {
// VITE_API_URL wordt ingebakken tijdens build // VITE_API_URL wordt ingebakken tijdens build
__API_URL__: JSON.stringify(process.env.VITE_API_URL ?? "/api"), __API_URL__: JSON.stringify(process.env.VITE_API_URL ?? "/api"),
__APP_VERSION__: JSON.stringify(process.env.VITE_APP_VERSION ?? pkg.version), __APP_VERSION__: JSON.stringify(process.env.VITE_APP_VERSION || pkg.version),
}, },
}); });
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "vibefinance", "name": "vibefinance",
"version": "0.1.9", "version": "1.0.10",
"description": "VibeFinance — persoonlijk vermogensbeheer", "description": "VibeFinance — persoonlijk vermogensbeheer",
"private": true, "private": true,
"repository": "https://vibehoogie.duckdns.org/vibe/VibeFinance", "repository": "https://vibehoogie.duckdns.org/vibe/VibeFinance",
+170 -44
View File
@@ -9,6 +9,16 @@
# .\release.ps1 -SkipTests # sla de testsuite over (niet aanbevolen) # .\release.ps1 -SkipTests # sla de testsuite over (niet aanbevolen)
# .\release.ps1 -DryRun # toon alle commando's zonder iets uit te voeren # .\release.ps1 -DryRun # toon alle commando's zonder iets uit te voeren
# .\release.ps1 -Force # sla conflictcontrole over, overschrijf tags # .\release.ps1 -Force # sla conflictcontrole over, overschrijf tags
# .\release.ps1 -NoChangelog # publiceer bewust zonder release-notes
#
# Het script werkt zelf vier dingen bij, dus die hoef je niet met de hand te
# doen: de changelog-kop `## [Niet vrijgegeven]` -> `## [x.x.x] - datum`, de
# versie in frontend/package.json, de versiebadge in README.md, en ná het
# publiceren de versie in de root package.json -> `x.x.(x+1)-dev`.
#
# LET OP bij -Force: dat verwijdert de bestaande tag lokaal en op origin, en
# Gitea gooit de bijbehorende release daarmee weg. Onder -DryRun wordt dat
# alleen gemeld (dat was eerder niet zo — zie stap 4).
# #
# Vereisten: # Vereisten:
# - Docker Desktop actief # - Docker Desktop actief
@@ -16,6 +26,9 @@
# - docker buildx builder actief: docker buildx create --use # - docker buildx builder actief: docker buildx create --use
# - Git geinstalleerd en working directory is de repo root # - Git geinstalleerd en working directory is de repo root
# - root package.json bevat een schoon versienummer ZONDER -dev suffix # - root package.json bevat een schoon versienummer ZONDER -dev suffix
# - Changelog.md beschrijft deze versie, onder `## [Niet vrijgegeven]` of
# onder een kop `## [x.x.x]`; anders breekt het script af (-NoChangelog
# omzeilt dat)
# - GITEA_TOKEN in de omgeving (token met release-rechten) voor stap 5, # - GITEA_TOKEN in de omgeving (token met release-rechten) voor stap 5,
# de Gitea-release waar de update-melding in de app op afgaat # de Gitea-release waar de update-melding in de app op afgaat
@@ -23,7 +36,10 @@ param(
[switch]$NoBuild, [switch]$NoBuild,
[switch]$SkipTests, [switch]$SkipTests,
[switch]$DryRun, [switch]$DryRun,
[switch]$Force [switch]$Force,
# Releasen zonder inhoudelijke release-notes. Zonder deze switch breekt het
# script af wanneer de changelog niets over deze versie zegt.
[switch]$NoChangelog
) )
$ErrorActionPreference = "Stop" $ErrorActionPreference = "Stop"
@@ -75,42 +91,13 @@ if ($appVersion -match "-dev$") {
exit 1 exit 1
} }
# ── Versie synchroniseren naar frontend/package.json ────────────────────── # Git-tag met `v`-prefix, net als de release-naam hieronder en net als de tags
# Een droogloop schrijft hier niet: anders laat -DryRun een gewijzigd bestand # t/m v1.0.0. Gitea toont de tag op de release-pagina, dus zonder prefix stond
# achter, en tagt de echte release daarna een vervuilde working tree. # daar het kale nummer. De image-tags in de registry houden bewust géén `v`:
$frontendPkgPath = Join-Path $PSScriptRoot "frontend/package.json" # dat is de conventie voor container-tags.
$frontendPkg = Get-Content $frontendPkgPath -Raw | ConvertFrom-Json # De update-check verdraagt beide: zowel backend/src/routes/update.js als
if ($frontendPkg.version -ne $appVersion) { # useUpdateCheck.js strippen een leidende `v` voordat ze versies vergelijken.
if ($DryRun) { $gitTag = "v$appVersion"
Write-Host "[DRY-RUN] Versie naar frontend synchroniseren: $($frontendPkg.version) -> $appVersion" -ForegroundColor Yellow
} else {
$frontendPkg.version = $appVersion
[System.IO.File]::WriteAllText($frontendPkgPath, ($frontendPkg | ConvertTo-Json -Depth 10))
Write-Host "Versie gesynchroniseerd naar frontend: $appVersion" -ForegroundColor Green
}
}
# ── Versiebadge in README.md ──────────────────────────────────────────────
# Anders loopt die badge stil achter: hij stond drie releases achter voordat
# dit erin zat, en het script kent het nummer toch al. Zelfde regel als
# hierboven: een droogloop meldt het, maar schrijft niet.
$readmePath = Join-Path $PSScriptRoot "README.md"
if (Test-Path $readmePath) {
$readme = [System.IO.File]::ReadAllText($readmePath)
$patroon = "badge/versie-[^-]+-"
$nieuw = "badge/versie-$appVersion-"
$huidig = [regex]::Match($readme, $patroon)
if ($huidig.Success -and $huidig.Value -ne $nieuw) {
if ($DryRun) {
Write-Host "[DRY-RUN] Versiebadge in README.md: $($huidig.Value) -> $nieuw" -ForegroundColor Yellow
} else {
[System.IO.File]::WriteAllText($readmePath, ($readme -replace $patroon, $nieuw))
Write-Host "Versiebadge in README.md bijgewerkt: $appVersion" -ForegroundColor Green
}
}
}
$gitTag = "$appVersion"
# ── Registry tags (één image voor frontend + backend) ───────────────────── # ── Registry tags (één image voor frontend + backend) ─────────────────────
$appBase = "$registry/$appImage" $appBase = "$registry/$appImage"
@@ -151,12 +138,13 @@ function Write-Step {
Write-Host "── $Label" -ForegroundColor Cyan Write-Host "── $Label" -ForegroundColor Cyan
} }
# Haalt de sectie van deze versie uit Changelog.md als release-notes. # Haalt de sectie van deze versie uit changelog-tekst. Werkt op een string en
function Get-ChangelogSection { # niet op het bestand, zodat een droogloop kan valideren tegen de kop die hij
param([string]$Version) # zou schrijven zonder die daadwerkelijk te schrijven.
$path = Join-Path $PSScriptRoot "Changelog.md" function Get-ChangelogSectionFromText {
if (-not (Test-Path $path)) { return "" } param([string]$Text, [string]$Version)
$lines = Get-Content $path if (-not $Text) { return "" }
$lines = $Text -split "\r?\n"
$start = -1 $start = -1
$needle = "^##\s*\[" + [regex]::Escape($Version) + "\]" $needle = "^##\s*\[" + [regex]::Escape($Version) + "\]"
for ($i = 0; $i -lt $lines.Count; $i++) { for ($i = 0; $i -lt $lines.Count; $i++) {
@@ -172,6 +160,99 @@ function Get-ChangelogSection {
return (($out -join "`n").Trim()) return (($out -join "`n").Trim())
} }
# ── Changelog-kop vrijgeven ───────────────────────────────────────────────
# De release-notes komen uit de sectie `## [x.x.x]` in Changelog.md. Stond het
# werk nog onder `## [Niet vrijgegeven]`, dan vond het script niets en werd stil
# de placeholder "Release x.x.x" gepubliceerd — dat gebeurde bij 0.1.9 en 1.0.2,
# en is achteraf alleen met een PATCH op de release te herstellen. Daarom zet het
# script de kop nu zelf om, en breekt het af als er niets te melden valt.
# Zelfde regel als bij de andere auto-updates: een droogloop meldt, maar schrijft
# niet.
$changelogPath = Join-Path $PSScriptRoot "Changelog.md"
$nieuweKop = "## [$appVersion] - $(Get-Date -Format 'yyyy-MM-dd')"
$changelogTxt = if (Test-Path $changelogPath) { [System.IO.File]::ReadAllText($changelogPath) } else { "" }
$kopPatroon = "(?m)^##\s*\[" + [regex]::Escape($appVersion) + "\]"
# Niet `\s*$`: `\s` matcht ook newlines, en dan slokt de match de lege regel ná
# de kop op — die verdween daardoor bij het omzetten. En niet `[ \t]*$` alleen:
# dit bestand heeft CRLF (zie .gitattributes), dus `$` staat achter een `\r` die
# geen space of tab is. De lookahead laat het regeleinde buiten de match, zodat
# de CRLF intact blijft.
$onderwegPatroon = "(?m)^##[ \t]*\[[ \t]*Niet vrijgegeven[ \t]*\][ \t]*(?=\r?$)"
# Eerst alleen in het geheugen omzetten. Schrijven gebeurt na de controle
# hieronder: bij een lege `[Niet vrijgegeven]`-sectie zou de kop anders al
# vervangen zijn terwijl het script daarna afbreekt, en dan blijft er een
# aangepaste changelog achter bij een release die niet doorging.
$kopOmzetten = ($changelogTxt -notmatch $kopPatroon -and $changelogTxt -match $onderwegPatroon)
if ($kopOmzetten) {
# $nieuweKop bevat geen '$', dus veilig als vervangingstekst.
$changelogTxt = [regex]::Replace($changelogTxt, $onderwegPatroon, $nieuweKop)
}
if (-not (Get-ChangelogSectionFromText $changelogTxt $appVersion) -and -not $NoChangelog) {
Write-Host ""
Write-Host "Changelog.md zegt niets over $appVersion." -ForegroundColor Red
Write-Host ""
Write-Host "Zonder notes komt er alleen de tekst 'Release $appVersion' in de Gitea-release," -ForegroundColor Yellow
Write-Host "en dat is achteraf alleen met een PATCH op de release te herstellen." -ForegroundColor Yellow
Write-Host ""
Write-Host "Zet je wijzigingen in Changelog.md onder een kop:" -ForegroundColor White
Write-Host " ## [Niet vrijgegeven]" -ForegroundColor White
Write-Host "Die zet dit script bij de volgende run zelf om naar '$nieuweKop'." -ForegroundColor White
Write-Host ""
Write-Host "Bewust zonder release-notes publiceren? Draai met -NoChangelog." -ForegroundColor DarkGray
Write-Host ""
Write-Host "Er is niets gepubliceerd." -ForegroundColor DarkGray
exit 1
}
# De controle is doorstaan, dus nu pas schrijven.
if ($kopOmzetten) {
if ($DryRun) {
Write-Host "[DRY-RUN] Changelog-kop vrijgeven: '## [Niet vrijgegeven]' -> '$nieuweKop'" -ForegroundColor Yellow
} else {
[System.IO.File]::WriteAllText($changelogPath, $changelogTxt)
Write-Host "Changelog-kop vrijgegeven: $nieuweKop" -ForegroundColor Green
}
}
# ── Versie synchroniseren naar frontend/package.json ──────────────────────
# Staat na de changelog-controle: anders laat een afgebroken release wél een
# gewijzigde working tree achter terwijl hij "niets gepubliceerd" meldt.
# Een droogloop schrijft hier niet, om dezelfde reden.
$frontendPkgPath = Join-Path $PSScriptRoot "frontend/package.json"
$frontendPkg = Get-Content $frontendPkgPath -Raw | ConvertFrom-Json
if ($frontendPkg.version -ne $appVersion) {
if ($DryRun) {
Write-Host "[DRY-RUN] Versie naar frontend synchroniseren: $($frontendPkg.version) -> $appVersion" -ForegroundColor Yellow
} else {
$frontendPkg.version = $appVersion
[System.IO.File]::WriteAllText($frontendPkgPath, ($frontendPkg | ConvertTo-Json -Depth 10))
Write-Host "Versie gesynchroniseerd naar frontend: $appVersion" -ForegroundColor Green
}
}
# ── Versiebadge in README.md ──────────────────────────────────────────────
# Anders loopt die badge stil achter: hij stond drie releases achter voordat
# dit erin zat, en het script kent het nummer toch al. Zelfde regel als
# hierboven: een droogloop meldt het, maar schrijft niet.
$readmePath = Join-Path $PSScriptRoot "README.md"
if (Test-Path $readmePath) {
$readme = [System.IO.File]::ReadAllText($readmePath)
$patroon = "badge/versie-[^-]+-"
$nieuw = "badge/versie-$appVersion-"
$huidig = [regex]::Match($readme, $patroon)
if ($huidig.Success -and $huidig.Value -ne $nieuw) {
if ($DryRun) {
Write-Host "[DRY-RUN] Versiebadge in README.md: $($huidig.Value) -> $nieuw" -ForegroundColor Yellow
} else {
[System.IO.File]::WriteAllText($readmePath, ($readme -replace $patroon, $nieuw))
Write-Host "Versiebadge in README.md bijgewerkt: $appVersion" -ForegroundColor Green
}
}
}
# ── Samenvatting ─────────────────────────────────────────────────────────── # ── Samenvatting ───────────────────────────────────────────────────────────
Write-Host "" Write-Host ""
Write-Host "════════════════════════════════════════" -ForegroundColor Cyan Write-Host "════════════════════════════════════════" -ForegroundColor Cyan
@@ -365,15 +446,25 @@ if (-not $NoBuild) {
Write-Step "[4/5] Git-tag aanmaken en pushen" Write-Step "[4/5] Git-tag aanmaken en pushen"
if ($Force) { if ($Force) {
# LET OP: deze twee commando's gingen niet via Invoke-Cmd en hadden dus geen
# -DryRun-controle. Een `-DryRun -Force` verwijderde daardoor écht de tag,
# lokaal en op origin — en Gitea ruimt de release op zodra de tag verdwijnt.
# Zo is de release van 1.0.2 verloren gegaan.
if ($DryRun) {
Write-Host "[DRY-RUN] git tag -d $gitTag" -ForegroundColor Yellow
Write-Host "[DRY-RUN] git push origin :refs/tags/$gitTag" -ForegroundColor Yellow
} else {
Write-Host "Bestaande lokale tag verwijderen (indien aanwezig)..." -ForegroundColor Yellow Write-Host "Bestaande lokale tag verwijderen (indien aanwezig)..." -ForegroundColor Yellow
$ErrorActionPreference = "SilentlyContinue" $ErrorActionPreference = "SilentlyContinue"
git tag -d $gitTag 2>$null | Out-Null git tag -d $gitTag 2>$null | Out-Null
$ErrorActionPreference = "Stop" $ErrorActionPreference = "Stop"
Write-Host "Bestaande remote tag verwijderen (indien aanwezig)..." -ForegroundColor Yellow Write-Host "Bestaande remote tag verwijderen (indien aanwezig)..." -ForegroundColor Yellow
Write-Host " Gitea verwijdert de bijbehorende release mee." -ForegroundColor DarkGray
$ErrorActionPreference = "SilentlyContinue" $ErrorActionPreference = "SilentlyContinue"
git push origin ":refs/tags/$gitTag" 2>$null | Out-Null git push origin ":refs/tags/$gitTag" 2>$null | Out-Null
$ErrorActionPreference = "Stop" $ErrorActionPreference = "Stop"
} }
}
Write-Host "Git-tag aanmaken: $gitTag" -ForegroundColor White Write-Host "Git-tag aanmaken: $gitTag" -ForegroundColor White
Invoke-Cmd @("git", "tag", "-a", $gitTag, "-m", "Release $gitTag") Invoke-Cmd @("git", "tag", "-a", $gitTag, "-m", "Release $gitTag")
@@ -405,7 +496,10 @@ if (-not $releaseApi) {
Write-Host "Geen GITEA_TOKEN in de omgeving — release overgeslagen." -ForegroundColor Yellow Write-Host "Geen GITEA_TOKEN in de omgeving — release overgeslagen." -ForegroundColor Yellow
Write-Host " Zonder release ziet de app geen update-melding voor $appVersion." -ForegroundColor DarkGray Write-Host " Zonder release ziet de app geen update-melding voor $appVersion." -ForegroundColor DarkGray
} else { } else {
$notes = Get-ChangelogSection $appVersion # Uit $changelogTxt en niet opnieuw van schijf: in een droogloop is de kop
# nog niet geschreven, en dan zou de payload hier de placeholder tonen —
# precies wat je met een droogloop wilt uitsluiten.
$notes = Get-ChangelogSectionFromText $changelogTxt $appVersion
if (-not $notes) { $notes = "Release $appVersion" } if (-not $notes) { $notes = "Release $appVersion" }
$payload = @{ $payload = @{
tag_name = $gitTag tag_name = $gitTag
@@ -451,6 +545,38 @@ Write-Host " Git-tag: $gitTag -> origin" -ForegroundColor Green
Write-Host "════════════════════════════════════════" -ForegroundColor Cyan Write-Host "════════════════════════════════════════" -ForegroundColor Cyan
Write-Host "" Write-Host ""
# ── Versie naar de volgende dev-cyclus ────────────────────────────────────
# Was voorheen stap 4 met de hand. Gebeurt hier, ná het publiceren, zodat een
# afgebroken release het versienummer niet vooruit zet. Bijkomend voordeel: de
# `-dev` suffix laat dezelfde release niet nog eens draaien (de controle boven
# in dit script weigert een `-dev` versie), dus een dubbele publicatie is
# meteen uitgesloten. Wil je bewust opnieuw releasen, zet het nummer dan zelf
# terug.
if ($appVersion -match '^(\d+)\.(\d+)\.(\d+)$') {
$volgende = "{0}.{1}.{2}-dev" -f $Matches[1], $Matches[2], ([int]$Matches[3] + 1)
$rootPkgPath = Join-Path $PSScriptRoot "package.json"
if ($DryRun) {
Write-Host "[DRY-RUN] Versie in package.json: $appVersion -> $volgende" -ForegroundColor Yellow
} else {
# Gerichte tekstvervanging in plaats van ConvertTo-Json: dat zou het hele
# bestand herschrijven en de opmaak van de andere velden aantasten.
$rootTxt = [System.IO.File]::ReadAllText($rootPkgPath)
$treffer = [regex]::Match($rootTxt, '"version"\s*:\s*"' + [regex]::Escape($appVersion) + '"')
if ($treffer.Success) {
$rootTxt = $rootTxt.Remove($treffer.Index, $treffer.Length).Insert($treffer.Index, '"version": "' + $volgende + '"')
[System.IO.File]::WriteAllText($rootPkgPath, $rootTxt)
Write-Host "Versie in package.json naar de volgende cyclus: $volgende" -ForegroundColor Green
Write-Host " Commit dit mee, samen met de changelog-kop en de README-badge." -ForegroundColor DarkGray
Write-Host ""
} else {
Write-Host "Kon '$appVersion' niet terugvinden in package.json — versie niet bijgewerkt." -ForegroundColor Yellow
}
}
} else {
Write-Host "Versie '$appVersion' is geen x.y.z — volgende dev-versie niet automatisch gezet." -ForegroundColor Yellow
}
# Expliciete 0: anders erft de caller de exitcode van het laatste # Expliciete 0: anders erft de caller de exitcode van het laatste
# docker-commando (bijv. de registry-check). # docker-commando (bijv. de registry-check).
exit 0 exit 0
+1
View File
@@ -9,6 +9,7 @@
# ./release.sh -SkipTests # sla de testsuite over (niet aanbevolen) # ./release.sh -SkipTests # sla de testsuite over (niet aanbevolen)
# ./release.sh -DryRun # toon alle commando's zonder iets uit te voeren # ./release.sh -DryRun # toon alle commando's zonder iets uit te voeren
# ./release.sh -Force # sla conflictcontrole over, overschrijf tags # ./release.sh -Force # sla conflictcontrole over, overschrijf tags
# ./release.sh -NoChangelog # publiceer bewust zonder release-notes
# #
# Combinaties: # Combinaties:
# ./release.sh -Force -NoBuild # ./release.sh -Force -NoBuild