Compare commits
3
Commits
2d7e67e315
...
v1.0.13
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4181ac3f0f | ||
|
|
2799bc12ee | ||
|
|
e9192a8aba |
@@ -335,9 +335,67 @@ dan is het antwoord `no configuration file provided: not found`. Met
|
|||||||
Pullen zelf werkt anoniem zolang de organisatie public is — zie
|
Pullen 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).
|
||||||
|
|
||||||
|
### De scan meldt 46 CVE's op postgres:18-alpine
|
||||||
|
|
||||||
|
Dat klopt, en het blijft zo. Ze zitten allemaal in één bestand:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Alle bevindingen komen uit /usr/local/bin/gosu, niets uit de Alpine-pakketten
|
||||||
|
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
|
||||||
|
aquasec/trivy image --scanners vuln postgres:18-alpine
|
||||||
|
```
|
||||||
|
|
||||||
|
`gosu` is een klein Go-programma dat het postgres-entrypoint gebruikt om van
|
||||||
|
root naar de `postgres`-gebruiker te schakelen. Upstream compileert het met Go
|
||||||
|
1.24.6, en alle meldingen zijn CVE's in die Go-standaardbibliotheek. Je kunt er
|
||||||
|
niets aan verhelpen met een `pull`: dit ís de nieuwste versie.
|
||||||
|
|
||||||
|
Wat wél is gedaan: beide compose-bestanden starten de container met
|
||||||
|
`user: "postgres"`. Het entrypoint roept gosu alleen aan als het als root start
|
||||||
|
(`if [ "$(id -u)" = '0' ]`), dus die tak wordt overgeslagen en de binary draait
|
||||||
|
nooit. De CVE's zijn daarmee onbereikbaar — de scanner kijkt naar wat aanwezig
|
||||||
|
is, niet naar wat uitgevoerd wordt.
|
||||||
|
|
||||||
|
> Dit werkt ook op een volume dat ooit als root is aangemaakt: de
|
||||||
|
> data-directory is dan al eigendom van postgres. Getest met een bestaande
|
||||||
|
> database; die start en de data blijft intact.
|
||||||
|
|
||||||
|
**Waarom geen eigen image zonder gosu.** Dat kan (`FROM postgres:18-alpine` +
|
||||||
|
`RUN rm -f /usr/local/bin/gosu`) en zet de scan op nul, maar je ruilt een
|
||||||
|
onbereikbaar risico voor een echt risico: je moet dat image dan zelf herbouwen
|
||||||
|
bij elke postgres-patch. Precies die achterstand was het probleem toen het image
|
||||||
|
vier maanden op 18.4 stond met 143 bevindingen. Met het officiële image haalt
|
||||||
|
`docker compose pull` de patches vanzelf op, en zodra upstream gosu met een
|
||||||
|
nieuwere Go herbouwt is het gratis opgelost. `test.ps1` waarschuwt daarom bij
|
||||||
|
stap 4 als `postgres:18-alpine` in de registry nieuwer is dan wat je lokaal hebt.
|
||||||
|
|
||||||
### PostgreSQL versie-upgrade
|
### PostgreSQL versie-upgrade
|
||||||
|
|
||||||
Bij een upgrade van PostgreSQL (bijv. 16 → 18) is de bestaande data niet compatibel. Stappen:
|
Twee soorten, met een belangrijk verschil. Kijk eerst welke je voor je hebt:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec vibefinance-postgres postgres --version
|
||||||
|
```
|
||||||
|
|
||||||
|
**Patch binnen dezelfde major** (18.4 → 18.6) — de data-directory blijft
|
||||||
|
geldig, dus je hoeft niets te dumpen. Image ophalen en de container opnieuw
|
||||||
|
laten maken; het volume blijft staan:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose pull postgres
|
||||||
|
docker compose up -d postgres # let op: géén -v
|
||||||
|
```
|
||||||
|
|
||||||
|
> Gebruik hier nooit `docker compose down -v`. Dat wist het volume en dan is je
|
||||||
|
> database echt weg, terwijl deze upgrade dat helemaal niet vraagt.
|
||||||
|
|
||||||
|
De tag `postgres:18-alpine` beweegt mee met de patches, dus `pull` is genoeg —
|
||||||
|
je hoeft niets in `docker-compose.yml` aan te passen. Loont de moeite: tussen de
|
||||||
|
image van 18.4 en die van 18.6 zat een verschil van 143 naar 45 bevindingen in
|
||||||
|
de kwetsbaarhedenscan, vooral in de Alpine-pakketten eronder.
|
||||||
|
|
||||||
|
**Major-upgrade** (bijv. 16 → 18) — dán is de bestaande data niet compatibel en
|
||||||
|
moet je er wel doorheen met een dump:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. Backup maken
|
# 1. Backup maken
|
||||||
|
|||||||
+124
@@ -2,6 +2,130 @@
|
|||||||
|
|
||||||
## [Niet vrijgegeven]
|
## [Niet vrijgegeven]
|
||||||
|
|
||||||
|
### Opgelost
|
||||||
|
- **De kaart "Rendement Eigen Vermogen" op Voortgang stond leeg.** Bedrag en
|
||||||
|
percentage waren sinds de eerste commit hardcoded als `—` in de JSX; er zat
|
||||||
|
geen berekening achter en `evRows` werd niet eens uit de context gehaald. Het
|
||||||
|
oogde als een datafout — lege database, mislukte API-call — maar de kaart was
|
||||||
|
nooit afgebouwd. Hij rekent nu met dezelfde formule als het blok "Rendement"
|
||||||
|
op Eigen vermogen: huidige waarde min geïnvesteerde fiat, gedeeld door die
|
||||||
|
inleg. Groen bij winst, rood bij verlies, en `—` zolang er niets is ingelegd,
|
||||||
|
zodat er niet door nul wordt gedeeld
|
||||||
|
- Beide kaarten tonen daarmee hetzelfde totaal. Dat is bewust: twee plekken die
|
||||||
|
"rendement" noemen horen niet twee verschillende getallen te geven
|
||||||
|
|
||||||
|
### Gewijzigd
|
||||||
|
- De achtergrond achter een popup is lichter: `rgba(0,0,0,0.75)` naar `0.45` in
|
||||||
|
de gedeelde `Overlay`. Op 0.75 was van het scherm eronder vrijwel niets meer
|
||||||
|
te zien, terwijl je bij het invullen van een modal juist wilt kunnen nakijken
|
||||||
|
waar je vandaan kwam. Gedimd genoeg om de modal de aandacht te laten houden
|
||||||
|
- De popups van NavBar, Instellingen en Gebruikersbeheer gebruiken de gedeelde
|
||||||
|
`Overlay` niet maar hadden hun eigen backdrop op `rgba(0,0,0,0.55)` met
|
||||||
|
`backdropFilter: blur(4px)`. Alle drie staan nu op dezelfde 0.45, en de blur
|
||||||
|
is eruit: die maakte de achtergrond onscherp in plaats van zichtbaar, en
|
||||||
|
zonder dat weg te halen bleven ze alsnog afwijken van de rest. Vier
|
||||||
|
popup-backdrops, één waarde
|
||||||
|
|
||||||
|
## [1.0.12] - 2026-09-18
|
||||||
|
|
||||||
|
### Beveiliging
|
||||||
|
- **De postgres-container draait niet meer als root.** Beide compose-bestanden
|
||||||
|
starten hem met `user: "postgres"`. Alle 46 bevindingen op `postgres:18-alpine`
|
||||||
|
zitten in één bestand — `/usr/local/bin/gosu`, dat upstream met Go 1.24.6
|
||||||
|
compileert — en het entrypoint roept gosu alleen aan als het als root start.
|
||||||
|
Non-root starten slaat die tak over, dus de binary draait nooit meer. De
|
||||||
|
Alpine-pakketten in dat image zijn zelf schoon
|
||||||
|
- Getest op een bestaande database die ooit als root was aangemaakt: die start
|
||||||
|
als non-root en de data blijft intact. Ook een verse database initialiseert
|
||||||
|
gewoon
|
||||||
|
- Bewust géén eigen postgres-image gebouwd om die binary te verwijderen. Dat zou
|
||||||
|
de scan op nul zetten maar je verplichten het image zelf te herbouwen bij elke
|
||||||
|
postgres-patch — precies de achterstand die het image vier maanden op 18.4 met
|
||||||
|
143 bevindingen hield. De afweging staat in `BEHEER.md`, zodat de volgende
|
||||||
|
scan niet opnieuw tot dezelfde vraag leidt
|
||||||
|
- `test.ps1` waarschuwt bij stap 4 wanneer `postgres:18-alpine` in de registry
|
||||||
|
nieuwer is dan wat er lokaal staat. Die achterstand is het werkelijke risico;
|
||||||
|
het getal in de scan is dat niet
|
||||||
|
|
||||||
|
## [1.0.11] - 2026-09-18
|
||||||
|
|
||||||
|
### Beveiliging
|
||||||
|
- **PostgreSQL 18.4 → 18.6.** Het `postgres:18-alpine` image dat in gebruik was
|
||||||
|
dateerde van vier maanden geleden. Dat is een patch binnen dezelfde major, dus
|
||||||
|
de data-directory blijft geldig en er komt geen dump/restore aan te pas — het
|
||||||
|
volume blijft gewoon staan. De kwetsbaarhedenscan op het image gaat van 143
|
||||||
|
naar 45 bevindingen, het grootste deel in de Alpine-pakketten eronder. Wat
|
||||||
|
overblijft (1 kritiek, 21 hoog) zit allemaal in Go's `stdlib` v1.24.6 in een
|
||||||
|
meegecompileerde binary; dat kan alleen upstream oplossen
|
||||||
|
- `BEHEER.md` beschreef alleen de major-upgrade, met een `docker compose down
|
||||||
|
-v` erin. Wie dat bij een patch-upgrade zou volgen, wist zijn database zonder
|
||||||
|
dat het nodig is. De twee gevallen staan nu apart beschreven
|
||||||
|
- **De imagescan staat op nul.** De scan van 1.0.10 gaf 39 bevindingen
|
||||||
|
(1 kritiek, 12 hoog, 15 middel, 11 laag). Geen enkele kwam uit deze
|
||||||
|
applicatie: twintig zaten in `libssl3` en `libcrypto3` (Alpine in het
|
||||||
|
node-image liep achter op 3.5.8-r0) en negentien in de dependencies die npm
|
||||||
|
zelf meelevert — `tar`, `pacote`, `sigstore`, `brace-expansion`, `picomatch`,
|
||||||
|
`ip-address`. De eigen `ip-address` van de app stond al op 10.7.0 en was dus
|
||||||
|
nooit het probleem
|
||||||
|
- Het productie-image doet nu een `apk upgrade` aan het eind van de build. Het
|
||||||
|
node-image wordt minder vaak herbouwd dan Alpine patches uitbrengt, dus
|
||||||
|
OpenSSL loopt er structureel op achter
|
||||||
|
- npm, npx, corepack en yarn gaan uit het productie-image. De container start
|
||||||
|
met `node src/server.js` en gebruikt nooit een pakketbeheerder; ze zaten er
|
||||||
|
alleen in omdat het base-image ze meelevert. Weg met de tool is weg met die
|
||||||
|
hele categorie, ook bij de volgende ronde — npm's dependencies zijn een
|
||||||
|
terugkerende bron van meldingen
|
||||||
|
- Nieuwe teststap **[4/5] Imagescan** met Trivy, die het gebouwde image scant en
|
||||||
|
faalt op CRITICAL of HIGH waarvoor een fix bestaat (`--ignore-unfixed`, want
|
||||||
|
een CVE zonder patch hoort je release niet te blokkeren). `-Strict` trekt de
|
||||||
|
lat naar MEDIUM en LOW, `-SkipScan` slaat hem over. Stap 1 kijkt naar de
|
||||||
|
`package.json`'s en had geen van deze 39 gezien; deze kijkt naar wat er
|
||||||
|
werkelijk in het image zit. `release.ps1` draait dezelfde scan vóór het
|
||||||
|
pushen, dus een image met openstaande CVE's komt de registry niet meer in
|
||||||
|
|
||||||
|
### Toegevoegd
|
||||||
|
- **Export naar HTML.** Onder Instellingen staat naast backup en restore nu
|
||||||
|
"Exporteren naar HTML": één zelfdragend bestand met al je gegevens, bedoeld
|
||||||
|
als laatste vangnet. Draait de app niet meer, is de database weg en ligt er
|
||||||
|
geen backup, dan is dit bestand nog steeds te openen — in elke browser, op een
|
||||||
|
computer zonder internet. Er zit niets van buiten in: opmaak staat inline,
|
||||||
|
geen lettertypen, geen afbeeldingen, geen scripts
|
||||||
|
- De pagina toont een samenvatting met de KPI's, en daaronder eigen vermogen en
|
||||||
|
schulden per categorie met subtotalen en een eindtotaal, de voortgangstabel
|
||||||
|
met verschil per moment, en de historie van EV en schuld. Bedragen staan met
|
||||||
|
twee decimalen — anders dan het scherm, want dit is een archiefstuk waar de
|
||||||
|
exacte waarde in hoort — rechts uitgelijnd met cijfers van gelijke breedte
|
||||||
|
- **Het bestand is tegelijk een backup.** Onderin staat de complete ruwe data in
|
||||||
|
een `<script type="application/json">`-blok dat de browser niet uitvoert en
|
||||||
|
niet toont. "Backup herstellen" accepteert daarom nu naast de ZIP ook zo'n
|
||||||
|
HTML-bestand. Eén bestand dat je zowel kunt lezen als kunt terugzetten
|
||||||
|
- Uitvoer wordt zowel als HTML als binnen het JSON-blok ge-escaped. Een post die
|
||||||
|
`Fonds <A> & co` heet, of een `</script>` in de gegevens, sloopt de pagina
|
||||||
|
niet en breekt de ingebakken backup niet open
|
||||||
|
- Nieuwe `utils/htmlExport.js`, met `leesHtmlBackup` voor de weg terug. Die
|
||||||
|
gebruikt `DOMParser` en geen reguliere expressie: de gegevens kunnen van alles
|
||||||
|
bevatten, en een parser die HTML echt begrijpt trapt daar niet in. Geen nieuwe
|
||||||
|
dependency
|
||||||
|
- De voortgangstabel is Datum plus één kolom per categorie plus vier vaste
|
||||||
|
kolommen — bij zes categorieën twaalf stuks, en die passen op geen enkel
|
||||||
|
scherm. De tabellen scrollen daarom horizontaal in plaats van kolommen buiten
|
||||||
|
beeld te laten vallen. De datumkolom blijft daarbij staan, want zonder die
|
||||||
|
kolom weet je niet meer naar welk moment je kijkt, en een schaduwrandje
|
||||||
|
verschijnt aan de kant waar nog inhoud zit. Dat laatste is een CSS-truc met
|
||||||
|
meeschuivende achtergronden: de pagina mag geen scripts bevatten, maar zonder
|
||||||
|
hint ziet niemand dat er rechts meer staat. Op print vervalt het allemaal en
|
||||||
|
wordt de tabel kleiner gezet, zodat alle kolommen op papier alsnog meekomen
|
||||||
|
- De **initiële schuld** stond wel in de gegevens maar niet op de pagina. Hij
|
||||||
|
hoort niet bij een schuldblok, dus zonder eigen regel viel hij buiten de
|
||||||
|
export. Staat er nu onder de schuldentabel, met "afgelost sinds start" erbij
|
||||||
|
- Stortingen op een **verwijderde categorie** blijven in oude voortgangsrijen
|
||||||
|
staan, maar de app toont alleen kolommen van bestaande categorieën — die
|
||||||
|
bedragen waren dus nergens meer te zien. De export geeft ze alsnog een kolom,
|
||||||
|
gemerkt als `Verwijderd (c9)`. Voor een bestand dat alles moet bewaren wat je
|
||||||
|
ooit hebt ingevoerd is stil weglaten het verkeerde gedrag
|
||||||
|
|
||||||
|
## [1.0.10] - 2026-09-17
|
||||||
|
|
||||||
### Opgelost
|
### Opgelost
|
||||||
- Het laatste label op de x-as viel over zijn buurman heen (`mei '26` en `26`
|
- 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
|
over elkaar). De stap werd vanaf het begin van de reeks geteld en het laatste
|
||||||
|
|||||||
+23
@@ -60,6 +60,29 @@ ARG APP_VERSION=dev
|
|||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
ENV APP_VERSION=$APP_VERSION
|
ENV APP_VERSION=$APP_VERSION
|
||||||
|
|
||||||
|
# ── Aanvalsoppervlak beperken ─────────────────────────────────────────────────
|
||||||
|
# Twee stappen die samen elke CVE uit de imagescan wegnemen. Ze staan hier en
|
||||||
|
# niet in een andere stage: alleen dit image gaat naar de registry.
|
||||||
|
#
|
||||||
|
# 1. OS-pakketten bijwerken. Het node-image wordt minder vaak herbouwd dan
|
||||||
|
# Alpine patches uitbrengt, dus libssl3 en libcrypto3 lopen er structureel
|
||||||
|
# op achter — goed voor twintig CVE's in de laatste scan. Dit haalt ze op
|
||||||
|
# het moment van bouwen alsnog bij.
|
||||||
|
#
|
||||||
|
# 2. De pakketbeheerders eruit. De container start met `node src/server.js` en
|
||||||
|
# gebruikt npm, npx, corepack of yarn nooit; ze zitten er alleen in omdat
|
||||||
|
# het base-image ze meelevert. Alle negentien Node-CVE's uit de scan zaten
|
||||||
|
# in npm's eigen meegeleverde dependencies (tar, pacote, sigstore,
|
||||||
|
# brace-expansion, picomatch, ip-address) en hadden niets met deze
|
||||||
|
# applicatie te maken. Weg met de tool is weg met die hele categorie —
|
||||||
|
# inclusief de volgende ronde, want ze komen steeds terug.
|
||||||
|
RUN apk upgrade --no-cache \
|
||||||
|
&& rm -rf /usr/local/lib/node_modules/npm \
|
||||||
|
/usr/local/lib/node_modules/corepack \
|
||||||
|
/usr/local/bin/npm /usr/local/bin/npx /usr/local/bin/corepack \
|
||||||
|
/usr/local/bin/yarn /usr/local/bin/yarnpkg \
|
||||||
|
/opt/yarn-v*
|
||||||
|
|
||||||
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
|
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ Eigen vermogen · Schulden · Voortgang · Gebruikersbeheer
|
|||||||

|

|
||||||

|

|
||||||

|

|
||||||

|

|
||||||

|

|
||||||
|
|
||||||
<img src="docs/screenshot-dashboard.png" alt="VibeFinance dashboard" width="820">
|
<img src="docs/screenshot-dashboard.png" alt="VibeFinance dashboard" width="820">
|
||||||
@@ -117,11 +117,12 @@ uitzonderingen nodig. Handige acties:
|
|||||||
### Testen
|
### Testen
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
.\test.ps1 # dependency-check + backend-tests + build + smoketest
|
.\test.ps1 # deps + backend-tests + build + imagescan + smoketest
|
||||||
.\test.ps1 -Unit # dependency-check + backend-tests
|
.\test.ps1 -Unit # dependency-check + backend-tests
|
||||||
.\test.ps1 -NoSmoke # zonder de stack te starten
|
.\test.ps1 -NoSmoke # zonder de stack te starten (imagescan draait wél)
|
||||||
.\test.ps1 -Strict # faal als een dependency achterloopt
|
.\test.ps1 -Strict # faal op achterlopende deps; scan ook op MEDIUM/LOW
|
||||||
.\test.ps1 -SkipDeps # dependency-check overslaan (bijv. offline)
|
.\test.ps1 -SkipDeps # dependency-check overslaan (bijv. offline)
|
||||||
|
.\test.ps1 -SkipScan # imagescan overslaan (bijv. offline)
|
||||||
```
|
```
|
||||||
|
|
||||||
Alles loopt in Docker — Node op de host is niet nodig. De backend-tests draaien
|
Alles loopt in Docker — Node op de host is niet nodig. De backend-tests draaien
|
||||||
@@ -141,6 +142,26 @@ backend/package.json (15 dependencies)
|
|||||||
alles actueel
|
alles actueel
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Stap 4 scant het gebouwde image met [Trivy](https://trivy.dev) en blokkeert bij
|
||||||
|
CRITICAL of HIGH waarvoor een fix bestaat. Dat is iets anders dan stap 1: die
|
||||||
|
kijkt naar de `package.json`'s, deze naar wat er daadwerkelijk in het image zit
|
||||||
|
— de Alpine-pakketten en alles wat het base-image meelevert. Dat onderscheid is
|
||||||
|
niet theoretisch. Bij de scan van 1.0.10 kwamen alle 39 bevindingen daarvandaan
|
||||||
|
en geen enkele uit de applicatie zelf:
|
||||||
|
|
||||||
|
| Bron | Aantal | Waar het vandaan kwam |
|
||||||
|
|---|---|---|
|
||||||
|
| `libssl3` + `libcrypto3` | 20 | Alpine in het node-image liep achter op 3.5.8-r0 |
|
||||||
|
| npm's eigen dependencies | 19 | `tar`, `pacote`, `sigstore`, `brace-expansion`, `picomatch`, `ip-address` |
|
||||||
|
|
||||||
|
Een dependency-check had er nul van gezien. Het productie-image doet daarom aan
|
||||||
|
het eind van de build een `apk upgrade` en gooit npm, npx, corepack en yarn
|
||||||
|
eruit: de container start met `node src/server.js` en gebruikt nooit een
|
||||||
|
pakketbeheerder. Dat bracht de scan op nul, en houdt hem daar — npm's
|
||||||
|
dependencies zijn een terugkerende bron die nu simpelweg niet meer in het image
|
||||||
|
zit. `release.ps1` draait dezelfde scan vóór het pushen, dus een image met
|
||||||
|
openstaande CVE's komt de registry niet in.
|
||||||
|
|
||||||
### Productie release
|
### Productie release
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
@@ -169,9 +190,9 @@ build — anders zou de release met een placeholder als notes online komen.
|
|||||||
| 📊 **Dashboard** | KPI's over inleg, vermogen, winst en ROI, de voortgang naar je doel, en de grafiek met vermogen tegen inleg |
|
| 📊 **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 |
|
||||||
| 📉 **Schuld** | Schulden bijhouden per blok met afbetalingsoverzicht |
|
| 📉 **Schuld** | Schulden bijhouden per blok met afbetalingsoverzicht |
|
||||||
| 📈 **Voortgang Schuld & EV** | Grafieken en trends van EV en schuld over tijd (apart bij te houden) |
|
| 📈 **Voortgang Schuld & EV** | KPI-kaarten met de verdeling van schuld en EV, het rendement en de netto waarde, plus grafieken en trends van EV en schuld over tijd (apart bij te houden) |
|
||||||
| 👥 **Gebruikers** | Accounts aanmaken, bewerken en beheren (Admin-rol vereist) |
|
| 👥 **Gebruikers** | Accounts aanmaken, bewerken en beheren (Admin-rol vereist) |
|
||||||
| ⚙️ **Instellingen** | Kleurthema, licht/donker, backup downloaden en backup terugzetten |
|
| ⚙️ **Instellingen** | Kleurthema, licht/donker, backup downloaden, backup terugzetten en export naar HTML |
|
||||||
|
|
||||||
De grafiek "Ontwikkeling van Eigen Vermogen" op het dashboard tekent twee lijnen
|
De grafiek "Ontwikkeling van Eigen Vermogen" op het dashboard tekent twee lijnen
|
||||||
op één as: je netto waarde per voortgangsmoment, en daaronder de cumulatief
|
op één as: je netto waarde per voortgangsmoment, en daaronder de cumulatief
|
||||||
@@ -181,6 +202,13 @@ inleglijn is per definitie gelijk aan de KPI "Totaal geïnvesteerde fiat" — be
|
|||||||
rekenen het oudste voortgangsmoment als startinleg en tellen daar de stortingen
|
rekenen het oudste voortgangsmoment als startinleg en tellen daar de stortingen
|
||||||
per rij bij op.
|
per rij bij op.
|
||||||
|
|
||||||
|
Rendement betekent overal hetzelfde: huidige waarde min geïnvesteerde fiat,
|
||||||
|
gedeeld door die inleg. De kaart "Rendement Eigen Vermogen" op Voortgang en het
|
||||||
|
blok "Rendement" op Eigen vermogen tonen daarom hetzelfde totaal — dat laatste
|
||||||
|
splitst het alleen nog uit per categorie. Zolang er geen inleg is vastgelegd
|
||||||
|
staat er `—` in plaats van een percentage, want zonder noemer is er geen
|
||||||
|
rendement te berekenen.
|
||||||
|
|
||||||
De x-as noemt maanden (`sep '26`), niet losse datums: de grafiek rekent uit
|
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
|
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
|
laatste punt zodat dat er altijd bij staat. Bij een langere reeks dunt hij
|
||||||
@@ -209,6 +237,28 @@ downloadt een ZIP (`data.json` + `meta.json`) met je eigen financiële data,
|
|||||||
"Backup terugzetten" leest zo'n ZIP weer in en schrijft hem via `/api/data` weg.
|
"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`.
|
Dat is de gebruikersvariant; een volledige database-dump staat in `BEHEER.md`.
|
||||||
|
|
||||||
|
Daarnaast staat er **"Exporteren naar HTML"**: één zelfdragend bestand met al je
|
||||||
|
gegevens, bedoeld als laatste vangnet. Draait de app niet meer, is de database
|
||||||
|
weg en ligt er geen backup, dan opent dit bestand nog steeds — in elke browser,
|
||||||
|
op een computer zonder internet. Er zit niets van buiten in: de opmaak staat
|
||||||
|
inline, er worden geen lettertypen, afbeeldingen of scripts geladen. Je ziet een
|
||||||
|
samenvatting met de KPI's, eigen vermogen en schulden per categorie met
|
||||||
|
subtotalen, de initiële schuld met wat er sinds de start is afgelost, de
|
||||||
|
voortgangstabel en de historie.
|
||||||
|
|
||||||
|
Alles wat je ooit hebt ingevoerd komt mee, ook wat de app zelf niet meer toont:
|
||||||
|
stortingen op een inmiddels verwijderde categorie krijgen een eigen kolom
|
||||||
|
(`Verwijderd (c9)`). De voortgangstabel is bij zes categorieën twaalf kolommen
|
||||||
|
breed en scrollt horizontaal in plaats van kolommen af te kappen; de datumkolom
|
||||||
|
blijft daarbij staan. Op print vervalt de scrollbalk en wordt de tabel kleiner
|
||||||
|
gezet, zodat alle kolommen op papier alsnog passen.
|
||||||
|
|
||||||
|
Het bestand is tegelijk een backup: onderin staat de complete ruwe data in een
|
||||||
|
`<script type="application/json">`-blok dat de browser niet uitvoert en niet
|
||||||
|
toont. "Backup herstellen" accepteert daarom naast de ZIP ook zo'n HTML-bestand.
|
||||||
|
Eén bestand dat je zowel kunt lézen als kunt terugzetten — zie
|
||||||
|
[`frontend/src/utils/htmlExport.js`](frontend/src/utils/htmlExport.js).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🏗️ Architectuur
|
## 🏗️ Architectuur
|
||||||
@@ -244,7 +294,7 @@ vibefinance/
|
|||||||
│ ├── version.js # Draaiende versie (__APP_VERSION__ uit de build)
|
│ ├── version.js # Draaiende versie (__APP_VERSION__ uit de build)
|
||||||
│ ├── constants/ # index.js (kleuren, tabs, begindata), themas.js
|
│ ├── constants/ # index.js (kleuren, tabs, begindata), themas.js
|
||||||
│ ├── utils/ # format.js (bedragen/datums), storage.js,
|
│ ├── utils/ # format.js (bedragen/datums), storage.js,
|
||||||
│ │ # grafiek.js (SVG-padberekening)
|
│ │ # grafiek.js (SVG), htmlExport.js (export)
|
||||||
│ ├── hooks/ # useTheme, useUpdateCheck, useKolomBreedtes
|
│ ├── hooks/ # useTheme, useUpdateCheck, useKolomBreedtes
|
||||||
│ ├── context/ # AppContext (global state + API sync + backup)
|
│ ├── context/ # AppContext (global state + API sync + backup)
|
||||||
│ ├── components/
|
│ ├── components/
|
||||||
@@ -253,7 +303,7 @@ vibefinance/
|
|||||||
│ │ ├── LoginBackground.jsx
|
│ │ ├── LoginBackground.jsx
|
||||||
│ │ ├── NavBar.jsx
|
│ │ ├── NavBar.jsx
|
||||||
│ │ ├── ProfielPopup.jsx
|
│ │ ├── ProfielPopup.jsx
|
||||||
│ │ ├── InstellingenModal.jsx # Thema + backup/restore
|
│ │ ├── InstellingenModal.jsx # Thema + backup/restore + export
|
||||||
│ │ └── GebruikersBeheer.jsx
|
│ │ └── GebruikersBeheer.jsx
|
||||||
│ └── pages/
|
│ └── pages/
|
||||||
│ ├── DashboardTab.jsx
|
│ ├── DashboardTab.jsx
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ services:
|
|||||||
postgres:
|
postgres:
|
||||||
image: postgres:18-alpine
|
image: postgres:18-alpine
|
||||||
container_name: vibefinance-postgres-dev
|
container_name: vibefinance-postgres-dev
|
||||||
|
# Zie docker-compose.yml: non-root starten omzeilt gosu, waar alle CVE's
|
||||||
|
# van dit image in zitten.
|
||||||
|
user: "postgres"
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: ${POSTGRES_DB:-vibefinance}
|
POSTGRES_DB: ${POSTGRES_DB:-vibefinance}
|
||||||
POSTGRES_USER: ${POSTGRES_USER:-vibefinance}
|
POSTGRES_USER: ${POSTGRES_USER:-vibefinance}
|
||||||
|
|||||||
@@ -16,6 +16,13 @@ services:
|
|||||||
image: postgres:18-alpine
|
image: postgres:18-alpine
|
||||||
container_name: vibefinance_postgres
|
container_name: vibefinance_postgres
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
# Meteen als postgres draaien in plaats van als root. Het entrypoint schakelt
|
||||||
|
# anders zelf terug met `gosu`, en juist die binary bevat alle 46 CVE's van
|
||||||
|
# dit image (Go stdlib 1.24.6, meegecompileerd door upstream). Start de
|
||||||
|
# container al als postgres, dan slaat het entrypoint die tak over en wordt
|
||||||
|
# gosu nooit uitgevoerd. Werkt ook op een volume dat ooit als root is
|
||||||
|
# aangemaakt — de data-directory is dan al van postgres.
|
||||||
|
user: "postgres"
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: ${POSTGRES_DB:-vibefinance}
|
POSTGRES_DB: ${POSTGRES_DB:-vibefinance}
|
||||||
POSTGRES_USER: ${POSTGRES_USER:-vibefinance}
|
POSTGRES_USER: ${POSTGRES_USER:-vibefinance}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "vibefinance-frontend",
|
"name": "vibefinance-frontend",
|
||||||
"version": "1.0.10-dev",
|
"version": "1.0.13-dev",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -172,7 +172,7 @@ export default function GebruikersBeheer({ onClose }) {
|
|||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
style={{
|
style={{
|
||||||
position: "fixed", inset: 0, zIndex: 400,
|
position: "fixed", inset: 0, zIndex: 400,
|
||||||
background: "rgba(0,0,0,0.55)", backdropFilter: "blur(4px)",
|
background: "rgba(0,0,0,0.45)",
|
||||||
display: "flex", alignItems: "center", justifyContent: "center",
|
display: "flex", alignItems: "center", justifyContent: "center",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -15,6 +15,14 @@ const RESTORE_ICON = (
|
|||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const EXPORT_ICON = (
|
||||||
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M14 3H7a1 1 0 00-1 1v16a1 1 0 001 1h10a1 1 0 001-1V7l-4-4z" stroke="currentColor" strokeWidth="1.8" strokeLinejoin="round"/>
|
||||||
|
<path d="M14 3v4h4" stroke="currentColor" strokeWidth="1.8" strokeLinejoin="round"/>
|
||||||
|
<path d="M9 12h6M9 16h6" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round"/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
function ActionRow({ icon, title, description, onClick }) {
|
function ActionRow({ icon, title, description, onClick }) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -34,14 +42,14 @@ function ActionRow({ icon, title, description, onClick }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function InstellingenModal({ onClose }) {
|
export default function InstellingenModal({ onClose }) {
|
||||||
const { T, darkMode, setDarkMode, thema, setThema, doBackup, doRestore } = useApp();
|
const { T, darkMode, setDarkMode, thema, setThema, doBackup, doRestore, doExport } = useApp();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
style={{
|
style={{
|
||||||
position: "fixed", inset: 0, zIndex: 400,
|
position: "fixed", inset: 0, zIndex: 400,
|
||||||
background: "rgba(0,0,0,0.55)", backdropFilter: "blur(4px)",
|
background: "rgba(0,0,0,0.45)",
|
||||||
display: "flex", alignItems: "center", justifyContent: "center",
|
display: "flex", alignItems: "center", justifyContent: "center",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -143,9 +151,16 @@ export default function InstellingenModal({ onClose }) {
|
|||||||
<ActionRow
|
<ActionRow
|
||||||
icon={RESTORE_ICON}
|
icon={RESTORE_ICON}
|
||||||
title="Backup herstellen"
|
title="Backup herstellen"
|
||||||
description="Herstel data vanuit een backup-bestand"
|
description="Herstel vanuit een backup-ZIP of HTML"
|
||||||
onClick={() => { doRestore(); onClose(); }}
|
onClick={() => { doRestore(); onClose(); }}
|
||||||
/>
|
/>
|
||||||
|
<div style={{ height: 1, background: T.border }} />
|
||||||
|
<ActionRow
|
||||||
|
icon={EXPORT_ICON}
|
||||||
|
title="Exporteren naar HTML"
|
||||||
|
description="Leesbare pagina die ook als backup werkt"
|
||||||
|
onClick={() => { doExport(); onClose(); }}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ function OverPopup({ onClose, nieuweVersie, T }) {
|
|||||||
{/* Overlay */}
|
{/* Overlay */}
|
||||||
<div onClick={onClose} style={{
|
<div onClick={onClose} style={{
|
||||||
position: "fixed", inset: 0, zIndex: 490,
|
position: "fixed", inset: 0, zIndex: 490,
|
||||||
background: "rgba(0,0,0,0.55)", backdropFilter: "blur(4px)",
|
background: "rgba(0,0,0,0.45)",
|
||||||
display: "flex", alignItems: "center", justifyContent: "center",
|
display: "flex", alignItems: "center", justifyContent: "center",
|
||||||
}}>
|
}}>
|
||||||
{/* Modal */}
|
{/* Modal */}
|
||||||
|
|||||||
@@ -319,7 +319,7 @@ export function Overlay({ children, onClose, zIndex = 400 }) {
|
|||||||
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||||
style={{
|
style={{
|
||||||
position: "fixed", inset: 0,
|
position: "fixed", inset: 0,
|
||||||
background: "rgba(0,0,0,0.75)",
|
background: "rgba(0,0,0,0.45)",
|
||||||
zIndex,
|
zIndex,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import JSZip from "jszip";
|
|||||||
import { INITIAL_DATA } from "../constants/index.js";
|
import { INITIAL_DATA } from "../constants/index.js";
|
||||||
import { STANDAARD_THEMA } from "../constants/themas.js";
|
import { STANDAARD_THEMA } from "../constants/themas.js";
|
||||||
import { migrateData, loadFromStorage, saveToStorage } from "../utils/storage.js";
|
import { migrateData, loadFromStorage, saveToStorage } from "../utils/storage.js";
|
||||||
|
import { bouwHtmlExport, leesHtmlBackup } from "../utils/htmlExport.js";
|
||||||
import { useTheme } from "../hooks/useTheme.js";
|
import { useTheme } from "../hooks/useTheme.js";
|
||||||
|
|
||||||
const AppContext = createContext(null);
|
const AppContext = createContext(null);
|
||||||
@@ -168,17 +169,43 @@ export function AppProvider({ children }) {
|
|||||||
if (saveTimer.current) clearTimeout(saveTimer.current);
|
if (saveTimer.current) clearTimeout(saveTimer.current);
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Backup / restore ──────────────────────────────────────────
|
// ── Backup / restore / export ─────────────────────────────────
|
||||||
|
const downloadZip = async (zip, bestandsnaam) => {
|
||||||
|
const blob = await zip.generateAsync({ type: "blob", compression: "DEFLATE" });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = bestandsnaam;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
|
||||||
const doBackup = async () => {
|
const doBackup = async () => {
|
||||||
const zip = new JSZip();
|
const zip = new JSZip();
|
||||||
const datum = new Date().toISOString().slice(0, 10);
|
const datum = new Date().toISOString().slice(0, 10);
|
||||||
zip.file("data.json", JSON.stringify({ ...data, evVoortgang: evRows, evVoortgangHistory }, null, 2));
|
zip.file("data.json", JSON.stringify({ ...data, evVoortgang: evRows, evVoortgangHistory }, null, 2));
|
||||||
zip.file("meta.json", JSON.stringify({ versie: 1, datum, app: "vibefinance", gebruiker: currentUser?.email }, null, 2));
|
zip.file("meta.json", JSON.stringify({ versie: 1, datum, app: "vibefinance", gebruiker: currentUser?.email }, null, 2));
|
||||||
const blob = await zip.generateAsync({ type: "blob", compression: "DEFLATE" });
|
await downloadZip(zip, `vibefinance_backup_${datum}.zip`);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export naar één zelfdragende HTML-pagina: leesbaar zonder de app, en met de
|
||||||
|
* ruwe data ingebakken zodat `doRestore` hem ook weer terug kan zetten.
|
||||||
|
*/
|
||||||
|
const doExport = () => {
|
||||||
|
const datum = new Date().toISOString().slice(0, 10);
|
||||||
|
const html = bouwHtmlExport({
|
||||||
|
cats, schuldBlokken, evRows, evVoortgangHistory, schuldHistory,
|
||||||
|
totEV, totSchuld, doel: ev?.doel || 0,
|
||||||
|
initieleSchuld: schuld?.initieleSchuld || 0,
|
||||||
|
gebruiker: currentUser?.email || "",
|
||||||
|
ruweData: { ...data, evVoortgang: evRows, evVoortgangHistory },
|
||||||
|
});
|
||||||
|
const blob = new Blob([html], { type: "text/html;charset=utf-8" });
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const a = document.createElement("a");
|
const a = document.createElement("a");
|
||||||
a.href = url;
|
a.href = url;
|
||||||
a.download = `vibefinance_backup_${datum}.zip`;
|
a.download = `vibefinance_${datum}.html`;
|
||||||
a.click();
|
a.click();
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
};
|
};
|
||||||
@@ -186,15 +213,25 @@ export function AppProvider({ children }) {
|
|||||||
const doRestore = () => {
|
const doRestore = () => {
|
||||||
const input = document.createElement("input");
|
const input = document.createElement("input");
|
||||||
input.type = "file";
|
input.type = "file";
|
||||||
input.accept = ".zip";
|
input.accept = ".zip,.html";
|
||||||
input.onchange = async (e) => {
|
input.onchange = async (e) => {
|
||||||
const file = e.target.files[0];
|
const file = e.target.files[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
try {
|
try {
|
||||||
const zip = await JSZip.loadAsync(file);
|
// Twee soorten bestanden met dezelfde inhoud: de backup-ZIP met
|
||||||
const entry = zip.file("data.json");
|
// `data.json`, en de geëxporteerde HTML-pagina met hetzelfde JSON in
|
||||||
if (!entry) throw new Error("Geen data.json gevonden in backup.");
|
// een script-blok. Zonder dit tweede pad is de belofte onderaan die
|
||||||
const parsed = JSON.parse(await entry.async("string"));
|
// pagina — "hiermee kun je herstellen" — niet waar te maken.
|
||||||
|
let parsed;
|
||||||
|
if (file.name.toLowerCase().endsWith(".html")) {
|
||||||
|
parsed = leesHtmlBackup(await file.text());
|
||||||
|
if (!parsed) throw new Error("Deze HTML-pagina bevat geen VibeFinance-gegevens.");
|
||||||
|
} else {
|
||||||
|
const zip = await JSZip.loadAsync(file);
|
||||||
|
const entry = zip.file("data.json");
|
||||||
|
if (!entry) throw new Error("Geen data.json gevonden in backup.");
|
||||||
|
parsed = JSON.parse(await entry.async("string"));
|
||||||
|
}
|
||||||
const migrated = migrateData({ eigenVermogen: parsed.eigenVermogen, schuld: parsed.schuld });
|
const migrated = migrateData({ eigenVermogen: parsed.eigenVermogen, schuld: parsed.schuld });
|
||||||
const newEvRows = parsed.evVoortgang || [];
|
const newEvRows = parsed.evVoortgang || [];
|
||||||
const newEvVH = parsed.evVoortgangHistory || [];
|
const newEvVH = parsed.evVoortgangHistory || [];
|
||||||
@@ -229,7 +266,7 @@ export function AppProvider({ children }) {
|
|||||||
evRows, setEvRows,
|
evRows, setEvRows,
|
||||||
evVoortgangHistory, setEvVoortgangHistory,
|
evVoortgangHistory, setEvVoortgangHistory,
|
||||||
users, setUsers,
|
users, setUsers,
|
||||||
doBackup, doRestore,
|
doBackup, doRestore, doExport,
|
||||||
}}>
|
}}>
|
||||||
{children}
|
{children}
|
||||||
</AppContext.Provider>
|
</AppContext.Provider>
|
||||||
|
|||||||
@@ -237,13 +237,22 @@ export default function VoortgangTab() {
|
|||||||
const [showAdd, setShowAdd] = useState(false);
|
const [showAdd, setShowAdd] = useState(false);
|
||||||
const [editRow, setEditRow] = useState(null);
|
const [editRow, setEditRow] = useState(null);
|
||||||
const {
|
const {
|
||||||
totEV, totSchuld, catTotals, schuldBlokken, schuldHistory,
|
totEV, totSchuld, cats, catTotals, evRows, schuldBlokken, schuldHistory,
|
||||||
evVoortgangHistory, setEvVoortgangHistory,
|
evVoortgangHistory, setEvVoortgangHistory,
|
||||||
data, setData, schuld, locked, darkMode, T,
|
data, setData, schuld, locked, darkMode, T,
|
||||||
} = useApp();
|
} = useApp();
|
||||||
|
|
||||||
const nettoWaarde = totEV - totSchuld;
|
const nettoWaarde = totEV - totSchuld;
|
||||||
|
|
||||||
|
// Rendement t.o.v. de geinvesteerde fiat, zelfde formule als op de EV-tab.
|
||||||
|
const totGeinvesteerd = cats.reduce(
|
||||||
|
(a, c) => a + evRows.reduce((b, r) => b + (r[c.id] || 0), 0),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
const rendementBedrag = totGeinvesteerd > 0 ? totEV - totGeinvesteerd : null;
|
||||||
|
const rendementPct = totGeinvesteerd > 0 ? (rendementBedrag / totGeinvesteerd) * 100 : null;
|
||||||
|
const rendementKleur = rendementBedrag === null ? T.muted : rendementBedrag >= 0 ? GREEN : RED;
|
||||||
|
|
||||||
const schuldCatsDash = schuldBlokken.map((b) => ({
|
const schuldCatsDash = schuldBlokken.map((b) => ({
|
||||||
naam: b.naam, color: b.color,
|
naam: b.naam, color: b.color,
|
||||||
subtot: b.items.reduce((a, i) => a + (i.waarde || 0), 0),
|
subtot: b.items.reduce((a, i) => a + (i.waarde || 0), 0),
|
||||||
@@ -377,11 +386,15 @@ export default function VoortgangTab() {
|
|||||||
<div style={{ fontWeight: 700, fontSize: 12, letterSpacing: "0.08em", textTransform: "uppercase", color: "#f59e0b", marginBottom: 12 }}>Rendement Eigen Vermogen</div>
|
<div style={{ fontWeight: 700, fontSize: 12, letterSpacing: "0.08em", textTransform: "uppercase", color: "#f59e0b", marginBottom: 12 }}>Rendement Eigen Vermogen</div>
|
||||||
<div style={{ padding: "10px 0", borderBottom: `1px solid ${T.border}` }}>
|
<div style={{ padding: "10px 0", borderBottom: `1px solid ${T.border}` }}>
|
||||||
<div style={{ fontSize: 11, color: T.muted, marginBottom: 4 }}>Bedrag</div>
|
<div style={{ fontSize: 11, color: T.muted, marginBottom: 4 }}>Bedrag</div>
|
||||||
<div style={{ fontSize: 15, fontWeight: 700, color: T.muted }}>—</div>
|
<div style={{ fontSize: 15, fontWeight: 700, color: rendementKleur }}>
|
||||||
|
{rendementBedrag === null ? "—" : fmt(rendementBedrag)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ padding: "10px 0" }}>
|
<div style={{ padding: "10px 0" }}>
|
||||||
<div style={{ fontSize: 11, color: T.muted, marginBottom: 4 }}>Percentage</div>
|
<div style={{ fontSize: 11, color: T.muted, marginBottom: 4 }}>Percentage</div>
|
||||||
<div style={{ fontSize: 15, fontWeight: 700, color: T.muted }}>—</div>
|
<div style={{ fontSize: 15, fontWeight: 700, color: rendementKleur }}>
|
||||||
|
{rendementPct === null ? "—" : `${rendementPct >= 0 ? "+" : ""}${rendementPct.toFixed(1)}%`}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</VCard>
|
</VCard>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,354 @@
|
|||||||
|
/**
|
||||||
|
* Export naar één zelfdragende HTML-pagina.
|
||||||
|
*
|
||||||
|
* Bedoeld als laatste vangnet: als de app niet meer draait, de database weg is
|
||||||
|
* en er geen backup ligt, moet dit ene bestand nog steeds al je gegevens tonen.
|
||||||
|
* Daarom zit alles erin — opmaak inline, geen scripts, geen lettertypen of
|
||||||
|
* afbeeldingen van buiten. Het bestand is op een computer zonder internet, over
|
||||||
|
* tien jaar, in elke browser nog te openen.
|
||||||
|
*
|
||||||
|
* Het is bovendien tegelijk een backup. Onderaan de pagina staat de complete
|
||||||
|
* ruwe data in een `<script type="application/json">`-blok dat de browser niet
|
||||||
|
* uitvoert en niet toont. "Backup herstellen" leest dat blok, dus je kunt dit
|
||||||
|
* bestand net zo goed terugzetten als een backup-ZIP. Eén bestand dat zowel
|
||||||
|
* leesbaar is voor jou als bruikbaar voor de app.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ── Formatters ────────────────────────────────────────────────────────────────
|
||||||
|
// Bewust met twee decimalen, anders dan de `fmt` op het scherm. Dit is een
|
||||||
|
// archiefstuk: hier hoort de exacte waarde in te staan, niet de afgeronde.
|
||||||
|
const euro = (n) =>
|
||||||
|
new Intl.NumberFormat("nl-NL", { style: "currency", currency: "EUR", minimumFractionDigits: 2 })
|
||||||
|
.format(Number(n) || 0);
|
||||||
|
|
||||||
|
const procent = (n) => `${((Number(n) || 0) * 100).toFixed(2).replace(".", ",")}%`;
|
||||||
|
|
||||||
|
const datumNl = (s) => {
|
||||||
|
if (!s) return "";
|
||||||
|
const [y, m, d] = String(s).split("-");
|
||||||
|
return d ? `${d}-${m}-${y}` : String(s);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tekst veilig in HTML zetten. Een post die "Fonds <A> & co" heet mag de pagina
|
||||||
|
* niet slopen, en opmerkingen zijn vrije tekst.
|
||||||
|
*/
|
||||||
|
const esc = (v) =>
|
||||||
|
String(v ?? "")
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """);
|
||||||
|
|
||||||
|
const cel = (v, klasse = "") => `<td${klasse ? ` class="${klasse}"` : ""}>${esc(v)}</td>`;
|
||||||
|
const kop = (v, klasse = "") => `<th${klasse ? ` class="${klasse}"` : ""}>${esc(v)}</th>`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tabel met kopregel. `num` bevat de kolomindexen met bedragen; die worden
|
||||||
|
* rechts uitgelijnd. Een rij mag `{ klasse, cellen }` zijn om zichzelf als
|
||||||
|
* subtotaal of eindtotaal te markeren.
|
||||||
|
*/
|
||||||
|
const maakTabel = (kolommen, rijen, num = []) => `
|
||||||
|
<div class="scroll">
|
||||||
|
<table>
|
||||||
|
<thead><tr>${kolommen.map((k, i) => kop(k, num.includes(i) ? "num" : "")).join("")}</tr></thead>
|
||||||
|
<tbody>${rijen.map((r) => `<tr${r.klasse ? ` class="${r.klasse}"` : ""}>${
|
||||||
|
(r.cellen || r).map((c, i) => cel(c, num.includes(i) ? "num" : "")).join("")
|
||||||
|
}</tr>`).join("")}</tbody>
|
||||||
|
</table>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
// ── Opmaak ────────────────────────────────────────────────────────────────────
|
||||||
|
// Licht en rustig: dit is een document om te lezen en eventueel te printen, niet
|
||||||
|
// het dashboard. Systeemlettertypen, zodat er niets van buiten geladen hoeft.
|
||||||
|
const STIJL = `
|
||||||
|
:root { color-scheme: light; }
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0; padding: 32px 20px 64px;
|
||||||
|
font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||||
|
color: #1e2330; background: #f6f7fb;
|
||||||
|
}
|
||||||
|
.blad { max-width: 1180px; margin: 0 auto; }
|
||||||
|
/* De voortgangstabel is Datum + één kolom per categorie + vier vaste kolommen.
|
||||||
|
Bij zes categorieën zijn dat elf kolommen, en dat past op geen enkel scherm
|
||||||
|
dat je toevallig bij de hand hebt. Liever horizontaal scrollen dan kolommen
|
||||||
|
die buiten beeld vallen: zichtbaar is belangrijker dan in één oogopslag. */
|
||||||
|
.scroll {
|
||||||
|
overflow-x: auto;
|
||||||
|
/* Schaduwtruc met vier achtergronden: de twee op 'local' schuiven mee en
|
||||||
|
dekken de schaduw af zolang je aan die kant al bent, de twee op 'scroll'
|
||||||
|
blijven staan. Resultaat is een randje dat alleen verschijnt aan de kant
|
||||||
|
waar nog inhoud zit. Puur CSS — de pagina mag geen scripts bevatten, en
|
||||||
|
zonder hint ziet niemand dat er rechts nog kolommen staan. */
|
||||||
|
background:
|
||||||
|
linear-gradient(to right, #fff 30%, rgba(255,255,255,0)) left center,
|
||||||
|
linear-gradient(to left, #fff 30%, rgba(255,255,255,0)) right center,
|
||||||
|
radial-gradient(farthest-side at 0 50%, rgba(30,35,48,0.16), rgba(30,35,48,0)) left center,
|
||||||
|
radial-gradient(farthest-side at 100% 50%, rgba(30,35,48,0.16), rgba(30,35,48,0)) right center;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-size: 40px 100%, 40px 100%, 14px 100%, 14px 100%;
|
||||||
|
background-attachment: local, local, scroll, scroll;
|
||||||
|
}
|
||||||
|
.scroll table { min-width: 100%; width: max-content; }
|
||||||
|
/* Datum blijft staan tijdens het scrollen; zonder die kolom weet je bij elf
|
||||||
|
kolommen niet meer naar welk moment je kijkt. */
|
||||||
|
.scroll th:first-child, .scroll td:first-child { position: sticky; left: 0; background: #fff; }
|
||||||
|
tr.subtotaal td:first-child { background: #fafbff; }
|
||||||
|
tr.totaal td:first-child { background: #f4f2ff; }
|
||||||
|
header { border-bottom: 3px solid #8b5cf6; padding-bottom: 16px; margin-bottom: 28px; }
|
||||||
|
h1 { margin: 0 0 4px; font-size: 26px; letter-spacing: -0.4px; }
|
||||||
|
.meta { margin: 0; color: #6b7280; font-size: 13px; }
|
||||||
|
h2 {
|
||||||
|
font-size: 15px; text-transform: uppercase; letter-spacing: 0.6px;
|
||||||
|
color: #6b7280; margin: 34px 0 10px; font-weight: 700;
|
||||||
|
}
|
||||||
|
section { background: #fff; border: 1px solid #e3e6ef; border-radius: 10px; padding: 4px 18px 14px; }
|
||||||
|
table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||||
|
th, td { padding: 8px 10px; text-align: left; border-bottom: 1px solid #eceef5; }
|
||||||
|
th { font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; color: #6b7280; }
|
||||||
|
/* Bedragen rechts en met cijfers van gelijke breedte, zodat kolommen uitlijnen */
|
||||||
|
.num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||||
|
tr.subtotaal td { font-weight: 700; background: #fafbff; }
|
||||||
|
tr.totaal td { font-weight: 800; border-top: 2px solid #d9dced; background: #f4f2ff; }
|
||||||
|
.kpi { display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 12px; padding: 14px 0; }
|
||||||
|
.kpi div { border: 1px solid #e3e6ef; border-radius: 8px; padding: 12px 14px; background: #fcfcff; }
|
||||||
|
.kpi span { display: block; font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; color: #6b7280; }
|
||||||
|
.kpi strong { font-size: 19px; font-variant-numeric: tabular-nums; }
|
||||||
|
.leeg { color: #9aa1b0; font-style: italic; padding: 10px 0; }
|
||||||
|
footer { margin-top: 34px; color: #6b7280; font-size: 12px; line-height: 1.6; }
|
||||||
|
footer code { background: #eceef5; padding: 1px 5px; border-radius: 4px; }
|
||||||
|
@media print {
|
||||||
|
body { background: #fff; padding: 0; }
|
||||||
|
section { border: none; border-radius: 0; padding: 0; }
|
||||||
|
h2 { margin-top: 22px; }
|
||||||
|
tr { break-inside: avoid; }
|
||||||
|
/* Papier scrollt niet, dus op print moet alles passen. Liggend formaat: een
|
||||||
|
tabel van twaalf kolommen krijg je staand met geen enkele lettergrootte
|
||||||
|
fatsoenlijk op een A4. Verder vervallen de scrollbalk en de schaduwhint,
|
||||||
|
en gaat de nowrap van de bedragen af — die hield de kolommen op hun volle
|
||||||
|
breedte en was precies waaraan het krimpen vastliep. */
|
||||||
|
@page { size: A4 landscape; margin: 12mm; }
|
||||||
|
.scroll { overflow: visible; background: none; }
|
||||||
|
.scroll table { width: 100%; min-width: 0; table-layout: fixed; }
|
||||||
|
.scroll th:first-child, .scroll td:first-child { position: static; }
|
||||||
|
table { font-size: 9px; }
|
||||||
|
th, td { padding: 3px 4px; }
|
||||||
|
.num { white-space: normal; }
|
||||||
|
.blad { max-width: none; }
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bouwt de complete HTML-pagina.
|
||||||
|
*
|
||||||
|
* @returns {string} het volledige bestand, klaar om weg te schrijven
|
||||||
|
*/
|
||||||
|
export function bouwHtmlExport({
|
||||||
|
cats = [],
|
||||||
|
schuldBlokken = [],
|
||||||
|
evRows = [],
|
||||||
|
evVoortgangHistory = [],
|
||||||
|
schuldHistory = [],
|
||||||
|
totEV = 0,
|
||||||
|
totSchuld = 0,
|
||||||
|
doel = 0,
|
||||||
|
initieleSchuld = 0,
|
||||||
|
gebruiker = "",
|
||||||
|
ruweData = null,
|
||||||
|
}) {
|
||||||
|
const nu = new Date();
|
||||||
|
const exportDatum = `${String(nu.getDate()).padStart(2, "0")}-${String(nu.getMonth() + 1).padStart(2, "0")}-${nu.getFullYear()}`;
|
||||||
|
|
||||||
|
// ── Samenvatting ────────────────────────────────────────────
|
||||||
|
// Zelfde opbouw als de dashboard-KPI's: het oudste voortgangsmoment is de
|
||||||
|
// startinleg, elke latere rij telt zijn stortingen erbij op.
|
||||||
|
const startEV = evRows.length > 0 ? (evRows[evRows.length - 1]?.nettoWaarde || 0) : 0;
|
||||||
|
const extraFiat = evRows.length > 1
|
||||||
|
? evRows.slice(0, -1).reduce((a, r) => a + cats.reduce((b, c) => b + (r[c.id] || 0), 0), 0)
|
||||||
|
: 0;
|
||||||
|
const totaalFiat = startEV + extraFiat;
|
||||||
|
const winst = totEV - totaalFiat;
|
||||||
|
|
||||||
|
const kpis = [
|
||||||
|
["Totaal eigen vermogen", euro(totEV)],
|
||||||
|
["Totale schuld", euro(totSchuld)],
|
||||||
|
["Netto waarde", euro(totEV - totSchuld)],
|
||||||
|
["Totaal geïnvesteerde fiat", euro(totaalFiat)],
|
||||||
|
["Winst op investering", euro(winst)],
|
||||||
|
["ROI", totaalFiat > 0 ? procent(winst / totaalFiat) : "—"],
|
||||||
|
["Doel eigen vermogen", euro(doel)],
|
||||||
|
["Voortgang naar doel", doel > 0 ? procent(Math.min(totEV / doel, 1)) : "—"],
|
||||||
|
];
|
||||||
|
|
||||||
|
// ── Eigen vermogen ──────────────────────────────────────────
|
||||||
|
// Per categorie de posten met een subtotaalregel eronder, en helemaal
|
||||||
|
// onderaan het eindtotaal. Zo is de pagina te lezen zonder te rekenen.
|
||||||
|
const evRijen = [];
|
||||||
|
for (const c of cats) {
|
||||||
|
const items = c.items || [];
|
||||||
|
for (const i of items) evRijen.push([c.naam, i.naam, euro(i.waarde)]);
|
||||||
|
// Eerste kolom leeg: het label zegt al om welke categorie het gaat, en de
|
||||||
|
// naam er nog eens naast maakt de regel alleen maar drukker.
|
||||||
|
evRijen.push({
|
||||||
|
klasse: "subtotaal",
|
||||||
|
cellen: ["", `Subtotaal ${c.naam}`, euro(items.reduce((a, i) => a + (i.waarde || 0), 0))],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (evRijen.length) evRijen.push({ klasse: "totaal", cellen: ["", "Totaal eigen vermogen", euro(totEV)] });
|
||||||
|
|
||||||
|
// ── Schulden ────────────────────────────────────────────────
|
||||||
|
const schuldRijen = [];
|
||||||
|
for (const b of schuldBlokken) {
|
||||||
|
const items = b.items || [];
|
||||||
|
for (const i of items) schuldRijen.push([b.naam, i.naam, euro(i.waarde)]);
|
||||||
|
schuldRijen.push({
|
||||||
|
klasse: "subtotaal",
|
||||||
|
cellen: ["", `Subtotaal ${b.naam}`, euro(items.reduce((a, i) => a + (i.waarde || 0), 0))],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (schuldRijen.length) schuldRijen.push({ klasse: "totaal", cellen: ["", "Totale schuld", euro(totSchuld)] });
|
||||||
|
// De initiële schuld is het beginpunt waartegen het afbetalingsoverzicht in de
|
||||||
|
// app rekent. Hij staat los van de blokken, dus zonder eigen regel valt hij
|
||||||
|
// buiten de export — en dan mist de pagina een ingevoerd gegeven.
|
||||||
|
if (initieleSchuld) {
|
||||||
|
schuldRijen.push({ klasse: "subtotaal", cellen: ["", "Initiële schuld (startpunt)", euro(initieleSchuld)] });
|
||||||
|
schuldRijen.push({ klasse: "subtotaal", cellen: ["", "Afgelost sinds start", euro(initieleSchuld - totSchuld)] });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Voortgang eigen vermogen ────────────────────────────────
|
||||||
|
// Oplopend op datum: een archief lees je van oud naar nieuw.
|
||||||
|
const oplopend = [...evRows].sort((a, b) => String(a.datum).localeCompare(String(b.datum)));
|
||||||
|
|
||||||
|
// Verwijder je een categorie, dan blijven de stortingen ervan in de oude
|
||||||
|
// voortgangsrijen staan — de app toont alleen de kolommen van bestaande
|
||||||
|
// categorieën, dus die bedragen zie je nergens meer. Voor een export die
|
||||||
|
// "alles wat is ingevoerd" moet tonen is dat precies het verkeerde gedrag, dus
|
||||||
|
// die wezen krijgen hier alsnog een kolom, gemerkt met hun ruwe sleutel.
|
||||||
|
const bekend = new Set(cats.map((c) => c.id));
|
||||||
|
const vaste = new Set(["datum", "nettoWaarde", "opmerkingen"]);
|
||||||
|
const wezen = [...new Set(oplopend.flatMap((r) => Object.keys(r)))]
|
||||||
|
.filter((k) => !bekend.has(k) && !vaste.has(k))
|
||||||
|
.filter((k) => oplopend.some((r) => typeof r[k] === "number"));
|
||||||
|
|
||||||
|
const kolomIds = [...cats.map((c) => c.id), ...wezen];
|
||||||
|
const voortgangRijen = oplopend.map((r, i) => {
|
||||||
|
const storting = kolomIds.reduce((a, id) => a + (r[id] || 0), 0);
|
||||||
|
const verschil = i > 0 ? (r.nettoWaarde || 0) - (oplopend[i - 1].nettoWaarde || 0) : null;
|
||||||
|
return [
|
||||||
|
datumNl(r.datum),
|
||||||
|
...kolomIds.map((id) => euro(r[id])),
|
||||||
|
euro(storting),
|
||||||
|
euro(r.nettoWaarde),
|
||||||
|
verschil === null ? "—" : euro(verschil),
|
||||||
|
r.opmerkingen || "",
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Historie EV / schuld ────────────────────────────────────
|
||||||
|
// Twee reeksen die los worden bijgehouden en alleen op datum samenkomen; een
|
||||||
|
// moment kan er dus maar één van hebben.
|
||||||
|
const datums = [...new Set([
|
||||||
|
...evVoortgangHistory.map((r) => r.datum),
|
||||||
|
...schuldHistory.map((s) => s.datum),
|
||||||
|
])].sort((a, b) => String(a).localeCompare(String(b)));
|
||||||
|
|
||||||
|
const historieRijen = datums.map((d) => {
|
||||||
|
const e = evVoortgangHistory.find((r) => r.datum === d);
|
||||||
|
const s = schuldHistory.find((x) => x.datum === d);
|
||||||
|
const ev = e ? (e.ev ?? null) : null;
|
||||||
|
const sc = s ? (s.schuld ?? null) : null;
|
||||||
|
return [
|
||||||
|
datumNl(d),
|
||||||
|
ev === null ? "—" : euro(ev),
|
||||||
|
sc === null ? "—" : euro(sc),
|
||||||
|
ev !== null && sc !== null ? euro(ev - sc) : "—",
|
||||||
|
e?.opmerkingen || "",
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
const sectie = (titel, inhoud, leegTekst) =>
|
||||||
|
`<h2>${esc(titel)}</h2><section>${inhoud || `<p class="leeg">${esc(leegTekst)}</p>`}</section>`;
|
||||||
|
|
||||||
|
// De ruwe data gaat mee zodat dit bestand ook als backup dient. `<` wordt
|
||||||
|
// ge-escaped, anders sluit een `</script>` in de gegevens het blok voortijdig
|
||||||
|
// af en is zowel de pagina als de backup stuk.
|
||||||
|
const jsonBlok = ruweData
|
||||||
|
? `\n<script type="application/json" id="vibefinance-backup">${
|
||||||
|
JSON.stringify(ruweData).replace(/</g, "\\u003c")
|
||||||
|
}</script>\n`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
return `<!doctype html>
|
||||||
|
<html lang="nl">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>VibeFinance — overzicht ${esc(exportDatum)}</title>
|
||||||
|
<style>${STIJL}</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="blad">
|
||||||
|
<header>
|
||||||
|
<h1>VibeFinance</h1>
|
||||||
|
<p class="meta">Volledig overzicht · geëxporteerd op ${esc(exportDatum)}${gebruiker ? ` · ${esc(gebruiker)}` : ""}</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<h2>Samenvatting</h2>
|
||||||
|
<section>
|
||||||
|
<div class="kpi">${kpis.map(([l, v]) => `<div><span>${esc(l)}</span><strong>${esc(v)}</strong></div>`).join("")}</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
${sectie("Eigen vermogen", evRijen.length ? maakTabel(["Categorie", "Post", "Waarde"], evRijen, [2]) : "", "Geen vermogensposten ingevoerd.")}
|
||||||
|
|
||||||
|
${sectie("Schulden", schuldRijen.length ? maakTabel(["Blok", "Onderdeel", "Waarde"], schuldRijen, [2]) : "", "Geen schulden ingevoerd.")}
|
||||||
|
|
||||||
|
${sectie("Voortgang eigen vermogen", voortgangRijen.length
|
||||||
|
? maakTabel(
|
||||||
|
[
|
||||||
|
"Datum",
|
||||||
|
...cats.map((c) => c.naam),
|
||||||
|
// Een verwijderde categorie heeft geen naam meer; de sleutel is dan
|
||||||
|
// het enige wat er nog van over is.
|
||||||
|
...wezen.map((k) => `Verwijderd (${k})`),
|
||||||
|
"Totaal storting", "Netto waarde", "Verschil", "Opmerkingen",
|
||||||
|
],
|
||||||
|
voortgangRijen,
|
||||||
|
// Alle bedragkolommen: vanaf kolom 1 tot en met "Verschil".
|
||||||
|
Array.from({ length: kolomIds.length + 3 }, (_, i) => i + 1),
|
||||||
|
)
|
||||||
|
: "", "Geen voortgangsmomenten vastgelegd.")}
|
||||||
|
|
||||||
|
${sectie("Historie eigen vermogen en schuld", historieRijen.length
|
||||||
|
? maakTabel(["Datum", "Eigen vermogen", "Schuld", "Netto waarde", "Opmerkingen"], historieRijen, [1, 2, 3])
|
||||||
|
: "", "Geen historie vastgelegd.")}
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<p><strong>Dit bestand is ook een backup.</strong> Onderin staat je complete
|
||||||
|
data in machineleesbare vorm, onzichtbaar op het scherm. Ga in VibeFinance
|
||||||
|
naar <code>Instellingen → Backup herstellen</code> en kies dit
|
||||||
|
<code>.html</code>-bestand om alles terug te zetten.</p>
|
||||||
|
<p>De pagina staat volledig op zichzelf: geen internet, geen lettertypen of
|
||||||
|
scripts van buitenaf. Bewaar hem naast je backup-ZIP, dan heb je één bestand
|
||||||
|
dat je zowel kunt lézen als kunt terugzetten.</p>
|
||||||
|
</footer>
|
||||||
|
</div>${jsonBlok}</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Haalt de ingebakken backup uit een geëxporteerde pagina terug.
|
||||||
|
*
|
||||||
|
* Bewust met DOMParser en niet met een reguliere expressie: de gegevens kunnen
|
||||||
|
* zelf van alles bevatten, en een parser die HTML écht begrijpt trapt daar niet
|
||||||
|
* in. De pagina wordt niet uitgevoerd — DOMParser draait geen scripts, en het
|
||||||
|
* blok is `type="application/json"`, dus ook in een browser geen code.
|
||||||
|
*
|
||||||
|
* @returns {object|null} de data, of null als het geen export-pagina is
|
||||||
|
*/
|
||||||
|
export function leesHtmlBackup(htmlTekst) {
|
||||||
|
const doc = new DOMParser().parseFromString(htmlTekst, "text/html");
|
||||||
|
const blok = doc.getElementById("vibefinance-backup");
|
||||||
|
if (!blok) return null;
|
||||||
|
return JSON.parse(blok.textContent);
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
export * from "./format.js";
|
export * from "./format.js";
|
||||||
export * from "./storage.js";
|
export * from "./storage.js";
|
||||||
export * from "./grafiek.js";
|
export * from "./grafiek.js";
|
||||||
|
export * from "./htmlExport.js";
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "vibefinance",
|
"name": "vibefinance",
|
||||||
"version": "1.0.10",
|
"version": "1.0.13",
|
||||||
"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",
|
||||||
|
|||||||
@@ -5,7 +5,8 @@
|
|||||||
# 1. Dependency-check (staan package.json's op de laatste versies?)
|
# 1. Dependency-check (staan package.json's op de laatste versies?)
|
||||||
# 2. Backend-tests (Dockerfile target 'test' → node --test, zonder database)
|
# 2. Backend-tests (Dockerfile target 'test' → node --test, zonder database)
|
||||||
# 3. Productiebuild (Vite-build + app-image; faalt de frontend, faalt dit)
|
# 3. Productiebuild (Vite-build + app-image; faalt de frontend, faalt dit)
|
||||||
# 4. Smoketest (productie-image + postgres, /api/health en / opvragen)
|
# 4. Imagescan (Trivy op het gebouwde image; OS- én npm-pakketten)
|
||||||
|
# 5. Smoketest (productie-image + postgres, /api/health en / opvragen)
|
||||||
#
|
#
|
||||||
# De smoketest publiceert geen hostpoorten en gebruikt een eigen netwerk, dus
|
# De smoketest publiceert geen hostpoorten en gebruikt een eigen netwerk, dus
|
||||||
# hij botst niet met een draaiende dev-stack.
|
# hij botst niet met een draaiende dev-stack.
|
||||||
@@ -14,14 +15,17 @@
|
|||||||
# .\test.ps1 # alle stappen
|
# .\test.ps1 # alle stappen
|
||||||
# .\test.ps1 -Unit # dependency-check + backend-tests
|
# .\test.ps1 -Unit # dependency-check + backend-tests
|
||||||
# .\test.ps1 -NoSmoke # zonder de stack te starten
|
# .\test.ps1 -NoSmoke # zonder de stack te starten
|
||||||
# .\test.ps1 -Strict # faal als een dependency achterloopt
|
# .\test.ps1 -Strict # faal als een dependency achterloopt; scan ook op
|
||||||
|
# # MEDIUM en LOW in plaats van alleen CRITICAL/HIGH
|
||||||
# .\test.ps1 -SkipDeps # dependency-check overslaan (bijv. offline)
|
# .\test.ps1 -SkipDeps # dependency-check overslaan (bijv. offline)
|
||||||
|
# .\test.ps1 -SkipScan # imagescan overslaan (bijv. offline)
|
||||||
|
|
||||||
param(
|
param(
|
||||||
[switch]$Unit,
|
[switch]$Unit,
|
||||||
[switch]$NoSmoke,
|
[switch]$NoSmoke,
|
||||||
[switch]$Strict,
|
[switch]$Strict,
|
||||||
[switch]$SkipDeps
|
[switch]$SkipDeps,
|
||||||
|
[switch]$SkipScan
|
||||||
)
|
)
|
||||||
|
|
||||||
$ErrorActionPreference = "Stop"
|
$ErrorActionPreference = "Stop"
|
||||||
@@ -43,7 +47,7 @@ function Write-Step {
|
|||||||
# Vergelijkt de versies in frontend/ en backend/package.json met het
|
# Vergelijkt de versies in frontend/ en backend/package.json met het
|
||||||
# npm-register. Bewuste uitzonderingen staan in docker/update-exceptions.json.
|
# npm-register. Bewuste uitzonderingen staan in docker/update-exceptions.json.
|
||||||
if (-not $SkipDeps) {
|
if (-not $SkipDeps) {
|
||||||
Write-Step "[1/4] Dependency-check"
|
Write-Step "[1/5] Dependency-check"
|
||||||
|
|
||||||
$checkArgs = @("node", "docker/check-updates.mjs")
|
$checkArgs = @("node", "docker/check-updates.mjs")
|
||||||
if ($Strict) { $checkArgs += "--strict" }
|
if ($Strict) { $checkArgs += "--strict" }
|
||||||
@@ -59,11 +63,11 @@ if (-not $SkipDeps) {
|
|||||||
Write-Host "Let op: er zijn nieuwere versies beschikbaar (zie hierboven)." -ForegroundColor Yellow
|
Write-Host "Let op: er zijn nieuwere versies beschikbaar (zie hierboven)." -ForegroundColor Yellow
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Write-Step "[1/4] Dependency-check overgeslagen (-SkipDeps)"
|
Write-Step "[1/5] Dependency-check overgeslagen (-SkipDeps)"
|
||||||
}
|
}
|
||||||
|
|
||||||
# ── 2. Backend-tests ──────────────────────────────────────────────────────
|
# ── 2. Backend-tests ──────────────────────────────────────────────────────
|
||||||
Write-Step "[2/4] Backend-tests"
|
Write-Step "[2/5] Backend-tests"
|
||||||
docker build --target test -t $testImage $PSScriptRoot
|
docker build --target test -t $testImage $PSScriptRoot
|
||||||
if ($LASTEXITCODE -ne 0) { Write-Error "Testimage bouwen mislukt"; exit 1 }
|
if ($LASTEXITCODE -ne 0) { Write-Error "Testimage bouwen mislukt"; exit 1 }
|
||||||
|
|
||||||
@@ -78,7 +82,7 @@ if ($Unit) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# ── 3. Productiebuild (inclusief Vite-build) ──────────────────────────────
|
# ── 3. Productiebuild (inclusief Vite-build) ──────────────────────────────
|
||||||
Write-Step "[3/4] Productie-image bouwen"
|
Write-Step "[3/5] Productie-image bouwen"
|
||||||
docker build --target production `
|
docker build --target production `
|
||||||
--build-arg APP_VERSION=$appVersion `
|
--build-arg APP_VERSION=$appVersion `
|
||||||
--build-arg VITE_APP_VERSION=$appVersion `
|
--build-arg VITE_APP_VERSION=$appVersion `
|
||||||
@@ -87,13 +91,67 @@ docker build --target production `
|
|||||||
if ($LASTEXITCODE -ne 0) { Write-Error "Productiebuild mislukt"; exit 1 }
|
if ($LASTEXITCODE -ne 0) { Write-Error "Productiebuild mislukt"; exit 1 }
|
||||||
Write-Host "Productie-image gebouwd: $prodImage" -ForegroundColor Green
|
Write-Host "Productie-image gebouwd: $prodImage" -ForegroundColor Green
|
||||||
|
|
||||||
|
# ── 4. Imagescan ──────────────────────────────────────────────────────────
|
||||||
|
# Stap 1 kijkt naar de package.json's; deze kijkt naar wat er daadwerkelijk in
|
||||||
|
# het image zit — de Alpine-pakketten en alles wat het base-image meelevert.
|
||||||
|
# Dat onderscheid is niet theoretisch: bij de scan van 1.0.10 kwamen alle 39
|
||||||
|
# bevindingen daarvandaan (OpenSSL in Alpine, en npm's eigen dependencies) en
|
||||||
|
# geen enkele uit deze applicatie. Een dependency-check had er nul van gezien.
|
||||||
|
#
|
||||||
|
# --ignore-unfixed: alleen falen op iets waar een fix voor bestaat. Anders
|
||||||
|
# blokkeert een CVE zonder patch je release zonder dat je er iets aan kunt doen.
|
||||||
|
if (-not $SkipScan) {
|
||||||
|
Write-Step "[4/5] Imagescan (Trivy)"
|
||||||
|
|
||||||
|
$trivyCache = Join-Path $env:USERPROFILE ".cache\trivy"
|
||||||
|
New-Item -ItemType Directory -Force -Path $trivyCache | Out-Null
|
||||||
|
|
||||||
|
# Normaal blokkeren alleen CRITICAL en HIGH; -Strict trekt de lat naar alles.
|
||||||
|
$ernst = if ($Strict) { "CRITICAL,HIGH,MEDIUM,LOW" } else { "CRITICAL,HIGH" }
|
||||||
|
|
||||||
|
# Uitvoer opvangen en alleen tonen als er iets is: Trivy print bij succes
|
||||||
|
# een tabelregel voor élk schoon pakket, en dat is honderden regels ruis
|
||||||
|
# waarin een echte melding juist wegvalt.
|
||||||
|
$scanUit = docker run --rm `
|
||||||
|
-v /var/run/docker.sock:/var/run/docker.sock `
|
||||||
|
-v "${trivyCache}:/root/.cache" `
|
||||||
|
aquasec/trivy:latest image `
|
||||||
|
--scanners vuln --severity $ernst --ignore-unfixed --exit-code 1 --quiet `
|
||||||
|
$prodImage 2>&1
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
$scanUit | ForEach-Object { Write-Host $_ }
|
||||||
|
Write-Error "Imagescan vond op te lossen kwetsbaarheden ($ernst)"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
Write-Host "Imagescan schoon ($ernst)" -ForegroundColor Green
|
||||||
|
|
||||||
|
# Het postgres-image scannen we niet als gate: alle bevindingen daarin zitten
|
||||||
|
# in /usr/local/bin/gosu, en die binary wordt nooit uitgevoerd omdat de
|
||||||
|
# compose-bestanden de container al als `postgres` starten (zie BEHEER.md).
|
||||||
|
# Wat wél misgaat is achterstand: het image stond vier maanden op 18.4 met
|
||||||
|
# 143 bevindingen omdat niemand pullde. Daar waarschuwen we voor — falen zou
|
||||||
|
# onterecht zijn, want een release van de app hangt er niet van af.
|
||||||
|
$pgTag = "postgres:18-alpine"
|
||||||
|
$lokaal = (docker image inspect $pgTag --format '{{index .RepoDigests 0}}' 2>$null)
|
||||||
|
if ($lokaal) {
|
||||||
|
$remote = (docker buildx imagetools inspect $pgTag --format '{{.Manifest.Digest}}' 2>$null)
|
||||||
|
if ($remote -and $lokaal -notlike "*$remote*") {
|
||||||
|
Write-Host "Let op: $pgTag is nieuwer in de registry — draai 'docker compose pull postgres'." -ForegroundColor Yellow
|
||||||
|
} else {
|
||||||
|
Write-Host "$pgTag is actueel" -ForegroundColor Green
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Write-Step "[4/5] Imagescan overgeslagen (-SkipScan)"
|
||||||
|
}
|
||||||
|
|
||||||
if ($NoSmoke) {
|
if ($NoSmoke) {
|
||||||
Write-Host ""
|
Write-Host ""
|
||||||
Write-Host "Smoketest overgeslagen (-NoSmoke) — klaar." -ForegroundColor Green
|
Write-Host "Smoketest overgeslagen (-NoSmoke) — klaar." -ForegroundColor Green
|
||||||
exit 0
|
exit 0
|
||||||
}
|
}
|
||||||
# ── 4. Smoketest op het productie-image + postgres ────────────────────────
|
# ── 5. Smoketest op het productie-image + postgres ────────────────────────
|
||||||
Write-Step "[4/4] Smoketest (app + postgres)"
|
Write-Step "[5/5] Smoketest (app + postgres)"
|
||||||
|
|
||||||
function Remove-SmokeStack {
|
function Remove-SmokeStack {
|
||||||
docker rm -f $smokeApp $smokeDb 2>$null | Out-Null
|
docker rm -f $smokeApp $smokeDb 2>$null | Out-Null
|
||||||
|
|||||||
Reference in New Issue
Block a user